Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsで学ぶSOLID(3)リスコフの置換原則(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

訳注: 原文のparent classは原則として「基底クラス」と訳出しました。

Railsで学ぶSOLID(3)リスコフの置換原則(翻訳)

「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。

それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを向上させることを目的としています。

  1. 単一責任の原則SRP: Single responsibility principle)
  2. オープン/クローズの原則OCP: Open/closed principle)
  3. リスコフの置換原則LSP: Liskov Substitution Principle)(本記事)
  4. インターフェイス分離の原則ISP: Interface Segregation Principle)
  5. 依存関係逆転の原則DIP: Dependency Inversion Principle)

今回は、3番目の「リスコフの置換原則」を見ていきましょう。

3: リスコフの置換原則(LSP)

基底クラスから派生したクラスは、望ましくない挙動を一切伴わずに、常に基底クラスと置き換え可能でなければならない。

次のように表すこともできます。

STの派生型であるとすると、プログラム内における型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とは「抽象を作り出す」ことであり、「概念を作り出す」ことではありません!

もう少し改良を加える

正直に申し上げると、完全無欠の解決方法というものはありません(いつものことですが)。

  1. 上の例に登場するクラスたちに共通の振る舞いが存在しないことを認識します。こういう2つのクラスを結合してはいけません。振る舞いの異なる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つを自分自身に問いかけるべきです。

  1. Bは、Aの完全なインターフェイス(すべてpublicメソッドとして)を「Aに期待するのと同じようにBを使える形で」公開したいのか?
    -- この場合(おそらく)継承が必要でしょう
  2. Bは、Aが公開している振る舞いの一部だけが欲しいのか?
    -- この場合(おそらく)コンポジションが必要でしょう

その後で、LSPを使って以下の問いかけに答えます。

この型を継承すべきか?

重要: もちろん、LSPだけが決定因子ではありません。「A is Bなのかどうか(ABなのか、そうでないのか)」という問いかけの方が中心にあることを忘れてはなりません。上の2つの問いかけは決定に役立ちますが、それらの問いかけは解決方法そのものではありません。

「よい継承関係の作成」は、これだけで別記事が一本書ける(下手すると本が一冊書ける)ほどの重たい話題です。今ここで申し上げたいのは、LSPを満たすことは必要だが、LSPは「よい継承を作り出す条件のひとつに過ぎない」ということです。

LSP違反の兆し

LSP違反の兆候を示す典型的なサインをいくつか目にすることがあります。

  • 派生クラスで、基底クラスのメソッドをオーバーライドしてまったく新しい振る舞いを追加している
  • 派生クラスで、スーパークラスのメソッドを空メソッドでオーバーライドしている
  • 派生クラスで、スーパークラスから継承したメソッドの一部について「クライアント側で呼んではならない」と書かれている
  • 派生クラスで、(チェックが行われていない)追加の例外をスローしている

まとめ

もうお気づきかと思いますが、私たちはこれらの原則を、動的型付け言語であるRubyのコンテキストで解釈しています。そのため、この特定の原則の意味がさして重要ではないように思えるかもしれません。しかしいずれにしろ、私はこの原則を甘く見ないようにしています。

Rubyではインターフェイスの一貫性を保つことを強制されませんし、その気になれば基底クラスと異なる型を派生クラスから返すことだってできます。しかしそんなことをすべきでしょうか?私はそうは思いません。それは非常に、非常にまずいやり方です。空の配列、論理値、文字列のどれが返されるかわからないメソッドを書いたとしたらどうでしょうか?

結論はシンプルです。オブジェクト指向プログラミングのよい手法の実践は、どんなときでも賞賛に値します。どんな言語を使っているかは関係ありません。繰り返しますが、ここでは常識を働かせることが肝心です。動的型付け言語のメリットを十分享受しつつ、そのことに責任を持って使いましょう。

動的型付け言語では柔軟性が高まることは間違いありません。しかし私は個人的に、その柔軟性のおかげであらゆる作業が楽になるとは限らないと考えています。動的型付け言語の柔軟性はもっと注意深く扱い、乱用せぬよう自らを律しなければなりません。

関連記事

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

Rubyのクラスメソッドがリファクタリングに抵抗する理由(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。