基礎●●○○○

オブジェクト指向

Object-Oriented Programming ・ おぶじぇくとしこう

データとそれを操作する手続きをオブジェクトにまとめて設計する考え方。カプセル化・継承・ポリモーフィズムが柱。

✦ 30秒で試す — 航海同じ「話せ」という命令で、犬は吠え、猫は鳴くペンギンを渡された既存のコードはどうなる?予想してみる

概要

オブジェクト指向プログラミング(OOP: Object-Oriented Programming)は、データと、そのデータを操作する手続きをひとまとまりの「オブジェクト」として束ね、オブジェクト同士のやり取りとしてプログラムを組み立てる設計の考え方です。たとえば「銀行口座」というオブジェクトは、残高というデータと、入金・出金という操作をセットで持ちます。残高を書き換えたければ必ず入金・出金という窓口を通る — この「窓口を通さないとデータに触れない」という境界づくりが、オブジェクト指向の出発点です。

Java・C#・Ruby・Python・Swift など、現在広く使われている言語の多くはオブジェクト指向を主要な様式として採用しており、クラス・インスタンス・メソッドといった語彙はプログラミングの共通語になっています。フレームワークの設計、デザインパターン、DDD(ドメイン駆動設計)といった上位の設計論も、オブジェクト指向の語彙の上に築かれています。

柱として挙げられるのが「カプセル化」「継承」「ポリモーフィズム」の3つです。ただし本質は個々の機能ではなく、変更の影響が及ぶ範囲を境界で区切り、大きなプログラムを「独立して理解できる小さな部品の集まり」に分解することにあります。

なぜ生まれたか

オブジェクト指向以前の主流だった手続き型プログラミングでは、プログラムは「手続き(関数)の列」と「それらが読み書きするデータ」に分かれていました。小さいうちは問題ありませんが、プログラムが大きくなると、あるデータ(たとえばグローバルな設定や共有のデータ構造)を何十もの手続きがあちこちから読み書きするようになります。すると「この変数はどこから書き換えられ得るのか」が誰にも追えなくなり、1か所の修正が思わぬ場所を壊す — データと手続きが散らばっていることが、規模の拡大とともに致命傷になっていきました。

オブジェクト指向はこの問題に「データと、それを触ってよい手続きを同じ場所に置き、外からはその手続き経由でしか触れなくする」という答えを出しました。データを書き換え得るコードがオブジェクトの内側に閉じ込められるため、追跡すべき範囲が一気に狭まります。1967年の Simula 67 がシミュレーションの登場人物を表すためにクラスとオブジェクトの原型を生み、1970年代の Smalltalk が「すべてはオブジェクトであり、メッセージを送り合う」という思想として完成させました。その後 1980年代の C++、1990年代の Java を通じて、オブジェクト指向は産業の主流になりました。

詳細

カプセル化 — 境界を作り、内部を隠す

カプセル化は、データ(フィールド)と操作(メソッド)を1つのオブジェクトにまとめ、内部の表現を外から直接触れないよう隠すことです。銀行口座の例なら、残高フィールドを外部から直接代入できてしまうと「残高がマイナスにならない」という不変条件を全利用箇所で守らせる必要がありますが、出金メソッドの中でだけ検査すれば、守るべき場所は1か所で済みます。関心を1か所に凝集させ、モジュール間の結び付きを弱く保つ — 結合度と凝集度の理想を、言語機能として実現する仕組みと言えます。

手続き型: データと手続きが散在オブジェクト指向: 境界の内側に凝集共有データ誰でも直接書き換え可手続きA手続きB手続きC手続きD書き換え箇所が追えない口座オブジェクト残高 — 外から不可視入金する出金する明細オブジェクト履歴 — 外から不可視記録する一覧を返す利用側のコードメソッドという窓口を通してのみ触れる
手続き型で散在するデータと手続き(左)と、オブジェクトに凝集した構造(右)の比較

継承とポリモーフィズム — 同じ呼び方で違う振る舞い

継承は、既存のクラス(親)の性質を引き継いだ新しいクラス(子)を定義する仕組みです。「口座」を継承した「普通口座」「定期口座」は、共通の性質を親から受け継ぎつつ、出金ルールなど自分固有の部分だけを上書き(オーバーライド)できます。

そして継承以上に重要なのがポリモーフィズム(多態性)です。呼び出す側は「口座」という抽象だけを知っていて「出金する」と依頼し、実際にどのルールで処理されるかは、実行時にそのオブジェクトの実体(普通口座か定期口座か)が決めます。呼び出し側のコードを一切変えずに新しい種類を追加できる — これが、条件分岐の羅列でプログラムを書き分けていた手続き型との決定的な違いです。GUI ツールキットの「描画する」、例外処理の階層、ORM のモデルクラスなど、フレームワークの拡張点はほぼすべてポリモーフィズムでできています。

利用側のコード「描画して」と依頼図形 — 抽象描画する — 中身は未定円円として描く四角形四角形として描く三角形三角形として描くどれに振り分けるかは実行時にオブジェクトの実体が決める
ポリモーフィズム — 呼び出し側は抽象だけを知り、実体が振る舞いを決める

実行時の振り分け — 動的ディスパッチと仮想関数表

「実行時に実体が決める」は、どのような仕組みで実現されているのでしょうか。呼び出し側のコードは s.draw() と書くだけで、s が円なのか四角形なのかを if で調べてはいません。C++ や Java のような静的型の言語では、ふつうクラスごとに1つ、仮想関数表(vtable)と呼ばれる「メソッドの番地の一覧表」が用意され、各オブジェクトはその表を指す隠れたポインタ(vptr)を先頭に持っています。親クラスと子クラスの表は同じ並び(描画は必ず [1] 番目、など)に揃えられ、子が上書きした欄だけ子の実装の番地に差し替わっています。

呼び出し側s.draw()s の実体はどちらか一方円のオブジェクトvptr半径 r = 2四角形のオブジェクトvptr辺 a = 3円の vtable[0] 面積[1] 描画四角形の vtable[0] 面積[1] 描画円を描く四角を描く表の並びは親子で共通。子が上書きした欄だけ、指す先の実装が違う
動的ディスパッチ — s.draw() は「オブジェクトの vptr をたどり、表の [1] 番目を呼ぶ」に翻訳される

つまり s.draw() は、コンパイル時に「s の vptr を読み、その表の [1] 番目に書かれた番地へ飛ぶ」という2回の読み出しと1回の間接呼び出しに翻訳されます。新しい図形のクラスを足しても、呼び出し側のコードは1文字も変わりません。新しいクラスが自分の表を持ち込むだけだからです。一方、Python・Ruby・JavaScript のような動的な言語では、表の番号ではなくメソッドの「名前」でクラス(JavaScript ならプロトタイプ)の連鎖を実行時にさかのぼって探します。毎回探すと遅いので、処理系は「前回この場所で見つかった実装」を覚えておくインラインキャッシュで高速化しています。どちらの方式でも、振り分けの判断が呼び出し側から消え、オブジェクトの側に移っている点が本質です。

継承の濫用と「合成を優先せよ」

継承は強力ですが、コード再利用の手段として濫用すると親子が密結合になります。子は親の内部実装に依存するため、親の変更が全子孫に波及し、階層が深くなるほど「どの振る舞いがどの階層で決まるのか」を追うのが困難になります。継承は、根から自分までの経路にあるすべてのクラスの性質を丸ごと受け継ぐ仕組みなので、「一部だけ欲しい」ができません。「ペンギンは鳥だが飛べない」に代表される is-a 関係のほころびは、その古典的な例です。飛ぶを「例外を投げる」で上書きすれば、鳥として渡された先のコードが壊れます。これが SOLID 原則のうちリスコフの置換原則(子は親の代わりにそのまま使えなければならない)の違反です。

この経験から、デザインパターンの GoF 本(1994年)は「クラス継承よりオブジェクト合成を優先せよ」という原則を掲げました。飛ぶ・泳ぐといった振る舞いを部品として持たせ(has-a)、組み合わせで多様性を表現する方が、変更に強い構造になります。下の部品で、同じ動物を継承の木に置く場合と、部品を組み合わせる場合を比べてみてください。木のどこに置いても、要らない能力を受け継ぐか足りない能力を書き足すことになり、「両方の子」に置くと多重継承の菱形問題も顔を出します。

⚡ 体験: 継承か、合成か — 必要な能力だけを持たせる
作る動物
継承(is-a の木)合成(has-a の部品)class 動物鳴くclass 鳥飛ぶclass 魚泳ぐエラ呼吸鳥の子両方の子魚の子ペンギンペンギン要る能力: 泳ぐ・鳴く能力の部品(つかんでカードへ)飛ぶ泳ぐ鳴くエラ呼吸
いまの形をコードにすると
  1. class ペンギン extends ? { } // 木のどこに置く?
  2. const penguin = new 動物("ペンギン", []) // 合成

左上のペンギンのカードをつかんで、継承の木のどこか(鳥の子・両方の子・魚の子)に置いてみてください。ペンギンに要るのは 泳ぐ・鳴く です。

現代の言語設計(Go や Rust)がクラス継承そのものを持たず、インターフェイス(トレイト)と合成だけを提供しているのは、この教訓の反映と言えます。Java や C# がクラスの多重継承を禁じ、インターフェイスの多重実装だけを許しているのも、菱形の継承で「どの親の実装を使うのか」があいまいになる問題を避けるためです。

メッセージングとしてのオブジェクト指向

Smalltalk を設計したアラン・ケイは、1998年に Squeak のメーリングリストへ投稿した文章で「オブジェクトという言葉を作ったことを申し訳なく思う。多くの人が小さいほうのアイデアに目を向けてしまった。大きなアイデアはメッセージングだ」と書いています。オブジェクトを「内部状態を隠し持ち、メッセージを受け取って自律的に振る舞う小さなコンピュータ」と見なし、システム全体をその協調として設計する。この見方に立つと、オブジェクト指向は言語機能の話ではなく「責務をどう分割し、誰に何を依頼するか」という設計思想になります。

実際、独立したプロセスとして動くサービスが、公開した API やメッセージキューだけを通じてやり取りするマイクロサービスの構図は、このメッセージング観をネットワークの規模で再演したものと見ることができます。中身を隠し、窓口だけを公開するという点で、カプセル化と同じ発想です。

実務では、オブジェクト指向は関数型プログラミングと対立するものではなく、併用されるのが普通です。ドメインの概念と不変条件はオブジェクトに閉じ込め、データ変換は純粋な関数で書く。境界の設計に興味が向いたら、DDD やリファクタリングの語彙へ進むと、オブジェクト指向を「動くコードの書き方」から「変更に強い設計の道具」として捉え直せます。

試問

左は継承のクラスの木(動物の下に鳥と魚、その下に3つの置き場)で、アヒルのカードを置き場に置くと、根からそこまでの経路にあるクラスの能力を受け継ぎます。右は合成で、能力の部品(飛ぶ・泳ぐ・鳴く・エラ呼吸)をアヒルのカードに付けられます。作りたいのは、飛ぶ・泳ぐ・鳴くの3つの能力を持つアヒルです。

✦ 命題挑戦中

アヒル(飛ぶ・泳ぐ・鳴く)に、要らない能力を1つも持たせず、同じコードを書き直すこともなく、3つの能力をそろえよ

  • アヒルが 飛ぶ・泳ぐ・鳴く をすべて持つ
  • 要らない能力(エラ呼吸など)を持たない
  • 足りない能力を、別のクラスと同じコードで書き足さない
⚡ 体験: 継承か、合成か — 必要な能力だけを持たせる
継承(is-a の木)合成(has-a の部品)class 動物鳴くclass 鳥飛ぶclass 魚泳ぐエラ呼吸鳥の子両方の子魚の子アヒルアヒル要る能力: 飛ぶ・泳ぐ・鳴く能力の部品(つかんでカードへ)飛ぶ泳ぐ鳴くエラ呼吸
いまの形をコードにすると
  1. class アヒル extends ? { } // 木のどこに置く?
  2. const duck = new 動物("アヒル", []) // 合成

左上のアヒルのカードをつかんで、継承の木のどこか(鳥の子・両方の子・魚の子)に置いてみてください。アヒルに要るのは 飛ぶ・泳ぐ・鳴く です。