再利用性の高いコードを書くにはどうすればいいか自分なりに悩んで調べた結果、オブジェクト指向の設計の原則を理解することに辿りつた。全体像だけ把握したい場合は、サマリーだけ見ていただければ結構です。
サマリー
オブジェクト指向の設計の原則を整理したが、これらを抽象化すると以下のことを実現するための経験則であると言える。
1. 一緒に変わりやすいまとまりに分けるため
変更されやすい、同じ抽象度のまとまりでクラスやインターフェースがまとまっていることで、必要な単位でそれらを再利用することができる。必要以上にまとまっていると、予期せぬ結合や変更があった場合に影響が出る範囲が大きくなってしまう。
2. 変わりにくいものに依存する
変わりにくいものに依存することで、すでに書いた実装を修正するコストを下げることができる。
変わりにくいものを見つけるには、本質的にやろうとしていることは何かを考えて抽象化することである。
この2つに収斂されたのは、オブジェクト指向ができた背景を知ると妥当だと言える。
オブジェクト指向は、ソースコードの再利用性を高めることを目的としている。
それを実現するためにオブジェクト指向は以下の手段を用いることができる。
- クラス : 使って変わりやすいものをまとめる
- 継承 : 抽象と具体を分ける
- ポリモーフィズム: 抽象インターフェースに依存する
そして、オブジェクト指向の設計の原則はこれらを適切に使うための原則であるので、満たされると1.変わりやすい単位でまとまっていたり、2. 変わりにくいものに依存した状態になるはずである。
次に、それぞれのオブジェクト指向の設計の原則は下記の5つである。
- 単一責任の原則(SRP) : 単純な役割でなく変わりやすい抽象度でクラスを分ける
- インターフェース分離の原則(ISP): 変わりやすい単位でインターフェースを分ける
- 依存関係逆転の原則(DIP) : 変わりにくい抽象に依存する
- オープンクローズドの原則(OCP) : 適切な基準の抽象化ができているかどうかの指標
- リスコフの置換原則(LSP) : 継承が正しい抽象になっているのかの指標
それでは個別にオブジェクト指向の設計の原則を説明する。
単一責任の原則(SRP: Single Responsibility Principle)
概要
クラスを変更する理由は1つ以上存在してはならない
なぜそれが重要なのか?
クラスの複数の役割があれば、その1つ1つが変更理由になるから。
クラスが複数の役割を担っている場合、それらの役割は結合してしまう。その結果、ある役割が変更を受けると、そのクラスが担っている他の役割も影響を受け不具合が生じる可能性がある。こういった類の結合はある部分が変更を受けると、予想もしない形で壊れてしまうような脆い設計を生み出してしまう。
例えば、寿司の出前の一覧をCSV出力するクラスを考えてみる。寿司の注文SushiOrder
は、注文を申し込まれるとregistered
、配達が完了するとdelivered
のステータスになる。registered
の場合は申し込み日時receipt_date
で、delivered
の場合は配達日時delivery_date
でソートしてCSV出力する。
class SushiOrdersCorntroller
def csv_download
service = SushiOrder::CreateCSVService.new(params)
end
end
class SushiOrder::CreateCSVService
def initialize(sort_params)
@status = sort_params[:status]
end
def execute
CSV.generate do |csv|
output_orders.each do |order|
csv << header
csv << columns(order)
end
end
end
# CSV出力する注文をステータスに応じて並び替える
def output_orders
if @status == 'registered'
SushiOrder.where(status: 'registered').order(receipt_date: :desc)
else
SushiOrder.where(status: 'deliveried').order(delivery_date: :desc)
end
end
def header
%w(ID 申込先 金額 申込日時 配達日時)
end
def columns(order)
[
order.id,
order.company_name,
order.amount,
order.receipt_date,
order.delivery_date
]
end
end
その後、仕様の追加で別コントローラからは申込ステータスregistered
を金額amount
順でCSV出力することになった。
しかし、この場合はSushiOrder::CreateCSVService
を利用することができない。また、ステータスに応じて並び替えるアルゴリズムに変更が生じた場合、このService(#output_orders
)も変更する必要が生じる。その変更によって同じクラス内にあるCSV出力の他のアルゴリズムに影響を与える可能性も出てくる。(今回はそこまで複雑に絡み合っていないが) なぜなら、共通のスコープで詳細が結びつけられているから。
これらの原因は、このServiceは
1. SushiOrderをCSV出力する
2. パラメータに応じてSushiOrderを並び替える
という2つの役割を担って、それぞれが1つのクラスで絡み合っているから。
このように1つのクラスが役割を2つ以上担っていると、移植性のなさや脆さを呈するようになる。
役割とは何か?
実際に役割を適切な抽象度で認識することは難しい。
先ほどの例だと「パラメータに応じて並び替えてCSV出力する」という単一の役割と考えることもできるし、「CSV出力する」役割を「headerをCSV出力する」役割と「実際の注文をCSV出力する」役割に分解して考えることもできる。
単一責任の原則(SRP)では「役割(責任)= 変更理由」と定義している。しかし、クラスが2つ以上の役割を担っているかどうかを見極めるのが非常に難しいことがある。それは、我々には役割をグループに分けて考える習慣があるからだ。
class Report::GeneralLedger
def execute
calculate
...
output
end
# 勘定科目ごとに金額を集計する
def calculate
end
# 総勘定元帳を出力する
def output
end
end
この総勘定元帳のクラスには2つの役割がある。1つ目は「金額の集計」であり、2つ目に「レポートの出力」がある。
これらの役割は分離すべきか?
それはアプリケーションが今後どのように変更されるかどうか次第になる。1つ目の役割である「金額の集計」のメソッドが他のオブジェクトでも利用される場合、この設計は「移植性のなさ」を呈する。一方で2つの役割が同時に変更されるようなケースでは、これらを分離する必要はない。そうしてしまうと逆に設計が不必要に複雑になる。
ここに必然的な法則がある。「変更の理由が変更の理由たるのは、実際に変更の理由が生じた場合だけである」
変更の兆候もないのに単一責任の原則(SRP)を適用するのは賢明ではない。これは、他の原則についても同様だ。
まとめ
- 変更理由 = 役割
- クラスを変更する理由は1つ以上存在してはならない
- なぜなら変更する理由が複数あると
- それぞれの役割を分割して利用することが困難になる
- それぞれの役割の詳細が互いに見えるので依存しやすくなる
- 役割はそれらがまとまって変更される単位で捉える
インターフェース分離の原則(ISP: Interface Segregation Principle)
概要
クライアントに、クライアントが利用しないメソッドへの依存を強制してはならない
理由
単一責任の原則(SRP)のインターフェース版といった感じ。理由が似ている。この原則の目的は複数のインターフェースが一部だけしか強い関連性を持っていない場合に対して、強い関連性のあるインターフェース同士でまとめて、それぞれ別のクラスでグループ化することを指向している。そうすることによって、必要な単位で分けて利用することができるから。それをせずに利用すると利用されないいくつかのインターフェースが存在するので、サブクラスに不必要な複雑性が伴う。またインターフェースに変更が生じた場合、そのインターフェースを利用していなかったとしても、そのクラスを利用しているサブクラスである限り、影響がないかどうか確認する必要がでてくる。
例えば、乗り物Vehicle
クラスにエンジンに関するインターフェース(#start_engine
, #stop_engine
)が存在するがエンジンを利用しない自転車などのBicycle
クラスでは利用されない。利用されないインターフェースがあってもVehicle#start_engine
に変更があった場合はそれを継承しているBicycle
も確認するまでそうだと分からないので確認しないといけなくなる。また本来、自転車ではエンジンを使わないが、その意図が伝えられず、利用者に考えさせる不必要な複雑性を持ってしまう。
class Vehicle
# エンジンをかける
def start_engine
end
# エンジンを止める
def stop_engine
end
# 出発する
def depature
end
end
class Car < Vehicle
def drive
start_engine
depature
...
stop_engine
end
end
class Bicycle < Vehicle
# 自転車はエンジンを使わない
def drive
depature
...
end
end
まとめ
- インターフェース分離の原則は、インターフェースを変わりやすい単位でまとめるという原則
- そうすることで、インターフェースを再利用しやすくなる
- インターフェースに変更があった時にそのクラスを利用しているサブクラスを確認するコストが最小限になる
- 本来は利用しないという意図が伝わらずそのクラスの利用者に考えさせるような複雑性を持ってしまう
依存関係逆転の原則(DIP: Dependency Inversion Principle)
概要
1. 上位のモジュールは下位のモジュールに依存してはならない、どちらのモジュールも「抽象」に依存すべきである
2. 「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである
要するに依存関係逆転の原則(DIP)は「抽象」に依存せよという経験則になっている。
この経験則によると、
- 具体的なクラスへの参照を保持するような変数があってはならない
- 具体的なクラスから派生するクラスがあってはならない
- 基本クラスで実装されているメソッドを上書きしてしまうようなメソッドがあってはならない
理由
なぜ、「抽象」に依存すべきなのかというと、「抽象」の方が変わりにくいから。
依存先が変わると依存元にも影響が波及し、変更を要する。そのため、できるだけ依存先は変わりにくいものがいいということ。逆に考えると、依存しているクラスが具体的なものであっても頻繁に変更されないクラスであれば、この経験則に従う必要はない。例えばStringクラスなどの言語の標準クラスになっているものなどは、滅多に変更されることはない。
以下の例は旅行クラス(Trip
)が整備士(Mechanic
)、旅行企画者(TripCoordinator
)のオブジェクトを受け取って、それぞれに旅行の準備に必要な手続きを命令する実装になっている。整備士には旅行に必要な自転車を、旅行企画者には必要な飲食を準備してもらう。
# 『オブジェクト指向設計実践ガイド』p126より
class Trip
attr_reader :bicycles, :customers, :destination
def prepare(preparers)
# インターフェースではなく具体的なクラスに依存している
preparers.each do |preparer|
if preparer.is_a?(Mechanic)
preparer.prepare_bicycles(bicycles)
elsif preparer.is_a?(TripCoordinator)
preparer.buy_food(customers)
else
nil
end
end
end
end
class Mechanic
def prepare_bicycles(bicycles)
end
end
class TripCoordinator
def buy_food(customers)
end
end
ここでいう「抽象」とは実装の詳細が変更されても影響を受けない本質的な部分のことを意味する。今回の例でのTrip#prepare
が依存すべき「抽象」は準備を命令すること。「誰に」、「どのような準備」も何でも構わない。これらは具体的な実態と手段になっている。旅行クラスが、旅行の準備を依頼することはなかなか変わりにくいが、どのようなクラスにどのような準備の仕方をするかという情報は具体的であり、変更されやすい。
# 『オブジェクト指向設計実践ガイド』p127より
class Trip
attr_reader :bicycles, :customers, :destination
def prepare(preparers)
preparers.each do |preparer|
# 誰にも、どのようにも気にせず「旅行の準備をする」というメッセージを送っているだけ
preparer.prepare_trip(self)
end
end
end
class Mechanic
# 共通のインターフェースを持つ
def prepare_trip(trip)
trip.bicycles.each do |bicycle|
prepare_bicycle(bicycle)
end
end
end
class TripCoordinator
# 共通のインターフェースを持つ
def prepare_trip(trip)
buy_food(trip.customer)
end
end
一般的に、「誰に」、「どのような」という情報よりも「何を」という情報だけを持つインターフェースの方が変わりにくい。
まとめ
- 依存関係逆転の原則は「抽象」に依存せよという経験則
- 具体的なクラスは抽象的なクラスに依存し、依存関係は具体的なクラスで終始してはいけない
- 結局、変わりにくいものに依存した方が安定でかつ、再利用性が高くなるからそうしているだけ
オープンクローズドの原則(OCP: Open-Closed Principle)
概要
ソフトウェアの構成要素(クラス、モジュール、関数など)は拡張に対して開いていて(Openで)、修正に対して閉じて(Closedで)いなければならない
オープンクローズドの原則(OCP)に従って設計されたモジュールには以下のような特徴を持つ。
1. 拡張に対して開かれている(Open)
これはモジュールの振る舞いを拡張できるという意味である。アプリケーションの仕様要求が変更されても、モジュールに新たな振る舞いを追加することでその変更に対処できる。つまり、そのモジュールの処理内容を変更できるのだ。
2. 修正に対して閉じている(Closed)
モジュールの振る舞いを拡張しても、そのモジュールのソースコードやバイナリコードは全く影響を受けない。すでにモジュールがコンパイルされてバイナリ形式になっているものは、それがリンクライブラリであろうと、DLLであろうと、Javaのjarファイルであろうと手を触れる必要がない。
それを実現するにはどうすればいいか?
この原則が満たされるべきなのは明白である。なぜならそれが満たされていた方が、ソフトウェアの構成要素の再利用性が高まりメンテナンスしやすい実装になるからだ。問題は、これが実現されるにはどうすればいいのか?ということである。
結論から言うと「抽象」に依存することで満たされることが多い。「抽象」は変わりにくいので、新しい仕様が追加された場合、それを修正することなく(修正に閉じて)、新しくコードを追加する(拡張に対して開いている)ことで機能を拡張することができる。
先ほどの旅行クラスの例で考えると、仕様の追加があり、旅行の準備には案内人Conductor
に目的地までの道のりを決めてもらう必要が生じた。抽象のprepare_trip
というインターフェースに依存せず、具体的なクラスに依存している場合は、クラスを特定するif文を修正し、送るべきメッセージを追加する必要がある。これは変更に閉じていない。
class Trip
attr_reader :bicycles, :customers, :destination
def prepare(preparers)
# インターフェースではなく具体的なクラスに依存している
preparers.each do |preparer|
if preparer.is_a?(Mechanic)
preparer.prepare_bicycles(bicycles)
elsif preparer.is_a?(TripCoordinator)
preparer.buy_food(customers)
# 既存のif文の条件を修正してConductorクラスのメソッド呼び出しを追加した
elsif preparer.is_a?(Conductor)
preparer.decide_route(destination)
else
nil
end
end
end
end
...
class Conductor
def decide_route(destination)
end
end
一方で、Trip#prepare
が抽象的なインターフェースであるprepare_trip
に依存していると、これ自体は変わらないのでTrip#prepare
の実装自体は変更する必要がない。つまり変更に閉じている。そして、Conductor
クラスに新たにそのインターフェースを持つメソッドを追加するだけで、振る舞いの拡張ができる。つまり拡張に開いている。
class Trip
attr_reader :bicycles, :customers, :vehicle
def prepare(preparers)
preparers.each do |preparer|
# prepare_tripというインターフェースに依存しているので、今までの実装をそのまま使える
preparer.prepare_trip(self)
end
end
end
...
class Conductor
# 新しく共通のインターフェースを持つメソッドを追加しただけ
def prepare_trip(trip)
decide_route(trip.destination)
end
end
ここで依存関係逆転の原則(DIP)に従った実装を使ったようにオープンクローズドの原則(OCP)は、それを満たしていない場合に他の原則が満たしていないことを示唆するものになっている。
まとめ
- オープンクローズドの原則(OCP)は拡張に開いて修正に閉じているオブジェクト指向の設計の理想の状態
- それを実現するには「抽象」に依存する必要がある
- それに従っていない場合は、他の原則にも違反している場合が多い
リスコフの置換原則(LSP: Liskov Substitution Principle)
概要
派生型はその基本型と置換可能でなければならない
ここで望まれるのは、次に述べるような置換可能な性質である。:S型のオブジェクトo1の各々に、対応するT型のオブジェクトo2が1つ存在し、Tを使って定義されたプログラムPに対してo2の代わりにo1を使ってもPの振る舞いが変わらない場合、SはTの派生型であると言える。
要するに、SがTのサブクラス => P(o1<S>)
とP(o2<T>)
の振る舞いが変わらない、場合リスコフの置換原則(LSP)を満たすということである。もっと平易な言葉を使うと、「基本クラスを使っている箇所でサブクラスを使っても問題なく動かなければならない」ということである。
違反するとどうなるか?
リスコフの置換原則(LSP)に違反すると、基本クラスと派生クラスのis_aの関係が振る舞いレベルで満たされていないことがわかる。
これが一度でも違反されると基本クラスを使った処理のすべてに、その例外的なサブクラスの振る舞いがあるかどうかを確認しなければなくなる。
class Base
def f
# 何らかのコード
end
end
class Sub < Base
# override
def f
raise 'このクラスでは使うべきでないメソッドです'
end
end
class MyClass
def g(base)
base.f unless base.is_a?(Sub)
end
end
契約による設計との関係
「契約による設計」を使えばリスコフの置換原則を満たすことができる。
まず用語の説明をする。
- 事前条件: そのメソッドを実行する前に成立していなければならない条件
- 事後条件: メソッドが終了した時に成立していなければならない条件
とする。
「契約による設計」とは、このようなものを指す。
ルーチンの再定義をする場合、事前条件についてはオリジナルと等しいか、それより弱い条件と置き換え、事後条件についてはオリジナルと等しいか、それより強い条件と置き換える
要するに、このようになっている必要がある。
- 派生クラスの事前条件:基本クラスに課せられている事前条件よりも緩い(派生クラスは基本クラスの許可することを全て満たす)
- 派生クラスの事前条件 ⊂ 基本クラスの事前条件
- 派生クラスの事後条件:基本クラスの事後条件を全て含んでいなければならない(派生クラスは基本クラスの事後条件を無視してはならない)
- 基本クラスの事後条件 ⊂ 派生クラスの事後条件
上記を満たすことでリスコフの置換原則(LSP)が満たされることがわかる。
まとめ
- リスコフの置換原則(LSP)は、基本クラスとサブクラスの振る舞いの同等性を確認するためのもの
- これを満たすには、基本クラスを使っている箇所でサブクラスを使っても問題なく動かなければならない