はじめに
自分が書いてないコードで色々辛い思いをしてきたので、どういう場合に継承が破綻するのか調べてみました。
この記事でわかるであろうこと
- 継承を(破綻しないように)ちゃんと用いるにはリスコフの置換原則と契約による設計の理解が必要なこと
リスコフの置換原則とは
リスコフの置換原則とは、一言でいうと「派生型(サブクラス)はその基底型(スーパークラス)と置換可能でなければならない」という原則です。
* リスコフの置換原則を守らないと必然的に開放/閉鎖原則に違反するため、リスコフの置換原則を守ることは重要です。
下記のコードはSquare(正方形)がRectangle(長方形)を継承しています。つまり、派生型がSquare、基底型がRectangleです。
正方形は長方形を特化したもの(見た感じではis-a関係が成立している)と言えるので継承を使うことは一見正しそうに見えます。
「派生型はその基底型と置換可能でなければならない」ということは、Rectangleを使用している箇所をSquareで置き換えても正常に動く(振る舞いが変わらない)場合に、リスコフの置換原則に従っているということになります。
下記のコードはリスコフの置換原則に従っていません。
new Rectangle();
(基底型)の箇所をnew Square();
(派生型)に置き換えるとassertに引っかかるため、置換したときに正常に動きません。
たぶんこれだけだと、「言葉としてはわかるけど...」って感じになると思います。
なので、後ほど契約による設計と絡めて見ていきます。
* すでにSquareのインターフェースがイケてない状態になっています。width=でheightも変わるのは利用者視点で見ると怖いですよね。
main() {
var rectangle = Rectangle();
var user = User(rectangle: rectangle);
var area = user.calculateArea(20, 30);
assert(area == 600); // RectangleをSquareに変えるとここで引っかかる
}
class User {
final Rectangle _rectangle;
User({Rectangle rectangle}): this._rectangle = rectangle;
calculateArea(int height, int width) {
_rectangle.height = height;
_rectangle.width = width;
return _rectangle.area();
}
}
class Rectangle {
int _height;
int _width;
Rectangle({int height, int width}): this._height = height, this._width = width;
set height(int height) {
_height = height;
}
int get height => _height;
set width(int width) {
_width = width;
}
int get width => _width;
area() {
return _width * _height;
}
}
class Square extends Rectangle {
Square({int height, int width}): super(height: height, width: width);
set height(int height) {
_height = height;
_width = height;
}
set width(int width) {
_width = width;
_height = width;
}
}
契約による設計とは
契約プログラミング(けいやくプログラミング、Programming By Contract)または契約による設計(けいやくによるせっけい、Design By Contract)とは、プログラムコードの中にプログラムが満たすべき仕様についての記述を盛り込む事で設計の安全性を高める技法。
具体的には、「もしそちらが事前条件を満たした状態で私を呼ぶと約束して下さるならば、お返しに、事後条件を満たす状態を最終的に実現することをお約束します。」という契約を結ぶことを言います。
* クラス不変条件等の不変条件については省略しています。
下記のコードが契約による設計に従ったものです。
// ...省略
area() {
assert(_width <= 0 || _height <= 0); // 事前条件
var area = width * height;
return area;
}
// ...省略
契約による設計に従うことで、ソフトウェアの信頼性をあげることができます。
契約による設計、例外、表明の関係について個人的なまとめを別に書きました。
リスコフの置換原則と契約による設計の関係
リスコフの置換原則と契約による設計については下記のような関係があります。
事前条件を派生型で強めることはできない。つまり、上位の型よりも強い事前条件を持つ派生型を作ることはできない。
事後条件を派生型で弱めることはできない。つまり、上位の型よりも弱い事後条件を持つ派生型を作ることはできない。
さらに、この原則によれば、派生型のメソッドが発生する例外は、上位の型のメソッドが発生する例外の派生型か、上位のメソッドの例外と同じものでなければならない。
「リスコフの置換原則とは」で記載したRectangleとSquareのwidthのsetterの事後条件にフォーカスしてコードを追加してみます。
Rectangleのwidthのsetterの事後条件は、widthのみが変わることなので、コードで書くとassert(_width == width && _height == old.height);
です。
Squareのwidthのsetterの事後条件は、widthとheightが両方変わることなので、コードで書くとassert(_width == width && _height == height);
です。
Squareでは、Rectangleの事後条件の_height == old.height
が消えており、条件が弱まっています。
このことより、
事後条件を派生型で弱めることはできない。つまり、上位の型よりも弱い事後条件を持つ派生型を作ることはできない。
に従っておらず、リスコフの置換原則に従っていないことがわかります。
class Rectangle {
// ...
set width(int width) {
var old = this;
_width = width;
assert(_width == width && _height == old.height);
}
// ...
}
class Square {
// ...
set width(int width) {
_width = width;
_height = width;
assert(_width == width && _height == height);
}
// ...
}
契約による設計の観点が入ると、「振る舞いが変わらない」とはどういうことかが明確になり、リスコフの置換原則に従っているかどうかの判断がわかりやすいと思います。
おわりに
色々書きましたが、継承は難しい(間違ったことをしやすいし、されやすい)ので、「継承よりもコンポジション」をちゃんと守ることが大事だと思います。
なにか間違っている箇所やより新しい考え方などがあったら教えていただけると助かります。
* 「もしそちらが事前条件を満たした状態で私を呼ぶと約束して下さるならば、お返しに、事後条件を満たす状態を最終的に実現することをお約束します。」は契約による設計の紹介からの引用になっています。
参考
- 契約による設計の紹介
- PHP7 で堅牢なコードを書く - 例外処理、表明プログラミング、契約による設計
- アジャイルソフトウェア開発の奥義 ロバート・C・マーチン
- オブジェクト指向入門 第2版 原則・コンセプト バートランド・メイヤー
- 達人プログラマ Andrew Hunt, David Thomas