リスコフの置換原則は同じ結果ではなく上位型の契約を保つことを求める

異なる実装へ置き換えれば、処理方法や結果の一部が変わるのは当然である。 リスコフの置換原則が求めるのはその差をなくすことではなく、上位型について成り立つ性質が下位型についても成り立つことである。

Go の io.Reader には、ファイル、文字列、ネットワーク接続など異なる入力元を実装として渡せる。 次の関数は入力元の種類ではなく、io.Reader の契約だけに依存している。

func readHeader(r io.Reader) ([]byte, error) {
	buf := make([]byte, 4)
	_, err := io.ReadFull(r, buf)
	return buf, err
}

ファイルと strings.Reader では、データの取得方法も遅延も異なる。 それでも双方が io.Reader の契約を守る限り、readHeader は入力元を区別せずに利用できる。

一方、Go の型検査が確かめるのは Read というメソッドの形であり、その意味上の契約までは確かめない。 たとえば io.Reader の Read は、返すバイト数 n が 0 以上 len(p) 以下であると定めている。

type BrokenReader struct{}

func (BrokenReader) Read(p []byte) (int, error) {
	return len(p) + 1, nil
}

BrokenReader は io.Reader を型として満たすが、Read の契約を破っている。 io.Reader を正しく実装していると仮定したコードへ BrokenReader を渡すと、そのコードについて成り立っていた性質を保てない。

置換可能性は「どの実装も観測可能な結果が同一である」ことを意味しない。 上位型が許した入力を下位型が拒否せず、上位型が約束した結果を下位型が弱めず、上位型の不変条件を下位型も維持することを意味する。 実装ごとの差は、その契約が許す範囲に残る。

この区別は、クラス継承の判断にも使える。 自然言語上の is-a 関係が成立しても、子クラスが親クラスより厳しい事前条件を追加したり、親クラスの事後条件を弱めたりするなら、意味上のサブタイプにはならない。 コンポジションを初期値にするのは、実装を再利用したいだけの場面で、この契約まで誤って引き継がないためでもある。

https://www.cs.cmu.edu/~wing/publications/LiskovWing94.pdf

https://pkg.go.dev/io#Reader

#software-design #object-oriented-programming #liskov-substitution-principle #subtyping #go