概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: SOLID Principles #3: Liskov Substitution Principle | Netguru Blog on Ruby/Ruby on Rails
- 原文公開日: 2018/03/21
- 著者: Marcin Jakubowski
- サイト: netguru
訳注: 原文のparent classは原則として「基底クラス」と訳出しました。
Railsで学ぶSOLID(3)リスコフの置換原則(翻訳)
「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。
それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを向上させることを目的としています。
- 単一責任の原則(SRP: Single responsibility principle)
- オープン/クローズの原則(OCP: Open/closed principle)
- リスコフの置換原則(LSP: Liskov Substitution Principle)(本記事)
- インターフェイス分離の原則(ISP: Interface Segregation Principle)
- 依存関係逆転の原則(DIP: Dependency Inversion Principle)
今回は、3番目の「リスコフの置換原則」を見ていきましょう。
3: リスコフの置換原則(LSP)
基底クラスから派生したクラスは、望ましくない挙動を一切伴わずに、常に基底クラスと置き換え可能でなければならない。
次のように表すこともできます。
S
がT
の派生型であるとすると、プログラム内における型T
のオブジェクトは、プログラム内の望ましいプロパティを一切変更することなく、型S
のオブジェクトに置き換え可能である。
実践的に書くと、派生クラスは基底クラスを継承するときに、常に基底クラスの挙動を一切変えないようにすべきであるということです。
最も古典的なLSP違反例を以下のスニペットで示します(Gist)。
class Rectangle
attr_accessor :height, :width
def calculate_area
width * height
end
end
class Square < Rectangle
def width=(width)
super(width)
@height = width
end
def height=(height)
super(height)
@width = height
end
end
rectangle = Rectangle.new
rectangle.height = 10
rectangle.width = 5
rectangle.calculate_area # => 50
square = Square.new
square.height = 10
square.width = 5
square.calculate_area # => 25
数学的にはどこもおかしくはありません。ここで扱っているのは正方形なので、高さと幅が同じでなければなりません。高さ(と幅)を10に設定し、続いて幅(つまり高さも)を5に設定して面積を算出します。
基底クラスと派生クラスで同じ手順を踏んでいますが、両者の振る舞いが異なっていることが見て取れます。インターフェイスが基底クラスと派生クラスで一貫していないのです。
つまり、publicなインターフェイス(そしてもちろんそれらの振る舞いも)は、基底クラスと派生クラスで同じになってなければならないということが言えます。
LSPとポリモーフィズム
もうひとつ極めて重要な点があります。「得られる結果が異なるからLSPに違反する」のではありません。「期待しない振る舞いが生じるからLSPに違反する」のです。
次の例で考えてみましょう(Gist)。
class Shape
def draw
raise NotImplementedError
end
end
class Rectangle < Shape
def draw
# 四角形を描画
end
end
class Circle < Shape
def draw
# 円を描画
end
end
draw
メソッドは、派生クラスに応じて異なる図形を描画しますが、描画はまったく期待を裏切っていません。
この点をもう少し考えてみましょう。「得られる結果が異なれば即LSP違反」ということになってしまえば、OOPの極めて強力なツールであるポリモーフィズムは全部LSP違反になってしまいます。
基底クラスのメソッドを派生クラスでオーバーライドするとき、クラスの振る舞いは変更すべきではありませんが、派生クラスの特定の側面によって振る舞いを拡張(extend)できます。
前提条件を派生型で増強してはならず、事後条件を派生型で弱めてはならない。
または次のようにも言えます。
サブクラスは、要求事項を(基底クラスよりも)増やすべきではなく、できることを(基底クラスよりも)減らすべきではない。
LSPに従うことで、自信を持ってポリモーフィズムを使えるようになり、期待しない結果が生じる心配なしに、基底クラスを参照している派生クラスを呼び出せるようになります。
問題はいったいどこに潜んでいるのか
この問題は、抽象化の中に潜んでいます。数学的には、正方形は四角形の一種ですが、プログラミングにおいては(少なくともこの場合は)違います。つまり、抽象化のモデリングに誤りがあったというだけのことです。
私がこの例を愛する理由
上の例には、OOPに関する重要なポイントが1つ示されているからです。OOPは、現実世界を単純にオブジェクトにマッピングしただけのものではありません。
OOPとは「抽象を作り出す」ことであり、「概念を作り出す」ことではありません!
もう少し改良を加える
正直に申し上げると、完全無欠の解決方法というものはありません(いつものことですが)。
- 上の例に登場するクラスたちに共通の振る舞いが存在しないことを認識します。こういう2つのクラスを結合してはいけません。振る舞いの異なる2つのクラスを作るだけにしましょう。
-
インターフェイスの型を「シミュレート」する抽象レイヤーを1つ追加します(解決は継承によっても行われますが、方法は異なります)。
第2の解決法の例を示します(Gist)。
class Shape
def calculate_area
raise NotImplementedError
end
end
def Rectangle < Shape
attr_accessor :height, :width
def calculate_area
height * width
end
end
def Square < Shape
attr_accessor :side_length
def calculate_area
side_length * side_length
end
end
この方法の最大のデメリットは何だかおわかりでしょうか。クラスの派生は(実際のインターフェイスとは逆に)1つの基底クラスからだけ行えます(もちろん、インターフェイスを継承するのではなくインターフェイスを実装します)。つまり、これらのクラスを介してさらに別の振る舞いを共有する理由があるとしても、この手法によって阻止されます。
いずれにしろ、本シリーズで扱っているのはRubyという動的型付け言語なのですから、私たちはそのようなことを強制するつもりはありません。ここでもっとも重要なのは「常識を働かせる」ことです。静的型付け言語の解決法を強引に持ち込むことが最善とは限りません。
LSPは「よい継承」を決定づける因子なのか?
「継承よりコンポジション」(composition over inheritance)という言葉を目にしたことがあるかと思います。しかし、時には継承が必要になることもあれば継承が欲しいときもありますし、継承するしかないこともあります。そのことには何も問題はありません。継承はOOPの部品、それも極めて強力な部品なのです。LSPを満たすことは、「正しく作成された継承関係」の兆しにはなるでしょう。
訳注: 「継承よりコンポジション」は、『Effective Java』の有名な言葉です。
以下の2つを自分自身に問いかけるべきです。
B
は、A
の完全なインターフェイス(すべてpublicメソッドとして)を「A
に期待するのと同じようにB
を使える形で」公開したいのか?
— この場合(おそらく)継承が必要でしょうB
は、A
が公開している振る舞いの一部だけが欲しいのか?
— この場合(おそらく)コンポジションが必要でしょう
その後で、LSPを使って以下の問いかけに答えます。
「この型を継承すべきか?」
重要: もちろん、LSPだけが決定因子ではありません。「A is B
なのかどうか(A
はB
なのか、そうでないのか)」という問いかけの方が中心にあることを忘れてはなりません。上の2つの問いかけは決定に役立ちますが、それらの問いかけは解決方法そのものではありません。
「よい継承関係の作成」は、これだけで別記事が一本書ける(下手すると本が一冊書ける)ほどの重たい話題です。今ここで申し上げたいのは、LSPを満たすことは必要だが、LSPは「よい継承を作り出す条件のひとつに過ぎない」ということです。
LSP違反の兆し
LSP違反の兆候を示す典型的なサインをいくつか目にすることがあります。
- 派生クラスで、基底クラスのメソッドをオーバーライドしてまったく新しい振る舞いを追加している
- 派生クラスで、スーパークラスのメソッドを空メソッドでオーバーライドしている
- 派生クラスで、スーパークラスから継承したメソッドの一部について「クライアント側で呼んではならない」と書かれている
- 派生クラスで、(チェックが行われていない)追加の例外をスローしている
まとめ
もうお気づきかと思いますが、私たちはこれらの原則を、動的型付け言語であるRubyのコンテキストで解釈しています。そのため、この特定の原則の意味がさして重要ではないように思えるかもしれません。しかしいずれにしろ、私はこの原則を甘く見ないようにしています。
Rubyではインターフェイスの一貫性を保つことを強制されませんし、その気になれば基底クラスと異なる型を派生クラスから返すことだってできます。しかしそんなことをすべきでしょうか?私はそうは思いません。それは非常に、非常にまずいやり方です。空の配列、論理値、文字列のどれが返されるかわからないメソッドを書いたとしたらどうでしょうか?
結論はシンプルです。オブジェクト指向プログラミングのよい手法の実践は、どんなときでも賞賛に値します。どんな言語を使っているかは関係ありません。繰り返しますが、ここでは常識を働かせることが肝心です。動的型付け言語のメリットを十分享受しつつ、そのことに責任を持って使いましょう。
動的型付け言語では柔軟性が高まることは間違いありません。しかし私は個人的に、その柔軟性のおかげであらゆる作業が楽になるとは限らないと考えています。動的型付け言語の柔軟性はもっと注意深く扱い、乱用せぬよう自らを律しなければなりません。