概要
新卒さん向けに、オブジェクト指向についての解説をするための投稿です。
何回かに分けて投稿をします。
第二回目は、カプセル化についての説明をします。
第一回目:オブジェクト指向とは何か
第二回目:カプセル化←今ココ
対象読者
【初心者向け】 ☆★☆☆☆ 【上級者向け】
オブジェクト指向について、なんとなく研修やら技術書やらで読んだり聞いたりしたことのある人を対象にしてます。
また、多少でもコードは読めるという方も対象にしています。(Javaですが)
カプセル化についてのおさらい
最初に、カプセル化とは何かをWikipediaからおさらいしましょう。
プログラミングにおけるカプセル化(カプセルか、英: encapsulation)とは、データ(属性)とメソッド(手続き)を一つのオブジェクトにまとめ、その内容を隠蔽することを言う。カプセル化の概念は、D.L.パルナスの情報隠蔽(information hiding)の構成概念の一つとして見ることができる。
オブジェクト指向プログラミングにおけるオブジェクトは、クラスによる情報のカプセル化を行うことで作られる。
カプセル化 - Wikipedia
このカプセル化については、おそらく研修などでも習っていることでしょう。Javaのようなオブジェクト指向言語では、 private
や protected
といったアクセス修飾子でカプセル化を実現しています。基本的に、 private
修飾子がついたメソッドや変数は、外から参照することができなくなります。
よくあるカプセル化の理解
さて、このカプセル化ですが、少なくともぼくが読んだことのある入門書にはせいぜい以下のようなことくらいしか書かれてはいません。
(もちろん、ぼくが出会ってないだけでちゃんと書かれてる入門書もあるでしょうが)
- カプセル化はデータや内部実装を隠蔽することを言う
- 外部に見せたくないデータやメソッドを中に閉じ込めるための手段
- 隠蔽することで、内部の処理をとじこめておくことができる
- カプセル化をすることで、そのクラスを使う側は内部処理を知らなくても実装ができる
- カプセル化によって、外側から内部処理に干渉させなくすることができる
- メンバ変数は基本は
private
またはprotected
にしておくのが良い
これらは、たしかに間違えてはいません。ただ、オブジェクト指向とカプセル化を結びつけて理解するという点では、上記の説明だけでは若干弱いというか、不完全ではないかと思っています。
カプセル化は、オブジェクト指向における基本概念かつ最重要概念のひとつです。この概念について正しく理解しておくと、今後のオブジェクト指向の理解をより深めることができると思います。
なぜカプセル化が必要なのか(よくある例)
よくあるのが、以下のような説明です(それも間違えてはいないので、おさらいとして記載します)。
カプセル化のない世界
たとえば、カプセル化されていない世界を想像しましょう。ここでは紙クラスで、紙には字が書かれるという関心事があることを想定して表現をしてみます。
public class Paper {
public String text;
}
また、タイプライターというクラスも想定しましょう。それは、紙に字を書くことが関心事です。
それは、以下のように表現することができるでしょう。
public class TypeWriter {
public Paper paper;
public void write(String text) {
this.paper.text = text;
}
}
更に、紙に書かれた文字を読み上げる人を想定しましょう。とりあえずTextReaderとでも名付けますね。
public class TextReader {
public Paper paper;
public String read() {
return this.paper.text;
}
}
そして、最後に 紙に字を書き、それを読み上げる という業務用件をmainメソッドで表現をします。
public class Main {
public static void main(String[] args) {
Paper paper = new Paper();
TypeWriter writer = new TypeWriter();
writer.paper = paper;
writer.write("これはテストです");
TextReader reader = new TextReader();
reader.paper = paper;
System.out.println(reader.read()); // これはテストです と出力される
}
}
簡単な実装かと思うので、何をしてるのかは説明はしません。
さて、これだけなら別に何もカプセル化してないことの弊害はありません。期待した動作と同じものが常に出力されることでしょう。
ただ、何かのきっかけで、 TextReader
クラスに以下の改修をしたらどうでしょう。
public class TextReader {
public Paper paper;
public String read() {
this.paper.text = "修正したよ〜";
return this.paper.text;
}
}
このように修正して Main
を実行した時、コンソール上には 「修正したよ〜」 と表示されることになります。
Mainでは「これはテストです」を入れたはずなのに!
これはとても簡単な例ですし、記述量も少ないので、変な修正をした場所もすぐ気づくことができるでしょう。また、実装した人が個人であればなおさらすぐに気付けるとも思います(そもそも、個人でやってたら TextReader
にそんな修正をすることもないでしょうが)。
ただ、これが100クラス、200クラスと膨大な数のクラスを扱う中〜大規模システムや、長い処理の場合、話が別です。その中で、Paperクラスをimportしているクラスが少なければまだ問題はありませんが、多くのクラスがPaperクラスを参照している場合、一体どこで内部のデータが書き換えられたのか、参照しているクラスの全部の処理を確認する必要がでてきます。
しかも大部分は TextWriterクラス
のように正しい処理をしているでしょうから、その中から悪さをしている処理(≒バグ)を見つけだすのは、規模が大きくなればなるほど至難の業になります。また、 大人数でやっていたら誰がどんな経緯でその処理を書いたのか把握しきれなくなるので、そもそもそれがバグなのか、正しい処理なのかを判断することすら難しくなります。
カプセル化してないと改修が難しくなる
上記は、バグが混入した場合の自体を記載しました。
ただカプセル化してない場合に一番問題となるのは、 Paperクラス
自体を変更した時です。たとえば、内部変数の「text」を「data」に変更した場合、全体の修正が必要になります。
修正をするということは、品質を保つためにはそこのテストもしなおさなければなりません。システムの規模が大きくなればなるほど改修のコストが高くになることは容易に想像がつくでしょう。
そんな状態を防ぐため、内部のデータは極力外部からは参照できないように隠蔽することが推奨されます。
まあ、間違えてはいないんだけど
上記がよくあるカプセル化が必要な説明です。間違えてはいません。
まとめると、「以下を防ぐためにカプセル化を行う」いうのがよく書かれてる内容です。
- カプセル化をしないと、内部のデータをどこでも書き換えることができてしまう
- その結果、そのデータが間違えた値に書き換えられたというバグが発生した場合に全体を確認する必要がでる
- また、改修をする際に多大なコストがかかるようになる
繰り返しますが、間違えてはいないです。
ただ、これらの説明だけでは、オブジェクト指向とカプセル化をリンクさせて理解することはちょっと難しいような気もします。
上記のコードに対しての違和感
ところで、そもそも上記の実装に対して違和感をもった人も多いのではないでしょうか。全部公開されているのなら、そもそもどうして TypeWriter
TypeReader
のようなクラスや、 wirte
read
みたいなメソッドをかまさなきゃいけないのか、と。無駄に感じますよね。
上記は、以下のように書くとよりシンプルになるし、同じ結果を出力させることができます。
public class Main {
public static void main(String[] args) {
Paper paper = new Paper();
paper.text = "これはテストです";
System.out.println(paper.text); // これはテストです と出力される
}
}
これでも全然問題ありません。 可読性も上がったようにさえ思えます。さっきみたいなバグが混入しそうにも見えませんね。
ただ、これでもやはり違和感をおぼえるひとはいると思います。そもそもなぜPaperクラスが必要なの? と。
もっと単純化できますね。
public class Main {
public static void main(String[] args) {
System.out.println("これはテストです"); // これはテストです と出力される
}
}
同じ結果になったし、更には1行で書くことができました。ブラボー!
…とはなりません。
そもそもなんのための「オブジェクト」だっけ?
何がだめなのでしょう。それは、上の方に書いた「紙には字が書かれる」という 関心事 が根こそぎなくなってしまっている点です。
第一回 にも書きましたが、オブジェクト指向とは「人のためにある」ものであり、「関心事を、それ単体として独立して存在できる単位で、ひとつのクラスの中に閉じ込める」ためのものです。
後者については、実はカプセル化そのものです(だからカプセル化は最重要概念なのです)。前者も非常に重要で、オブジェクトは、そのシステムの関心事を表現するために存在しているとも言えます。
上のコードでいえば、最後の例でもシステム的には正しいふるまいをします。システムはオブジェクト指向なんて必要としていません。そんなものがない時代から、正しいプログラムはいつだって正しく動いています。オブジェクト指向は「そのプログラムが何をしたいのか」という関心事を整理し、説明するために存在するものであり、関心事はカプセル化の活躍によってより強調されます。
「強調される」それも、カプセル化において忘れられがちですが、非常に重要なポイントです。
強調されるとはどういうことか
それを説明するために、「色:Color」というクラスを例にだします。
色には、赤や青、黄色、緑、茶色、紫…といろいろあります。そして、システムではよく「RGB(光の三原色)+A(透過)」で表現されることが多く、実際 java.awt.Color
クラスなんかも、以下のようなコンストラクタを持っています。
Color(int r, int g, int b, int a)
上記の場合、赤緑青透過を「0〜255」によって設定します。赤なら「Color(255,0,0,255)」、黒なら「Color(0,0,0,255)」というように指定をすることができます。そしてColorクラスは、 getRed()
getBlue()
といった、その色の「0〜255」までの値を取得するメソッドも用意されています。とてもシンプルでわかりやすいですね。
そう聞くと、単純に「きっと内部でもr,g,b,aの値それぞれのメンバ変数を持っているのだろう」と思うかもしれません。でも、実際には以下のような変数を持っています。
Color.java(java8)
int value;
private float frgbvalue[] = null;
private float fvalue[] = null;
private float falpha = 0.0f;
private ColorSpace cs = null;
なんか、意外といろいろもってますね。そして、明確に「赤」「緑」「青」というような形で保持はしていません。それらは、例えば以下のように「value」の中にひとつの数値でまとめられています。
value = ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | ((b & 0xFF) << 0);
さて、仮にこれらが全部「public」として公開されているものだとしましょう。それらの数値を見て、そのColorクラスが現在どの色を表しているか、そしてどのようにその値が使われているのか、すぐわかる人はどれくらいいるでしょう?
上記の実装を見れば「valueに赤青緑透過がまとめられてるんだな」という程度のことならわかるとは思います。でも、それが実際どう使われているのかは、更に実装を読む必要がありそうです。他の frgbbvalue
fvalue
はどうでしょう。それも、どんなデータが入り、どういう使われ方をしているのかは中を見てみなければわかりません。
繰り返しになりますが、オブジェクト指向はソフトウェアが表現したい関心事を人が理解するために存在しています。
Colorクラスは「色」という関心事を表現しています。そして、その関心事とはあくまでも「結果的に実現したいこと」なんです。色を使うとなると、システムなどでボタンや文字を色によって区別したいという関心事があって使うのでしょうが、内部で実際どうデータを保持され、実際どう使われているのか、なんて関心事は、Colorクラス内部にしかありません。ボタンや文字の色の区別(関心事)を実現するためにぼくら(外部から使う側)がColorクラスから本当に得たい内容は、その色が何色を表しているのか、それをどう指定するのか、といったことだけです。
わざわざ内部実装を見なければわからない情報がpublicとして公開されていてもただのノイズにしかならないばかりか、最初に説明したように使い方がわからない人が思わぬ場所で場所で誤った使い方をしてしまうリスクも発生します。そのノイズを無くし、抽象化した関心事をより鮮明に表現をするというのもカプセル化の重要な役割です。public変数/メソッドは、そのクラスがどの関心事を表現しているのかを整理した結果付与された、実装者からのメッセージです。そのクラスのpublicな関心事は、公開不要なものをカプセル化で内部隠蔽することでより強調されます。
クラスは関心事を表現している
これも第一回 の繰り返しになりますが、オブジェクト指向でプログラミングをする際には、このことを意識することが非常に重要です。
それを踏まえた上で、先程のコードをもう一度見てみましょう。
public class Main {
public static void main(String[] args) {
System.out.println("これはテストです"); // これはテストです と出力される
}
}
先程も言った通り、ここには最初何がしたかったのかという関心事が何もありません。単に「これはテストです」と、コンソールに出力するというだけになりました。
なので、もう一度「紙には字を書き、読み上げる」という最初の関心事を表現できる形にしてみましょう。次は、ちゃんとカプセル化も考慮して一気に記述します。
public class Paper {
private String[] text;
public Paper() {
this.text = new String[]{};
}
public void appendText(String text) {
if (text == null ) {
return;
}
// テキストを1文字ずつ配列に変更
String[] str = text.split("");
// Java8以降のStream APIを使用して、Streamを作成
// もともとのテキストと、新たに追加するテキストを結合して配列に変換している
this.text = Stream.concat(stream(), Stream.of(str)).toArray(String[]::new);
}
public Stream<String> stream() {
return Stream.of(this.text);
}
}
public class TypeWriter {
private Paper paper;
public TypeWriter(Paper paper) {
this.paper = paper;
}
public void write(String text) {
this.paper.appendText(text);
}
}
public class TextReader {
private Paper paper;
public TextReader(Paper paper) {
this.paper = paper;
}
public String readAll() {
// Stream APIのCollectors.joining()で文字列を結合している
// 連続したデータをひとつひとつ読み上げていっているというイメージ
return this.paper.stream().collect(Collectors.joining());
}
}
public static void main(String[] args) {
Paper paper = new Paper();
TypeWriter writer = new TypeWriter(paper);
writer.write("これはテストです。");
TextReader reader = new TextReader(paper);
System.out.println(reader.readAll()); // これはテストです と出力される
}
一部、コメントにてやってることを補足しました。
こう見ると、だいぶまどろっこしいことをやってると思うかもしれません。ただ「main」関数を見ると、このプログラムが何を表現しているかは1行で書いたものよりわかりやすいのではないかと思います。人は、自分で実装をした場所だとしても、何を意図してそう実装したのか忘れたりすることがあります。なので、***多少まどろっこしくなったとしてもコード上に実装の意図を表現できるのはとても強力です。***簡単な例だとあまり実感がわかないかもしれませんが、システムが複雑で大規模になればなるほど、その威力が発揮されます。
そして、今回 Paper
クラスでは、内部では String
配列でデータをもたせており、それをprivateメソッドで隠蔽しています。
この Paper
クラスはあくまでも**「文字を書く紙を表現する」ことが関心事であり、内部でそれをどんな風に実現しているのかは関心の範囲外です。**別に String
自身で実現しようが、 List<String>
で実現しようが、 Stream<String>
で実現しようが、結果的に実現できているのであればどうでもいいことです。 Paper
クラス自身では、どう実現するかというのは関心事になるでしょうが、外に公開する必要のない関心事はカプセル化で閉じ込めることによって、そのクラスが本来扱いたい関心事がよりわかりやすくなります。
補足:getter/setterは悪か
ところで、研修先などでおそらく初期に習うものとして「getter」「setter」があると思います。多くのシステムで当たり前のように使われているのが実情ですが、この「getter」「setter」を悪とする議論があることをご存知でしょうか。ちょっと調べても、わんさかそのような記事がでてきます。
Getter/Setterは悪だ。以上。
結局のところgetter/setterは要るのか?要らないのか?
フィールドごとに getter/setter を用意するな
「getter/setterがカプセル化の概念を壊すから」というのが反対派の主張です。
たとえば、また Paper
クラスで考えてみます(また単純化します)。
public class Paper {
private String text;
public void setText(String text) {
this.text = text;
}
public String getText() {
return this.text;
}
}
これが典型的なものですね。いわゆる「JavaBeans」とも呼ばれたりします。
【初心者向け】10分で絶対にわかる!JavaBeansとは
これ、textのアクセス修飾子をpublicにすることとどう違いがあるのか説明できるでしょうか?
一見すると、結局「setText」を使ってどこからでも内部のデータを変更できるのだから、メンバ変数を変更することと変わらないようにも思えますよね。
まぁ、もちろん明確に違います。メソッドにすることで後々データの保持の仕方を変えることもできますし、内部で入力チェックをすることだってできるでしょう。たとえば、以下のように改修してみましょう。
public class Paper {
private String[] texts = new String[]{};
public void setText(String text) {
if (text == null ) {
throw new IllegalArgumentException("テキストはnull禁止です");
}
this.text = Stream.of(text.split("")).toArray(String[]::new);
}
public String getText() {
return this.stream().collect(Collectors.joining());
}
private Stream<String> stream() {
return Stream.of(this.texts);
}
}
と、このようにメソッドにしておくと後で内部を変更することは容易になります。それが、メンバ変数をpublicとしておくことの決定的な違いでしょう。メンバ変数を公開するよりは、後々の改修は遥かに楽にはなります。
ぼくもどちらかというと反対派(でも一部許容していいと思ってる)
これはぼく個人の意見ですが、ぼくもどちらかというとsetter/getterを使うのは反対派です。理由は、反対派の言ってるようにカプセル化を壊していると思うからです。また、これも第一回 の繰り返しになりますが、オブジェクト指向とは、手続きの対象を抽象化し、そのデータ(変数またはプロパティ)とコード(関数またはメソッド)のセットを基本要素にして物事の解析と組み立てを行うプログラミング・パラダイムとして誕生した知識体系です。これは、以下に言い換えることができます。
「原則として、そのデータを加工(処理)する責務は、そのクラスにある」
これは後々にも詳しく説明したいと思っていますが、オブジェクト指向における非常に重要な概念です。カプセル化の根源にある考え方だとも言えます。getterはまだしも、setterは、そのクラスが、自分の持つデータの加工を外部にゆだねていることになっており、クラスの責務を放棄していると思っています。
とはいえ、システム開発においてはよくはないが、多少は許容されるべき必要悪でもあるとも考えています。
何故なら、これも繰り返しですが「システムは別にオブジェクト指向を求めていない」からです。あくまで人が理解しやすく、改修や保守を容易にするためにオブジェクト指向は存在しています。であれば、オブジェクト指向原理主義を徹底しなければならないわけではなく、ケースによっては許容されても良いというのがぼくの考えです。全てをオブジェクト指向的に表現したからといって、幸せになれるかというとそれはまた別の話です。オブジェクト指向での表現は、そのクラスが再利用されるケースが多ければ多いほど威力を発揮しますが、1クラス程度にしか参照されないのであれば、むしろまどろっこしくなります。
許容されてもいいと思うケース
たとえば、以下のケースならぼくはgetter/setterは許容されても良いと思っています。
- RequestやResponseのようにデータを受け取ったり返すだけだったり、Viewにデータを渡すためといった、データのやり取り専門と割り切っているクラス(Data Transfer Object)
- フレームワークが参照するために必要なケース
- 関連している1〜2クラス程度からしか参照されず、その後も他のクラスから参照されることはないと思われるクラス
許容すべきでないと思うケース
逆に、以下については使用は極力控えるべきだと思っています。
- ビジネスロジック内でgetterを呼び、ビジネスロジック内で加工してsetterで値を入れるようなケース
- その加工は、そのデータを保持しているクラスでできないか検討するべきです
- そのクラスが、他の3クラス以上から参照されるケース or 今後参照される可能性があるケース
- 本当は2クラス以上に参照されそうな時点で、getter/setterを使わないやり方を検討するのが望ましいと思います
- なんとなく考えもなしに使っているケース
- 「ぼーっと生きてんじゃねぇよ」ってチ○ちゃんに叱られますよ
まとめ
最後に、カプセル化について簡単にまとめます。
- カプセル化はデータや内部実装を隠蔽することを言う
- カプセル化によって、外側から内部処理に干渉させなくすることができる
-
関心の分離はカプセル化を駆使することでより加速する
(関心事のみをpublicとして公開することで、内部と外部で関心事を分離できる)
次回は「継承とインタフェース」について書こうかと思います。