📚 背景知識(読んでから問題へ)
Day 020では「小さいインターフェース」を利用側で定義し、暗黙的実装によって結合を緩める設計を学びました。今日はコード再利用の話です。Goにはclassもextendsもありません。継承(inheritance)の代わりにGoが用意しているのがembedding(埋め込み)です。
構造体にフィールド名を書かずに型だけを書くと、その型が「埋め込まれた(embedded)フィールド」になります。
type BaseLogger struct {
Prefix string
}
type TimestampLogger struct {
BaseLogger // 埋め込み。フィールド名を書かない
Clock func() string
}
これによりTimestampLoggerのインスタンスは、BaseLoggerが持つフィールド・メソッドをあたかも自分のものであるかのように呼び出せます(フィールド昇格・メソッド昇格)。tl.Prefixはtl.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.ReadWriteCloserはio.Reader + io.Writer + io.Closerの埋め込みです)。インターフェース埋め込みは単なるメソッドセットの合併であり、構造体embeddingのような「知らないうちに落とし穴にはまる」問題は起きません。
📝 問題
簡易ロギングフレームワークを実装してください。
Part A: 構造体embeddingと「仮想ディスパッチがない」ことの実演
BaseLogger構造体を定義すること。フィールドはPrefix string。- メソッド
Log(msg string) string:"[Prefix] msg"の形式の文字列を返す - メソッド
LogAndAnnounce(msg string) string: 内部でl.Log(msg)を呼び出し、結果の先頭に"ANNOUNCE: "を付けて返す
- メソッド
TimestampLogger構造体を定義し、BaseLoggerを(フィールド名なしで)埋め込むこと。さらに独自フィールドClock func() string(時刻を返す関数。テストでモックできるようにするため)を持つこと。TimestampLoggerにLog(msg string) stringメソッドを定義し、BaseLogger.Logをオーバーライド(同名メソッドで上書き)すること。返り値は"[Prefix] HH:MM:SS msg"の形式(l.Clock()を呼んで時刻文字列を取得する)。- 次の2つの呼び出し結果を予測してから実際にコードを実行し、結果が一致することを確認すること。
tl.Log("直接呼び出し")→ タイムスタンプが付くtl.LogAndAnnounce("間接呼び出し")→ タイムスタンプが付かない(BaseLogger.LogAndAnnounce内のl.Log(msg)はBaseLogger.Logを指すため)- この結果を「実行結果と考察」として1〜2文でなぜそうなるか説明すること
Part B: インターフェースを使った「本物の多態性」への書き換え
Loggerインターフェース(Log(msg string) stringの1メソッド)を定義することLogAndAnnounceと同じ動作をする独立関数func Announce(l Logger, msg string) stringを定義すること(構造体メソッドではなく、Loggerインターフェースを受け取るパッケージ関数にする)Announce(tl, "Announce経由")を呼び出すと、今度は正しくTimestampLogger.Log(タイムスタンプ付き)が使われることを確認すること
Part C: インターフェース埋め込み
Readerインターフェース(Read() (string, bool))とWriterインターフェース(Write(s string) error)をそれぞれ定義すること- 両方を埋め込んだ
ReadWriterインターフェースを定義すること(io.ReadWriteCloserと同じパターン) MemBuffer構造体を実装し、Read/Write両方のメソッドを実装することでReadWriterを暗黙的に満たすこと(内部は[]stringのスライスと読み取り位置posで構わない)var _ ReadWriter = (*MemBuffer)(nil)のようなコンパイル時アサーションで、MemBufferがReadWriterを満たすことを保証すること
すべてgo runまたはgo testでそのまま動作するコードとして提示すること。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Part Aの核心は「メソッドの中から自分自身のメソッドを呼ぶとき、それは静的に決まる」という点です。BaseLogger.LogAndAnnounceはBaseLogger型のメソッドとしてコンパイルされるので、その中のl.Log(msg)のlは常にBaseLogger型です。TimestampLoggerがいくらLogをオーバーライドしても、BaseLoggerのコードは書き換わりません。まずこの一文を頭に入れてから実装してください。
ヒント2 — アプローチ
TimestampLoggerのLogメソッドの中で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)は、変数に代入せず型チェックだけを行うイディオムです。コンパイル時にMemBufferがReadWriterを満たしていなければここでエラーになります
ヒント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.go・main_test.goを1つのモジュール(go.modでmodule 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 解説
BaseLoggerを定義し、埋め込まれる前提のメソッドを書くtype BaseLogger struct {
Prefix string
}
func (l BaseLogger) Log(msg string) string {
return fmt.Sprintf("[%s] %s", l.Prefix, msg)
}
値レシーバにしているのは、TimestampLogger側も値として埋め込むため(埋め込みフィールドがポインタでない限り、値レシーバのメソッドは値・ポインタ両方から昇格して呼び出せる)。
TimestampLoggerにBaseLoggerをフィールド名なしで埋め込むtype TimestampLogger struct {
BaseLogger
Clock func() string
}
BaseLoggerという型名がそのままフィールド名になる。これによりtl.Prefix・tl.Log(...)・tl.LogAndAnnounce(...)が、tl.BaseLoggerを経由せずに直接呼び出せる(昇格)。
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自身のコードには一切影響しない。
tl.LogAndAnnounce("間接呼び出し")を呼ぶと、内部で実行されるのはBaseLogger.LogAndAnnounceのコードであり、そのコードが呼ぶl.Log(msg)のlはBaseLogger型のレシーバである。よってタイムスタンプは付かない。ここでJava的な直感(「サブクラスがオーバーライドしたら常にそちらが呼ばれる」)を持ち込むと予測を外す。これがGoのembeddingが「継承ではない」と言われる最大の理由。
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における「多態性が欲しければインターフェースを使え」という原則の具体例。
type ReadWriter interface {
Reader
Writer
}
構造体embeddingと違い、インターフェース埋め込みには「実行時の落とし穴」が存在しない。単にReaderとWriterのメソッドセットを足し合わせているだけであり、ReadWriterを満たすには両方のメソッドをすべて実装すればよい。MemBufferはReader・Writerという名前を一切知らなくても、シグネチャが一致していれば自動的にReadWriterを満たす。
💡 設計思想・なぜこう書くのか
🌐 他言語との比較
| 観点 | 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.ReadCloser・io.WriteCloser・io.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によるコード再利用の限界(型ごとに埋め込み先を書き分ける必要がある)を、型パラメータによる再利用でどう補完できるかを学びます