Day 021 — embedding(構造体・インターフェースの埋め込み)

2026-08-18 🔵 中級者 / Phase 2 実装 embedding(構造体・インターフェースの埋め込み)

📚 背景知識(読んでから問題へ)

Day 020では「小さいインターフェース」を利用側で定義し、暗黙的実装によって結合を緩める設計を学びました。今日はコード再利用の話です。Goにはclassextendsもありません。継承(inheritance)の代わりにGoが用意しているのがembedding(埋め込み)です。

構造体にフィールド名を書かずに型だけを書くと、その型が「埋め込まれた(embedded)フィールド」になります。

type BaseLogger struct {
	Prefix string
}

type TimestampLogger struct {
	BaseLogger // 埋め込み。フィールド名を書かない
	Clock func() string
}

これによりTimestampLoggerのインスタンスは、BaseLoggerが持つフィールド・メソッドをあたかも自分のものであるかのように呼び出せます(フィールド昇格・メソッド昇格)。tl.Prefixtl.BaseLogger.Prefixの糖衣構文であり、tl.Log(...)tl.BaseLogger.Log(...)の糖衣構文です。

ここで最も重要な注意点があります。embeddingは継承ではありません。仮想メソッドディスパッチ(virtual method dispatch)を提供しません。 Java/Python/C++のクラス継承では、親クラスのメソッドAの中から自分自身のメソッドBを呼ぶと、子クラスがBをオーバーライドしていればそちらが呼ばれます(動的束縛)。Goのembeddingではそうなりません。BaseLoggerのメソッドの中で自分のメソッドを呼んでも、それは常にBaseLogger自身のメソッドを指し、外側の型(TimestampLogger)が同名メソッドをオーバーライドしていても切り替わりません。埋め込まれた型は「自分が誰に埋め込まれているか」を一切知らないからです。これはGoの設計上、意図的な制約です——「暗黙のポリモーフィズムより、明示的な合成」というGoの哲学が構造体embeddingにも表れています。

真の多態性(同じインターフェース型に対して異なる実装が呼ばれる)が必要な場合は、構造体embeddingではなくインターフェースを使います。インターフェースにも同じ「埋め込み」の書き方があり、複数の小さいインターフェースを1つにまとめるのに使われます(例: io.ReadWriteCloserio.Reader + io.Writer + io.Closerの埋め込みです)。インターフェース埋め込みは単なるメソッドセットの合併であり、構造体embeddingのような「知らないうちに落とし穴にはまる」問題は起きません。

📝 問題

簡易ロギングフレームワークを実装してください。

Part A: 構造体embeddingと「仮想ディスパッチがない」ことの実演

  1. BaseLogger構造体を定義すること。フィールドはPrefix string
    • メソッドLog(msg string) string: "[Prefix] msg"の形式の文字列を返す
    • メソッドLogAndAnnounce(msg string) string: 内部でl.Log(msg)を呼び出し、結果の先頭に"ANNOUNCE: "を付けて返す
  2. TimestampLogger構造体を定義し、BaseLoggerを(フィールド名なしで)埋め込むこと。さらに独自フィールドClock func() string(時刻を返す関数。テストでモックできるようにするため)を持つこと。
  3. TimestampLoggerLog(msg string) stringメソッドを定義し、BaseLogger.Logをオーバーライド(同名メソッドで上書き)すること。返り値は"[Prefix] HH:MM:SS msg"の形式(l.Clock()を呼んで時刻文字列を取得する)。
  4. 次の2つの呼び出し結果を予測してから実際にコードを実行し、結果が一致することを確認すること。
    • tl.Log("直接呼び出し") → タイムスタンプが付く
    • tl.LogAndAnnounce("間接呼び出し") → タイムスタンプが付かないBaseLogger.LogAndAnnounce内のl.Log(msg)BaseLogger.Logを指すため)
    • この結果を「実行結果と考察」として1〜2文でなぜそうなるか説明すること

Part B: インターフェースを使った「本物の多態性」への書き換え

  1. Loggerインターフェース(Log(msg string) stringの1メソッド)を定義すること
  2. LogAndAnnounceと同じ動作をする独立関数func Announce(l Logger, msg string) stringを定義すること(構造体メソッドではなく、Loggerインターフェースを受け取るパッケージ関数にする)
  3. Announce(tl, "Announce経由")を呼び出すと、今度は正しくTimestampLogger.Log(タイムスタンプ付き)が使われることを確認すること

Part C: インターフェース埋め込み

  1. Readerインターフェース(Read() (string, bool))とWriterインターフェース(Write(s string) error)をそれぞれ定義すること
  2. 両方を埋め込んだReadWriterインターフェースを定義すること(io.ReadWriteCloserと同じパターン)
  3. MemBuffer構造体を実装し、Read/Write両方のメソッドを実装することでReadWriterを暗黙的に満たすこと(内部は[]stringのスライスと読み取り位置posで構わない)
  4. var _ ReadWriter = (*MemBuffer)(nil)のようなコンパイル時アサーションで、MemBufferReadWriterを満たすことを保証すること

すべてgo runまたはgo testでそのまま動作するコードとして提示すること。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Part Aの核心は「メソッドの中から自分自身のメソッドを呼ぶとき、それは静的に決まる」という点です。BaseLogger.LogAndAnnounceBaseLogger型のメソッドとしてコンパイルされるので、その中のl.Log(msg)lは常にBaseLogger型です。TimestampLoggerがいくらLogをオーバーライドしても、BaseLoggerのコードは書き換わりません。まずこの一文を頭に入れてから実装してください。

ヒント2 — アプローチ
  • TimestampLoggerLogメソッドの中でl.Prefixのように書くと、これは埋め込まれたBaseLogger.Prefixが昇格されたものなので、直接アクセスできます(l.BaseLogger.Prefixと書く必要はありません)
  • Clock func() stringのようなフィールドにすることで、テスト時はfunc() string { return "12:00:00" }のような固定値を返す関数を注入でき、実時刻に依存しないテストが書けます(time.Now()を直接呼ぶとテストのたびに結果が変わってしまう)
  • Part BのAnnounce関数はBaseLoggerにもTimestampLoggerにも属さない、パッケージレベルの独立関数にしてください。インターフェース引数を受け取ることで、渡された実際の型のLogメソッド(動的な実装)が呼ばれます
  • Part Cのvar _ ReadWriter = (*MemBuffer)(nil)は、変数に代入せず型チェックだけを行うイディオムです。コンパイル時にMemBufferReadWriterを満たしていなければここでエラーになります
ヒント3 — コード骨格
package main

import "fmt"

type BaseLogger struct {
	Prefix string
}

func (l BaseLogger) Log(msg string) string {
	// TODO: "[Prefix] msg" 形式
}

func (l BaseLogger) LogAndAnnounce(msg string) string {
	// TODO: l.Log(msg) を呼び、"ANNOUNCE: " を前置
}

type TimestampLogger struct {
	BaseLogger
	Clock func() string
}

func (l TimestampLogger) Log(msg string) string {
	// TODO: "[Prefix] HH:MM:SS msg" 形式(l.Clock() を使う)
}

// Part B
type Logger interface {
	Log(msg string) string
}

func Announce(l Logger, msg string) string {
	// TODO: BaseLogger.LogAndAnnounce と同じロジックを、インターフェース経由で実装
}

// Part C
type Reader interface {
	// TODO
}
type Writer interface {
	// TODO
}
type ReadWriter interface {
	Reader
	Writer
}

type MemBuffer struct {
	data []string
	pos  int
}

// TODO: MemBuffer に Read/Write を実装
var _ ReadWriter = (*MemBuffer)(nil)

func main() {
	tl := TimestampLogger{
		BaseLogger: BaseLogger{Prefix: "APP"},
		Clock:      func() string { return "12:00:00" },
	}
	fmt.Println(tl.Log("直接呼び出し"))
	fmt.Println(tl.LogAndAnnounce("間接呼び出し"))
	fmt.Println(Announce(tl, "Announce経由"))
}

模範解答

ファイル: main.go

package main

import "fmt"

// ── Part A: 構造体embeddingとメソッドのオーバーライド ──

// BaseLogger は埋め込まれる側の基本ロガー。
type BaseLogger struct {
	Prefix string
}

func (l BaseLogger) Log(msg string) string {
	return fmt.Sprintf("[%s] %s", l.Prefix, msg)
}

// LogAndAnnounce は l.Log(msg) を呼び出す。
// 重要: この l は常に BaseLogger 型として解決される(静的束縛)。
// TimestampLogger が Log をオーバーライドしていても、この呼び出しは
// BaseLogger.Log のままであり、TimestampLogger.Log には切り替わらない。
func (l BaseLogger) LogAndAnnounce(msg string) string {
	return "ANNOUNCE: " + l.Log(msg)
}

// TimestampLogger は BaseLogger を埋め込み、Log を同名メソッドで上書き(シャドーイング)する。
type TimestampLogger struct {
	BaseLogger
	Clock func() string // テストで固定時刻を注入できるようにする
}

// Log は BaseLogger.Log をシャドーイングする。
// tl.Log(...) と直接呼んだ場合はこちらが呼ばれるが、
// BaseLogger.LogAndAnnounce の内部からは呼ばれない。
func (l TimestampLogger) Log(msg string) string {
	return fmt.Sprintf("[%s] %s %s", l.Prefix, l.Clock(), msg)
}

// ── Part B: インターフェース経由の「本物の多態性」──

// Logger は Log(msg string) string を持つ型の最小契約。
type Logger interface {
	Log(msg string) string
}

// Announce は BaseLogger.LogAndAnnounce と同じロジックだが、
// インターフェース引数として受け取ることで動的ディスパッチになる。
// l.Log(msg) は「実際に渡された型」の Log メソッドを呼ぶ。
func Announce(l Logger, msg string) string {
	return "ANNOUNCE: " + l.Log(msg)
}

// ── Part C: インターフェースの埋め込み ──

type Reader interface {
	Read() (string, bool)
}

type Writer interface {
	Write(s string) error
}

// ReadWriter は Reader と Writer のメソッドセットを合併しただけの
// インターフェース。io.ReadWriteCloser と同じパターン。
type ReadWriter interface {
	Reader
	Writer
}

// MemBuffer はメモリ上の文字列キューとして Read/Write を実装する。
type MemBuffer struct {
	data []string
	pos  int
}

func (b *MemBuffer) Write(s string) error {
	b.data = append(b.data, s)
	return nil
}

func (b *MemBuffer) Read() (string, bool) {
	if b.pos >= len(b.data) {
		return "", false
	}
	v := b.data[b.pos]
	b.pos++
	return v, true
}

// コンパイル時アサーション: *MemBuffer が ReadWriter を満たすことを保証する。
// 満たしていなければ go build がここでエラーになる。
var _ ReadWriter = (*MemBuffer)(nil)

func main() {
	tl := TimestampLogger{
		BaseLogger: BaseLogger{Prefix: "APP"},
		Clock:      func() string { return "12:00:00" },
	}

	fmt.Println(tl.Log("直接呼び出し"))
	fmt.Println(tl.LogAndAnnounce("間接呼び出し"))
	fmt.Println(Announce(tl, "Announce経由"))

	buf := &MemBuffer{}
	buf.Write("hello")
	buf.Write("world")
	for {
		v, ok := buf.Read()
		if !ok {
			break
		}
		fmt.Println("read:", v)
	}
}

ファイル: main_test.go

package main

import "testing"

func TestLogOverrideVsLogAndAnnounce(t *testing.T) {
	tl := TimestampLogger{
		BaseLogger: BaseLogger{Prefix: "APP"},
		Clock:      func() string { return "12:00:00" },
	}

	// 直接 Log を呼べば TimestampLogger.Log(タイムスタンプ付き)が使われる
	got := tl.Log("msg1")
	want := "[APP] 12:00:00 msg1"
	if got != want {
		t.Errorf("tl.Log() = %q, want %q", got, want)
	}

	// BaseLogger.LogAndAnnounce 経由だと BaseLogger.Log が使われ、
	// タイムスタンプは付かない(仮想ディスパッチが無いことの実演)
	got2 := tl.LogAndAnnounce("msg2")
	want2 := "ANNOUNCE: [APP] msg2"
	if got2 != want2 {
		t.Errorf("tl.LogAndAnnounce() = %q, want %q", got2, want2)
	}

	// Announce() はインターフェース経由なので TimestampLogger.Log が使われる
	got3 := Announce(tl, "msg3")
	want3 := "ANNOUNCE: [APP] 12:00:00 msg3"
	if got3 != want3 {
		t.Errorf("Announce(tl, ...) = %q, want %q", got3, want3)
	}
}

func TestMemBufferSatisfiesReadWriter(t *testing.T) {
	var rw ReadWriter = &MemBuffer{}
	if err := rw.Write("a"); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if err := rw.Write("b"); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	v1, ok1 := rw.Read()
	if !ok1 || v1 != "a" {
		t.Errorf("first Read() = (%q, %v), want (\"a\", true)", v1, ok1)
	}
	v2, ok2 := rw.Read()
	if !ok2 || v2 != "b" {
		t.Errorf("second Read() = (%q, %v), want (\"b\", true)", v2, ok2)
	}
	_, ok3 := rw.Read()
	if ok3 {
		t.Errorf("third Read() should return ok=false")
	}
}
▶ 実行結果を見る(go run . / go test ./... で検証済み)
$ go run .
[APP] 12:00:00 直接呼び出し
ANNOUNCE: [APP] 間接呼び出し
ANNOUNCE: [APP] 12:00:00 Announce経由
read: hello
read: world

$ go test ./...
ok  	example.com/day021	0.002s

main.gomain_test.goを1つのモジュール(go.modmodule example.com/day021と宣言)として同一パッケージmainに配置しています。

🧭 実行結果と考察

📌
tl.LogAndAnnounce(...)が呼ぶl.Log(msg)は、BaseLogger.LogAndAnnounceメソッドの中に静的にコンパイルされたBaseLogger.Logの呼び出しである。TimestampLoggerはフィールド昇格・メソッド昇格によってLogAndAnnounceを「借りている」だけであり、BaseLoggerのコード自体は「自分が今どの外側の型に埋め込まれているか」を実行時に一切知らないため、TimestampLogger.Logへ動的に切り替わることはない。一方Announce(l Logger, msg string)はインターフェース引数としてlを受け取っているため、l.Log(msg)は実行時に渡された具体的な型(TimestampLogger)のメソッドテーブルを参照する、真の動的ディスパッチになる。

🪜 Step-by-Step 解説

1
BaseLoggerを定義し、埋め込まれる前提のメソッドを書く
type BaseLogger struct {
	Prefix string
}
func (l BaseLogger) Log(msg string) string {
	return fmt.Sprintf("[%s] %s", l.Prefix, msg)
}
値レシーバにしているのは、TimestampLogger側も値として埋め込むため(埋め込みフィールドがポインタでない限り、値レシーバのメソッドは値・ポインタ両方から昇格して呼び出せる)。
2
TimestampLoggerBaseLoggerをフィールド名なしで埋め込む
type TimestampLogger struct {
	BaseLogger
	Clock func() string
}
BaseLoggerという型名がそのままフィールド名になる。これによりtl.Prefixtl.Log(...)tl.LogAndAnnounce(...)が、tl.BaseLoggerを経由せずに直接呼び出せる(昇格)。
3
Logをオーバーライド(シャドーイング)する
func (l TimestampLogger) Log(msg string) string {
	return fmt.Sprintf("[%s] %s %s", l.Prefix, l.Clock(), msg)
}
TimestampLoggerに同名のLogメソッドを定義すると、tl.Log(...)と書いたときはメソッド解決規則(「一番浅い階層にあるメソッドが優先される」)によりこちらが選ばれ、BaseLogger.Logは隠れる。しかしこれは「上書き」ではなく「隠蔽(シャドーイング)」であり、BaseLogger自身のコードには一切影響しない。
4
落とし穴を実際に踏んでみる
tl.LogAndAnnounce("間接呼び出し")を呼ぶと、内部で実行されるのはBaseLogger.LogAndAnnounceのコードであり、そのコードが呼ぶl.Log(msg)lBaseLogger型のレシーバである。よってタイムスタンプは付かない。ここでJava的な直感(「サブクラスがオーバーライドしたら常にそちらが呼ばれる」)を持ち込むと予測を外す。これがGoのembeddingが「継承ではない」と言われる最大の理由。
5
インターフェースで動的ディスパッチを実現する
type Logger interface { Log(msg string) string }
func Announce(l Logger, msg string) string {
	return "ANNOUNCE: " + l.Log(msg)
}
Announceはレシーバとしてメソッドを持つのではなく、Loggerインターフェース型の引数lを受け取る独立関数にする。l.Log(msg)は実行時にlが保持する具体的な型情報(itable)を参照してメソッドを解決するため、TimestampLoggerを渡せばTimestampLogger.Logが呼ばれる。これがGoにおける「多態性が欲しければインターフェースを使え」という原則の具体例。
6
インターフェース埋め込みで小さいインターフェースを合成する
type ReadWriter interface {
	Reader
	Writer
}
構造体embeddingと違い、インターフェース埋め込みには「実行時の落とし穴」が存在しない。単にReaderWriterのメソッドセットを足し合わせているだけであり、ReadWriterを満たすには両方のメソッドをすべて実装すればよい。MemBufferReaderWriterという名前を一切知らなくても、シグネチャが一致していれば自動的にReadWriterを満たす。

💡 設計思想・なぜこう書くのか

📌
embeddingは「コード再利用の道具」であり「型階層を作る道具」ではない: Goに継承がない理由は、継承が生み出す「深い型階層」と「脆い基底クラス問題(fragile base class problem)」——基底クラスの変更が、意図せず全サブクラスの挙動を変えてしまう問題——を避けるためである。構造体embeddingは「このフィールド・メソッド群をそっくり借りてくる」というコピー的な感覚に近く、階層ではなく合成(composition)として設計されている。仮想ディスパッチが無いのは制約ではなく、「埋め込まれた型の振る舞いは、埋め込まれた型の中だけで完結する」という予測可能性を保つための設計判断である。
📌
多態性が必要なら、埋め込みではなくインターフェースを使う、という住み分け: Goのコードベースを読むと「構造体embedding=コード再利用(DRY)」「インターフェース=多態性(ポリモーフィズム)」ときれいに役割が分かれていることが多い。両方を1つの機能(クラス継承)でまかなおうとする言語と違い、Goは2つの機能を意図的に分離することで、それぞれをシンプルに保っている。

🌐 他言語との比較

観点Go(embedding)Java(継承)Python(継承)TypeScript(継承/ミックスイン)
メソッドの再利用フィールド昇格・メソッド昇格によるコピー的合成extendsによる型階層の構築多重継承・MRO(Method Resolution Order)extends(単一継承)、ミックスインで補完
仮想ディスパッチ無い(埋め込まれた型のメソッドは常に自分自身の実装を呼ぶ)有る(デフォルトで全メソッドが仮想、finalで無効化)有る(すべてのメソッドが実質的に仮想)有る(デフォルトで仮想、overrideは任意)
"is-a" 関係の明示明示的な"is-a"はない。埋め込みは"has-a"に近いが、メソッド昇格により見た目は"is-a"に近づくextendsが明示的に"is-a"を宣言するclass Sub(Base):が明示的に"is-a"を宣言するextendsが明示的に"is-a"を宣言する
多態性の実現方法インターフェース経由(構造的部分型)継承 or インターフェース実装(implementsダックタイピング or ABC(抽象基底クラス)インターフェース実装 or 継承
落とし穴「オーバーライドしたのに呼ばれない」(本記事のPart Aの現象)脆い基底クラス問題・深すぎる継承階層多重継承時のMROの複雑さ(ダイヤモンド問題)ミックスインの型定義が複雑になりがち

Goのembeddingで最も注意すべきは、他言語の継承に慣れたエンジニアほど「オーバーライドすれば常にそちらが呼ばれる」という誤った直感を持ち込みやすい点である。Goでは「埋め込まれた型のメソッドの中で自分自身のメソッドを呼んでいる場合、それは静的に決まる」という事実を体で覚えておく必要がある。

🏆 実務での使いどころ

  • 共通フィールド・共通メソッドの集約: CreatedAt/UpdatedAtのような監査用フィールドと、それに関するヘルパーメソッドをAuditFields構造体にまとめ、複数のドメインモデル(User, Orderなど)に埋め込むことで、各モデルの定義をシンプルに保てる
  • 標準ライブラリのラッパー: sync.Mutexを構造体に埋め込んでLock()/Unlock()をそのまま昇格させる、あるいはhttp.ResponseWriterを埋め込んでレスポンスのステータスコードを記録するミドルウェア用ラッパーを作る、といったパターンが頻出する
  • インターフェース埋め込みによる段階的な契約の合成: io.Reader/io.Writer/io.Closerのように、小さい契約を用途に応じてio.ReadCloserio.WriteCloserio.ReadWriteCloserと柔軟に組み合わせられるのは、標準ライブラリがインターフェース埋め込みを徹底しているから
  • 「オーバーライドの罠」を避けるための設計判断: あるフィールドの挙動を外側の型で完全に差し替えたい場合、構造体embeddingではなく、最初からインターフェース経由の依存注入(Day 017や本日のPart Bのパターン)で設計しておくと、後から「オーバーライドしたのに反映されない」という事故を防げる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
埋め込んだ型のメソッドをオーバーライドすれば、埋め込み元のメソッド内部からの呼び出しも自動的に切り替わると思い込むJava/Pythonの仮想メソッド(動的束縛)に慣れているためGoの構造体embeddingには仮想ディスパッチが無い。埋め込まれた型のコード内の自己呼び出しは常にその型自身のメソッドを指す。多態性が必要ならインターフェースを使う
embeddingを「継承」と呼んでしまい、深い階層を作ろうとする用語が似ているため直感的に継承と混同するembeddingはコード再利用の合成機構であり、型階層("is-a")を表明するものではない。埋め込みの多用は継承と同じ脆さを持ち込むため、1〜2階層にとどめるのが実務での目安
埋め込みフィールドを値型で持つかポインタ型で持つかを深く考えずに決めるフィールド昇格の挙動が値でもポインタでも同じに見えるためポインタで埋め込むとnilのまま昇格フィールドにアクセスしてパニックする危険がある。値で埋め込めば常に有効な状態を保てるが、構造体全体のコピーコストが増える。大きい構造体を頻繁に埋め込む場合はポインタ埋め込み+ゼロ値チェックを検討する
インターフェース埋め込みも構造体embeddingと同じ落とし穴があると思い込む「埋め込み」という同じ言葉が使われているためインターフェース埋め込みは単なるメソッドセットの合併であり、実行時の動的挙動は一切変わらない。落とし穴があるのは構造体embeddingだけ

🚀 次のステップ

  • 発展: TimestampLoggerをさらに埋め込んだRequestScopedLogger(リクエストIDを付与する3階層目)を作り、tl.Log(...)tl.LogAndAnnounce(...)それぞれの呼び出しでどのメソッドが解決されるかを、階層を1つ増やした状態で改めて予測・検証してみましょう
  • 次回予告: Day 022 — ジェネリクス基礎(型パラメータ)(実装)。embeddingによるコード再利用の限界(型ごとに埋め込み先を書き分ける必要がある)を、型パラメータによる再利用でどう補完できるかを学びます

🎯 自己評価

自分の回答

気づき・メモ