コンポジションは実装の再利用とサブタイプ関係を分離する

既存クラスの処理を使いたいという理由だけでクラス継承すると、実装の再利用と同時にサブタイプ関係まで作られる。 この二つが必要になる理由は同じとは限らないため、「Composition over inheritance」という原則は、別のオブジェクトを部品として持つ コンポジション を設計の初期値にする。

Go では、インターフェースを満たす値を構造体のフィールドとして持つ形でコンポジションを表せる。

type Writer interface {
	Write(string)
}

type Logger struct {
	writer Writer
}

コンポジションは「何を部品として持つか」という構造を表す。 その部品へ実際の処理を任せる 委譲 は、コンポジションを利用する振る舞いである。

func (l Logger) Log(message string) {
	l.writer.Write("[LOG] " + message)
}

この Logger は Writer を持つが、Write を自身の操作として公開しない。 公開する操作を Log に限定したまま、渡された Writer の実装を再利用できる。

埋め込みが公開する操作

Go の埋め込みもコンポジションの一種だが、名前付きフィールドとは公開される操作が異なる。

type FileWriter struct{}

func (FileWriter) Write(text string) {
	fmt.Println(text)
}

type Logger struct {
	FileWriter
}

埋め込まれた FileWriter の Write は Logger へ昇格するため、呼び出し側は Logger.Write を実行できる。 Logger が FileWriter のサブタイプになるわけではないが、昇格したメソッドによって同じインターフェースを満たす場合はある。

type Writer interface {
	Write(string)
}

var _ Writer = Logger{}
var _ FileWriter = Logger{} // コンパイルエラー

したがって、内側の操作をそのまま外側へ公開したいなら埋め込みが合う。 外側の型の責務に合わせて操作を選びたいなら、名前付きフィールドと明示的な委譲が合う。

継承を選ぶ条件

「Composition over inheritance」は「実装継承を禁止する」という規則ではない。 言語や実行環境が特定の基底クラスを要求する場合や、基底クラスが継承を拡張点として設計されている場合には、実装継承が意図を直接表すこともある。 一方、インターフェースによってサブタイプ関係を表し、コンポジションによって実装を組み合わせる設計は、実装継承を使わずに多相性と再利用を両立できる。

自然言語で「子は親の一種である」と言えるだけでは、継承を選ぶ根拠として足りない。 子を親として扱うコードが親の契約へ依存できるかどうかは、リスコフの置換原則で別に確かめる必要がある。

https://go.dev/doc/effective_go#embedding

#software-design #object-oriented-programming #composition #delegation #inheritance #go