Liskov Substitution Principle
基底クラスを使用している箇所で、その基底クラスを派生クラスに置き換えても動作しなければならない
オブジェクト指向の象徴的な仕掛け「継承」。この継承の使い方についての原則です。
この原則が理解できないなら、継承は使わないほうがよい。といえるくらい大事な概念。
SOLID原則と呼ばれる、主要5原則のひとつ。
- Single Responsibility Principle 単一責任原則
- Open Closed Principle 開放閉鎖原則
- Liskov Substitution Principle リスコフ置換原則
- Interface Segregation Principle インターフェース分離原則
- Dependency Inversion Principle 依存性逆転原則
どんなときに使える?
おかしな継承の仕方をしているとき
FileWriterクラスを継承して、DataFileWriterクラス作りましたー
// ファイル操作クラス
class FileWriter {
// ファイル書き込み
public void writeFile(String filename) {
...
}
}
// データファイル操作クラス
class DataFileWriter extends FileWriter {
public void writeDataFile(String filename) {
this.writeFile(filename);
}
}
それ、継承の使い方おかしくない…?
この原則に違反するのはたいてい、実装継承をしているとき、です。
この実装、
何がおかしいの…?
と感じる人もいるかもしれません。そんなかたはぜひ、この原則を学びましょう。
継承とはなにか?
基底クラスと派生クラス
継承で出てくるのが、基底クラスと派生クラス。
基底クラス「動物」、派生クラス「犬」「猫」。動物に鳴けといえば、犬ならワン、猫ならニャー。
オブジェクト指向の説明でよく出てきますね。プログラミングでの基底クラスと派生クラスの考え方は、以下のとおり。
- 基底クラス 抽象的なインターフェース
- 派生クラス 抽象インターフェースの実装
であれば、抽象を実装で置き換えられないといけない、ということです。
具体的な例
// 動物クラス
class Animal {
public void makeSound() {
System.out.println("Animal is making a sound");
}
}
// 犬クラス
class Dog extends Animal {
public void makeSound() {
System.out.println("ワンワン");
}
}
// 猫クラス
class Cat extends Animal {
public void makeSound() {
System.out.println("ニャーン");
}
}
// 動物を鳴かせるクラス
class AnimalSoundMaker {
public void makeAnimalSound(Animal animal) {
animal.makeSound();
}
}
// メインクラス
public class Main {
public static void main(String[] args) {
AnimalSoundMaker soundMaker = new AnimalSoundMaker();
Animal animal1 = new Dog();
soundMaker.makeAnimalSound(animal1);
Animal animal2 = new Cat();
soundMaker.makeAnimalSound(animal2);
}
}
AnimalSoundMaker
はanimal
を受け取ってmakeSound
で鳴かせます。
animal
はDog
の場合もCat
の場合もありえます。Dog
ならワンワン、Cat
ならニャーンと鳴きます。
これは
public void makeAnimalSound(Animal animal) {
animal.makeSound();
}
のAnimalを
public void makeAnimalSound(Dog animal) {
animal.makeSound();
}
のように、AnimalをDogに置き換えても動作する。ということになります。
では、亀(Turtle
)ならどうでしょうか。亀は一般的には鳴きません。
public void makeAnimalSound(Turtle animal) {
animal.makeSound(); // 亀は鳴かない。。
}
つまり、Turtle
がmakeSound
できないなら、Turtle
はAnimal
から派生すべきでないのです。
インターフェース継承と実装継承
このお話がピンとこないなら。。「実装継承」を使っているのかもしれません。
継承には2種類あると言われます。
- インターフェース継承 基底クラスのインターフェースを引き継ぐ継承
- 実装継承 基底クラスの実装を引き継ぐ継承
インターフェース継承は、上記の例です。makeSound インターフェースを引き継いでいます。
実装継承とは、基底クラスで共通処理を実装、派生クラスで個々の独自処理を実装する、ということです。
実装継承の問題点
新人くんのプログラムをもう一度見てみましょう。
// ファイル操作クラス
class FileWriter {
// ファイル書き込み
public void writeFile(String filename) {
...
}
}
// データファイル操作クラス
class DataFileWriter extends FileWriter {
public void writeDataFile(String filename) {
this.writeFile(filename);
}
}
FileWriterでファイル書き込み処理を実装、DataFileWriterでその処理を使っています。これが「実装の継承」です。
実装継承は、基底クラスと派生クラスの間に、強力な依存関係が生じてしまいます。
すると、基底クラスだけ直す、または派生クラスだけ直す、ということが簡単にできなくなります。これは好ましいことではないですね。
実装継承かどうかの確認ポイント
継承を使うときは、以下のポイントを確認してみてください。あてはまるなら、それは実装継承です。直すことをおススメします。
- 基底クラス型の変数を使って処理している箇所がない
- 派生クラスで、基底クラスのどのメソッドもオーバーライドしていない
詳しくみていきましょう。
基底クラス型変数を使っていない?
インターフェース継承を使うなら、このようなコードがどこかに現れるはず。
// ファイル操作オブジェクトを生成
FileWriter writer = new DataFileWriter();
// ファイルを書きこむ
writer.writeFile();
DataFileWriter
オブジェクトを生成して、それをFileWriter
インターフェースで受け取る。そしてFileWriter
インターフェースを使って操作する。
実体がなにであるかを気にせず、インターフェースを使って操作できるようになります。
基底クラスでオブジェクトを受け取るコードが出てこないなら、それはたぶん実装継承です。
どのメソッドもオーバーライドしていない?
実体がなにであるかを気にせず、インターフェースを使って操作できるようにする。
ということは、インターフェースのメソッドを、実体オブジェクトのメソッドで置き換える必要があるわけです。
どのメソッドもオーバーライドしていない派生クラスなら、インターフェースの意味がないクラスです。
継承より「委譲」を使う
実装継承をなくすための黄金則は、「継承より委譲」です。
そのクラスを継承するのでなく、中に取り込んで、処理を委ねる(委譲)のです。
// ファイル操作クラス
class FileWriter {
// ファイル書き込み
public void writeFile(String filename) {
...
}
}
// データファイル操作クラス
class DataFileWriter extends FileWriter {
FileWriter _writer;
public DataFileWriter(FileWriter writer) {
this._writer = writer;
}
public void writeDataFile(String filename) {
_writer.writeFile(filename);
}
}
DataFileWriter
の中に、FileWriter
オブジェクトを持ち、そのオブジェクトのメソッドを使う、という実装です。
こうすることで、FileWriter
とDataFileWriter
の、独立性が保たれます。
練習:円-楕円問題
リスコフ置換原則を考えるときの例題に、「円-楕円問題」という例題があります。
円クラスと楕円クラスがあります。似通った性質がありそうなので継承関係にできそうです。
さて、円クラスと楕円クラス、どちらが基底クラスでどちらが派生クラスとするべきでしょうか?
まとめ
冒頭の話をもう一度言います。この原則が理解できないなら、継承は使わないほうがよいです。
継承は、かつてはすばらしい機構としてもてはやされていました。しかし今では、デメリットのほうが目立つという意見が多数です。
ちなみに、比較的新しい言語であるGoやRustは、継承という仕掛け自体が存在しません。別の仕掛けで抽象化を実現しています。
C++やJavaなどでは、まだまだ継承が登場する機会はあるでしょう。継承を使うときはよく考えて、必要最小限で使用しましょう。
- Single Responsibility Principle 単一責任原則
- Open Closed Principle 開放閉鎖原則
- Liskov Substitution Principle リスコフ置換原則
- Interface Segregation Principle インターフェース分離原則
- Dependency Inversion Principle 依存性逆転原則
コメント