「リスコフの置換原則」と「正方形は長方形の部分型なのか問題」
◯ 結論
- 正方形は長方形の部分型と定義したほうがよい気がする... 個人の感想です...
◯ キッカケ
正方形 <:
長方形 と定義すると問題が発生することがあるよね...という記事
◯ 参考
四角形の部分集合関係を表現した図
両方定義できるよね...という記事
-
正方形
<:
長方形 -
正方形
:>
長方形
◯ 意味するところ
Qiita の記事の意味するところは
- 「オブジェクト指向とは、現実世界を正しく捉えること」という理解はデメリットのほうが大きい
ではなく
- 引数として与えられたプロパティに "範囲外の値" を代入するような副作用のある関数を書いたらダメだよ。子クラスを引数として関数に渡した場合、その関数の中で親クラスの値を代入したらダメだよ、それはリスコフの置換原則に違反した実装だよ
ってことなのかな?と感じました。
◯ 長方形の例より簡単な例
実数を再代入する関数 setPI
に整数を実引数として与えた。
type RealNumberObject = {
value: number;
}
type NaturalNumberObject = {
value: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
}
type Result = // true
NaturalNumberObject extends RealNumberObject ? true : false
function setPI(realNumberObject: RealNumberObject): void {
realNumberObject.value = Math.PI;
}
const naturalNumberObject: NaturalNumberObject = { value: 1 }
setPI(naturalNumberObject);
console.log(naturalNumberObject.value) // 3.14
// 型エラーを生じさせずに 1 | 2 | ... | 10 以外の数を代入できた
この問題をわかりにくくしているのは複数のプロパティ, 縦と横が絡んでるからわかりにくくなっている気がしました。そこでプロパティを一個に限定して問題を考え直してみると、値の範囲の問題だと感じました。
◯ 長方形の例
引数として与えられたプロパティに "範囲外の値" を代入するような副作用のある関数を書いたらダメだよ
class Rectangle {
constructor(public x: number, public y: number) {}
}
class Square extends Rectangle {
constructor(public x: number) {
super(x, x)
}
}
const square = new Square(1)
console.log(square.x) // 1
console.log(square.y) // 1
function setRectangleLengths(rectangle: Rectangle){
rectangle.x = 2
rectangle.y = 3
}
/**
* この関数が Square 型を受けたときのコードの意味は
* 引数 正方形
* 副作用 長方形 <--- 部分集合の範囲外の値を副作用として与えている...
*/
setRectangleLengths(square)
console.log(square.x) // 2
console.log(square.y) // 3
増田さんは "型 = 値の種類 = 値の範囲の定義 + 有効な操作の定義" っておっしゃっている...
- 関数の引数
- 広い範囲・大きい集合を指定
- 関数の中身
- 与える副作用はなるべく小さい範囲・小さい部分集合に限定
したほうがよいみたいな?
共変、反変と近い感じがするような... 違うけど...
TypeScript は引数として与えられた範囲、部分集合の情報について、
- 引数
- 違反を検知してくれる
- 内部の実装
- 違反しているものを検知してくれない...(コードを実行せずに静的に検知できるのかな?)
『「オブジェクト指向とは、現実世界を正しく捉えること」という理解はデメリットのほうが大きい』ではなくて『「部分集合関係とは、現実世界を正しく捉えること」という理解はデメリットのほうが大きい』という主張になってしまっている気がする...
でも部分集合関係って現実世界を正しく捉える上ですごく役に立つから、デメリットのほうが大きいという主張は、ちょっと厳しいのかなと感じました(個人の感想です)
結局、こういう関数の外側・シグネチャではなく関数の内部・実装を検査するときはどういうアプローチがいいんだろう?setter を設けて都度、代入される値の範囲を検査して実行時に検査するのがベターなのかな... 実行する前に静的に検査する方法ってあるのかな...
あと
- 正方形 ⊆ 長方形
だけど、正方形のほうが長方形よりプロパティ数が少ないから
- 正方形
:>
長方形
って定義するとスッキリする場合もあるよね、と「『科学的モデリング』継承⑤〜継承パラドックス」で書かれていました(一般にプロパティ数の少ないほうが基底クラスになるため)。
ただ、これは結構厳しいのではと感じました。どうあがいてもリスコフの置換原則が成り立たせることが難しいからです(リスコフの置換原則が成り立ちそうな関数が思い浮かばない...)。
class Square {
constructor(public x: number) {}
}
class Rectangle extends Square {
constructor(x: number, public y: number) {
super(x)
}
}
const rectangle = new Rectangle(1, 2)
console.log(rectangle.x) // 1
console.log(rectangle.y) // 2
function calcSquareArea(square: Square){
return square.x * square.x
}
// どうあがいてもリスコフの置換原則が成り立たない
console.log(calcSquareArea(rectangle) === 1 * 2) // false
TODO: Integer 型は TypeScript には無いから Python にサンプルコードを書き換えよう...
上記のコードをもって実数と自然数は「「オブジェクト指向とは、現実世界を正しく捉えること」という理解はデメリットのほうが大きい - Qiita」という主張は困難なのではないのかな?という気がしました。
- リスコフの置換原則ってあれやな、部分集合の範囲外に変化するような副作用を与えるな、ってことか。
- 別に int, double や長方形、正方形が現実世界と乖離しているわけではない...
リスコフの置換原則って要するに
魔法使いから戦士に転職させるのはヤバいって言う話で
まあ魔法使いから戦士に転職させてもいいけど、
もう魔法を忘れてしまったキャラに魔法使わせんなよっていう話しか