Ruby
オブジェクト指向

オブジェクト指向設計実践ガイドから学ぶ再利用性の高い設計について

「オブジェクト指向設計実践ガイド」で学ぶことが多かったので、何度も読み返して学んだことを整理した。もともと英語版を読んでいたので、合計6〜7周くらい読んだ気がする。再利用性の高いオブジェクトの設計ができるようになりたい方が読めば良さそう。

サマリー

この本の主張を一言にまとめると「変更されにくいものに依存せよ」だと思う。

インターフェースの作り方も変更されやすい「どうするか」という詳細のアルゴリズムを排除して、変更されにくいインタフェースに外部のオブジェクトが依存できることを志向しているし、ダックタイピングも型より抽象的なインターフェースに依存すれば変更されにくいことから話が始まっている。
継承に関しても、変わりやすい「何を」の部分をサブクラスに任せて、変わりにくい初期化の方法などはスーパークラスで請け負った方がいいという考え方からフックメソッドが紹介されている。

変更可能なコードとは?

変更が簡単なコードと以下のように定義する。

  • 変更は副作用をもたらさない
  • 要件の変更が小さければ、コードの変更も相応して小さい
  • 既存のコードは簡単に再利用できる
  • 最も簡単な変更方法はコードの追加である(ただし追加するコードはそれ自体変更が容易なものとする)

上記のように定義するとすれば、書くべきコードには次の性質(TRUE)が伴う。

  • Transparent (明白):変更の結果が、その変更部分でも、それを利用している部分でも明確である
  • Reasonable (合理的):変更処理の労力と変更による便益が釣り合っている
  • Usable (再利用可能性):別の部分でも再利用可能である
  • Exemplary (模範的):他の開発者にとって、変更しやすく、保守しやすい

単一責任のクラスを設計する

クラスもメソッドも一つの役割に一貫すべき。

# 『オブジェクト指向設計実践ガイド』p41より
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
    chainring / cog.to_f * (rim + (tire * 2))
  end
end

なぜ単一責任が重要なのか?
そもそもクラスはその目的を果たすための一つ以上の責任を持つ。
しかし、役割が複数あると、その1つ1つが変更理由になってしまう。また、ある理由により変更した部分が、他の役割に影響してしまい、連鎖的に変更が必要になるなどといったケースが生じうる。

そのため、二つ以上の責任をクラスは、振る舞いを幾つか再利用したい場合に、必要な部分だけを手に入れることがより難しくなる。したがって、クラスを変更する理由は複数存在すべきではない。

クラスが単一責任かどうか見極める
一文でクラスを説明してみる。andがある場合は二つ以上の責任を負っている。
orがつく場合は、二つ以上あり、かつ互いにあまり関連しない責任を負っている。

Ex. 自転車へのギアの影響を計算する
=> gear_inchesはしっくりくるが、tireサイズはしっくりこない

改善例

# 『オブジェクト指向設計実践ガイド』p56より
class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, wheel)
    @chainrign = chainring
    @cog = cog
    @wheel = wheel
  end

  def gear_inches
    ratio * wheel.diameter
  end

  def raito
    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
end

変更されやすい&依存している箇所を分離する

データの隠蔽
変更されやすかったり、参照するために複雑なデータ構造への知識が必要な変数はそれへのアクセス用のメソッドを作ることでカプセル化する。
それによって、変更が生じても一箇所だけ修正すれば済むようになる。

1 変数の隠蔽

# 『オブジェクト指向設計実践ガイド』p46より
class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainrign = chainring
    @cog = cog
    @rim = rim
    @tire = tire
  end

  # 一漕ぎあたりの回転数
  # 計算ロジックを他の箇所から分離する
  def ratio
    @chainring / @cog.to_f
  end
end

2 データ構造の隠蔽

Structを使って、特定のデータ構造への直接参照から構造体を介した間接参照にする。
複雑なデータ構造への直接の参照は混乱を招く。データ構造が変わると参照しているすべての箇所を変更する必要が出てくる。例えば、配列への参照などは、どのインデックスにどの種類のデータが格納されているかの理解を伴う。

# 『オブジェクト指向設計実践ガイド』p50より
class Wheel
  attr_reader :data
  def initialize(data)
    @data = data
  end

  def diameters
    data.collect do |cell|
      cell[0] + (cell[1] * 2)
    end
  end
end

@data = [[622, 20], [622, 23], [599, 30], [559, 40]]
Wheel.new(data).diameters
# 『オブジェクト指向設計実践ガイド』p50より
class Wheel
  attr_reader :wheels
  def initialize(data)
    @wheels = wheelify(data)
  end

  def diameters
    wheels.map {|wheel| wheel.rim + (wheel.tire * 2) }
  end

  private

  Wheel = Struct.new(:rim, :tire)
  def wheelify(data)
    data.map {|cell| Wheel.new(cell[0], cell[1]) }
  end
end

クラス間の依存度を下げる

オブジェクトへの依存とは?
あるオブジェクトのメソッド呼び出しを別のオブジェクトが実行する場合をメッセージがオブジェクト間で通信され、振る舞いが実現されると考える。

その送るべきメッセージと実現される振る舞いを他のオブジェクトが知っていることが大前提になる。
この他のオブジェクトについて知っているべき情報をオブジェクトへの依存と表現する。

具体的にはオブジェクトが次のものを知っている時、オブジェクトには依存関係がある。
(下記のコードのWheel.new(rim, tire).diameter

  • 他のクラスの名前: Gearはwheelと言う名前のクラスを知っている
  • self以外のどこかに送るメッセージの名前: GearはWheelのインスタンスがdiameterに応答することを知っている
  • メッセージが要求する引数: GearはWheel.newの引数がrimとtireが必要であることを知っている
  • それらの引数の順番: GearはWheel.newの最初の引数がrimで二番目がtireである必要を知っている
# 『オブジェクト指向設計実践ガイド』p56より
class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainrign = chainring
    @cog = cog
    @rim = rim
    @tire = tire
  end

  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    ratio * Wheel.new(rim, tire).diameter
  end
end

class Wheel
  attr_reader :rim, :tire
  def initialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def diameters
    rim + (tire * 2)
  end
end

外部クラスへの依存度を下げる

オブジェクトに依存関係がある場合は、再利用性の低下や変更に伴う予期せぬ副作用が生じる。
他のオブジェクトについて知っているべき情報が多ければ多いほど、それが変更された時に同様にメッセージの送り方を変更する理由になるから。

外部のクラスのインスタンス生成処理には以下のような手段で依存度を下げることができる。

  • Dependency Injection
    • 内部で外部クラスのインスタンスを生成せず、初期化時の引数にインスタンスを渡す
    • クラス名への依存度がなくなり、メソッド名のみになる
  • Dependency Isolation(メソッド切り出し)
    • 上記ができない場合は、依存している箇所のみを別のメソッドに分離する
    • インスタンス生成方法が変わってもそこのみを修正すればいい
  • メソッドへの引数の渡し方をハッシュ形式にする
    • 変数名を順に並べて引数にするのではなく、ハッシュ形式で引数に渡すと順序への依存がなくなる
  • 依存方向の選択(自身よりも変更されないものへ依存する)
    • あるクラスは他のクラスよりも要件が変わりやすい
    • 具象クラスは抽象クラスよりも変わる可能性が高い
    • 多くクラスから依存されたクラスを変更すると広範囲に影響が及ぶ
class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args[:chainring]
    @cog = args[:cog]
    @wheel = args[:wheel] # DI: 初期化時の引数にインスタンスを渡す
  end

  def gear_inches
    ratio * wheel.diameter # diameterというパブリックインターフェースに依存する
  end
end

Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches
class Wheel
  def initialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def diameter
    ...
  end
end

柔軟なインターフェースを作る

なぜインターフェースの設計が重要なのか?
インターフェースとは、オブジェクトに属するメソッド名と引数のこと。

オブジェクト指向アプリケーションは単なる「クラス」の集合だけではない。
依存しあうクラス間を疎結合にすることによって、それぞれのクラスを着脱可能なコンポーネントして扱うことができる。
それによってクラスの再利用性が高まる。

そこで問題になるのは、外部のクラスに自身のクラスの内部をどのように「明らかにする」ことになる。
理由は、クラスが何を「する」かではない。クラスは外部のクラスにメッセージを送って(パブリックインターフェースを呼び出して)関連し合っているので、クラス間の依存関係(外部クラスについて知っていなければならない知識)は、このメッセージの設計が重要になるから。

パブリックインターフェース

  • パブリックインターフェースの役割
    • クラスの主要な責任を明らかにする
    • 外部から実行されることが想定される
    • 気まぐれに変更されない
    • 他者がそこに依存しても安全
    • テストで完全に文書化される
  • パブリック・インターフェースの要件
    • 明示的にどのようなものか特定できる
    • 処理内容よりも処理結果のみが外部から分かる
    • 予測ができ、変更されない名前を持っている
    • パラメーターの追加はハッシュを使う

どのようなパブリックインターフェースであるべきか?
「どのように」伝えるのではなく、「何を」を頼む。
相手が誰かも何をするかも知らずに他のオブジェクトと共同作業できるオブジェクトは新しく、また予期していなかった方法で再利用できる。

このパブリックとプライベートの違いが存在するのは、それが最も効率的に仕事をする方法だからです。お客さんが直接料理の指示をするようになっているとすればどうなるでしょうか。例えば食料がなくなって代替案が必要になった場合、お客さんはその都度また料理法を学ばなければなりません。メニューを使うことによって、この問題は回避されます。メニューを使えば、厨房が「どのように」料理を作るかは一切関知せずに、「何を」望むかをお客さんに頼ませることができます。ですから、そういった問題は起きません。
(『オブジェクト指向設計実践ガイド』p90より)

要するに、インターフェースが抽象的でかつ本質的であれば、そのインターフェースは変更されづらく、外部のオブジェクトが依存しやすくなる。
パブリックかプライベートかの区分は、変更可能性が低いかどうかの基準であり、それは外部から依存可能かどうかの判断のために存在する。

クラスにまたがるインターフェース

ダックタイピングとは?
クラス間で共有されたパブリックインターフェースを使ってメッセージを送る方法

もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルである

ダック・タイピングをするためのパブリックインターフェースの考え方

  • オブジェクトが「何である」かではなく「何をする」かで考える
  • 何が必要かを考えどうするかはメッセージを送った先のオブジェクトに任せる
  • 個別的な役割ではなく、本質的に何を求められているかを考えて同じメソッド名にする
    • 何が必要かを抽象化することで、共通するインターフェースを見つける
    • クラス名が不明でも同じ名前のメソッドに渡せば、必要な処理をしてくれる

ダック・タイピングの見つけ方

  • Switch文で引数に応じて複数のクラスのメソッドを呼び出している
  • kind_of?, is_a?でクラス名に応じて処理をしている
  • responds_to?でメソッド名に応じて処理をしている
# 『オブジェクト指向設計実践ガイド』p126より
class Trip
  attr_reader :bicycles, :customers, :vehicle
  def prepare(preparers)
    preparers.each do |preparer|
      case preparer
      when Mechanic
        preparer.prepare_bicycles(bicycles)
      when TripCoordinator
        preparer.buy_food(customers)
      when Driver
        preparer.fill_water_tank(vehicle)
      end
    end
  end
end

class Mechanic
  def prepare_bicycles(bicycles)
  end
end

class TripCoordinator
  def buy_food(customers)
  end
end

class Driver
  def fill_water_tank(vehicle)
  end
end
# 『オブジェクト指向設計実践ガイド』p127より
class Trip
  attr_reader :bicycles, :customers, :vehicle
  def prepare(preparers)
    preparers.each do |preparer|
      preparer.prepare_trip(self)
    end
  end
end

class Mechanic
  def prepare_trip(trip)
    trip.bicycles.each do |bicycle|
      prepare_bicycle(bicycle)
    end
  end
end

class TripCoordinator
  def prepare_trip(trip)
    buy_food(trip.customer)
  end
end

class Driver
  def prepare_trip(trip)
    fill_water_tank(trip.vehicle)
  end
end

クラスの継承

テンプレートメソッド

  • スーパークラスで大まかな処理構造を決めて、サブクラスで具体的な設計を任せるためのメソッド
  • 実装し忘れを防ぐために、親クラスで、エラーを発生させるようにする
    • raise NotImplementedError

フックメソッド

  • テンプレートメソッドと異なり、オーバーライドするかどうかは任意
  • スーパークラスに「いつ」「どうするか」のアルゴリズの知識を委ねて、具体的に「何を」するかをサブクラスで実装するためのメソッド
    • Ex. サブクラスでsuperを必ず書くという暗黙の前提と、スーパークラスではどのように初期化するか、それをいつ実行するかの判断を排除できる
# 『オブジェクト指向設計実践ガイド』p145より
class Bicycle
  attr_reader :style, :size, :tape_color, :front_shock, :rear_shock

  def initialize(args)
    @style = args[:style]
    @size = args[:size]
    @tape_color = args[:tape_color]
    @front_shock = args[:front_shock]
    @rear_shock = args[:rear_shock]
  end

  def spares
    if style == :road
      {
        chain: '10-speed',
        tire_size: '23',
        tape_color: tape_color
      }
    else
      {
        chain: '10-speed',
        tire_size: '2.1',
        rear_shock: rear_shock
      }
    end
  end
end
# 『オブジェクト指向設計実践ガイド』p175より
class Bicycle
  attr_reader :size, :chain, :tire_size

  def initialize(args={})
    @size = args[:size]
    @chain = args[:chain]
    @tire_size = args[:tire_size]
    post_initialize(args)
  end

  def spares
    {
      tire_size: tire_size,
      chain: chain
     }.merge(local_spares)
  end

  # subclass may override
  def post_initialize(args)
    nil
  end

  def local_spares
    {}
  end
end

class RoadBike < Bicycle
  attr_reader :tape_color
  def post_initialize(args)
    @tape_color = args[:tape_color]
  end

  def local_spares
    {tape_color: tape_color}
  end
end

class MountainBike < Bicycle
  attr_reader :front_shock, :rear_shock
  def post_initialize(args)
    @front_shock = args[:front_shock]
    @rear_shock = args[:rear_shock]
  end

  def local_spares
    {rear_shock: rear_shock}
  end
end