既存クラスの処理を使いたいという理由だけでクラス継承すると、実装の再利用と同時にサブタイプ関係まで作られる。 この二つが必要になる理由は同じとは限らないため、「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