"いい"コードとは 2(SOLIDの原則 L 編)
前回の記事を見ていない人は、
こちらから見てくれると助かる。Liskov Substitution Principle (リスコフの置換原則)
概要
難しそうな名前であるが、なんてことはない。
継承先の振る舞いの方針を示しているだけである。
オブジェクト指向を触ったことがあるなら、継承がどれほど便利か理解できるだろう。
しかしそもそも、継承とは、強制的に結合させている状態である。
もちろん、凝集性が高いコードを目指すのは一つの方針であるが、かといってなんでもかんでも継承を使ってしまうと、とんでもない蜘蛛の巣のような依存関係が出来上がる。
継承の便利さと危険性
私がまとめた、Tidy Firstにも言及されていたが、結局、いいコードを書こうとする工程を省いて開発速度だけを求めた形で開発してしまうと、可読性が下がったり、ふるまいを追加する工程で矛盾が発生し、結果開発スピードが追い抜かされてしまうといった結果になる。
私が好きな言葉に、「便利さを追求すればするほど、凶器性が増す。」といったものがある。
まさにこれで、継承とは、すごく便利で、膨大なリスクをはらんだものなのである。
ではどのような方針で継承させるべき?
ではどのようにしたら、少しでも継承のリスクを抑えることができるのか。
ここでは紹介しよう。
Wikipediaにて、リスコフの置換原則は、
1.事前条件(preconditions)を、派生型で強めることはできない。派生型では同じか弱められる。
2.事後条件(postconditions)を、派生型で弱めることはできない。派生型では同じか強められる。
3.不変条件(invaritants)は、派生型でも保護されねばならない。派生型でそのまま維持される。
4.基底型の例外(exception)から派生した例外を除いては、派生型で独自の例外を投げてはならない。
と示されている。
今回は1から3について、わかりやすくまとめていこう。
1.親より事前条件を厳しくするな!!
具体例を挙げると、
class Animal {
speak(message: string | null) {
console.log(`動物が言ってる: ${message}`);
}
}
class Dog extends Animal {
speak(message: string | null) {
console.log(`わんちゃん「${message ?? '...(無言)'}」`);
}
}
こちらはサブクラスでnullを許容しており(いいか悪いかはさておき)、事前処理で同じか、弱められている。
class Cat extends Animal {
speak(message: string) {
if (message === null) {
throw new Error("にゃんちゃんは無言NGだにゃ!");
}
console.log(`にゃんちゃん「${message}」`);
}
}
しかしこの場合、nullを許容していないので、規約に反する。
つまり簡単に言うと、
親クラスで
引数がnullでもOK
と言っていたのに、
サブクラスで
nullはNG
としてしまうと規約に違反する。
2.サブクラスでは事後条件を、弱めるな!!
やるって言ったのに、やらないのは許されないよねという
人として当たり前のことを言っている規約である。
これは、ご飯を食べたらおなかが満たされるメソッドである。
class Animal {
eat(): string {
// 🍽️ごはんを食べる
return "お腹が満たされた";
}
}
class Dog extends Animal {
eat(): string {
return "お腹が満たされて、しっぽふりふり!";
}
}
Animalを継承したDogが、おなかが満たされて、しっぽを振っているので、これは事後条件を強めている。
class Cat extends Animal {
eat(): string {
return "まあ、ちょっとだけ食べた";
}
}
これが悪い例で、親クラスでおなかが満たされたはずなのに、なぜか子クラスでちょっとだけしか食べていないことになってしまっている。
つまり、
飼い主(=親クラス)はこう思ってる。
「この子にごはんあげたら、満足するって信じてるよ!」
でもにゃんちゃん(サブクラス)がこう返してきたら…?
「気が向いたときしか食べないし、満腹になるとは言ってにゃい!」😼
無茶苦茶である。
3. この家の家族である以上、家のルールは守れ!!
class Animal {
protected isAlive: boolean = true;
move() {
if (!this.isAlive) throw new Error("死んでる動物は動けません!");
console.log("動いた!");
}
}
ここでは、生きている動物がふるまいを獲得するという条件を設定している。
つまり、すべての振る舞いは「生きてること」が大前提となっている。
class Dog extends Animal {
bark() {
if (!this.isAlive) throw new Error("死んでるわんちゃんは鳴けないよ!");
console.log("わんわん!");
}
}
ワンちゃんは生きているので、ここでは鳴いている。これはルールを守っている。
class Cat extends Animal {
// 不変条件を破る:死んでても鳴けることにしちゃった
meow() {
console.log("にゃーん...(幽霊)");
}
}
ただ、なぜかこちらでは、猫が幽霊になって鳴けることになっている。
どう考えてもよくない。(もちろん、幽霊のネコはかわいいが、それとこれとは別問題である。)
結論
事前はゆるく、事後はきびしく、不変は死守!
継承元と継承先のクラスの振る舞いを同じにして、正しい継承で、リスクを減らしていこう。
Discussion