この記事の内容
オブジェクト指向は難しい!わかった気になって実践すると詰みます... ウギャー
この記事は10年以上オブジェクト指向と戦った筆者が、通常とは異なるアプローチでオブジェクト指向を解説したものです。
筆者はJavaを使って本格的なシステム開発をしたことがありませんが、オブジェクト指向言語として最もポピュラーなJavaをベースにオブジェクト指向について解説させていただきました。
また、この記事の続編にあたります「なぜオブジェクト指向は難しいのか」を更に2年の時を経て執筆させて頂きました!是非こちらも一読していただけると嬉しいです。
オブジェクト指向三大要素の謎
オブジェクト指向三大要素ってありますよね。オブジェクト指向は「カプセル化」「継承」「ポリモーフィズム」の3つの要素で成り立つと言われています。最近では、この三大要素が語られる傾向は薄いようですが、一度は耳にしたことがあるのではないでしょうか?
この「オブジェクト指向三大要素」ですが、実はオブジェクト指向を理解する大きな妨げになってしまっているのです。
オブジェクト指向に不可欠なのは「ポリモーフィズム」であり、オブジェクト指向を超えて重要な原則は「カプセル化」と「正しい名前付け」です。
これから三大要素の「継承」「ポリモーフィズム」「カプセル化」を解明していきます。それから、なぜ「カプセル化」と「正しい名前付け」がオブジェクト指向を超えて重要なのか解説させていただきます。
それでは本題に入る前に、なぜオブジェクト指向で書くのかという根本的な所から立ち返ってみましょう。
なぜオブジェクト指向で書くのか
なぜオブジェクト指向で書くのか考えたことはありますか?意外にもこのことを意識せず、なんとなくオブジェクト指向でプログラミングしてる方は多いと思います。
オブジェクト指向で書く理由、それは変更に対して柔軟に対応するためです。
プログラムは日々変化する必要があります。更新しなければ、そのプログラムは水を与えられない植物のようにジワジワ枯れていきます。植物を育てるには定期的にメンテナンスする必要があるように、プログラムもまたメンテナンスが必要です。GitHubなどでフレームワークやライブラリが日々更新されているのは、プログラムが枯れないようにするためです。
オブジェクト指向によるアプリケーション開発は、変更されない箇所を軸に、頻繁に変更されるであろう箇所をクラスに抽出するプログラミングスタイルです。
例えば、店舗がどんどん増えていくファーストフードのシステムを開発することになったとしましょう。店舗が増えていくということは、そこは「頻繁に変更される箇所」なのでクラスに抽出して設計する必要があります。そうすると近い将来起こる「店舗が増える変更」に対して柔軟に対応できるようになります。
一見、システムの柔軟性というものは素晴らしいものに思えます。しかし柔軟性を取り入れすぎると、逆にシステムのメンテナンスを面倒なものに変えてしまうことがあります。そのため、柔軟性と保守性のバランスが大切です。
今回例に取り上げたのは、ファーストフードのシステムなので「食品以外のものを販売する」というような変更が発生することは、あまり考えられません。「もしかしたらガソリンやタイヤを販売するかもしれないじゃないか!可能性はゼロではないのだから柔軟に対応できるように設計するべきだ!」と思うかもしれません。しかし、将来的に変更のない箇所を無駄に柔軟に設計してしまうことは過剰実装であり、システムの設計を複雑なものに変えてしまいかねません。
システムには、変更の可能性が「高い箇所」と「低い箇所」とが存在し、オブジェクト指向は、変更の可能性が低い箇所を土台に、高い箇所に気を配って設計する必要があります。そのため、オブジェクト指向でシステムの設計をするときは、予め変更が起こりうるであろうポイントを広い視点で予測整理しておくことが大切です。
なぜオブジェクト指向で書くのか?それは、予め頻繁に変更されるであろう箇所をクラスに抽出することで、システムが変更に対して柔軟に対応できるようにするためなのです。
また、オブジェクト指向の最大の価値は「わかりやすさと利便性」にあります。このことにつきましてはこの記事の続編にあたる「なぜオブジェクト指向は難しいのか」をご覧下さい。
さて、オブジェクト指向でなぜ書くのか大まかな理由がわかったところで、次は「継承」「ポリモーフィズム」「カプセル化」の三大要素を解明していきます!
継承
みなさん大好き継承。継承は親クラスの機能を受け継ぐ機能です。しかしこれは継承の本質ではありません。
継承の本質はインターフェイスです。Javaではinterface
を使ってインターフェイスを定義できますが、継承もまたインターフェイスと同じ役割を果たします。
オブジェクト指向は難しいですが、継承は簡単に理解できるため、オブジェクト指向をわかったつもりになれます。これは、オブジェクト指向の混乱の原因のヒトツです。
クラスの継承とinterface
の違いは、継承はスーパークラスから機能を受け継ぐということです。
そのため、継承はクラス同士の関係が「AはBである」と表現できる時にクラスを抽象的にまとめられるものということになります。つまり「馬は動物である」はOK。「虫はトンボである」はNGです。
そんなの当たり前だ!本にたくさん書いてあるよ!しかし、どうもコードの海に溺れていると他のクラスの機能を使いたいがために安易に継承してしまい、気が付いたらこの「AはBである」という原則を破ってしまうことがあります。
ひどい時なんかは、子クラスで重複したメソッドやプロパティをなんでもかんでも親クラスに定義した神クラスが出来上がり、オブジェクト指向でプログラミングしない方がよかったのでは?といった状態になることもあります。
抽象(スーパークラス)とは実態の無いただの概念です。犬や猫の髭を引っ張ることはできても、誰も「動物」という抽象的なものに触れることはできません。もしあなたが何らかの動物に触れているのならば、それは「動物」ではなく「犬」や「猫」といった、もっと具体的なものになります。
オブジェクト指向は時折「現実世界をそのままプログラムに表現できる」と言われますが、いったいこれはどう解釈すれば良いでしょうか?もしも何らかのスーパークラスを修正した場合、この修正を現実にどう反映して解釈できるのでしょう?
じつのところ、解釈できないと思います。無理やり解釈するならば
「スーパークラスを書き換える」ということは「魚」と「カエル」の間のような抽象的遺伝子情報を操作し「もしも遺伝子がこうなってたら〜!」と呪文を唱えて世界を再構築することを意味する
でしょうか。
いったいこれのどこが「現実世界をプログラムに表現」なのでしょうか!?確かに、人は遺伝子操作が可能なったので、このように置き換えて解釈することはできるかもしれません。しかし、こんな解釈では混乱を深めるばかりです。わかりやすくするためにわざわざプログラムを現実世界に当てはめたはずなのに...
実は「生命」を取り除くことで、オブジェクト指向の世界をイメージしやすくする思考法があります。「ものづくり」の世界に創造できないものを持ち込むと混乱するのです。
人は神様ではないので、現実世界でモノを作るときに「車」や「自転車」は作れても「犬」や「猫」のような生きた動物は作れません。人が作るモノは基本的に「カラクリ」であり、「車」は、エンジン、ハンドル、ブレーキ、ホイール、などの部品で構成されていて、機能の受け継ぎなど行っていません。
詳しくは「オブジェクト指向にdogやanimalを持ち込むと混乱する話」をご覧ください。
継承の説明がなされるとき、必ずと言っていいほど「犬」や「猫」を使った動物の例え話がなされ、実際に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
は疎結合性をまったく破壊しないので気にしなくて良いです
また、このようなnew
したインスタンスをコンストラクタに渡して利用する手法は、インスタンスの無駄な生成が省けるだけでなく、コンストラクタのパラメータを確認すれば、そのクラスが何を必要として動くのか一目瞭然となります。しかもこの実装方法どこかで見たことありますよね?そう、コンポジションです!
そのためパーツから自動車を作るような自然なオブジェクト作りができることに繋がるだけでなく、柔軟性をも持たせることができるのです。
ここで言うコンポジションは、オブジェクト注入(Dependency Injection)と言うべきかもしれません。コンポジションは、あるクラスに他のクラスのインスタンスを持たせることで、そのクラスに存在しない機能を持たせることができるテクニックであり、オブジェクトを外から動的に注入して使うことが多いため実質DIと同じと言えます。しかし、コンポジションは厳密にオブジェクト注入の意味を含みません。今ではDIと呼ばれるよりわかりやすい用語が存在するため、オブジェクト注入(Dependency Injection)と呼んだほうが良さそうです。
カプセル化
実のところ、最も重要かつ難しいのがこの「カプセル化」です。カプセル化は、継承やポリモーフィズムとは比較にならないほど重要なものです。
そんな大げさな!と思うかもしれませんが、事実「カプセル化」はオブジェクト指向どころかプログラミングを越えた重要な原則となります。(僕はプログラミング以外の分野でカプセル化という用語をよく使います)
カプセル化のことをゲッターとセッターだと思ってる人がいますが、これは大きな間違いです。カプセル化とは抽象化のことであり、外から見てそのものが複雑でない状態を作るということです。そしてその状態を作るのはとても難しいのです。
僕は自動車に詳しくないので、自動車のカプセルを開けたら(つまり車を分解したら)バラバラになった自動車を元に戻せなくなるでしょう。しかし、僕はそんな自分では管理しきれない複雑な鉄の塊を運転することができます。なぜなら、ハンドルを操作しアクセルを踏めば前に進むということを知っているからです。
しかし、小刻みにブレーキを踏まなければスリップし、小まめにギアチェンジする必要があり、カーブするときは倒れないように体重移動をしなければ倒れてしまう自動車があったら、僕はそれを運転できません。複雑だからです。しかしブレーキにABSを搭載し、ギアはオートマチックで、カーブするときは倒れないよう重心が設計されていれば、あれこれ余計なことを考えずに運転できます。
つまりカプセル化とは無駄を省き洗練させてわかりやすいものを作るということです。
もしもエレベーターにアクセルとブレーキが搭載されていたら、出勤時はエレベーターをギロチンマシーンに変えぬよう、最善の注意を払って運転しなければならなくなります。もちろん、プロのエレベータードライバーが24時間つきっきりで操作してくれるのであれば、もしかしたら現在よりも無駄な動きの無い素早い移動を提供してくれた可能性はあります。
しかし、エレベータードライバーという職業は残念ながら存在しません。どうやら初期のエレベーター設計者は、エレベーターを手動でコントロールさせることは危険だと判断し、アクセルとブレーキの概念をカプセルの内側に閉じ込めておいてくれたようです。そのおかげで、私たちもエレベーターをボタンで操作することができるようになりました。
このカプセル化を行う上で意識しておくと良いことがあります。それはクラスの役割は一つにするということです。
クラスの役割が一つ以上になってしまうと洗練されたカプセル化からかけ離れてしまうことになります。これはビールの栓抜き借りたが、よく考えたら十得ナイフを持っていた。というような話に関連づけるとわかりやすいかもしれません。
十得ナイフは便利ですが、コンピュータの世界において十得ナイフは必要ありません。もし現実世界においても四次元ポケットが存在したら十得ナイフはいらなくなるでしょう。栓抜きを必要とした時、四次元ポケットから何を取り出すでしょうか?わざわざ十得ナイフを取り出して十得ナイフの栓抜きを使うようなことをするでしょうか?答えはNOです。四次元ポケットからは栓抜きを取り出して使います。
このようにコンピュータの世界は四次元ポケットが存在する世界なので「なんでもできる便利な道具」より「何ができるか明確な道具」の方が利便性が高まることになります。プログラミングの世界では欲しい時に欲しいものを手に入れることができるため、十得ナイフのような複数の異なる役割を持ったクラスやモジュールは必要ないのです。
また、カプセル化とは直接的な繋がりはないものの、カプセル化に強く関連する重要な仕上げが存在します。それは、正しい名前付けです。
自動車には「自動車」という名前が、栓抜きには「栓抜き」という名前が付けられています。もしあなたが何かをカプセル化した場合、そのものにまだ名前が付いていないならば、それに正しい名前をつけるということが、カプセル化の最後の仕上げとなります。
適切な名前付けの重要性については「正しい名前を付けることが大切な理由」にも記載させて頂いております。
実はこの「無駄を省き洗練させてわかりやすくする」というカプセル化はオブジェクト指向の原則というより、デザインの原則でありデザインそのものなのです。
見渡してみると、身の回りにはカプセル化された人工物で溢れています。冷蔵庫や洗濯機、歯ブラシや歯磨き粉、デスクやチェア、ディスプレイやスピーカーにコンピュータ。
カプセル化と正しい名前付けが何故そんなにも重要なのでしょうか?それは「カプセル化」が創造であり「名前付け」は、創造したモノを認識してもらうために個別化する行為だからです。
デザインの本質は「人間のプログラミング」であり、人を導くための設計に必要なことは繰り返し思考し続けて反映することでしか得られません。そのため、カプセル化とはとても時間がかかるデザイン行為なのです。
カプセル化が難しいと思った事ないけどなーという意見をいただいた事がありますが、僕は作ったものが最終的に結局必要なかったという体験を何度もしています。
食べ物を冷やしたい時、キッチンなら冷蔵庫を使う事が一般的なので「冷蔵庫を使う」という考えが即座に出てきます。しかしシステムの世界では選択肢が多く、即座に安心して使えるものが揃ってるわけではありません。
もし世界に料理という概念が存在しなかったとして、そこにどんな道具を用意したら効率よく料理できるか見つけ出すというのは容易いことではありません。
カプセル化されたモジュールは、時間をかけてデザインする必要があり、勢いで作ったモノは大抵洗練されていません。洗練されていない道具を使って料理するとどこかで問題が起こり、新しい道具に切り替える必要性に迫られたり、道具の修理を要求されたりします。
自動販売機でジュースを買ったら出てこない。おかしいなと思って、自動販売機の中身を開けると、そこには複雑な仕組みが展開されます。問題を解決するには、その仕組みを調査して修理する必要がある。人が作ったモノが複雑すぎて自分には直せないということはよくあります。これが販売機であれば「くそう!損した!」で諦める事ができますが、システムの開発を簡単に諦めることはできません。
カプセル化はデザインそのものであり、洗練されたデザインはどうしても時間のかかるものです。中途半端にカプセル化を行うとそれは、パンドラの箱を作る行為となるのです。
iPhoneを使うのは便利で簡単ですが、iPhoneを作ったり修理することは誰もができるものではありません。ですから、カプセルを開かなくて済むよう時間をかけて磨き上げる事が大切なのです。
まとめ
オブジェクト指向三原則には偏りがあり、適切な重要度認識をしなければ大きな混乱を招くことがわかりました。
オブジェクト指向に不可欠なのはポリモーフィズムであり、ポリモーフィズムは抽象に対してプログラミングすることで生まれます。そして、継承の本質は機能引き継ぎでなく共通点を抽象的にまとめ上げられるインターフェイスでした。そして、機能引き継ぎには継承よりコンポジションが使えます。
「継承」や「ポリモーフィズム」とは異なる重要なデザインの原則として「カプセル化」が存在し、ものづくりに不可欠なのは「カプセル化」と「正しい名前を付け」です。
オブジェクト指向にデザインが深く関わる事実を知って驚いている方もいるかもしれません。しかし、オブジェクト指向でプログラミングすればするほど、どのようなクラスを作成して、どう組み合わせるのか?といったことで悩むようになります。
そして、それら悩みを解決する手法をパターン化したものが「デザインパターン」として、書店に置いてあります。実は、このデザインパターンを学習して初めてオブジェクト指向プログラミングが理解できるようになると僕は思っています。
なぜオブジェクト指向は難しいのか?それは、カプセル化がデザインそのものであり、深い思考とセンス、そして時間が必要となるからです。
より詳しい話は、この記事の続編にあたる「なぜオブジェクト指向は難しいのか」をご覧ください。
プログラミングとは、言い方を変えればシステムデザインです。
プログラマはデザイナーであり、デザインとは目的を達成するために「わかりやすくする」ということ。僕たちは「わかりやすくする」ために仕事をしているのです。