この記事の内容
オブジェクト指向って難しい!わかった気になって実践すると詰みます... ウギャー
この記事は10年以上オブジェクト指向と戦った筆者が、オブジェクト指向を通常とは異なるアプローチで解説したものです。
筆者はJavaを使って本格的なシステム開発をしたことがありませんが、オブジェクト指向言語として最もポピュラーなJavaをベースにオブジェクト指向について解説させていただきます。
また、この記事の続編にあたる「なぜオブジェクト指向は難しいのか」を2年の時を経て完成させて頂きました!是非こちらも一読していただけると嬉しいです!
オブジェクト指向三大要素の謎
オブジェクト指向三大要素ってありますよね。オブジェクト指向は「カプセル化」「継承」「ポリモーフィズム」の3つの要素で成り立つと言われます。最近では、この三大要素が語られる傾向は薄いようですが、一度は耳にしたことがあるのではないでしょうか?
この「オブジェクト指向三大要素」ですが、実はオブジェクト指向を理解する大きな妨げになってしまっているのです。
オブジェクト指向に不可欠なのは「ポリモーフィズム」であり、オブジェクト指向を超えて重要なのは「カプセル化」と「正しい名前付け」なのです。
これから「継承」「ポリモーフィズム」「カプセル化」について通常とは異なるアプローチで解明していきます。そして何故、「カプセル化」と「正しい名前付け」がオブジェクト指向を超えて重要となるのかを解説いたしましょう!
それでは本題に入る前に、なぜオブジェクト指向で書くのかという根本的なコトからおさらいします!
なぜオブジェクト指向で書くのか
なぜオブジェクト指向で書くのか考えたことはありますか?意外にもこのことを意識せず、なんとなくオブジェクト指向でプログラミングしてる方は多いと思います。
オブジェクト指向で書く理由、それは変更に対して柔軟に対応するためです。
プログラムは日々更新する必要があります。更新しなければ、そのプログラムは時間と共にじわじわ枯れていきます。植物を育てるには定期的に水を与えるなどのメンテナンスが必要なのと同じで、プログラムにもメンテナンスが必要です。GitHubなどでフレームワークやライブラリが日々更新されているのはプログラムが枯れないようにするためなのです。
オブジェクト指向によるアプリケーション開発は、変更されない箇所を軸に、頻繁に変更されるであろう箇所をクラスに抽出するプログラミングスタイルです。
例えば、店舗がどんどん増えていくファーストフードのシステムを開発することになったとしましょう。店舗が増えていくということは、そこは「頻繁に変更される箇所」なのでクラスに抽出して設計する必要があります。そうすると近い将来起こる「店舗が増える変更」に対して柔軟に対応できるようになります。
一見、システムの柔軟性は素晴らしいものに思えます。しかし柔軟性を取り入れすぎてクラス抽出しすぎると、今度は逆にシステムのメンテナンスを面倒なものに変えてしまいます。そのため、柔軟性と保守性のバランスをとることが大切なのです。
今回例に取り上げたのは、ファーストフードのシステムなので「食品以外のものを販売する」というような変更が発生することは、あまり考えられません。「もしかしたらガソリンやタイヤを販売するかもしれないじゃないか!可能性はゼロではないのだから柔軟に対応できるように設計するべきだ!」と思うかもしれません。しかし、将来的に変更のない箇所を無駄に柔軟に設計することは過剰実装であり、プロジェクトの設計を複雑なものに変えてしまうため、行わない方が良いこともあるのです。
システムには、変更の可能性が「高い箇所」と「低い箇所」が存在し、オブジェクト指向は、変更の可能性が低い箇所を土台に、高い箇所に気を配って設計する必要があります。そのため、オブジェクト指向でシステムの設計をするときは、コーディング前に予めシステムの箇所の変更度を整理しておくことが大切です。
なぜオブジェクト指向で書くのか?それは、予め頻繁に変更されるであろう箇所をクラスに抽出することで、システムが変更に対して柔軟に対応できるようにするためなのです。
また、オブジェクト指向の最大の価値は「わかりやすさと利便性」にあります。このことについては「なぜオブジェクト指向は難しいのか」をご覧下さい。
なぜオブジェクト指向で書くのか、その大まかな理由がわかったところで、次は「継承」「ポリモーフィズム」「カプセル化」の三大要素を解明していきます!
継承
みなさん大好き継承。継承は親クラスの機能を受け継ぐ機能です。しかしこれは継承の本質ではありません。
継承の本質はインターフェイスです。Javaではinterface
を使ってインターフェイスを定義できますが、継承もまたインターフェイスと同じ役割を果たします。
オブジェクト指向は難しいですが、継承は簡単に理解できるため、オブジェクト指向をわかったつもりになれます。これは、オブジェクト指向の混乱の原因のヒトツです。
クラスの継承とinterface
の違いは、継承はスーパークラスから機能を受け継ぐということです。
そのため、継承はクラス同士の関係が「AはBである」と表現できる時にクラスを抽象的にまとめられるものということになります。つまり「馬は動物である」はOK。「虫はトンボである」はNGです。
そんなの当たり前だ!本にたくさん書いてあるよ!しかし、どうもコードの海に溺れていると他のクラスの機能を使いたいがために安易に継承してしまい、気が付いたらこの「AはBである」という原則を破ってしまうことがあります。
ひどい時なんかは、子クラスで重複したメソッドやプロパティをなんでもかんでも親クラスに定義した神クラスが出来上がり、オブジェクト指向でプログラミングしない方がマシといった状態になってしまうほどです。
また、オブジェクト指向は「現実世界をそのままプログラムに表現できる」とよく言われますが、実はこれもオブジェクト指向の混乱の原因となっています。
人間は神様ではないので、現実世界でモノを作るときに「車」や「自転車」は作れても「犬」や「猫」のような生きた動物は作れません。
人間が作るモノは基本的に「カラクリ」であり、「車」は、エンジン、ハンドル、ブレーキ、ホイール、などの部品で構成されていて、機能の受け継ぎなど行っていません。
そもそも抽象(スーパークラス)とは実態の無いただの概念です。犬や猫の髭を引っ張ることはできても、誰も「動物」という抽象的なものに触れることはできません。もしあなたが何らかの動物に触れているのならば、それは「動物」ではなく「犬」や「猫」といった、もっと具体的なものに必ずなります。
オブジェクト指向は「現実世界をそのままプログラムに表現できる」とよく言われますが、何らかのスーパークラスを修正した場合、この修正を現実にどう反映して解釈すればよいのでしょうか?
僕は解釈できないと思います。無理やり解釈するならば、こうでしょうか?
「スーパークラスを書き換える」ということは「魚」と「カエル」の間のような抽象的遺伝子情報を無理やり操作し「もしも遺伝子がこうなってたら〜!」と呪文を唱えて世界を再構築すること
これのどこが「現実世界をプログラムに表現」なのでしょうか!?人は遺伝子操作が可能になり、今や神に近づいた存在なのかもしれません。しかし、そういうことじゃないでしょう!そんな屁理屈じみた概念を持ってきたところで、わかりにくいだけです!
実は、オブジェクト指向の世界に生命を持ち込むと、オブジェクト指向を正しくイメージできなくなります。
プログラマはコードを打ち込むことで仮想世界を創造します。しかしコードによって創られた世界は「ものづくりの世界」です。現実世界で人が神様のように生命の創造ができないように、仮想世界もまた生命を創造することができません。(同じことを繰り返し喋るカラクリ生命は作れるけど!)このことは「オブジェクト指向に生命を持ち込むな!」で詳しく解説させて頂きました。
つまり、オブジェクト指向の世界を現実とイメージすると、現実世界と仮想世界とのギャップに混乱することになるのです。
筆者のオススメのオブジェクト指向イメージ法は、プログラミングによって作られる仮想世界を、生命の存在しないSFロボットワールドと考えることです
オブジェクト指向を解説する際、よくDogやCat、Animalを用いますが、実際にシステムを構築するときに作るオブジェクトは、エンジンやタイヤような部品であり、それらをまとめ上げた車などを作成するものです。
そして継承とは、エンジンやタイヤを交換可能にする場合に、エンジンやタイヤの規格に当たる抽象クラスを作成するために使うのです。
つまり、継承は親クラスから機能を受け継ぐためのものではなく、継承の本質は、交換可能なパーツを作成するために共通点を「規格」としてまとめ上げられるインターフェイスなのです。
こんなことを言うと混乱させてしまうかもしれませんが、オブジェクト指向ではクラスを拡張する目的で継承を利用することもできます。これは、既に存在する具象クラスの役割を後からインターフェースの役割に転換させるようなことを可能にしますが、考え方は違っても技術的には同じことをしているだけです
継承が機能受け継ぎでない証拠として、異なるクラスの機能を利用する方法に「オブジェクトコンポジション」があります。コンポジションを使えば人がパーツを組み合わせて「車」を作るような自然なものづくりができるし、実際に継承よりコンポジションの方がよく使います。
コンポジションの使い方は、下記のコードを見ただけですぐ理解できるでしょう。
Engine engine = new JetEngine();
Handle handle = new QuickHandle();
Brake brake = new AntilockBrake();
Wheel wheel = new StudlessWheel();
Car car = new Car(engine, handle, brake, wheel);
このように作った方が、自然なオブジェクト作りができるだけでなく、インスタンス生成時にエンジンを変えたり、ハンドルを変えたりすることが容易となり、柔軟性が生まれます。
組み合わせごとに大量のクラスを作る必要もありません。よく作る組み合わせのオブジェクトがある場合には、それらを生成するファクトリを作成すれば、何度も部品から作る手間も無くなります。
継承は親クラスの機能を受け継ぎますが、これは開発効率を上げるための優しさ的仕様であり、継承の本質はインターフェイスなのです。もしあなたが継承の本質を「機能の受け継ぎ」と勘違いした途端、オブジェクト指向はあなたに牙を剥くでしょう。
ポリモーフィズム
継承の本質はインターフェイスであると説明しましたが、ポリモーフィズムはそのインターフェイス(抽象・規格)に対してプログラムするということです。
もっと具体的に言うとAnimal animal = new Dog();
としたりAnimal animal = new Cat();
としたりして、犬だろうが猫だろうが動物だよねってことで、動物という抽象概念に対してプログラムするということです。
Animal animal = new Dog();
animal.bark(); // dog.bark();でないため抽象に対してプログラミングできている
このように抽象クラスに対してプログラミングすることで、抽象クラスに属するクラスのインスタンスは、何でも動かすことができるようになります。
Javaにおいてはクラスの継承の他に、interface
を使うことができますが、このinterface
は、犬と車を「鳴く奴ら」という概念でまとめて、犬も車も「鳴く物」として扱うことができるというものです。犬は「ワン」と鳴き、車は「ぷっぷー」と鳴きます。
このポリモーフィズムの考え方はプログラムに留まりません。例えば、電子レンジは食べ物を温めてくれる便利な道具ですが、電子レンジの本質は「マイクロ波を出す装置」です。この様に、そのものの本質を捉えていれば意外な使い方ができるものです。電子レンジに「食べ物を温めるもの」という制限はありません。(説明書には変なもの入れるなって書かれてるだろうけれど!)
身近なもので言えば、iPhoneはポリモーフィズムに溢れています。おそらくiPhoneはAppleが発売前には想像もしなかったアプリやアクセサリが登場したはずです。
コードも同じ。なるべく様々な使い方ができる様に、本質的な、抽象的なコードを書くべきなのです。そしてそれら抽象に対して作用するプログラムが出来上がることでポリモーフィズム(多態性)が生まれます。
特に意識していないのにtoString()メソッドが機能して思わぬメッセージが出力された経験はありませんか?あれは、まさに想定していなかった動作がポリモーフィズムによって問題なく機能した瞬間です
ポリモーフィズムの理解が深まったところで今度は視点を変えて考えてみましょう。
ポリモーフィズムを意識したコードを書くには「抽象」に気を配ることが大切だということは分かりました。しかし、抽象ばかりに気を取られてはなりません。抽象に対してプログラムするということは、逆に具象に対してプログラムしてはいけないということになります。
ここで衝撃的な事実をお伝えしましょう!実は「new」は具象です!ですから、ポリモーフィズムを意識する上でnewの扱いには最大限の注意を払う必要があります。
実は、new
はポリモーフィズムを破壊するとんでもない奴です。もしもnew
を使わずにプログラムできれば良いのですが、必ずどこかでnew
を使わなければならない。では、どこでnew
すればいいのでしょうか?ファクトリです!
ポリモーフィズムの破壊を閉じ込めるためnew
をクラスに抽出するということです!つまり、以下のようにします。
Animal animal = animalFactory.create('dog');
animal.bark();
一見new Dog();
を遠回しに実装しただけじゃないか!と思うかもしれませんが、この遠回しが重要です。
ファクトリの内部ではnew Dog();
が行なわれているためanimal
変数にはDog
インスタンスが代入されます。しかし、ファクトリを通してインスタンスを取得すると実態はDog
であるもののAnimal
型のインスタンスが得られることとなります。そのためファクトリを使ってインスタンス生成したプログラマは否が応にも抽象度の高いAnimal
インスタンスを扱うことを強制されるのです。
そのため、何も考えずともファクトリを使ってインスタンス生成していれば、抽象に対して自然とプログラミングすることができ、ポリモーフィズムの破壊が守られます。
もしファクトリを利用せずanimalFactory.create('dog');
をnew Dog();
にした場合、困ったことにそのコードを書いたクラスはDog
クラスに依存してしまいDog
クラス無しでは動かなくなってしまいます。
しかし、ファクトリを通してDog
インスタンスをAnimal
として受け取れば、クラスの依存はDog
から抽象度の高いAnimal
へシフトし、具象への依存を避けられるのです。
何言ってんだ!新たにFactory
の依存が増えるじゃないか!と思われるかもしれません。しかし、重要なことは依存するクラスの数が増えることよりも具象に依存してしまわないようにすることなのです。
プログラムにはレイヤーが存在し、低レベルレイヤーのプログラムが、高レベルレイヤーのプログラムに依存するようなことはしてはなりません。もし、レイヤーの異なるプログラムが依存してしまった場合、抽象度の高いプログラムはモジュール性や疎結合性を大幅に失うこととなるのです。
自分の作ったプログラムがどのプログラムに依存しているか簡単に見分ける方法があります。
import
です!ソースコード上部にまとめて記述されることの多いこのimport
を見ればそのプログラムがどのプログラムに依存しているかがわかります。そしてもちろん、具象に対するimport
が使われていないほど、そのプログラムは疎結合性が高いということであり、コードの再利用性があること表します
えー!ファクトリを作るとか面倒すぎ!と、思うかもしれませんが、必ずしもファクトリを作る必要はありません。実は、ファクトリをゴリ押ししている僕はほとんどファクトリを作成したことがありません。もし、ファクトリが必要にならないプロジェクトにわざわざファクトリを作成してしまったら、それは行き過ぎた実装です。それは前述した「ファーストフードでガソリンを売ることを考慮」すること同じで、システムを複雑にし保守を面倒なものにするだけの存在となります。
そのため、ファクトリを作るまでもないインスタンス生成はメインクラスで行うようにしましょう。メインクラスでnew
したインスタンスを他のクラスに渡すのです!メインクラスはファクトリと同様new
の利用が許された場所です。
メインクラスは調理場のような存在であり、メインクラスに疎結合性やモジュール性は必要ありません。そのため、メインクラスがnew
の接着剤でベトベトに汚れてしまっても困ることはないのです。
もちろんメインクラスとファクトリ以外でも
new
の利用が許される箇所はあります。Scene
クラスやPage
クラスやRouter
クラスを継承したサブクラス内部などがそれに当たります。しかし、それらサブクラスに対してもコンポジションを利用してメインクラスからインスタンスを渡した方がインスタンスを再利用できるというメリットがあるため、結局のところメインクラスかファクトリ以外でnew
することは無いかもしれません。また、String
のような言語レベルで実装されたクラスのnew
は疎結合性をまったく破壊しない(Stringへの依存解消を努力する行為はプログラミング言語への依存を)ので気にしなくて良いです
また、このようなnew
したインスタンスをコンストラクタに渡して利用する手法は、インスタンスの無駄な生成が省けるだけでなく、コンストラクタのパラメータを確認すれば、そのクラスが何を必要として動くのか一目瞭然になります。しかもこの実装方法どこかで見たことありますよね?そう、コンポジションです!そのためパーツから自動車を作るような自然なオブジェクト作りができることに繋がるだけでなく、柔軟性をも持たせることができるのです。
ここで言うコンポジションは、オブジェクト注入(Dependency Injection)と言うべきかもしれません。コンポジションは、あるクラスに他のクラスのインスタンスを持たせることで、そのクラスに存在しない機能を持たせることができるテクニックであり、オブジェクトを外から動的に注入して使うことが多いため実質DIと同じと言えます。しかし、コンポジションは厳密にオブジェクト注入の意味を含みません。今ではDIと呼ばれるよりわかりやすい用語が存在するため、オブジェクト注入(Dependency Injection)と呼んだほうが良さそうです。
カプセル化
実のところ、最も重要かつ難しいのがこの「カプセル化」です。カプセル化は、継承やポリモーフィズムとは比較にならないほど重要です。
そんな大げさな!と思うかもしれませんが、事実「カプセル化」はオブジェクト指向どころかプログラミングを越えた重要な原則となります。(僕はプログラミング以外の分野でカプセル化という用語をよく使います)
カプセル化のことをゲッターとセッターだと思ってる人がいますが、これは大きな間違いです。カプセル化とは抽象化のことであり、外から見てそのものが複雑でない状態を作るということです。そしてその状態を作るのはとても難しいです。
僕は自動車に詳しくないので、自動車のカプセルを開けたら(つまり車を分解したら)バラバラになった自動車を元に戻せなくなるでしょう。しかし、僕はそんな自分では管理しきれない複雑な鉄の塊を運転することができます。なぜなら、ハンドルを操作しアクセルを踏めば前に進むということを知っているからです。(免許持ってないけどね!)
しかし、小刻みにブレーキを踏まなければスリップし、小まめにギアチェンジする必要があり、カーブするときは倒れないように体重移動をしなければ倒れてしまう自動車があったら、僕はそれを運転できません。複雑だからです。だけど、ブレーキにはABSを搭載し、ギアはオートマチックで、カーブするときは倒れないよう重心が設計されていれば、あれこれ余計なことを考えずに運転できます。
つまりカプセル化とは無駄を省き洗練させてわかりやすいものを作るということです。
もしもエレベーターにアクセルとブレーキが搭載されていたら、出勤時はエレベーターをギロチンマシーンに変えぬよう、最善の注意を払って運転しなければなりません。もちろん、プロのエレベータードライバーが24時間つきっきりで操作してくれるのであれば、もしかしたら現在よりも無駄な動きの無い素早い移動を提供してくれた可能性はあります。しかし、エレベータードライバーという職業は残念ながら存在しません。どうやら初期のエレベーター設計者は、エレベーターを手動でコントロールさせることは危険だと判断し、アクセルとブレーキの概念をカプセルの内側に閉じ込めておいてくれたようです。そのおかげで、私たちもエレベーターをボタンで操作することができるようになりました。
このカプセル化を行う上で意識しておくと良いことがあります。それはクラスの役割は一つにするということです。
クラスの役割が一つ以上になってしまうと洗練されたカプセル化からかけ離れてしまうことになります。これはビールの栓を抜きたいけど栓抜きを持っていなかったため借りに行ったが、良く考えたら十得ナイフを持っていた。というような話に関連づけるとわかりやすいかもしれません。
十得ナイフは便利ですが、コンピュータの世界において十得ナイフは必要ありません。もし現実世界においても四次元ポケットが存在したら十得ナイフはいらなくなるでしょう。栓抜きを必要とした時、四次元ポケットから何を取り出すでしょうか?わざわざ十得ナイフを取り出して十得ナイフの栓抜きを使うようなことをするでしょうか?答えはNOです。四次元ポケットからは栓抜きを取り出して使います。
このようにコンピュータの世界は四次元ポケットが存在する世界なので「なんでもできる便利な道具」より「何ができるか明確な道具」の方が利便性が高まることになります。プログラミングの世界では欲しい時に欲しいものを手に入れることができるため、十得ナイフのような複数の異なる役割を持ったクラスやモジュールは必要ないのです。
また、カプセル化とは直接的な繋がりはないものの、カプセル化に強く関連する重要な仕上げが存在します。それは、正しい名前付けです。
自動車には「自動車」という名前が、栓抜きには「栓抜き」という名前が付けられています。もしあなたが何かをカプセル化した場合、そのものにまだ名前が付いていないならば、それに正しい名前をつけるということが、カプセル化の最後の仕上げとなります。
ちなみに、適切な名前付けの重要性については「正しい名前を付けることが大切な理由」にも記載させて頂いております。
実はこの「無駄を省き洗練させてわかりやすくする」というカプセル化はオブジェクト指向の原則というより、デザインの原則でありデザインそのものなのです。
見渡してみると、身の回りにはカプセル化された人工物で溢れています。冷蔵庫や洗濯機、歯ブラシや歯磨き粉、デスクやチェア、ディスプレイやスピーカーにコンピュータ。
カプセル化と正しい名前付けが何故そんなにも重要なのか?それは「カプセル化」がオブジェクトを生み出す行為であり「名前付け」が生み出されたものを認識してもらうためのものだからです。
デザインの本質は「人間のプログラミング」であり、人を誘導するための設計に必要なことは繰り返し思考し続けて反映することでしか得られません。そのため、オブジェクト指向でプログラミングするには、デザインの深い理解とセンスが必要となるのです。
まとめ
オブジェクト指向三原則には重要度に偏りがあり、適切な重要性認識をしなければ大きな混乱を招くことがわかりました。
オブジェクト指向に不可欠なのはポリモーフィズムであり、ポリモーフィズムは抽象に対してプログラミングすることで生まれます。そして、継承の本質は共通点を抽象概念にまとめ上げられるインターフェイスなのです。また、機能引き継ぎには継承よりコンポジションが使えます。
「継承」や「ポリモーフィズム」とはレイヤーの異なるデザインの重要原則として「カプセル化」が存在し、ものづくりに不可欠なのは「カプセル化」と「正しい名前を付け」です。
オブジェクト指向にデザインが深く関わる事実を知って驚いている方もいるかもしれません。しかし、オブジェクト指向でプログラミングすればするほど、どのようなオブジェクトを作成して、どのようにやりとりするのか?といったことで悩むようになります。そしてそれらの悩みを解決するための解決手法をパターン化したものが「デザインパターン」と呼ばれ、書店に置いてあるのです。
実は、このデザインパターンを学習して初めてオブジェクト指向プログラミングができるようになります。
なぜオブジェクト指向は難しいのか?それはオブジェクト指向が本質的にものづくりであり、ものづくりにはデザインのセンスを必要とするからなのです。より詳しい話は、この記事の続編にあたる「なぜオブジェクト指向は難しいのか」をご覧ください。
プログラミングとは、言い方を変えればシステムデザインです。
プログラマはデザイナーであるべきであり、デザインとは目的を達成するために「わかりやすくする」ということ。すなわち、僕たちは「わかりやすくする」ために仕事をし、日々努力しなければならないのです。