オブジェクト指向設計実践ガイドを読む No.3【依存関係を管理する】
こんにちは。 sasumasa です。
今回はオブジェクト指向設計実践ガイドの第 3 章の「依存関係を管理する」について、まとめていきたいと思います。
なお、本記事はクラスの責務についてまとめた「オブジェクト指向設計実践ガイドを読む No.2」の続きとなっています。
オブジェクト指向設計における依存とは何か
例えば、以下のようなコードがあったとします。
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
# ...
end
puts Gear.new(52, 11, 26, 1.5).gear_inches # => 137.0909090909091
ここでの Gear クラスにはいくつかの不必要な依存があります。具体的には以下の 3 つです。
- gear_inches メソッドで Wheel という具体的なクラス名を想定している
- Wheel の初期化の方法( 2 つの引数を持っていることやその順番)について知っている
- Wheel のインスタンスが diameter メソッドを呼び出せることを知っている
この状態だと、上の 3 つを満たすクラス(つまりWheel)じゃないと Gear と協業はできず、Wheel になんらかの変更が生まれたら Gear クラスも変更しないといけなくなります。
プログラムはクラス同士やメソッド(や関数)同士が協業してユースケースを達成するものなので依存自体は避けられないのですが、依存度の度合いをコントロールすることは必要です。
対応策:依存オブジェクトの注入
こういった状況に対応する方法の 1 つは依存するオブジェクトを内部で持っておくのではなく、外部から注入することです。Dependency Injection(DI)と呼ばれています。
例えば先の例で言うと、Wheel.new(rim, tire) を外部から注入させるのです。
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def gear_inches
ratio * wheel.diameter
end
# ...
end
こうすることで Gear クラスは Wheel クラスという具体的なクラスの存在を意識しなくて済みます。あくまで想定しているのは wheel として渡されるオブジェクトが diameter に答えることだけです。
その他の依存関係の対応策
上記のように DI によって問題が解決できる時であればそうした方がいいですが、現実的にはそれが難しい時があるかもしれません。変更に厳しい制約があるかもしれません。
そういった場合に状況をまだマシにするために幾つかの方法があります。
インスタンス変数の作成を分離する
1 つ目の対応策は、先に述べた 3 つの不必要な依存を、とりあえず 1 つの箇所に分離しておくことです。
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.diameter
end
def wheel
@wheel ||= Wheel.new(rim, tire)
end
# ...
end
この書き方にしたからといって先の 3 つの不必要な依存は解決できていません。依然として Wheel クラスを知ってしまっているからです。
しかし、改善されたこともあります。wheel メソッドを生やすことで、gear_inches メソッドの依存数は減り、Wheel への依存が公然となりました。こうすることで依存は意識され、リファクタリングの時が来たらかんたんに対応できます。wheel メソッドの詳細を変えるだけでいいからです。
脆い外部メッセージを隔離する
先の方法で外部のクラスへの参照は隔離しました。次は外部へのメッセージを隔離しましょう。先のコードのこの部分に注目してみます。
def gear_inches
ratio * wheel.diameter
end
ここでは wheel が diameter に答えることを想定しています。このメソッドであれば特に問題ではないですが、もし gear_inches が複雑になったらどうなるでしょう。
def gear_inches
...# 複雑な処理
foo = some_intermediate_result * wheel.diameter
...# 複雑な処理
end
この状況では、wheel.diameter が gear_inches の複雑な処理の中に埋もれてしまっています。そして今や gear_inches は複雑な処理をしているので、その影響度も大きくなっています。
wheel.diameter の何かが変更されるたびに gear_inches に手を入れないといけなくなり、それはデグレを起こすかもしれません。
とりあえずの手段として、gear_inches 自体を変更しないといけなくなる可能性を減らしましょう。
def gear_inches
...# 複雑な処理
foo = some_intermediate_result * diameter
...# 複雑な処理
end
def diameter
wheel.diameter
end
引数への順番の依存を取り除く
今度は別のところに目を向けてみましょう。メソッドの引数についてです。上記の例では、例えば initialize メソッドの第一引数は chainring が渡されることを想定していました。これもまた順番に対する依存と言えます。
Ruby にはキーワード引数という便利な対応法があります。引数の順番ではなく、引数の key によって渡すべき値を決定できます。また、デフォルト値も設定できます。
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring: 40, cog: 18, wheel:)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
依存方向の管理
詳細をここで書くことはしませんが、実はこの Gear クラスと Wheel クラスの依存関係を逆にすることができます。つまり Wheel クラスに Gear を注入し、gear_inches を計算させるのです。
この依存方向の選択をする際には、以下のポイントを考慮する必要があると言われています。
- 変更の起きやすさを理解する:あるクラスは、他のクラスよりも要件が変わりやすい
- 具象と抽象を認識する:クラスによっては具象的なものと抽象的なものがある
- 大量に依存されたクラスを避ける:いろんなところから依存されたクラスはもはや「変更してはいけない」ものとなってしまう
- 問題となる依存関係を見つける:「変わりやすさ」と「依存されているものの数」を持って判断する
- 下の領域の A, B, C はコードがある場所として適切で、D を生み出さないようにしなければならない
要件が変わる可能性が低い | 要件が変わる可能性が高い | |
---|---|---|
依存されている数が多い | 抽象領域(A) | 危険領域(D) |
依存されている数が少ない | 中立領域(B) | 中立領域(C) |
オブジェクト指向設計実践ガイドの第 3 章の「依存関係を管理する」についてまとめました。
Discussion