オブジェクト指向設計実践ガイド_単一責任(SOLID)について
はじめに
今回は「オブジェクト指向設計実践ガイド」 第2章の単一責任(SOLID)を読んでその内容をまとめてみました。
単一責任クラスの設計
オブジェクト指向設計のシステムの基礎は「メッセージ」です。
しかし、その組織(メッセージのやり取りをする組織?)の構造でもっとも目立つのは「クラス」です。「メッセージ」こそが設計の核にありますが、まずクラスに属するものをどのように決めるかについて取り扱います。
第一にやるべきことは 「いますぐに求められる動作をする」 かつ 「あとにも簡単に変更できる」 ことです。
1. クラスに属するものを決める
問題は技術的知識に関することではなく、構造に関することです。
「アプリケーションの仕様、コードの書き方はすべて知っているけれど、それをどこに置けばよいのか分からない。」そんな状況を考えてみましょう。
メソッドをグループ分けし、クラスにまとめる
Rubyのようなクラスベースのオブジェクト指向言語ではクラス内にメソッドを定義します。
どんなクラスを作るかによってアプリケーションに対する考え方が変わるため、メソッドを正しくグループ分けし、クラスにまとめることはとても重要です。
変更が簡単なコードを組成する
-
「変更が簡単なコード」の定義
- 変更は副作用をもたらさない
- 要件の変更が小さければ、コードの変更も相応して小さい
- 既存コードは簡単に再利用できる
- 最も簡単な変更方法はコードの追加である(たたし追加するコートはそれ自体の変更が容易なものとする)
-
上記の定義を満たすためのコードは次のようなコードを指します
- 見通しが良い:変更がもたらす影響が明白である
- 合理的:変更にかかるコストは「変更がもたらす利益にふさわしい」
- 利用性が高い:新しい環境や予期せぬ環境でも再利用できる
- 模範的:上記の品質を自然と保つようなコードになっている
2. 単一の責任を持つクラスをつくる
「見通しが良い、合理的、利用性が高い、模範的」なコードを書くための第一歩は
それぞれのクラスが明確に定義された単一の責任を持つことです。
アプリケーションの例(自転車とギア)
自転車のギアは1漕ぎあたりに車輪がどれだけ回転するかを変えられます。
その回転数 車輪回転数(ギア比)=ギアの歯数➗漕ぐ回数
を求める「ギア」クラスを
作成してみます。
- ギアによる車輪回転数(ギア比)を求める「ギア」クラス
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 # -> 4.72727272727273
puts Gear.new(30, 27).ratio # -> 1.11111111111111
上記クラスは回転数(ギア比)の計算のみで進む距離はわかりません。
車輪の大きい自転車の場合、車輪が1回転するたびに進む距離は車輪
の小さい自転車に比べて長くなります。
「車輪の直径とギア比」による自転車の進み具合を「ギアインチ」と言いますが、
「ギアインチ」を求める公式の振る舞いを追加で実装してみます。
# 公式
ギアインチ=車輪の直径×ギア比
ただし
車輪の直径=リムの直径+タイヤの厚みの2倍とする
- ギアインチの振る舞いをGearクラスに追加
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
# タイヤはリムの周りを囲むので、車輪の直径を計算するためには2倍する
ratio * (rim + (tire * 2))
end
end
puts Gear.new(52, 11, 26, 1.5).gear_inches
# -> 137.090909090909
puts Gear.new(52, 11, 24, 1.25).gear_inches
# -> 125.272727272727
- 「ギアインチ」追加により既存コードが動かなくなった。
puts Gear.new(52, 11).ratio # 「ギアインチ」を追加する前は動いてたコード
# ArgumentError: wrong number of arguments (2 for 4)
# from (irb):20:in 'initialize'
# from (irb):20:in 'new'
# from (irb):20
単一責任は重要
Gearクラスは「進化する」アプリケーションの一部です。変更が簡単なアプリケーションは再利用可能なクラスで構成されます。2つ以上の責任を持つクラスは、簡単に再利用できません。
クラスが単一責任かどうか見極める
現状、Gearクラスは「ギア比、ギアインチ、タイヤサイズ、リムサイズ」を持っています。
ギア比はGearクラスからすれば理にかなっていますが、その他はしっくりきません。
Gearクラスが複数の責任を持つのは確かです。
3. 変更を歓迎するコードを書く
データではなく、振る舞いに依存する
-
インスタンス変数の隠蔽
インスタンス変数をアクセサで包む
class Gear
attr_reader :chainring, :cog # <-------インスタンス変数の隠蔽
def initialize(chainring, cog)
@chainring = chainring
@cog = cog
end
def ratio
chainring / cog.to_f # <-------インスタンス変数を使わず、アクセサを使う
end
end
-
データ構造の隠蔽
仮に「車輪の直径」計算の責任を持つクラスを作成したとします。
そのクラスの中でRubyのStructクラスを使ってデータ構造を包み隠します。
class RevealingReferences
attr_reader :wheels
def initialize(data)
@wheels = wheelify(data)
end
def diameters
wheels.collect {|wheel|
wheel.rim + (wheel.tire * 2)}
end
Wheel = Struct.new(:rim, :tire)
def wheelify(data)
data.collect {|cell|
Wheel.new(cell[0], cell[1])}
end
end
あらゆる箇所を単一責任にする
-
メソッドから余計な責任を抽出する
上記class RevealingReferences
のdiameters
はwheelsの繰り返し処理とそれぞれのwheelを計算しているので、2つの責任を持っています。
以下のように2つに分割し、単純化できます。# 配列を繰り返し処理する def diameters wheels.collect {|wheel| diameter(wheel)} end # 「1つ」の車輪の直径を計算する def diameter(wheel) wheel.rim + (wheel.tire * 2)) end
では、Gearクラスの
gear_inches
メソッドはどうでしょうか。
確かにgear_inchesはGearクラスの責任ですが、
gear_inchesの中には「車輪の直径」の計算が隠されています。def gear_inches # タイヤはリムの周りを囲むので、車輪の直径を計算するためには2倍する ratio * (rim + (tire * 2)) end
なので、以下のように責任を分割できます。
# ギアインチの計算 def gear_inches ratio * (rim + (tire * 2)) end # 車輪の直径を計算 def diameter rim + (tire * 2) end
-
クラス内の余計な責任を隔離する
Gearクラスに含まれいる車輪の振る舞いはGearクラスの責務でしょうか?Gearクラスを単一責任にするには車輪の直径を取り除く必要がありそうです。
そのため、車輪直径計算の責任を持つWheel Structを作成します。class Gear attr_reader :chainring, :cog, :wheel def initialize(chainring, cog, rim, tire) @chainring = chainring @cog = cog @wheel = Wheel.new(rim, tire) end def ratio chainring / cog.to_f end def gear_inches ratio * wheel.diameter end Wheel = Struct.new(:rim, :tire) do def diameter rim + (tire * 2) end end end
4. Wheel Structをクラス化し、ついに完成
当初の要件に加え、車輪の円周計算もほしいという追加要望があったとします。
車輪の円周は円周率PI と直径を掛け合わせたもので、Wheelは直径の計算ができます。
円周計算メソッドの追加で要望対応は可能ですが、
車輪円周はGearクラスの責任ではないため、WheelをGearから分割、クラス化し、
Wheelクラスに円周計算メソッドを追加します。
これでGearとWheelの責任分割は完了です。
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
# -> 91.106186954104
puts Gear.new(52, 11, @wheel).gear_inches
# -> 137.090909090909
puts Gear.new(52, 11).ratio
# -> 4.72727272727273
Discussion