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 を書いた人から見れば、渡したものが正方形だったせいで、答えが勝手に変わったことになります。
長方形の図は、最後の面積だけでなく、途中の二回の操作を順に追うと読みやすくなります。まず幅を5にします。その直後の正方形は、幅も高さも5になっています。次に高さを10にすると、幅まで10に変わります。最後の100は、面積の掛け算を間違えた結果ではありません。掛け算に渡す幅が、二回目の操作で変わってしまった結果です。
ここで「正方形だけ、先に高さを設定すればよい」と直したくなるかもしれません。けれど、その修正では、使う側が相手の種類を調べて順番を選ぶことになります。今度は別の図形が来たとき、同じ関数へ例外を足すでしょう。問題の中心は操作の順番ではなく、Rectangleの約束で扱えると言いながら、その約束をSquareが守れないことです。
では、幅を設定した直後だけを窓に映すと、何が見えるでしょうか。Rectangleでは、幅だけが5になり、前からあった高さは残ります。Squareでは、高さまで5になります。最後の面積を計算しなくても、setWidthの直後の状態を比べれば、約束の違いがすでに現れています。
このとき、幅が5であることだけを期待するテストなら、両方とも通ります。必要なのは、変わったものの確認だけではありません。変えてはいけない高さが保たれていることも確認します。変更の結果と、変更しないと約束した性質を一緒に観測するのが、事後条件を具体的に読む方法です。
コピーの処理でも同じです。URLを書いたことだけでなく、その後にcopiedを返すこと、失敗したときにcopiedとは返さないことがあります。一つの結果にだけ目を向けると、約束の別の半分が抜けます。「何が起きるか」と「何が起きないか」を、使う側が頼る範囲で書いてみると、確認したい条件が増やせます。
ただし、すべての内部状態を固定する必要はありません。長方形の幅と高さは、その例の公開された操作の意味に関係します。一方、途中の計算用の変数をどんな名前で持つかは、使う側の約束ではありません。確認するものを増やすときにも、前の観察窓へ戻り、使う側に必要な性質かを問い直します。
もしRectangleの契約を最初から「幅を変えたら、高さも変わることがある」と書き換えたら、Squareを受け入れやすくなるでしょう。しかし、今まで幅と高さを別々に設定していたresizeは、その新しい約束では期待した仕事をできません。子の都合に合わせて親の契約を弱くすれば、既存の利用側が困ることがあります。誰がその約束に頼っているかを調べる必要があるのは、このためです。
Shapeへ絞った例は、既存のRectangleをこっそり弱めたものではありません。「面積を読む」という別の共通の能力を取り出し、使う側も、その能力だけを利用する形にした例です。契約を変える変更なら、利用側も含めて確認します。約束を保って実装を替える変更と、約束自体を設計し直す変更を区別すると、レビューの範囲も選びやすくなります。
図へ戻ったら、赤い100を見て終わらず、「高さを変えたら幅も変わった」という一文まで書いてみてください。結果の数字から、破られた約束へ戻る練習です。この一文が書ければ、別のプログラムでも、何が同じで何が違うために交換できないのかを、原則の名前を使わずに説明できます。
この例を、自分のコードへそのまま移して「継承はすべて危険」と結論づける必要もありません。幅と高さを独立に変更できるという、具体的な約束が衝突している例です。どのメソッドの、どの結果が守れないかまで言葉にできれば、親子関係をやめるのか、操作の約束を変えるのか、選ぶ理由が説明できます。
型検査は、この違いを見つけない
注目したいのは、実行結果の後半です。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 として扱えます。
Shapeへ絞った例では、返り値が違っても大丈夫な理由も確かめておきます。呼び出し側は「どれも50になる」とは約束していません。それぞれが自分の面積を返すことに頼っています。正方形の25を見て例外扱いする必要はありません。どこまでを同じとするかを狭めたことで、二つの図形が無理なく同じ入口を使えるようになりました。
その代わり、Shapeだけを受け取ったコードから、幅を変更する仕事は頼めなくなります。これは能力を忘れたのではなく、その使う側には渡さないと決めた結果です。もし幅の変更が必要な場面が別にあるなら、その場面に必要な約束を考えます。一つの共通型で、図形に関するすべての仕事を引き受けようとしないことが、無理な取り替えを避ける助けになります。
なお、この説明例のreadonlyは、TypeScriptの型検査を通してプロパティへ再代入するのを防ぐ指定です。実行時にオブジェクトを凍結する命令ではありません。今回の例では変更用メソッドを公開しないことと組み合わせています。「readonlyと書けば、どんなJavaScriptからも絶対に変えられない」と、保証を広げて読まないでください。







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