PROMARI JOURNAL

LSP――Copyに「開くURL」を要求すると何が壊れるか

作成: / 公開: / 内容更新:

執筆:tamito0201 / 掲載・運営:プロマリ

丸い共有ボタンの裏側を掘る連載の第6回。SOLID の3つ目の原則 LSP を、長方形と正方形の例と、共有ボタンの実物で確かめます。コピーのボタンに「開く URL」を求める書き換えを実際に作ると、型検査は通り、テストが5件落ちました。事前条件・事後条件・不変条件の3つの約束を、図15点で読み解きます。

PASS IT ON

ひとつの発見を、次の会話へ。

まず一般的な例:正方形は長方形の代わりになるか

LSPの説明で必ずと言ってよいほど出てくるのが、長方形と正方形の例です。1ページ目で見た3つの約束のうち、事後条件が崩れる様子がよく分かります。共有ボタンの話に入る前に、この例で原則をつかんでおきましょう。

算数では、正方形は長方形の一種です。4つの角が直角で、たまたま4辺の長さがそろっている長方形、と言えます。そこで、プログラムでも「正方形は長方形の子」として作りたくなります。継承という仕組みを使うと、次のように書けます。

TypeScriptrect.ts(説明のために書いた例。約束を破っている形)
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行で、長方形と正方形をそれぞれ渡しています。

Shellrect.ts を動かした結果と、型検査の結果(Node 24.13・TypeScript 7.0.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 を書いた人から見れば、渡したものが正方形だったせいで、答えが勝手に変わったことになります。

図2-1 長方形の代わりに正方形を渡すと
図2-1 長方形の代わりに正方形を渡すと。実測(rect.ts を動かした結果)。正方形は setHeight(10) で幅まで10に変えるので、50のはずが100になりました。型検査は何も言いません。図2-1 長方形の代わりに正方形を渡すと。実測(rect.ts を動かした結果)。正方形は setHeight(10) で幅まで10に変えるので、50のはずが100になりました。型検査は何も言いません。

型検査は、この違いを見つけない

注目したいのは、実行結果の後半です。tsc は TypeScript の型検査を行うコマンドで、echo $? は直前のコマンドの終了コードを表示します。0 は「問題なし」という意味です。つまり、TypeScript の型検査は、このコードに何の文句も言いませんでした。

型検査が確かめるのは、形の合い方です。Square は Rectangle と同じ名前のメソッドを持ち、受け取る値と返す値の型も同じなので、Rectangle の代わりに渡してよい、と判断します。けれど、「幅を変えたら高さは変わらない」という中身の約束までは、型には書かれていません。LSP の違反は、型検査を通ったあとに、実行して初めて表に出ることが多いのです。

入口は狭めない、出口の約束は減らさない

この例で崩れたのは、長方形の setWidth の事後条件です。長方形の setWidth は「幅が渡した値になり、高さは変わらない」ことを約束していました。正方形の setWidth は、前半の「幅が渡した値になる」は守っていますが、後半の「高さは変わらない」を守っていません。約束が1つ減った、つまり事後条件が弱くなったのです。

図2.2-1 入口は広げてよい、出口の約束は減らさない
図2.2-1 入口は広げてよい、出口の約束は減らさない。説明(Meyer の決まり・Martin の記事 1996 から)。子は、親より多くを受け付けてよく、親より多くを約束してよい。逆に、受け付ける範囲を狭めたり、約束を減らしたりしてはいけません。図2.2-1 入口は広げてよい、出口の約束は減らさない。説明(Meyer の決まり・Martin の記事 1996 から)。子は、親より多くを受け付けてよく、親より多くを約束してよい。逆に、受け付ける範囲を狭めたり、約束を減らしたりしてはいけません。

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(図形)として取り出しました。

TypeScriptshape.ts(説明のために書いた例。約束を守っている形)
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 として扱えます。

COMMENTS
コメント…

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

PASS IT ON

この気づきを、誰かにも。

この記事を書いた人

Takaomi Murasaki

Promari SNS Shareの開発を通して、Webの仕組みや設計の選び方を紹介しています。動く実装と、その判断に至るまでを記事に残しています。

公開記事 10 件

プロフィールを見る
THANK YOU FOR READING.すべての記事へ ↗

FROM INSIGHT TO IMPACT

「できたらいいな」を、
動く仕組みに。

記事で見つけたヒントを、あなたの事業へ。
新しいサービスも、手間のかかる業務も。
いまの課題から、つくるべきものを一緒に考えます。

開発・AI・研修の実績を見る

まだ、仕様書はいりません。

「何から始める?」から、ご一緒に。

課題がまとまっていなくても大丈夫。テーマを選ぶと相談文をご用意します。
連絡先の必須入力は、お名前とメールアドレスだけ。

まずは課題の整理から相談する

ご相談後の流れ

  1. 01 内容を確認
  2. 02 メールでご連絡
  3. 03 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。