はじめに
この記事は、SOLID原則のまとめ の続きです。
今回は、SOLID原則のひとつである Liskov Substitution Principle(リスコフの置換原則)について、歴史的背景から「契約による設計」との関係、実務での違反パターンまで、コード例を交えながら解説します。
Liskov Substitution Principle(LSP)とは
LSP は 「派生型(子クラス)は、基本型(親クラス)と置換可能でなければならない」 という原則です。
つまり、親クラスを使っている箇所を子クラスに差し替えても、プログラムの振る舞いが壊れてはいけないということです。
歴史的背景
LSP は Barbara Liskov が1987年の基調講演 Data Abstraction and Hierarchy で最初に提唱しました。その後、1994年に Barbara Liskov と Jeannette Wing が論文 A Behavioral Notion of Subtyping で「振る舞いのサブタイピング(behavioral subtyping)」として原則を精密に定式化しています。
この論文で重要なのは、単に型の互換性だけでなく、振る舞いの互換性が求められるという点です。子クラスが親クラスと同じインターフェースを持っていても、振る舞いが異なれば LSP に違反します。
契約による設計(Design by Contract)との関係
LSP は Bertrand Meyer の 契約による設計(Design by Contract) と密接に関連しています。LSP を守るためには、以下の3つの契約を順守する必要があります。
事前条件(Precondition)
メソッドの呼び出し前に満たすべき条件です。派生型で事前条件を強めてはなりません。
<?php
class Logger {
public function log(string $message) {
echo $message . "\n";
}
}
// LSP 違反: 事前条件を強めている
class StrictLogger extends Logger {
public function log(string $message) {
if (strlen($message) < 10) {
throw new Exception('メッセージは10文字以上必要です');
}
echo $message . "\n";
}
}
親クラスの Logger は任意の文字列を受け付けますが、StrictLogger は10文字以上という新たな制約を追加しています。Logger を StrictLogger に置き換えると、これまで動いていたコードが例外で壊れてしまいます。
事後条件(Postcondition)
メソッドの実行後に保証すべき条件です。派生型で事後条件を弱めてはなりません。
<?php
class NumberGenerator {
public function generate(): int {
return rand(1, 100); // 常に正の整数を返す
}
}
// LSP 違反: 事後条件を弱めている
class WeakNumberGenerator extends NumberGenerator {
public function generate(): int {
return rand(-100, 100); // 負の数も返す可能性がある
}
}
親クラスは「正の整数を返す」という事後条件を持っていますが、子クラスはそれを弱めて負の数も返すようにしています。
不変条件(Invariant)
オブジェクトの生存期間を通じて常に真でなければならない条件です。派生型でも不変条件は維持されなければなりません。
LSP に違反した例: Rectangle / Square 問題
LSP 違反の最も有名な例として、Rectangle(長方形)と Square(正方形)の関係があります。
<?php
class Rectangle {
protected $width;
protected $height;
public function setWidth(int $width) {
$this->width = $width;
}
public function setHeight(int $height) {
$this->height = $height;
}
public function getArea(): int {
return $this->width * $this->height;
}
}
class Square extends Rectangle {
public function setWidth(int $width) {
$this->width = $width;
$this->height = $width; // 正方形なので高さも変更
}
public function setHeight(int $height) {
$this->width = $height; // 正方形なので幅も変更
$this->height = $height;
}
}
function printArea(Rectangle $rectangle) {
$rectangle->setWidth(5);
$rectangle->setHeight(4);
// Rectangle なら 5 * 4 = 20 を期待する
echo $rectangle->getArea() . "\n";
}
$rectangle = new Rectangle();
printArea($rectangle); // 20
$square = new Square();
printArea($square); // 16 -- 期待と異なる!
数学的には「正方形 is-a 長方形」ですが、プログラムにおいては Square が Rectangle の不変条件(幅と高さが独立に変更できる)を破っています。Rectangle を期待している printArea 関数に Square を渡すと、予期しない結果になります。
この例は、「is-a」の関係は継承の十分条件ではないことを教えてくれます。
LSP に準拠した例
継承ではなく、共通のインターフェースを使って設計し直します。
<?php
interface Shape {
public function getArea(): int;
}
class Rectangle implements Shape {
public function __construct(
private int $width,
private int $height
) {}
public function getArea(): int {
return $this->width * $this->height;
}
}
class Square implements Shape {
public function __construct(
private int $side
) {}
public function getArea(): int {
return $this->side * $this->side;
}
}
function printArea(Shape $shape) {
echo $shape->getArea() . "\n";
}
$rectangle = new Rectangle(5, 4);
printArea($rectangle); // 20
$square = new Square(4);
printArea($square); // 16
Rectangle と Square はそれぞれ Shape インターフェースを実装しており、互いに継承関係がありません。どちらを printArea に渡しても、それぞれの契約どおりに正しく動作します。
また、コンストラクタで値を受け取り setter を持たない イミュータブル(不変) な設計にしたことで、状態の変更に伴う契約の破壊を根本的に防いでいます。
実務での LSP 違反パターン
パターン1: 例外の追加
親クラスが投げない例外を子クラスで投げるのは、LSP 違反の典型です。
<?php
class FileReader {
public function read(string $path): string {
return file_get_contents($path);
}
}
// LSP 違反: 親にない制約と例外を追加
class SecureFileReader extends FileReader {
public function read(string $path): string {
if (!str_starts_with($path, '/secure/')) {
throw new Exception('セキュアディレクトリ以外は読み取れません');
}
return file_get_contents($path);
}
}
パターン2: メソッドの空実装
インターフェースのメソッドを空実装や throw new NotImplementedException() で済ませるのは、ISP(インターフェース分離の原則)と LSP の両方に違反しています。
パターン3: 戻り値の型の変更
親クラスが配列を返すメソッドで、子クラスが null を返す可能性があるのも LSP 違反です。
LSP 違反を見つけるチェックリスト
以下のポイントを確認すると、LSP 違反を発見しやすくなります。
- 子クラスのメソッドに、親クラスにない
throwやifによる制約が追加されていないか - 子クラスのメソッドの戻り値の型や範囲が、親クラスより狭く(または予期しない値に)なっていないか
- 親クラスの型でテストが通る箇所に子クラスを渡しても、同じテストが通るか
- 子クラスに空実装やスタブメソッドがないか
LSP 違反が引き起こす問題
LSP に違反した設計を放置すると、クライアント側で具体的な型ごとに条件分岐が必要になります。これは OCP(開放閉鎖の原則)の違反も引き起こし、型が増えるたびに条件分岐も増え続けるという悪循環に陥ります。
// LSP 違反が引き起こす悪循環の例
function processShape($shape) {
if ($shape instanceof Square) {
// Square 用の特別な処理...
} elseif ($shape instanceof Rectangle) {
// Rectangle 用の処理...
}
// 型が増えるたびにここに分岐が増える
}
このような instanceof による分岐の増殖は、LSP 違反の典型的な症状です。
まとめ
LSP は「子クラスは親クラスの代わりに使えなければならない」という原則です。その本質は、型の互換性だけでなく 振る舞いの互換性 にあります。「is-a」の関係に見えても、契約(事前条件・事後条件・不変条件)が異なる場合は継承を避け、インターフェースやイミュータブルな設計を検討することが大切です。