Go の構造体埋め込み:継承に代わるもの
1. 概要
Java や C# に慣れていれば、Go を書き始めるときの最初の質問はたいてい「extends はどこにあるの?」です。Go には継承がありません。その代わりに、Go には**構造体埋め込み(struct embedding)**があります。型をほかの構造体に埋め込んで、フィールドとメソッドを再利用する仕組みです。
この記事では、埋め込みの仕組み、継承との違い、そしてよくある落とし穴について説明します。読み終わるころには、埋め込みを使って動作を共有し、すっきりしたデコレータを書き、その特有のバグを回避できるようになるでしょう。
2. 構造体埋め込みの基本
埋め込みとは、型のみで、フィールド名を付けないフィールドの宣言です。
package main
import "fmt"
type Address struct {
Street string
City string
}
func (a Address) FullAddress() string {
return a.Street + ", " + a.City
}
type Customer struct {
Name string
Address // 埋め込みフィールド
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // フィールドが昇格
fmt.Println(c.FullAddress()) // メソッドが昇格
fmt.Println(c.Address.City) // 明示的なアクセス
}
Huế
12 Lê Lợi, Huế
Huế
分析:
AddressはCustomerに含まれていますが、フィールド名がありません。Go は自動的にフィールド名をAddress(つまり型の名前)として設定します。AddressのCityフィールドとFullAddress()メソッドが 昇格してCustomerのレベルに上がります。つまりc.Address.Cityの代わりにc.Cityと書けます。- 複合字句で初期化するときは、フィールド名
Address:を使う必要があります。Cityを直接Customerに入力するような構文はありません。
本質的には、Customer は Address を含む関係です。これは has-a の関係で、コンパイラが利便性のために構文を追加しているだけです。Customer は Address ではありません。Customer を Address を受け取る関数に渡すことはできません。
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // コンパイルエラー: type Customer の c を Address として使用できません
printCity(c.Address) // OK
3. シャドーイング(shadowing)によるメソッドの「オーバーライド」
外側の構造体が、埋め込まれた型のメソッドと同じ名前のメソッドを宣言すると、外側の構造体のメソッドが内側のメソッドを隠す(シャドーイング)ようになります。
package main
import "fmt"
type Logger struct {
Prefix string
}
func (l Logger) Log(msg string) {
fmt.Println(l.Prefix + msg)
}
type OrderService struct {
Logger
}
func (s OrderService) Log(msg string) {
s.Logger.Log("[order] " + msg) // 埋め込まれた型のメソッドを呼び出す
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)はOrderServiceのメソッドを呼び出します。これはより浅い深さにあるためです。s.Logger.Log(...)は元のメソッドをまだ呼び出せます。これは Java のsuper.log(...)に相当します。
一見するとオーバーライドのように見えます。しかし、これは多くの人が転ぶ場所で、次のセクションで示すとおりです。
4. 最大の落とし穴:仮想ディスパッチがない
Java では、親クラスが this.channel() を呼び出し、子クラスが channel() をオーバーライドすると、子クラスのバージョンが実行されます。Go はそうではありません。
package main
import "fmt"
type Notifier struct{}
func (n Notifier) Channel() string { return "email" }
func (n Notifier) Send(msg string) string {
return "[" + n.Channel() + "] " + msg
}
type SMSNotifier struct {
Notifier
}
func (s SMSNotifier) Channel() string { return "sms" }
func main() {
s := SMSNotifier{}
fmt.Println(s.Channel())
fmt.Println(s.Send("Đơn hàng đã giao"))
}
sms
[email] Đơn hàng đã giao
s.Send(...) は実質的に s.Notifier.Send(...) です。Send の内部では、receiver n は Notifier 型を持ちます。それが SMSNotifier の中にあることを知らないので、n.Channel() は常に "email" を返します。
これがコアの違いです。埋め込まれた型は、それを含む構造体へのバックリファレンスを持ちません。Java スタイルの「テンプレート メソッド」パターンは直接適用できません。
4.1. 修正方法:変動部分をインターフェースに分離する
変動する動作が必要な場合は、明示的に渡してください。
package main
import "fmt"
type Channel interface {
Name() string
}
type Email struct{}
func (Email) Name() string { return "email" }
type SMS struct{}
func (SMS) Name() string { return "sms" }
type Notifier struct {
Channel Channel
}
func (n Notifier) Send(msg string) string {
return "[" + n.Channel.Name() + "] " + msg
}
func main() {
fmt.Println(Notifier{Channel: SMS{}}.Send("Đơn hàng đã giao"))
fmt.Println(Notifier{Channel: Email{}}.Send("Hóa đơn tháng 10"))
}
[sms] Đơn hàng đã giao
[email] Hóa đơn tháng 10
これで、変動部分(Channel)は明確な dependency です。これはオーバーライドを模倣しようとするよりも「Go らしい」方法です。
5. 埋め込みとインターフェース
Go のインターフェースは暗黙的に満たされます。昇格されたメソッドも、外側の構造体のメソッドセットにカウントされます。したがって、埋め込みは構造体がインターフェースを満たすのを助け、メソッドを書き直す必要がなくなります。
package main
import "fmt"
type Describer interface {
FullAddress() string
}
type Address struct{ Street, City string }
func (a Address) FullAddress() string { return a.Street + ", " + a.City }
type Warehouse struct {
Code string
Address
}
func describe(d Describer) { fmt.Println(d.FullAddress()) }
func main() {
w := Warehouse{Code: "HN-01", Address: Address{"KCN Thăng Long", "Hà Nội"}}
describe(w) // Warehouse は昇格されたメソッドのおかげで Describer を満たす
}
KCN Thăng Long, Hà Nội
5.1. ポインタレシーバーに関する注意
埋め込みのときのメソッドセットのルールは以下の通りです。
S 内での宣言 |
S のメソッドセット |
*S のメソッドセット |
|---|---|---|
T を埋め込む |
receiver T のメソッド |
receiver T と *T のメソッド |
*T を埋め込む |
receiver T と *T のメソッド |
receiver T と *T のメソッド |
例えば、Counter が func (c *Counter) Inc() を持ち、Counter(ポインタではなく)を Stats に埋め込んだ場合、Inc() を持つインターフェースを満たすのは *Stats だけです。値型の Stats は満たしません。“Stats does not implement Incrementer (method Inc has pointer receiver)” という類のコンパイルエラーはここから来ることが多いです。
6. インターフェースを構造体に埋め込む:すっきりしたデコレータ
インターフェースを構造体に埋め込むことができます。構造体は自動的にインターフェースのすべてのメソッドを持つようになり、各メソッドは内部の値に委譲されます。変更したいメソッドだけをオーバーライドすればよいです。
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // すべてのメソッドを委譲
Logs []string
}
func (l *LoggingPayment) Charge(orderID string, amount int) error {
l.Logs = append(l.Logs, fmt.Sprintf("charge %s: %d", orderID, amount))
return l.PaymentService.Charge(orderID, amount)
}
LoggingPaymentはChargeだけを書き直します。Refundは内部のPaymentServiceに自動的に委譲されます。*LoggingPaymentはPaymentServiceを満たすため、呼び出し側が知らなくても、任意の実装をラップできます。
インターフェースが 10 個のメソッドを持ち、1 つだけをインターセプトする必要がある場合、この技術は繰り返されるコードを大幅に削減します。
注意: 埋め込まれたインターフェースフィールドが
nilの場合、コードは正常にコンパイルされます。しかし、オーバーライドされていないメソッド(例:Refund)を呼び出すと、実行時にnil ポインタデリファレンスパニックが発生します。常にコンストラクタを通じて初期化してください。例えばNewLoggingPayment(inner PaymentService)。
7. 委譲の動作をテストする
Go は JUnit の代わりに標準の testing パッケージを使用します。以下のテストは 2 つのことを確認します。Charge がログに記録され、まだ元のサービスに呼び出され、Refund がログなしで委譲されていることです。
package payment
import "testing"
type fakePayment struct {
charged []string
refunded []string
}
func (f *fakePayment) Charge(orderID string, amount int) error {
f.charged = append(f.charged, orderID)
return nil
}
func (f *fakePayment) Refund(orderID string) error {
f.refunded = append(f.refunded, orderID)
return nil
}
func TestLoggingPayment(t *testing.T) {
inner := &fakePayment{}
var svc PaymentService = &LoggingPayment{PaymentService: inner}
if err := svc.Charge("ORD-1", 250000); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := svc.Refund("ORD-1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
lp := svc.(*LoggingPayment)
if len(lp.Logs) != 1 || lp.Logs[0] != "charge ORD-1: 250000" {
t.Errorf("logs = %v, want [charge ORD-1: 250000]", lp.Logs)
}
if len(inner.charged) != 1 || len(inner.refunded) != 1 {
t.Errorf("inner not called: charged=%v refunded=%v", inner.charged, inner.refunded)
}
}
テストを実行します:
go test ./...
ok example.com/shop/payment 0.002s
行 var svc PaymentService = &LoggingPayment{...} もコンパイル時のチェックです。*LoggingPayment がインターフェースを満たさない場合、テストはビルドできません。本番コードでは、同じ目的のために var _ PaymentService = (*LoggingPayment)(nil) という使い慣れたアプローチを使用できます。
8. 名前の衝突と一般的なエラー
8.1. Ambiguous selector
同じ深さの2 つの埋め込まれた型が同じ名前のフィールドまたはメソッドを持つ場合、外側の構造体を通じてアクセスするとコンパイルエラーが発生します。
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // コンパイルエラー:曖昧なセレクタ o.Email
fmt.Println(o.Billing.Email) // OK
}
構造体 Order は有効なままです。エラーは o.Email にアクセスしようとしたときにのみ表示されます。対処方法は、明示的に呼び出すか、Order 自体に Email を宣言して両方を隠すことです。
8.2. 誤って API を公開する:sync.Mutex の例
type Inventory struct {
sync.Mutex
stock map[string]int
}
このコードは実行できます。しかし、埋め込まれたフィールド名 Mutex がエクスポートされているため、Lock() と Unlock() は現在 Inventory の public メソッドになっています。パッケージ外のコードはあなたのインベントリをロックできます。代わりに以下のように書くべきです。
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
一般的なルール:埋め込みは埋め込まれた型のすべてのメソッドをあなたの API に持ち込みます。それらを公開したくない場合は、名前付きフィールドを使用してください。
8.3. 埋め込まれた nil ポインタ
Address の代わりに *Address を埋め込むと、ポインタが nil のゼロ値を持つ構造体になります。その場合、c.City はパニックします。*T のメソッドセットが必要な場合、またはインスタンスを本当に共有する必要がある場合にのみポインタを埋め込み、必ず初期化してください。
9. 埋め込みをいつ使い、いつ使わないか
使うべき場合:
- 外側の構造体が埋め込まれた型のすべての動作を本当に公開したい場合。例えば
http.Handlerやio.Readerを埋め込むなど。 - 大きなインターフェースの少数のメソッドだけを変更するデコレータまたはラッパーを書く場合。
Audit{CreatedAt, UpdatedAt}のような共通フィールドを複数の entity に集約する場合。
使わないべき場合:
- 「親クラスが子クラスのメソッドを呼び出す」複数レベルの継承ツリーを再現しようとしている場合。インターフェースと明示的な dependency を使用してください(セクション 4.1)。
- 埋め込まれた型に公開したくないメソッドがある場合(セクション 8.2)。
- その型から 1 つか 2 つのメソッドだけが必要な場合。手書きされた名前付きフィールドと委譲メソッドの方が明確です。
注意: 埋め込みは is-a の関係を作成しません。ポリモーフィズムが必要な場合は、インターフェースを定義し、複数の型がそれを満たすようにしてください。「子構造体が親構造体である」ことを期待しないでください。
10. 結論
構造体埋め込みは Go の composition ツールで、少しの便利な構文が付いています。埋め込まれた型のフィールドとメソッドは外側の構造体に昇格されます。コードの再利用、インターフェースの満足、そしてすっきりしたデコレータを書くのに役立ちます。
しかし埋め込みは継承ではありません。仮想ディスパッチはなく、埋め込まれた型はそれを含む構造体のことを知らず、昇格されたすべてのメソッドはあなたの API の一部になります。3 つのことを覚えておいてください。変動する動作にはインターフェースを使用し、埋め込まれたインターフェースまたはポインタフィールドを完全に初期化し、メソッドを公開したくない場合は名前付きフィールドに切り替えることです。