オブジェクト指向
発想の転換
- 手続き型: データは関数間を渡り歩き、振る舞いから振る舞いへと移動する
- オブジェクト指向: データと振る舞いを持つオブジェクトが、メッセージを送り合って動作する
設計が解決する問題
- 変更を容易にする
- 設計に失敗すると変更に大きなコストが必要になる
依存関係と変更困難
- 正しくメッセージを届けるためにメッセージの送り手が受け取り手のことを知っている必要があり、この知識が依存関係を生じさせる
- 依存関係の管理がされず、オブジェクトが互いに知りすぎている状態になると変更が困難になる
- オブジェクトを一つ変更すると、一緒に動くオブジェクトも変更を加えることになり、その連鎖が際限なく続くことになる
- オブジェクト指向設計とは依存関係を管理すること
オブジェクト指向設計の道具
- SOLID
- 単一責任
- オープンクローズド
- リスコフの置換
- インターフェイス分離
- 依存性逆転
- DRY
- デメテルの法則
- 設計デザインパターン
- GoFが代表的
- オブジェクト指向設計で出てくる共通の問題に同じ名前をつけて解決するために作られた
手続き型とオブジェクト指向の簡単な比較
- 手続き型
- 新しいデータ型を作ることはない
- データは振る舞いから振る舞いへと引き渡される
- データがどこでどう変わるか追いにくく、影響は予測不可能
- オブジェクト指向
- オブジェクトがデータとそれを扱う振る舞いを持つ
- データへのアクセスをコントロールするのはそのオブジェクトのみ
単一の責任のクラスを作る
手続き型の状態
- ギアの比率によって一漕ぎでタイヤを何回転させるかを求める
chainring = 52
cog = 11
ratio = chainring / cog
puts ratio
chainring = 30
cog = 27
ratio = chainring / cog
puts ratio
class化
class Gear
attr_reader :chainring, :cog
def initialize(chainring, cog)
@chainring = chainring
@cog = cog
end
def ratio
@chainring / @cog.to_f
end
end
puts Gear.new(52, 11).ratio
puts Gear.new(30, 27).ratio
要求の増大
- 要求: 車輪のサイズに違いも考慮に入れて欲しい
class Gear
attr_reader :chainring, :cog, :rim, :tire
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
@rim = rim
@tire = tire
end
def ratio
@chainring / @cog.to_f
end
def gear_inches
ratio * (rim + (tire * 2))
end
end
puts Gear.new(52, 11, 26, 1.5).gear_inches
puts Gear.new(30, 27, 24, 1.25).gear_inches
puts Gear.new(52, 11).ratio # 以前は動いていたのに...
単一の責任を持つクラスを作る
- 単一の責任(もしくは凝縮度が高い)
- 定義
- クラスやメソッドは「1つの中心的な目的」だけを持ち、それに関連する処理だけを含むようにする
- メリット
- 変更が必要なときは、その責任を持つ1箇所だけ修正すればよい(DRYを実現)
- 変更容易性と可読性が向上
- 定義
- チェックポイント
- 余計な情報を返していないか?
- Gearさん、あなたの比を教えて => OK
- Gearさん、あなたのgear_inchesを教えて => NG
- Gearさん、あなたのタイヤのサイズを教えて => NG
- データを持ちすぎていないか?
- リムとタイヤを持っているのは本当にGearなのか?
- 1文でクラスの責任を説明してみる
- 現実:自転車へのギアの影響を計算する
- 理想:歯のある2つのスプロケット間の比を計算する
- 余計な情報を返していないか?
改善
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel=nil)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
@chainring / @cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def diameter
rim + (tire * 2)
end
def circumference
diameter * Math::PI
end
end
@wheel = Wheel.new(26, 1.5)
puts @wheel.circumference
puts Gear.new(52, 11, @wheel).gear_inches
puts Gear.new(52, 11).ratio
依存関係を理解する
問題のあるコード
class Gear
attr_reader :chainring, :cog, :rim, :tire
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
@rim = rim
@tire = tire
end
def gear_inches
ratio * Wheel.new(rim, tire).diameter
end
def ratio
chainring / cog.to_f
end
end
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def diameter
rim + (tire * 2)
end
def circumference
diameter * Math::PI
end
end
問題点
GearクラスがWheelクラスのことを知り過ぎている:
- GearはWheelクラスが存在することを知っている
- GearはWheelのインスタンスがdiameterに応答することを知っている
- GearはWheel.newにrimとtireが必要でそれらの引数の順番を知っている
- Wheelクラス以外が使えない
結果:
-
Wheel
の変更がGear
に波及 -
Gear
単体で再利用できない
疎結合なコードを書く
依存オブジェクトの注入
-
Gear
はdiameter
に応答できるオブジェクトだけ知ればよい - 具体的なクラス(
Wheel
)や初期化方法は知らないようにする - インスタンス変数でdiameterに応答できる何かを受け取るように変更する
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel=nil)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
@chainring / @cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def diameter
rim + (tire * 2)
end
def circumference
diameter * Math::PI
end
end
@wheel = Wheel.new(26, 1.5)
puts @wheel.circumference
puts Gear.new(52, 11, @wheel).gear_inches
puts Gear.new(52, 11).ratio
依存を隔離する
- 依存オブジェクトの注入により依存を取り去るのがベストではあるが、できない場合はクラス内で隔離する(Gearクラス内でWheel構造体を定義するとか)
- インスタンス変数の作成を分離する
def gear_inches
ratio * wheel.diameter
end
def wheel
@wheel ||= Wheel.new(rim, tire)
end
gear_inchesがより複雑になった時にはgear_inchesの複雑度を下げるために外部的な依存を取り除き簡単にする。Gearクラス内でWheelクラスを定義する。
wheelがdiameterを持つことを知らなくて済む
def gear_inches
foo = some * ratio * diameter
# 何か恐ろしい計算
end
def diameter
wheel.diameter
end
def wheel
@wheel ||= Wheel.new(rim, tire)
end
依存方向の管理
- 依存方向は逆転できる。「より安定したもの」に依存するようにする
- 例:
Wheel
がGear
に依存する構造に変えることで、Gear
がWheel
の詳細を知らなくて済む
class Gear
attr_reader :chainring, :cog
def initialize(chainring, cog)
@chainring = chainring
@cog = cog
end
def gear_inches(diameter)
ratio * diameter
end
def ratio
chainring / cog.to_f
end
end
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire, chainring, cog)
@rim = rim
@tire = tire
@gear = Gear.new(chainring, cog)
end
def diameter
rim + (tire * 2)
end
def gear_inches
@gear.gear_inches(diameter)
end
def circumference
diameter * Math::PI
end
end
ダックタイピングでコストを削減する
ダックタイピングを理解する
ダックタイピングとは、
「もしそれがアヒルのように歩き、アヒルのように鳴くなら、それはアヒルである」
という思想に基づく設計手法。クラスの型に依存せず、必要なインターフェイス(メソッド)を持っているかだけを見て動作させる。
ダックを見逃した例
この設計の問題は、prepareの種類が増えるたびにTripクラスを修正しなければならないこと。
新しい担当クラスが増えるほど、知るべきクラスやメソッドが増え、コストが膨らむ。
class Trip
attr_reader :bicycles, :customers, :vehicle
...
def prepare(prepares)
prepares.each do |prepare|
case prepare
when Mechanic
prepare.prepare_bicycles(bicycles)
when TripCoordinator
prepare.buy_food(customers)
when Driver
prepare.gas_up(vehicle)
prepare.fill_water_tank(vehicle)
end
end
end
end
ダックを見つけた例
- 拡張は「新しい担当クラスの追加」だけで完結し、既存コードは変更不要になる
class Trip
attr_reader :bicycles, :customers, :vehicle
...
def prepare(prepares)
prepares.each do |prepare|
prepare.prepare_trip(self)
end
end
end
ポリモーフィズムとダックタイピング
ポリモーフィズムとは、異なるクラスのオブジェクトが同じメッセージに応答できる能力
- Rubyでは、以下の手段でポリモーフィズムを実現できる
- ダックタイピング
- クラス継承
- モジュールによる振る舞いの共有
- 隠れたダックを認識する方法
- case文でクラス分岐
- kind_of?とis_a?
- respond_to? を使った型依存
- ダックを信頼する
- インターフェイスを定義し、必要なところを実装して、正しく振る舞ってくれると信頼する
- ダックタイピングを文章化する
- ダックタイプをするときはそのパブリックインターフェイスの文章化とテストを行う
- テストは優れた仕様書にもなるので一石二鳥
- ダック間でコードを共有する
- 基本的にダック同士は実装を共有しない
- ただし、ダック同士で実装を共有したいことがある
- 共有の方法は後述
- 賢くダックを選ぶ
- 不安定な依存を切るために使う
- 依存しているものが安定していればダック化する必要はない
継承によって振る舞いを獲得する
継承とメッセージ委譲
クラスを継承すると、サブクラスが理解できないメッセージは自動的にスーパークラスへ委譲されます。
Rubyや多くのオブジェクト指向言語では、この委譲が継承の基本的な仕組みです。
これにより、サブクラスはスーパークラスのメソッドを自然に利用できます。
抽象を見つける
抽象クラスを作る目的
- サブクラス間で共有する振る舞いの格納場所を提供する
- サブクラスが1つしかない抽象クラスは意味がない
- 共通の振る舞いは具象クラスから抽象クラスに昇格させる
- サブクラスがそれぞれ特化したものを用意する
テンプレートメソッドパターンを活用する
- テンプレートメソッドパターンでは、抽象クラス側でアルゴリズムの骨格を定義し、サブクラスで特化部分のみをオーバーライドする
- 特化部分はテンプレート化されたメソッドとしてサブクラスで実装
- 継承に限らず、モジュールでも適用可能
スーパークラスとサブクラス間の結合度を管理する
結合度を理解する
- 継承関係でも依存は発生している。悪い例として、サブクラスがスーパークラスの内部仕様まで把握しないと動かせない場合がある
- 例えば、サブクラスがsuperメソッドを呼び、その返り値がハッシュであることを知っているなど
フックメッセージで疎結合にする
- フックメッセージとは、スーパークラスで定義され、サブクラスでオーバーライドされることを前提にしたメソッド
- サブクラスは必要な部分だけ実装できる
- 実装は任意(デフォルトの実装を持たせられる)
- 「差し替えてもいいし、そのままでもいい」メソッド
モジュールでロールの振る舞いを共有する
継承は「親子関係」を作り、共通の振る舞いをスーパークラスに集約するが、
モジュールは「血縁関係のないオブジェクト」に共通の振る舞いを持たせるときに使う。
このような共有される振る舞いを ロール(Role) と呼ぶ。
ロールの理解
- ダックタイピングは「特定のメッセージシグネチャを共有する」だけだが、モジュールは「特定の振る舞いの実装」も共有する
- Rubyでの実現方法
- Rubyではモジュールを使う
- オブジェクトがモジュールをインクルードすると、モジュール内に定義されたメソッドも自動的に委譲される。応答できるメソッドは増える
メソッドの探索の仕組み
- モジュールは継承チェーンより先に探索される
- 複数モジュールを読み込んだ場合、探索は「読み込んだ逆順」で行われる
継承可能なコードを書くための原則
アンチパターン
- オブジェクトがtypeやcategoryという変数を使い、どんなメッセージをself(自分自身)に送るかを決めている
- => これは継承で解決すべき
- メッセージを受け取るオブジェクトのクラスを判定してメッセージを変える
- => これはダックタイピングで解決すべき
- インターフェイスだけでなく共通の振る舞いも共有する必要がある場合 => モジュール化で解決すべき
抽象に固執する
- スーパークラスのコードを使わないサブクラスはNG
- モジュールのコードを一部しか使わないオブジェクトはNG
契約を守る(リスコフの置換原則)
- 派生型は上位型と置換可能でなければならない
- つまりサブクラスがスーパクラスのインターフェイスを一致するようにする
- 同じ種類の入力を受け取り、同じ種類の出力を返す必要がある
- この制約を守っている場合にサブクラスで多少独自の処理を行うことはOK
前もって疎結合にする
- 継承する側でsuperを呼び出すコードを書くのを避ける
- 代わりにフックメッセージを使うことで依存を減らす
コンポジションでオブジェクトを組み合わせる
コンポーズとは
コンポーズ(Composition)は、個別の部品を組み合わせて複雑な全体を構築する行為です。
大きなオブジェクトとその部品は has-a の関係で結ばれます。
例えば、自転車はパーツという部品で構成され、パーツとはインターフェイスを介して情報交換を行います。
パーツは「ロール」であり、そのロールを満たす別のオブジェクトとも問題なく協力できます。
継承の特徴
継承のメリット
- 見通しが良いこと
- クラス階層に沿って動作を追いやすい
- 合理的であること
- 振る舞いの大きな変更を、小さなコード修正で実現できる
- 利用性が高いこと
- 親クラスや他サブクラスに影響を与えずに拡張可能
- => 拡張に開いており、修正には閉じている(オープン・クローズド原則)
- 模範的であること
- 既存のパターンに簡単に倣うことができる
継承のデメリット
- 継承が適さない問題に誤って継承を適用してしまう
- 問題に対して継承の選択が妥当であったとしても、他のプログラマーによって全く予期していなかった目的のために使われてしまう
- 親クラスのわずかな変更が広範囲に影響
- 階層が広く深くなると複雑度が格段に上がる
コンポジションの特徴
コンポジションのメリット
- 見通しが良い
- コンポジションを使うと小さな責任を持つオブジェクトが自然に増える
- 合理的である
- 不自然な is-a 関係を避け、has-a で表現
- 利用性が高い
- 部品の入れ替えが容易
コンポジションのデメリット
- 多くのパーツに依存する
- 各パーツは理解しやすくても、組み合わされた全体は理解しにくい
関係の選択
- 継承は「特殊化」のための仕組みで、既存コードの大部分を流用し、新規コード追加が少ないときに有効
- 継承の依存関係が強すぎるため、それ以外はコンポジションの方が良い
費用対効果の高いテストを設計する
変更可能なコードに必要なスキル
変更に強いコードを書くには、次の3つの能力が不可欠です。
- オブジェクト指向の理解
- リファクタリング能力
- リファクタリングとはソフトウェアの外部の振る舞いを保ったままで、内部の構造を改善していく作業
- 特に「具体的な要件が現れるまで決定を遅らせる」設計姿勢が重要
- より具体的な要件が現れた時にリファクタリングを行う
- 価値の高いテストを書く能力
- リファクタリングを自信を持って行えるだけの品質保証を提供する
意図を持ったテスト
テストの真の目的はコスト削減
不要に時間のかかるテストは避け、価値を最大化する設計が必要
テストの意図
- バグを見つける
- 仕様書となる
- 唯一信頼できる「動く仕様書」として、長期的に正しい情報を提供する
- 設計の決定を遅らせる
- インターフェイスに依存したテストがあれば、後から安全に設計を変更できる
- 抽象を支える
- 良い設計は自然と抽象に依存する独立する小さなオブジェクトの集まりになっていき、アプリケーションの振る舞いは、徐々にそれらの抽象が相互作用した結果となっていく
- 抽象が増えて来るとどんな変更であってもテストなしで安全に行うことが不可能となる
- 抽象オブジェクトの相互作用をテストし、安全な変更を可能にする
- 設計の欠陥を明らかにする
- セットアップが複雑なテストは、クラスが過剰にコンテキストを要求している兆候
何をテストするか知る
- パブリックインターフェイスに定義されるメッセージだけを対象にする
- 不安定な内部実装(プライベートメソッド)はテストしない
- オブジェクトの境界に入ってくる/出ていくメッセージを中心にテストする
- 受信メッセージ
- そのオブジェクトのパブリックインターフェイス
- メッセージの戻り値についてアサーションする(状態テスト)
- 送信メッセージ
- 副作用なし(クエリ) → 受信側のテストで担保(DRY)
- 副作用あり(コマンド) → 送り手の責務として回数と引数をアサーションする。送られたかのテストは振る舞いのテストであり、状態のテストではない
プライベートメソッドをテストしない理由
- 冗長: パブリックメソッドをテストすればプライベートメソッドが壊れていることに気づける
- 不安定: 不安定なプライベートのメソッドの変更があるたびにテストも直さないといけない、コストが大きい
- 誤解を招く: テスト対象のオブジェクトがどのように外部と連携しているかというドキュメントの役割を果たせなくなる
送信メッセージのテスト
- 副作用ありのコマンドメッセージはモックを利用して回数と引数を検証
- 依存オブジェクトの注入(DI)を行っていれば、モック差し替えは容易
ダックタイプのテスト
- 各ロールの担い手が、必要なメソッドを正しく実装しているかをアサーション
- このアサーションはモジュール化して使い回す