Zeals Advent Calendar 2019 の19日目の記事です。
サーバサイドエンジニアの小寺です。私がまだ初学者に近かった頃、「もっと早く知っておけばよかった!」と心の底から思ったオブジェクト指向設計についての知識を書き残しておこうと思います。
今回は、実務で求められる「保守性の高い読みやすいコード」を実践するために
オブジェクト指向設計実践ガイドを学習し、理解できたところを自分なりに要点をまとめ・メモし、備忘録としたものです。
(※4章までの内容が理解できれば十分実践していけると思いますので4章までしかまとめてないです)
参考書籍
オブジェクト指向設計実践ガイド
本書のサンプルコードを引用しています。
対象
- オブジェクト指向についてふんわり理解している人
- 責務がどうのこうのといったくだりをふんわり理解してる人
- オブジェクト指向設計を理解してステップアップしたい初学者
2章 単一責任を意識する
- なぜ単一責任が必要なのか
- 再利用したい
- 再利用できると未来の予期せぬ変更に対応できる
- 悪いコードを増殖させたくない
単一責任の見極め方
- 役割を聞く
# 「オブジェクト指向設計実践ガイド」 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
ratio * (rim + (tire * 2))
end
end
gear = Gear.new(52, 11, 26, 1.5)
gear.ratio
# ギアに対してギア比を聞くのは良い
gear.gear_inches
# ギアに対してgear_inchesを聞くのはしっくりこないような気がする
gear.tire
# ギアに対してタイヤのサイズを聞くのはもっとおかしい
インスタンス変数の隠蔽
# 「オブジェクト指向設計実践ガイド」 p46
class Gear
def initialize(chainring, cog)
@chainring = chainring
@cog = cog
end
def ratio
@chainring / @cog.to_f # <-- 破滅への道
end
end
アクセサメソッド(attr_reader)で隠す
# 「オブジェクト指向設計実践ガイド」 p46
class Gear
attr_reader :chainring, :cog # <--
def initialize
:
def ratio
chainring / cog.to_f # <--
end
end
実装的には以下と同じ
def cog
@cog
end
実際に得ている恩恵としては未来に複雑な実装が来た時に変更が簡単
def cog
if 条件
@cog * 何かの計算
else
(@cog * 何かの計算) + 複雑な計算
end
end
データ構造の隠蔽
# 「オブジェクト指向設計実践ガイド」 p48
class ObscuringReferences
attr_reader :data
def initialize(data)
@data = data
end
def diameters
# 0はリム, 1はタイヤ <-- 何のデータがどこにあるか知っている状態
# 配列の構造が変わった時などに怖い
data.collect {|cell|
cell[0] + (cell[1] * 2)
}
end
end
# 以下のデータが必要になる
@data = [[622, 20],[622, 23],...]
Structクラスを使って構造を包み隠す
# 「オブジェクト指向設計実践ガイド」 p50
class ObscuringReferences
attr_reader :wheels
def initialize(data)
@wheels = wheelify(data)
end
def diameters
# wheelが rim と tire を持ってることだけ知っていればいい
wheels.collect {|wheel|
wheel.rim + (wheel.tire * 2)
}
end
# これで誰でも wheel に rim/tire を送れる
Wheel = Struct.new(:rim, :tire)
def wheelify(data)
data.collect {|cell|
cell[0] + (cell[1] * 2)
}
end
end
メソッドから余計な責任を抽出する
メソッドもクラスと同じく単一の責任を持つべき(再利用が簡単になるので)
先ほどの diameters を見てみる
# 「オブジェクト指向設計実践ガイド」 p52
class ObscuringReferences
:
def diameters
# wheelsを繰り返し処理するのと、それぞれの直径を計算する
# といった2つの責任を持っている
wheels.collect {|wheel|
wheel.rim + (wheel.tire * 2)
}
end
:
end
上記のメソッドを簡単に変更できるようにしていく
# 「オブジェクト指向設計実践ガイド」 p52
class ObscuringReferences
:
def diameters
# 直径を計算する処理を別にした
wheels.collect {|wheel| diamete(wheel) }
end
# diameter を呼べるようになった(他で再利用可能になった)
def diameter(wheel)
wheel.rim + (wheel.tire * 2)
end
:
end
上記はよくある責任が複数あるわかりやすい例。
大抵はこれほど明確ではない。先ほどのGearクラスを思い出してみる。
# 「オブジェクト指向設計実践ガイド」 p53
class Gear
:
# 何か不確定でのちにトラブルを起こしそうな気がする
# このメソッドが2つ以上の責任を持ってしまっている
def gear_inches
ratio * rim + (tire * 2)
end
:
end
gear_inches に隠れている直径の計算を抽出してみる
# 「オブジェクト指向設計実践ガイド」 p53
class Gear
:
# Gearがgear_inchesの計算するのはよし
def gear_inches
ratio * diameter
end
# しかし、車輪の直径の計算までするのはおかしい
def diameter
rim + (tire * 2)
end
:
end
といったように、小さなリファクタリングではあるが Gear が持つべきでない責任が明らかになりました。
このように、あらゆるものを単一責任にしていくことで
- 隠蔽された性質を明らかにする
- 上記の例だと 「Gear は diameterを持つべきではない」
- コメントをする必要がない
- メソッドがコメントの役割を果たす
- 再利用を促進する
- 他のプログラマーは複製ではなく再利用をするようになる
- 他のクラスへの移動が簡単
- 小さなメソッドは簡単に動かすことができる
といった恩恵が受けれます
クラス内の余計な責任を隔離する
一旦全てを単一責任するとクラスのスコープが明白になってきます。
GearクラスにはいくつかのWheel(車輪)のような振る舞いが隠れていました。
ここで、新しくクラスを作ってもいいのですがそうなると変更のコストが大きいかもしれないのでコストを最小限にしつつGearの単一責任を保つようにします。
# 「オブジェクト指向設計実践ガイド」 p55
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 のような振る舞いを手に入れた
# 書籍ではこの後追加要望があったタイミングで新しいクラスを定義する
# 「あとで」決定できる力を取っておくことはアプリケーション開発において重要
Wheel = Struct.new(:rim, :tire) do
def diameter
rim + (tire * 2)
end
end
end
本書では、新しくアプリケーションに車輪に関する追加機能の要望が来たタイミングで Wheel クラスを新しく定義するよう書かれています。
アプリケーション開発において未来を予測することは困難なのでできる限り「あとで」決定できる力を残しておくのは非常に重要なことである
3章 依存関係を管理する
依存関係を認識する
オブジェクトが次のものを知っている時、オブジェクト間には依存がある
- 他のクラスの名前を知っている
- GearはWheelというクラスが存在すると予想している
- self以外のメッセージの名前
- GearはWheelがdiameterに応答できると知っている
- メッセージが要求する引数
- Gearは Wheel.newするときに rim とtireが必要なことを知っている
- 引数の順番
- 最初が rim で 2番目が tire だと知っている
# 「オブジェクト指向設計実践ガイド」 p60
class Gear
attr_reader :chainring, :cog, :rim, :tire
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
# Wheel.newするときに rim とtireが必要なことを知っている
@rim = rim
@tire = tire
end
def ratio
chainring / cog.to_f
end
def gear_inches
# Wheelというクラスが存在すると予想している
# Wheelがdiameterに応答できると知っている
# 最初が rim で 2番目が tire だと知っている
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
依存が発生している時、依存が強固になりすぎると2つのオブジェクトは変更することが難しくなってくるので2つのオブジェクトがあたかも1つのオブジェクトとして振る舞うようになってしまう。
疎結合なコードを書く
依存を減らすための技法を紹介する
依存オブジェクトの注入
クラス内でインスタンスを生成するのではなく diameterを知っているオブジェクトを受け取るようにしておく
# 「オブジェクト指向設計実践ガイド」 p66
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
:
end
# Gearはdiameterを知っているオブジェクトを要求する
Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches
依存を隔離する
- インスタンス変数の作成の分離
- 制約が厳しく、オブジェクトを注入できないような場合
- クラス内で分離するしか方法がないとき
# 「オブジェクト指向設計実践ガイド」 p68
class Gear
attr_reader :chainring, :cog, :rim, :tire
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
# Gearはまだ知りすぎているが、gear_inchesの依存が減っている
@rim = rim
@tire = tire
end
:
def gear_inches
ratio * wheel.diameter
end
# wheel が呼ばれるまでインスタンスは作成されない
def wheel
@wheel || Wheel.new(rim, tire)
end
end
- 脆い外部メッセージを隔離する
- 外部への参照がクラスに埋め込まれていて、どれが変更されやすい時
# 「オブジェクト指向設計実践ガイド」 p70
def gear_inches
# 恐ろしい計算
foo = some_intermedicate_result * wheel.diameter
# 恐ろしい計算
end
上記のように複雑な状況になった時にメソッドの中に依存が隠れてしまう。
そうした場合以下のように脆い部分を隔離する
# 「オブジェクト指向設計実践ガイド」 p71
def gear_inches
# 恐ろしい計算
foo = some_intermedicate_result * diameter
# 恐ろしい計算
end
def diameter
# もし、Wheelがdiameterに変更加えた場合副作用はここだけになる
wheel.diameter
end
引数の順番への依存を取り除く
- 初期化の際にハッシュ使う
# 「オブジェクト指向設計実践ガイド」 p73
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(args)
@chainring = args[:chainring]
@cog = args[:cog]
@wheel = args[:wheel]
end
:
end
# 冗長にはなったが、引数の順番を知っていなくても大丈夫になった
# ハッシュのkey名に依存はしているが、順番よりは健康的
Gear.new(
chanring: 52,
cog: 11,
wheel: Wheel.new(26, 1.5)
).gear_inches
安定性の高い引数をいくつか受け取って、安定性の低い引数はオプション引数で受け取るといった手法が多く用いられる
- 明示的にデフォルト値を設定する
# 「オブジェクト指向設計実践ガイド」 p74
def initialize(args)
# 真偽値以外の場合はシンプルに || を使える
@chainring = args[:chainring] || 40
@cog = args[:cog] || 18
@wheel = args[:wheel]
end
# 真偽値を使う or nilを設定する場合
def initialize(args)
@chainring = args.fetch(:chainring, 40)
@cog = args.fetch(:cog, 40)
@wheel = args[:wheel]
end
- 複数のパラメーターを用いた初期化を隔離する
- もし、引数を簡単に変更できないような状況下にあった場合ラッパークラスを定義して包み隠すようにする
# 「オブジェクト指向設計実践ガイド」 p77
# Gearがもし外部インターフェースの一部の場合(このクラスを変更できない場合)
module SomeFramework
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(args)
@chainring = args[:chainring]
@cog = args[:cog]
@wheel = args[:wheel]
end
:
end
end
# 外部のインタフェースをラップし、自信を変更から守っている
# 他のオブジェクトを生成することが目的のオブジェクトはファクトリーと呼ぶ
module GearWrapper
def self.gear(args)
SomeFramework::Gear.new(
args[:chainring],
args[:cog],
args[:wheel]
)
end
end
GearWrapper.new(
chanring: 52,
cog: 11,
wheel: Wheel.new(26, 1.5)
).gear_inches
このテクニックは自分で変更がきかない外部のインターフェースに依存する場合に適している。
依存方向の管理
「自分より変更が少ないものに依存すべし」
4章 柔軟なインターフェースを作る
パブリックインターフェースとは
クラスのパブリックインターフェースを作り上げるメソッドは以下のような特性を備えている
- クラスの主要な責任を明らかにする
- 外部から実行されることが想定される
- 気まぐれに変更されない
- 他者がそこに依存しても安全
- テストで完全に文書化されている
パブリックインターフェースを見つける
「アプリケーション例: 自転車旅行会社」
ユースケースとして
「参加者は適切な難易度の、特定の日付の、自転車を借りられる旅行の一覧をみたい」
見当をつけるためにシーケンス図を使う
オブジェクト間でやり取りされるメッセージを気軽に実験することができる
ここで起きる疑問
「Tripが利用可能な自転車まで調べなくてもいいんじゃないか?」
シーケンス図を描くことによって
「このオブジェクトが〇〇という責任を負うべきなのだろうか?」と疑問が湧くようになる
ある旅行に対して自転車が利用可能かどうかTripクラスが見つけ出すべきでない場合、
Bycycleクラスがありそう。
- Tripはsuiable_tripsに責任があり
- Bycycleはsuitable_bycycle に責任を持つ
変更後は Tripから余計な責任は取り除けたものの Customer に移しただけ
変更後は Customer が何を望むのかと他のオブジェクトがどのようにそれを準備するのかまで知ってしまっている
「どのように」を伝えるのではなく「何を」を頼む
新たなユースケース
「旅行が開始されるためには使われる自転車が全て整備されていることを確実にする」
TripはMechanicがどのように整備するのか(
clean_bicycleして
pump_tiresして
lube_chainして
check_brakesするという手順
)を知ってしまっている。
Mechanicが新たな整備手順を増やした時は、Tripも変更しなければならない
以下は対案
Tripにあった責任をほとんどMechanicに渡している
自転車の準備することに関して「どのように」はMechanicの責任になった
TripはMechanicにどんな改善があろうともprepare_bicycleから正しい振る舞いを得ることができる
上記のようにMechanic と Trip の会話が 「どのように」から「何を」に変わった副作用として
Mechanicのパブリックインターフェースのサイズが小さくなった
パブリックインターフェースが小さいということは他のところから依存されるメソッドがわずかしかないことを意味している
コンテキストの独立を模索
旅行の準備には「いつでも」自転車の準備が求められるためTripは「常に」prepare_bicycleメッセージを自身のMechanicへ送らなければならない
Tripが旅行が準備されることをMechanicに伝え、Mechanicは準備にbicyclesが必要なのでTripにコールバックをし、自転車の整備を行います。
こうして、整備士がどのように自転車を準備するかはMechanicクラスに隔離されました。
オブジェクトを見つけるためにメッセージを使う
Tripが知りすぎていたのを改善したが、今度はCustomerが知りすぎている。
上記のアプリケーションは要件を満たす新たなオブジェクトを必要としていることがなんとなく見えてきた。
新たなTripFinderは安定したパブリックインターフェースを提供し、複雑な内部に関しては隠している
シーケンス図便利ー
まとめ
単一責任を意識しながらコードを書けるようになりましょう
- そのために常にメソッドの役割をメソッド自身に問い続ける
- 抽出できるものはできる限り抽出しておくと未来に変更が簡単になる
クラスの中で依存関係があることを認識する
- 状況に応じて疎結合にするための技法を使い分ける
オブジェクト間で交わされるメッセージを中心にアプリケーションを設計する
- 議論を進める時にはシーケンス図が有効
- どのようにではなくオブジェクトが「何を」要求するかに注目する
おわり
- 書籍の中に重要な説明がたくさんあるので本読みましょう(オブジェクト指向設計実践ガイド)
- 今回はあくまでコードベースで要点まとめただけなので
- 柔軟に進化し続けましょう
明日は20日目の @neuneu39 の番です。お楽しみに!!!