オブジェクト指向と10年戦ってわかったこと

  • 2362
    いいね
  • 13
    コメント

この記事の内容

オブジェクト指向って難しい!わかった気になって実践すると詰みます!
7.png
この記事は10年以上オブジェクト指向と戦ったプログラマが、オブジェクト指向について通常とは異なるアプローチで解説したものです。

オブジェクト指向三原則の謎

オブジェクト指向三原則ってありますよね。オブジェクト指向はどうやら「カプセル化」「継承」「ポリモーフィズム」の3つの原則を守ることで成り立つらしいのです。

しかし、僕はこの三原則が我々に混乱を与えていると思っています!

実は、オブジェクト指向の考え方で不可欠なのは「カプセル化」であり、カプセル化には「正しい名前をつける」という隠れた原則があるんです。

この隠れた原則を守らなければ、オブジェクト指向はおろか、プログラミング全般で苦しむことになるでしょう。

この原則は正しい名前をつけることが大切な理由に密接な内容となっておりますので、こちらの記事も読んでいただくとより深い理解ができると思います。

これから「継承」「ポリモーフィズム」「カプセル化」について解説しますが、よくある入門書の解説とは少し異なる方法でオブジェクト指向三原則の本質を解明していきます。

それでは本題に入る前に、なぜオブジェクト指向で書くのかという根本的な部分から理解を深めていきましょう!

なぜオブジェクト指向で書くのか

5.png
なぜオブジェクト指向で書くのか考えたことはありますか?意外にもこのことを意識せず、なんとなくオブジェクト指向でプログラミングしてる人は多いと思います。

オブジェクト指向で書く理由、それは変更に対して柔軟に対応するためです。

プログラムは常にアップデートする必要があります。アップデートしなければ、そのプログラムは時間の経過と共にどんどん腐敗していきます。植物を育てるには定期的に水を与えるなどのメンテナンスが必要なのと同じで、プログラムにも保守が必要です。GitHubなどでフレームワークやライブラリが開発者の手によって日々アップデートされているのはプログラムが廃れないようにするためなのです。

オブジェクト指向によるアプリケーション開発は、変更されない箇所を軸に、頻繁に変更される箇所をクラスに抽出するプログラミングスタイルです。

例えば、ファーストフードのシステムを作成したとき、これから店舗がどんどん増えていく予定があったとします。店舗が増えるということは「店舗が増える変更」に対して柔軟に対応できるプログラムを作成する必要があります。しかし「食品を販売する」というファーストフードの前提条件に変更が発生することは、あまり考えられません。「ガソリンやタイヤを販売するかもしれないじゃないか!」と思うかもしれませんが、将来的に変更がない箇所を無駄に柔軟にすることは、システムの保守を面倒なものにしてしまいます。

システムには、変更の可能性が「高い箇所」と「低い箇所」が存在し、オブジェクト指向は、変更の可能性低い箇所を土台に、高い箇所に気を配って設計する必要があります。

もしも、地域によって味の好みが別れるため、東京店と大阪店では同じ名称のハンバーガーでもケチャップの種類や肉と野菜のバランスを変える必要が生まれたらどうでしょうか?「店舗が増える変更」に着目していなければ、その変更に柔軟に対応することは難しいでしょう。しかし、これらの変更にしっかり着目して設計しておけば、変更に柔軟に対応することが出来たのです。

このようにオブジェクト指向は、予め変更の可能性が高い箇所を整理し、クラスに抽出することで変更に柔軟に対応できるようにしておくプログラミングスタイルなのです。

それでは、オブジェクト指向で書く理由がわかったところで、次にオブジェクト指向三原則である「継承」「ポリモーフィズム」「カプセル化」について解明していきましょう!

継承

6.png

みなさん大好き継承。継承は親クラスの機能を受け継ぐ機能です。しかしこれは継承の本質ではありません。

継承の本質はインターフェイスです。Javaではinterfaceを使ってインターフェイスを定義できますが、継承もまたインターフェイスと同じ役割を果たします。

オブジェクト指向は難しいですが、継承は簡単に理解できるため、オブジェクト指向をわかったつもりになれます。これは、オブジェクト指向の迷いの原因の1つです。

クラスの継承とinterfaceの違いは、継承はスーパークラスから機能を受け継ぐということです。

そのため、継承はクラス同士の関係が「AはBである」と表現できる時にクラスを抽象的にまとめられるものということになります。つまり「馬は動物である」はOK。「虫はトンボである」はNGです。

そんなの当たり前だ!本にたくさん書いてあるよ!しかし、どうもコードの海に溺れていると他のクラスの機能を使いたいがために安易に継承してしまい、気が付いたらこの「AはBである」という原則を破ってしまうことがあります。

ひどい時なんかは、子クラスで重複したメソッドやプロパティをなんでもかんでも親クラスに定義した神クラスが出来上がり、オブジェクト指向でプログラミングしない方がマシといった状態になってしまうほどです。

また、オブジェクト指向は「現実世界をそのままプログラムに表現できる」とよく言われますが、実はこれもオブジェクト指向の迷いの原因になっています。

人間は神様ではないので、現実世界でモノを作るときに「車」や「自転車」は作れても「犬」や「猫」は作れません。そして「車」は、エンジン、ハンドル、ブレーキ、ホイール、などの部品で構成されていて、継承など行っていません。

そもそも抽象(スーパークラス)とは現実世界では実態の無いただの概念です。犬や猫の髭を引っ張ることはできても、誰も「動物」という抽象的なものに触れることはできません。もしあなたが何らかの動物に触れているのならば、それは「動物」ではなく「犬」や「猫」といった、もっと具体的なものに必ずなります。

オブジェクト指向は「現実世界をそのままプログラムに表現できる」とよく言われますが、何らかのスーパークラスを修正した場合、この修正を現実世界に置き換えた時にどう解釈すればよいのでしょうか?

僕は解釈できないと思います。無理やり解釈するならば、こうでしょうか?

「スーパークラスを書き換える」ということは「魚」と「カエル」の間のような抽象的遺伝子情報を無理やり操作し「もしも遺伝子がこうなってたら〜!」と呪文を唱えて世界を再構築すること

これのどこが「現実世界をプログラムに表現」なのでしょうか!?人間は遺伝子操作が可能になり、今や神に近づいた存在なのかもしれません。しかし、そういうことじゃないでしょう!そんな屁理屈じみた概念をプログラムの世界に持ってきたところで、わかりにくいだけです!

継承は機能を受け継ぐためのものではありません。継承の本質は、犬や猫を「動物」という抽象概念としてまとめ上げられるインターフェイスなのです。

その証拠として、別のクラスの機能を利用する方法にコンポジションがあります。コンポジションを使えば人間がパーツを組み合わせて「車」を作るような自然なオブジェクトの作り方ができるし、実際に継承よりコンポジションの方がよく使います。

コンポジションの使い方は、下記のコードを見ただけですぐ理解できるでしょう。

Engine engine = new JetEngine();
Handle handle = new QuickHandle();
Brake brake = new AntilockBrake();
Wheel wheel = new StudlessWheel();

Car car = new Car(engine, handle, brake, wheel);

このように作った方が、自然なオブジェクト作りができるだけでなく、インスタンス生成時にエンジンを変えたり、ハンドルを変えたりすることが容易になり、プログラムに柔軟性も生まれます。組み合わせごとに大量のクラスを作る必要もありません。よく作る組み合わせのオブジェクトがある場合には、それらを生成するファクトリを作成すれば、毎度部品から作る手間も無くなります。

継承は親クラスの機能を受け継ぎますが、これは開発効率を上げるための優しさ的仕様であり、継承の本質はインターフェイスなのです。もしあなたが継承の本質を「機能の受け継ぎ」だと勘違いした途端、オブジェクト指向はあなたに牙を剥くでしょう。

ポリモーフィズム

8.png
継承の本質はインターフェイスであると説明しましたが、ポリモーフィズムはそのインターフェイス(抽象)に対してプログラムするということです。

もっと具体的に言うとAnimal animal = new Dog();としたりAnimal animal = new Cat();としたりして、犬だろうが猫だろうが動物だよねってことで抽象度の高い動物という概念に対してプログラムするということです。

Animal animal = new Dog();
animal.bark(); // dog.bark();でないため抽象に対してプログラミングできている

このように抽象クラスの型に対してプログラミングすることで、抽象クラスに属するクラスのインスタンスであれば、そのプログラムにインスタンスを渡して動かすことができるようになります。

Javaにおいてはクラスの継承の他に、interfaceを使うことができますが、このinterfaceは、犬と車に対して「鳴く奴」という抽象概念でまとめて犬も車も「鳴く物」として扱うことができるというものです。犬は「ワン」と鳴き、車は「ぷっぷー」と鳴きます。

このポリモーフィズムの考え方はプログラムに留まりません。例えば、電子レンジは食べ物を温めてくれる便利な道具ですが、電子レンジの本質は「マイクロ波を出す装置」です。この様に、そのものの本質を捉えていれば意外な使い方ができるものです。電子レンジに「食べ物を温めるもの」という制限はありません。(説明書には変なもの入れるなって書かれてるだろうけれど!)

身近なもので言えば、iPhoneはポリモーフィズムに溢れています。おそらくiPhoneはAppleが発売前には想像もしなかったアプリやアクセサリが登場したはずです。

コードも同じ。なるべく様々な使い方ができる様に、本質的な、抽象的なコードを書くべきなのです。そしてそれら抽象に対して作用するプログラムが出来上がることでポリモーフィズム(多態性)が生まれます。

特に意識していないのにtoString()メソッドが機能して思わぬメッセージが出力された体験はありませんか?あれは、まさに想定していなかった動作がポリモーフィズムによって問題なく機能した瞬間です:smiley:

なんとなくポリモーフィズムの理解が深まったところで今度は視点を変えて見ましょう。

ポリモーフィズムを意識したコードを書くには「抽象」に気を配ることが大切だということは分かりました。しかし、抽象ばかりに気を取られてはなりません。抽象に対してプログラムするということは、逆に解釈すれば具象に対してプログラムしてはいけないということになります。

ここで衝撃的な事実をお伝えしましょう!実は「new」は具象です!ですから、ポリモーフィズムを意識する上でnewの扱いには最大限の注意を払う必要があります。

newはポリモーフィズムを破壊するとんでもない奴です。だからnewを使わずにプログラムできれば良いのですが、そんなことは不可能です。必ずどこかでnewを使わなければならない。では、どこでnewすればいいのでしょうか?ファクトリです!

ポリモーフィズムの破壊を閉じ込めるためnewをクラスに抽出するということです!つまり、以下のようにします。

Animal animal = factory.create('dog');
animal.bark();

一見new Dog();とすれば良い箇所を遠回しに実装しただけじゃないか!と思うかもしれませんが、この遠回しが重要です。ファクトリの内部では結局のところnew Dog();が行なわれているため、animal変数にはDogインスタンスが代入されるわけですが、ファクトリを通してインスタンスを生成した場合、手に入れたDogインスタンスはAnimal型のDogインスタンスなので、プログラマは抽象度の高いAnimalインスタンスを扱うことを強制されます。

そのため、何も考えずともファクトリを使ってインスタンス生成することさえしていれば、自然と抽象に対してプログラミングすることとなり、ポリモーフィズムの破壊が守られるわけです。

また、上記のfactory.create('dog');new Dog();に置き換えてしまった場合、そのコードを書いたクラスはDogクラスに依存することになります。

プログラムにはレイヤーが存在し、低レベルなレイヤーのプログラムが、高レベルのレイヤーのプログラムに依存することはしてはならないのです。もし、レイヤーの異なるプログラムが依存してしまった場合、そのプログラムのモジュール性や疎結合性は大幅に失われます。

つまり、ファクトリを通してDogインスタンスをAnimalとして受け取ることで、クラスの依存は、Dogからより抽象度の高いAnimalへシフトするというわけです。もちろんFactoryの依存が増えることにはなりますが、依存するクラスの数が増えることはさほど問題ではなく、問題なのは具象に依存してしまうことなのです。

自分の作ったプログラムがどのプログラムに依存しているか簡単に見分ける方法があります。importです!ソースコード上部にまとめて記述されることの多いこのimportを見ればそのプログラムがどのプログラムに依存しているかがわかります。もちろんimportが使われていないほど、そのプログラムの疎結合性は高いということになり、他のプロジェクトでコードを再利用することができる可能性があることを表します:smiley:

newする必要が生まれるたびにファクトリを作るとか面倒すぎ!と、思うかもしれませんが、必ずしもファクトリを作る必要はありません。実は、ファクトリをゴリ押ししている僕はほとんどファクトリを作成したことがありません。もし、ファクトリが必要ないインスタンスの生成に、わざわざファクトリを作成してしまったら、それは行き過ぎた実装です。それは前述した「ファーストフードでガソリンを売ることを考慮」すること同じで、システムの保守を面倒なものにしてしまうだけの存在になります。

そのため、ファクトリを作るまでもないインスタンス生成はメインクラスで行うようにしましょう。メインクラスでnewしたインスタンスを他のクラスに渡すのです!メインクラスはファクトリと同様newの利用が許された場所です。

メインクラスは調理場のような存在であり、メインクラスに疎結合性やモジュール性は必要ありません。そのため、メインクラスがnewの接着剤でベトベトに汚れてしまっても困ることはないのです。

もちろんメインクラスとファクトリ以外でもnewの利用が許される箇所はあります。SceneクラスやPageクラスやRouterクラスを継承したサブクラス内部などがそれに当たります。しかし、それらサブクラスに対してもコンポジションを利用してメインクラスからインスタンスを渡した方がインスタンスを再利用できるというメリットがあるため、結局のところメインクラスかファクトリ以外でnewすることは無いかもしれません。また、Stringのような最も低レベルなクラスのnewは疎結合性をまったく破壊しないので気にしなくて良いです:smiley:

また、このようなnewしたインスタンスをコンストラクタで渡して利用する手法は、インスタンスの無駄な生成が省けるだけでなく、コンストラクタのパラメータを確認すれば、そのクラスが何を必要として動くのか一目瞭然になります。しかもこの実装方法、実はコンポジションです!そのため自然なオブジェクト作りができることにも繋がるだけでなく、柔軟性を持たせることができるのです。

カプセル化

10.png
正直なところ、オブジェクト指向において最も重要かつ難しいのがこの「カプセル化」です。

継承やポリモーフィズムとはレイヤーが異なり、あまりにも重要すぎてプログラミングの枠を飛び越えてしまうほど重要な原則です。(実際プログラミング以外の場所で僕はカプセル化という用語をよく使う)

カプセル化のことをゲッターとセッターだと思ってる人がいますが、これは大きな間違いです。

カプセル化は、外から見てそのものが複雑でない状態を作るというのとです。そしてその状態を作るのはとても難しいです。

僕は自動車に詳しくないので、自動車のカプセルを開けたら(つまり車を分解したら)バラバラになった自動車を元に戻せなくなるでしょう。しかし、僕はそんな自分では管理しきれない複雑な鉄の塊を運転することができます。なぜなら、ハンドルを操作しアクセルを踏めば前に進むということを知っているからです。(免許持ってないけどね!)

しかし、小刻みにブレーキを踏まなければスリップし、小まめにギアチェンジする必要があり、カーブするときは倒れないように体重移動をしなければ倒れてしまう自動車があったら、僕はそれを運転できません。複雑だからです。だけど、ブレーキにはABSを搭載し、ギアはオートマチックで、カーブするときは倒れないよう重心が設計されていれば、あれこれ余計なことを考えずに運転できます。

つまりカプセル化とは無駄を省き洗練させてわかりやすいものを作るということです。

もしもエレベーターにアクセルとブレーキが搭載されていたら、出勤時はエレベーターをギロチンマシーンに変えぬよう、最善の注意を払って運転しなければなりません。もちろん、プロのエレベータードライバーが24時間つきっきりで操作してくれるのであれば、もしかしたら現在よりも無駄な動きの無い素早い移動を提供してくれた可能性はあります。しかし、エレベータードライバーという職業は残念ながら存在しません。どうやら初期のエレベーター設計者は、エレベーターを手動でコントロールさせることは危険だと判断し、アクセルとブレーキの概念をカプセルの内側に閉じ込めておいてくれたようです。そのおかげで、私たちもエレベーターをボタンで操作することができるようになりました。

このカプセル化を行う上で意識しておくと良いことがあります。それはクラスの役割は一つにするということです。

クラスの役割が一つ以上になってしまうと洗練されたカプセル化からかけ離れてしまうことになります。これはビールの栓を抜きたいけど栓抜きを持っていなかったため借りに行ったが、良く考えたら十得ナイフを持っていた。というような話に関連づけるとわかりやすいかもしれません。

十得ナイフは便利ですが、コンピュータの世界において十得ナイフは必要ありません。もし現実世界においても四次元ポケットが存在したら十得ナイフはいらなくなるでしょう。栓抜きを必要とした時、四次元ポケットから何を取り出すでしょうか?わざわざ十得ナイフを取り出して十得ナイフの栓抜きを使うようなことをするでしょうか?答えはNOです。四次元ポケットからは栓抜きを取り出して使います。

このようにコンピュータの世界は四次元ポケットが存在する世界なので「なんでもできる便利な道具」より「何ができるか明確な道具」の方が利便性が高まることになります。プログラミングの世界では欲しい時に欲しいものを手に入れることができるため、十得ナイフのような複数の異なる役割を持ったクラスやモジュールは必要ないのです。

また、カプセル化とは直接的な繋がりはないものの、カプセル化には大切な最後の仕上げが存在します。それは、正しい名前付けです。

自動車には「自動車」という名前が、栓抜きには「栓抜き」という名前が付けられています。もしあなたが何かをカプセル化した場合、そのものにまだ名前が付いていないならば、それに正しい名前をつけるということが、カプセル化の最後の仕上げになります。

このことについての詳細は冒頭でも述べましたが、正しい名前をつけることが大切な理由に記載してあるので、こちらの記事も加えて読んでいただくとより深い理解ができると思います。

実はこの「無駄を省き洗練させてわかりやすくする」というのはオブジェクト指向の原則というより「デザイン」の原則なのです。

オブジェクト指向に不可欠なのはカプセル化です。つまりこれは、オブジェクト指向でプログラミングするにはデザインのセンスが必要になるということになります。

そんなもの、難しくて当然です!

まとめ

オブジェクト指向三原則には偏りがあり、誤った解釈がなされる傾向があることが理解できたと思います。

継承の本質はインターフェイスであり、機能を引き継ぐ時はコンポジションを利用することができること。そして、抽象に対してプログラミングするコトでポリモーフィズムが生まれます。「継承」や「ポリモーフィズム」の原則とは異なる重要原則として「カプセル化」が存在し、オブジェクト指向に不可欠なのは「カプセル化」であるということ。カプセル化はオブジェクト指向の原則を超えたデザインの原則であり「正しい名前を付ける」ことはカプセル化の仕上げとして大切なポイントであり、失敗してはならないということ。

オブジェクト指向がデザインであるという言葉を聞いて驚いている人もいるかもしれません。しかし、オブジェクト指向でプログラミングすればするほど、どのようなオブジェクトを作成して、どのようにやりとりするのか?といったことで悩むようになります。そしてそれらの悩みを解決するための解決手法をパターン化したものが「デザインパターン」と呼ばれ、書店に置いてあるのです。

実は、このデザインパターンを学習して初めてオブジェクト指向プログラミングができるようになります。

プログラミングとは、言い方を変えればシステムデザインです。

プログラマはデザイナーであるべきであり、デザインとは目的を達成するために「わかりやすくする」ということ。すなわち、僕たちは「わかりやすくする」ために仕事をし、日々頑張らなければならないのです。