This page looks best with JavaScript enabled
⚠️

『オブジェクト志向設計実践ガイド』3章

 ·   ·  ☕ 6 分で読めます
✏️

3章

アンチパターン

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Gear
  attr_reader :chainring, :cog, :rim, :tire

  def initialize(chainring, cog, rim, tire)
    @chainring = chairning
    @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

Wheelへの変更によってGearへの変更が強制される。
このコードは、Gearに渡した値をWheelに渡しているので、依存性が高い。

依存関係がある とは

  • ほかのクラスの名前:Gearは、Wheelという名前のクラスが存在することを予想する
  • self以外のどこかに送ろうとするメッセージの名前:Gearは、Wheelのインスタンスがdiameterに応答することを予想している
  • メッセージが要求する引数:Gearは、Wheel.newにrimとtireが必要なことを知っている
  • それらの引数の順番:Gearは、Wheel.newの最初の引数がrimで、2番目がtireである必要があることを知っている

依存オブジェクトの注入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Gear
  attr_reader :chainring, :cog, :wheel

  # Wheelのインスタンスを引数で渡すようにする
  def initialize(chainring, cog, wheel)
    @chainring = chairning
    @cog = cog
    @wheel = wheel

  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    ratio * wheel.diameter
  end
end

Gear.new(52, 11, Wheel.new(12, 1.5))のように、引数でWheelのインスタンスを渡す。
依存オブジェクトの注入(依存性の注入とも呼ばれる)

Gearはwheel.diameterの部分でしかWheelとはつながっていない。しかも、知っていることは「wheelとして渡されるオブジェクトが#diameterメソッドを持っている」ということだけ。なので、ここで渡すwheel#diameterを持っているという条件を満たすだけ(返す型の問題とかはあるかもしれないけど)でいい。

依存を隔離する

インスタンス変数の作成を分離する

WheelをGearに注入できないとき。
妥協して、Gearクラス内でWheelを呼び出すメソッドとして分離する。

以下のように、wheelメソッドに切り出して包むことで、外部クラスを呼び出していることを明示する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Gear
  attr_reader :chainring, :cog, :rim, :tire

  def initialize(chainring, cog, rim, tire)
    @chainring = chairning
    @cog = cog
    @rim = rim
    @tire = tire
  end

  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    ratio * wheel.diameter
  end

  # wheelメソッドを追加
  def wheel
    @wheel ||= Wheel.new(rim, tire)
  end
end

脆い外部メッセージを隔離する

1
2
3
def gear_inches
  ratio * wheel.diameter
end

ratioself.ratioだけど、wheel.diameterwheel.diameterである。
前述したように、wheel.diameterという記述は「wheelがあること」「wheelとして渡されるオブジェクトが#diameterメソッドを持っている」を前提して記述されているので、崩れやすい声質を持っている。

そのため、以下のようにカプセル化し、外部のオブジェクトが持つメソッドを直接呼び出さないようにする。

1
2
3
4
5
6
7
8
def gear_inches
  ratio * diameter
end

def diameter
  # 個人的注釈: wheelがnilだったら???
  wheel.diameter
end

引数の順番への依存を取り除く

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Gear
  attr_reader :chainring, :cog, :wheel

  def initialize(chainring, cog, wheel)
    @chainring = chainring
    @cog = cog
    @wheel = wheel
  end
end

Gear.new(
  52,
  1.5,
  Wheel.new(26, 1.5)
).gear_inches

初期化時のデフォルトの値を変えたり引数を追加したり消したりする中で、すべての実装側の引数の順番も変更することになる。
なので、ハッシュを使って以下のように書き換えられる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Gear
  attr_reader :chainring, :cog, :wheel

  def initialize(args)
    @chainring = args[:chainring]
    @cog = args[:cog]
    @wheel = args[:wheel]
  end
end

Gear.new(
  chainring: 52,
  cog: 1.5,
  wheel: Wheel.new(26, 1.5)
).gear_inches

これによって記述は冗長になったが、この冗長性は悪くない。また、順番ではなくキー名に依存することになったが、この依存はより安定している。

デフォルト値を設定する

1
2
3
4
5
def initialize(args)
  @chainring = args[:chainring] || 40
  @cog = args[:cog] || 18
  @wheel = args[:wheel]
end

※注意
||falsenilと同様と捉えるので、明示的にnilfalseを設定できない。
なので、例えば@bool = arg[:boolean_thing] || trueは絶対にtrueになってしまう。
……この実装はHashの[]メソッドは存在しないキー(:chainringなど)のときはnilを返すことに依存しているから。

fetchを使用すると、この問題を解決できる。

1
2
3
4
5
def initialize(args)
  @chainring = args.fetch(:chainring, 40)
  @cog = args.fetch(:cog, 40)
  @wheel = args[:wheel]
end

Hash#fetch (Ruby 2.7.0 リファレンスマニュアル)

デフォルト値をまとめてdefaultsメソッドに置いておく方法もある。(fetchと同じ)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def initialize(args)
  args = defaults.merge(args)
  @chainring = args[:chairing]
  @cog = args[:cog]
  @wheel = args[:wheel]
end

def defaults
  { chainring: 40, cog: 18 }
end
1
2
3
4
5
irb(main):020:0> d = { chainring: 40, cog: 18 }
irb(main):021:0> d.merge({ chainring: 30 })
=> {:chainring=>30, :cog=>18}
irb(main):022:0> d.merge({wheel: wheel})
=> {:chainring=>30, :cog=>18, :wheel=>#<Wheel:0x00007fb4aa945848 @rim=52, @tire=15>}

フレームワークなどを使っていて、それそのものを変更することができない場合は、順番の依存性を切り離すためのラップメソッドを作る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Gearはフレームワークの一部なので、構造自体を変更することはできない
module SomeFramework
  class Gear
    def initialize(chainring, cog, wheel)
      @chainring = chainring
      @cog = cog
      @wheel = wheel
    end
  end
end

# なので、ラップするためのメソッドを作る
module GearWrapper
  def self.gear
    SomeFramework::Gear.new(args[:chainring],
                            args[:cog],
                            args[:wheel])
  end
end

GearWrapper.gear(
  chainring: 52,
  cog: 1.5,
  wheel: Wheel.new(26, 1.5)
).gear_inches

モジュールであってクラスではない。
GearWrapperの責任は、SomeFramework::Gearのインスタンスを作成すること。
モジュールにすることで、独立した固有のオブジェクトを定義し、GearWrapperインスタンスは作成されるつもりはないという意図がある。

このような他のクラスのインスタンスを作成する目的のモジュールには、オブジェクト指向設計ではファクトリーという名前を付けている(概念の話)。

依存方向の管理

例えば、Wheelクラスの中にGearオブジェクトを使うメソッドを追加したりすると、依存方向が無茶苦茶になる。

依存方向の原則は、具体的で変わりやすいクラスは、自身より抽象的で変わりにくいクラスに依存すべし。
あるクラスの変更は、依存するすべての変更につながるため。

  • あるクラスは他のクラスより要件が変わりやすい
    • Rubyの基本的なクラスなど変わりにくいものに依存するコードを書くべし
  • 具象クラスは抽象クラスよりも変わる可能性が高い
    • より抽象的なクラスに依存するようにすべし
    • ここで言う抽象というのは、個別でないみたいな感じ……。
      Gearという個別のオブジェクトではなく「#diameterメソッドを持つオブジェクト」のような
  • 多くのところから依存されたクラスを変更すると、広範囲に影響が及ぶ
    • 多くのところから依存されたオブジェクトは「絶対に変更されない」べし
    • 覚悟の話(?)

依存されている数・要件が変わる数の二軸でグリッド分けをすると、「変更されやすく依存が多い」ゾーンがあり、そこが危険領域になる。
抽象クラスは「変更されにくいが依存が多い」ゾーンになるが、このゾーンにある=>クラスが抽象になるというわけではない(この辺の言葉遊びよくわからんけど雰囲気わかるような……)。

Share on

END
END
@aiandrox

 
目次