この記事の目的
オブジェクト指向設計実践ガイドを読んで学んだことを簡単にまとめる。復習したい時に全ページを読み返すのは大変なので。
初心者なので間違っている部分があるかもしれません。指摘していだだけると嬉しいです。
(この記事は、オブジェクト指向とは何かを説明するものではなく、単にオブジェクト指向設計実践ガイドの各章を簡易的にまとめたものです。)
この章の目的
振る舞いが他のオブジェクトに定義されている時に、それにアクセスすることについて考える。適切に設計されたオブジェクトは単一責任であるため、問題の解決にはオブジェクト同士が共同作業をしなくてはならない。共同作業をするためには他のオブジェクトに関する知識が必要。しかし、この「知識」は依存関係を作るため、慎重に管理しなくてはならない。
依存関係を理解する
依存関係とはどのようなものか理解するために、以下のコードを用いる。
# このコードはオブジェクト指向設計実践ガイドからの引用です。
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 * Wheel.new(rim, tire).diameter
end
end
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def diameter
rim + (tire*2)
end
end
Gear.new(52, 11, 26, 1.5).gear_inches
一方のオブジェクトを変更した時、他方のオブジェクトを変更せざるを得ない場合、片方に依存しているオブジェクトがある。
- このコードは以下のような依存を持っている
- 他のクラスの名前を知っている
- self以外にメソッドを送っている
- 他のクラスのメソッドが必要とする引数を知っている(
Wheel#initialize
)- それらの引数の順番を知っている(
Wheel#initialize(rim ,tire)
)
- それらの引数の順番を知っている(
これらの依存によって、Wheelへの変更によってGearが変更される可能性が高くなる。これら2つのオブジェクトが共同作業をするためにはある程度の依存が必要だが、現在のコードでは不必要な依存が多いため、それらをなくして最低限の依存で動くようにしなくてはならない。
疎結合なコードを書く
依存を減らすとは、必要のない依存を認識して取り除くこと。
以下では依存を減らすためのコーディングテクニックについて説明する。
依存オブジェクトの注入
ほかのクラスにクラス名で参照すると依存が生まれる。
以下のコードでWheelクラスの名前が変更された時、Gear#gear_inches
も変更する必要がある。
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
end
# ~~~~~~
Gear.new(52, 11, 26, 1.5).gear_inches
GearとWheelが共同作業をするためにはどこかで何かがWheelのインスタンスを作成しなければならないため、「クラス名で参照する」という依存は無害に思えるが、このコードは見つけにくい問題を持っている。
現在のコードはWheelへの参照がgear_inches内にハードコーディングされているため、Gearは「Wheel以外のギアインチを計算しない」と宣言しているようなものである。Wheel以外のdiameter(直径)を持ち、ギアを扱うオブジェクトがあったとしても、Gearクラスはそのオブジェクトのギアインチを計算できない。
GearがWheel以外のオブジェクトのギアインチを計算できるようになるためには、コードを変更しなくてはならない。Gearがギアインチを計算するためにはdiameter
に応答できるオブジェクト(ダックタイプ)が必要。Gearはギアインチを計算したいオブジェクトがdiameter
を知っていればよいので、そのオブジェクトのクラス名や初期化法を知る必要はない。
以下のコードではGearの初期化時にdiameter
に応答するオブジェクトを渡す。
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.new(52, 11, Wheel.new(26, 1.5)).gear_inches
Gearはdiameter
に応答できるオブジェクトを@wheel
というインスタンス変数に入れているが、Gearはこの変数がWheelクラスのインスタンスかどうかを知らない。Gearが気にすることはwheel.diameter
が可能かどうかだけ。
Wheelインスタンスの作成をGearの外で行うようにしたことで、2つのクラスの間の結合が切り離された。これでGearはdiameterを持つどんなオブジェクトとも共同作業できるようになった。
このテクニックを「依存オブジェクトの注入」と呼ぶ。依存オブジェクトの注入ができるかどうかは「クラス名を知る責任」や「そのクラスに送るメソッド名を知る責任」が別の場所に属するのではないかと考えることで判別する。
では、「dimeter
に応答するオブジェクトを知る責任」はどこにあるのか?
依存を隔離する
不要な依存はクラス内で隔離しておいて、状況が許すようになった時に簡単に特定して除去できるようにする。
インスタンス(変数?)の作成を分離する()
何かの制約によりGearにWheelを注入できない時は、Gear内でWheelインスタンスの作成を隔離する。
以下に2つの例を示す
1つ目はWheelインスタンスの作成をGear#gear_inches
からGear#initialize
に移動する。
class Gear
attr_reader :chainring, :cog, :rim, :tire, :wheel
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
@wheel = Wheel.new(rim, tire)
end
def gear_inches
ratio * wheel.diameter
end
# ~~~~~~
end
2つ目はWheelインスタンスの作成を新しく定義されたGear#wheel
で行うようにする。
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
これらの変更では、Gearはまだ依存(Wheelの初期化方法の知識、Wheelインスタンスの作成を内部で行う)を持っているが、Gear#gear_inches
の依存数が減り、GearのWheelへの依存が明らかになった。このようなコードを書くことで依存が明確になり、再利用が簡単になる(コードのリファクタリングが簡単に)。
外部メッセージを隔離する
外部メッセージとは、self以外に送られるメッセージのこと。
def gear_inches
ratio * wheel.diameter
end
上記のコードではratio, wheelはselfに送られるが、diameterはwheelに送られる。
このコードはシンプルだが、状況がもっと複雑になることもある。
def gear_inches
# 難しい計算
foo = hogehoge * wheel.diameter
# もっと難しい計算
end
このコードでは、wheel.diameterは複雑なメソッドの深いところに埋め込まれてしまっている。このメソッドはGear#wheel
とwheel.diameter
が呼び出し可能であることに依存している。この外部への依存により、何かが変更されるたびにこのメソッドが壊れる可能性がある。
gear_inchesへの変更の可能性を減らすためには、外部への依存を取り除き、専用のメソッドにカプセル化する。
def gear_inches
ratio * diameter
end
def diameter
wheel.diameter
end
gear_inchesは外部クラス(Wheelクラス)と「その外部クラスのメソッド」に依存していたが、変更後はselfに送るメソッドに依存するようになった。これによって、gear_inches以外でwheel.diameter
を参照する際もself.diameter
を使うことができるので、コードがDRYになる。もしWheelがdiameterに変更を加えてもGear内の変更はGear#diameter
だけで済む。
このテクニックはメッセージへの参照がクラス内に埋め込まれていて、そのメッセージが変わる可能性が高い時に必要になる。参照を隔離することで、変更が起きた時の影響を少なくできる。
引数の順番への依存を取り除く
引数を要求するメッセージを送る時、送り手は引数についての知識を持たざるを得ない。また、送り手が引数を渡す時は、その引数を渡す順番についても認識している必要がある。
以下のコードではGear#initialize
は3つの引数を要求し、初期化するためには正しいものを正しい順番で渡す必要がある。
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel)
@chainring = chainring
@cog = cog
@wheel = wheel
end
end
Gear.new(52, 11, Wheel.new(26, 1.5))
この引数の順番への依存を、メソッドの引数をinitialize(args={})
とすることで取り除くことができる(キーワード引数でもできる)。キーの名前が引数に関するドキュメントにもなる。
class Hoge
def initialize(args={})
@foo = args[:foo]
@bar = args[:bar]
@baz = args[:baz]
end
end
Hoge.new(foo: 1, bar: '2', baz: 3)
外部フレームワークの引数への依存
「引数の順番への依存を取り除く」で取り上げたコードは、メソッドを自分で変更できる場合には有効だが、固定順の引数を必要とするものには外部フレームワークのメソッドなど、自分で変更を加えることができないものもある。
以下はGearクラスが外部フレームワークによって定義されていて、変更を加えることのできないという状況のコードである。
module SomeFramework
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel)
@chainring = chainring
@cog = cog
@wheel = wheel
end
# ~~~~
end
end
class Wheel
# ~~~
end
module GearWrapper
def self.gear(args)
SomeFramework::Gear.new(
args[:chainring],
args[:cog],
args[:wheel]
)
end
end
GearWrapper.gear(
:chainring => 52,
:cog => 11,
:wheel => Wheel.new(26, 1.5)
).gear_inches
このような時は、GearWrapper
モジュールを用いて、Gearのインスタンス作成を包み隠すことで依存を1箇所に留めることができる。もしアプリケーション内で頻繁にGearのインスタンスを作成しなければならない場合、GearWrapper
があれば「固定順の引数を渡す」コードを繰り返し書かずに済むため、DRYなコードになる。
また、GearWrapper.gear
は引数にハッシュを要求するので、SomeFramework::Gear
のインスタンスを作成するものに分かりやすいインターフェイスを提供できる。
GearWrapper
の唯一の責任は他のクラスのインスタンスを作成することだが、このようなオブジェクトのことを「ファクトリー」と呼ぶ
依存方向の管理
依存関係には方向があるが、その方向はどのように決めれば良いか説明する。
依存関係の逆転
これまでの例ではGear
がWheel または Gear#diameter
に依存していたが、この依存関係を逆転して、Wheel
がGear や Gear#gear_inches
に依存するコードを書くこともできる。
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, :gear
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
end
Wheel.new(26, 1.5, 52, 11).gear_inches # 137.0909090909091
このコードを見ると、依存の方向が変わったからといって、明らかな変化はないように見える。2つのオブジェクトは今も共同作業をしていて、計算の結果も同じである。
依存の方向が重要になるのは、変更が要求される未来である。将来もたらされる変更に適切に対応できるようになるため、依存方向は適切に選択しなくてはならない。
依存方向の選択
クラス同士が依存しなくてはならない場合は、「自分より変更されないものに依存する」ということを意識する。
- このフレーズは以下の考え方がもとになっている
- あるクラスは他のクラスより要件が変わりやすい
- 具象クラスは抽象クラスより変わる可能性が高い
- 多くの依存を持ったクラスを変更すると、影響が広範囲に及ぶ
あるクラスは他のクラスより要件が変わりやすい
依存する先のコードがどれくらい安定しているのかを意識しなくてはならない。自分で書いていない外部のフレームワークも変わる可能性がある。
具象クラスは抽象クラスより変わる可能性が高い
[依存オブジェクトの注入]で見たように、依存オブジェクトの注入をする前は具象的なコードに依存していたが、注入によってGearはdiameterメソッドに応答できるダックタイプに依存するようになった。これでGearは以前より抽象的なものに依存するようになった。
Wheelに依存していた時は、GearはWheelへの変更による影響を大きく受けていたが抽象的なダックタイプに依存するようになったことで安定性が増した。
多くの依存を持ったクラスを変更すると、影響が広範囲に及ぶ
多くの依存を持ったクラスへの変更が大きな影響をもたらすことは明白だが、多くの依存を持ったクラスを「持つ」ことの自体の影響も考慮する必要がある。多くの依存を持ったクラスへの変更がアプリケーションの様々なところに影響を与えることが分かっているため、そのクラスを「変更しないこと」が常に求められる。変更が生み出す影響を避けるために、そのクラスに手を加えなくなることは、アプリケーションのハンデの1つとなってしまう。
問題となる依存関係を発見する
上記で述べた事柄がアプリケーションのコードのどこかに当てはまるとすると、アプリケーションを構成するものを次の図に分類できる。
依存されている数 | ||
---|---|---|
多 | A : 抽象領域(変更少、影響大) | D : 危険領域(変更多、影響大) |
少 | B : 中立領域(変更大、影響少) | C : 中立領域(変更多、影響少) |
要件が変わる可能性 | 低 | 高 |
アプリケーションの全てのクラスをこの表に分類すると、適切に設計されているアプリケーションのクラスはA,B,Cに集まる。
- それぞれの領域の特徴
- A : 抽象クラスやインターフェイスが含まれることが多い
- B : 依存されておらず、また変更も少ないもの
- C : 具象的で変わりやすいクラスが含まれる
- D : 頻繁に変更され、また多く依存されている具象的なもの
アプリケーションの設計をする時は上記の表を参照しながら、「自分より変更されないものに依存する」を意識してコードを書く。