LSP――Copyに「開くURL」を要求すると何が壊れるか
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第6回。SOLID の3つ目の原則 LSP を、長方形と正方形の例と、共有ボタンの実物で確かめます。コピーのボタンに「開く URL」を求める書き換えを実際に作ると、型検査は通り、テストが5件落ちました。事前条件・事後条件・不変条件の3つの約束を、図15点で読み解きます。
まず一般的な例:正方形は長方形の代わりになるか
LSPの説明で必ずと言ってよいほど出てくるのが、長方形と正方形の例です。1ページ目で見た3つの約束のうち、事後条件が崩れる様子がよく分かります。共有ボタンの話に入る前に、この例で原則をつかんでおきましょう。
算数では、正方形は長方形の一種です。4つの角が直角で、たまたま4辺の長さがそろっている長方形、と言えます。そこで、プログラムでも「正方形は長方形の子」として作りたくなります。継承という仕組みを使うと、次のように書けます。
class Rectangle {
protected width: number;
protected height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }
area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
constructor(side: number) { super(side, side); }
setWidth(w: number): void { this.width = w; this.height = w; }
setHeight(h: number): void { this.width = h; this.height = h; }
}
function resize(r: Rectangle): number {
r.setWidth(5);
r.setHeight(10);
return r.area(); // 幅5・高さ10なら 50 のはず
}
console.log(resize(new Rectangle(2, 3)));
console.log(resize(new Square(2)));上から読んでいきます。Rectangle(長方形)は、幅(width)と高さ(height)を持ちます。setWidth は幅だけを、setHeight は高さだけを変え、area は面積を返します。次の Square(正方形)は、extends Rectangle で長方形を受け継いでいます。正方形は4辺がそろっていなければならないので、setWidth で幅を変えると、高さも同じ値に変えます。setHeight も同じです。
そのあとの resize は、長方形を受け取って、幅を5、高さを10にしてから面積を返す関数です。この関数を書いた人は「長方形なら、幅を変えても高さは変わらない」と思っています。だから、面積は 5 × 10 で 50 になるはずです。最後の2行で、長方形と正方形をそれぞれ渡しています。
$ node rect.ts
50
100
$ tsc --noEmit --strict --target es2022 --module nodenext --erasableSyntaxOnly rect.ts
$ echo $?
0長方形を渡すと50、正方形を渡すと100が出ました。正方形は、setHeight(10) で幅まで10に変えてしまったので、10 × 10 になったのです。resize を書いた人から見れば、渡したものが正方形だったせいで、答えが勝手に変わったことになります。
型検査は、この違いを見つけない
注目したいのは、実行結果の後半です。tsc は TypeScript の型検査を行うコマンドで、echo $? は直前のコマンドの終了コードを表示します。0 は「問題なし」という意味です。つまり、TypeScript の型検査は、このコードに何の文句も言いませんでした。
型検査が確かめるのは、形の合い方です。Square は Rectangle と同じ名前のメソッドを持ち、受け取る値と返す値の型も同じなので、Rectangle の代わりに渡してよい、と判断します。けれど、「幅を変えたら高さは変わらない」という中身の約束までは、型には書かれていません。LSP の違反は、型検査を通ったあとに、実行して初めて表に出ることが多いのです。
入口は狭めない、出口の約束は減らさない
この例で崩れたのは、長方形の setWidth の事後条件です。長方形の setWidth は「幅が渡した値になり、高さは変わらない」ことを約束していました。正方形の setWidth は、前半の「幅が渡した値になる」は守っていますが、後半の「高さは変わらない」を守っていません。約束が1つ減った、つまり事後条件が弱くなったのです。
Robert C. Martin は1996年の記事「The Liskov Substitution Principle」で、この例を取り上げ、Bertrand Meyer の「契約による設計」の決まりを引いて説明しました。子のメソッドは、親の事前条件を弱いものに置き換えてよく、事後条件を強いものに置き換えてよい。逆はいけない、という決まりです(when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.)。
言いかえると、子は親より多くの入力を受け付けてもかまいませんが、少なくしてはいけません。そして、親と同じかそれ以上の結果を約束してもかまいませんが、親より少ない結果しか約束しないのはいけません。使う側は親の約束しか知らないので、それより狭い入口や、それより少ない出口を用意されると、気づかないまま困ることになります。
守っている例:似ているだけなら、親子にしない
では、どう直せばよいのでしょうか。1つの方法は、正方形を長方形の子にするのをやめることです。長方形と正方形の両方が守れる約束だけを、共通の約束として取り出します。ここでは「面積を答えられる」という約束を Shape(図形)として取り出しました。
interface Shape {
area(): number;
}
class Rectangle implements Shape {
readonly width: number;
readonly height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
area(): number { return this.width * this.height; }
}
class Square implements Shape {
readonly side: number;
constructor(side: number) { this.side = side; }
area(): number { return this.side * this.side; }
}
const shapes: Shape[] = [new Rectangle(5, 10), new Square(5)];
console.log(shapes.map((s) => s.area()));最初の interface Shape が、共通の約束です。約束は area だけで、「面積を数で返す」ことしか決めていません。Rectangle と Square は、どちらも implements Shape で、この約束を守ると宣言しています。Square は長方形を受け継がず、辺の長さ(side)を1つだけ持ちます。幅と高さを別々に変えるメソッドは、どちらにもありません。形を作ったあとは変えない(readonly)ことにしたからです。最後の2行で、長方形と正方形を同じ Shape の並びに入れ、面積を順に求めています。動かすと [ 50, 25 ] が出ました。
これなら、Shape を使う側が頼っているのは「面積を答えてくれる」ことだけです。長方形も正方形も、その約束を守っています。どちらを渡しても、使う側は困りません。算数で「正方形は長方形の一種」であることと、プログラムで「正方形を長方形の代わりに使える」ことは、別の話なのです。似ているから親子にするのではなく、使う側が頼る約束を守れるから、取り替えられる。これが LSP の考え方です。
考えてみる:正方形は長方形の一種なのに、なぜ代わりに使えないの?
算数の「長方形」は、角が直角な四角形という性質を指します。一方、このプログラムの Rectangle は「幅と高さを別々に変えられる物」という約束まで含んでいました。正方形は、その約束を守れません。取り替えられるかどうかは、名前の意味ではなく、プログラムの中でどんな約束をしたかで決まります。幅と高さを変えるメソッドを持たない形にすれば、正方形も長方形も同じ Shape として扱えます。







このページ(2ページ目)の感想・質問・設計へのコメント