Struct Embedding in Go statt Vererbung
1. Übersicht
Wenn Sie Java oder C# gewohnt sind, ist die erste Frage beim Schreiben von Go oft: “Wo ist extends?”. Go hat keine Vererbung. Stattdessen hat Go Struct Embedding: Das Einbetten eines Typs in eine andere Struct, um Felder und Methoden wiederzuverwenden.
Dieser Artikel erklärt, wie Embedding funktioniert, was der Unterschied zur Vererbung ist, und häufige Fallstricke. Nach dem Lesen können Sie Embedding verwenden, um Verhalten zu teilen, saubere Decorator zu schreiben und typische Fehler zu vermeiden.
2. Struct Embedding Basics
Embedding ist die Deklaration eines Feldes nur mit Typ, ohne Namen:
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 // eingebettetes Feld
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // Feld wird gefördert
fmt.Println(c.FullAddress()) // Methode wird gefördert
fmt.Println(c.Address.City) // expliziter Zugriff
}
Huế
12 Lê Lợi, Huế
Huế
Analyse:
Addressliegt inCustomerohne Feldname. Go setzt automatisch den Feldnamen aufAddress, also den Namen des Typs.- Feld
Cityund MethodeFullAddress()vonAddresswerden gefördert aufCustomer. Darum können Siec.Citystattc.Address.Cityschreiben. - Beim Initialisieren mit einem Composite Literal müssen Sie trotzdem den Feldnamen
Address:verwenden. Es gibt keine Syntax zum “direkten Füllen” vonCityinCustomer.
Im Grunde enthält Customer eine Address. Dies ist eine has-a-Beziehung, mit etwas Syntaxbequemlichkeit vom Compiler hinzugefügt. Customer ist nicht Address. Sie können Customer nicht an eine Funktion weitergeben, die Address erwartet:
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // compile error: cannot use c (type Customer) as Address
printCity(c.Address) // OK
3. Methoden “überschreiben” durch Shadowing
Wenn die äußere Struct eine Methode mit dem gleichen Namen wie eine Methode des eingebetteten Typs deklariert, wird die Methode der äußeren Struct die innere Methode verbergen:
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) // Methode des eingebetteten Typs aufrufen
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)ruft die Methode vonOrderServiceauf, weil sie in einer geringeren Tiefe liegt.s.Logger.Log(...)kann immer noch das Original aufrufen. Das ist gleichbedeutend mitsuper.log(...)in Java.
Auf den ersten Blick sieht es wie ein Override aus. Aber hier stolpern viele Menschen, wie der nächste Abschnitt zeigt.
4. Der größte Fallstrick: kein Virtual Dispatch
In Java, wenn eine Elternklasse this.channel() aufruft und eine Kindklasse channel() überschreibt, wird die Version der Kindklasse ausgeführt. Go macht das nicht:
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(...) ist eigentlich s.Notifier.Send(...). Innerhalb von Send hat der Receiver n den Typ Notifier. Er weiß nicht, dass er sich in einem SMSNotifier befindet, also gibt n.Channel() immer "email" zurück.
Das ist der Unterschied im Kern. Der eingebettete Typ hat keine Rückreferenz zur Struct, die ihn enthält. Das “Template Method”-Muster aus Java funktioniert nicht direkt.
4.1. Wie man es behebt: Teilen Sie den sich ändernden Teil in ein Interface aus
Wenn Sie Verhalten benötigen, das sich ändern kann, übergeben Sie es explizit:
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
Jetzt ist der sich ändernde Teil (Channel) eine explizite Abhängigkeit. Das ist “Go-artiger” als zu versuchen, Override zu simulieren.
5. Embedding und Interfaces
Interfaces in Go werden implizit erfüllt. Geförderte Methoden werden auch in die Methodenmenge der äußeren Struct gezählt. Daher hilft Embedding einer Struct, ein Interface zu erfüllen, ohne Methoden neu schreiben zu müssen:
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 erfüllt Describer dank der geförderten Methode
}
KCN Thăng Long, Hà Nội
5.1. Hinweis zu Pointer Receivern
Die Methodenmenge-Regel beim Embedding ist wie folgt:
Deklaration in S |
Methodenmenge von S |
Methodenmenge von *S |
|---|---|---|
Einbetten von T |
Receiver T |
Receiver T und *T |
Einbetten von *T |
Receiver T und *T |
Receiver T und *T |
Wenn zum Beispiel Counter func (c *Counter) Inc() hat und Sie Counter (nicht einen Pointer) in Stats einbetten, dann erfüllt nur *Stats ein Interface mit Inc(). Ein Wert Stats nicht. Der Compile-Fehler “Stats does not implement Incrementer (method Inc has pointer receiver)” kommt oft von hier.
6. Einbetten eines Interfaces in eine Struct: sauberer Decorator
Sie können ein Interface in eine Struct einbetten. Die Struct wird automatisch alle Methoden des Interfaces haben, jede delegiert an den Wert innen. Sie müssen nur die Methoden überschreiben, die Sie ändern möchten:
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // delegiert alle Methoden
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)
}
LoggingPaymentschreibt nurChargeneu.Refundwird automatisch an diePaymentServiceinnen delegiert.*LoggingPaymenterfülltPaymentService, daher können Sie es um jede Implementierung herum umhüllen, ohne dass der Aufrufer es wissen muss.
Wenn ein Interface zehn Methoden hat und Sie nur eine abfangen müssen, spart diese Technik viel Boilerplate-Code.
Hinweis: Wenn das eingebettete Interface-Feld
nilist, wird der Code weiterhin normal kompiliert. Aber das Aufrufen einer Methode, die nicht überschrieben wird (z.B.Refund), verursacht einen Nil-Pointer-Dereference-Panic zur Laufzeit. Initialisieren Sie es immer über einen Constructor, z.B.NewLoggingPayment(inner PaymentService).
7. Testen des Delegationsverhaltens
Go verwendet das Standard-Package testing anstelle von JUnit. Der Test unten überprüft zwei Dinge: Charge wird protokolliert und ruft immer noch den ursprünglichen Service auf, während Refund delegiert wird, ohne protokolliert zu werden.
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)
}
}
Test ausführen:
go test ./...
ok example.com/shop/payment 0.002s
Die Zeile var svc PaymentService = &LoggingPayment{...} ist auch eine Compile-Zeit-Überprüfung: Wenn *LoggingPayment das Interface nicht erfüllt, wird der Test nicht erstellt. In Production-Code können Sie das vertraute Muster var _ PaymentService = (*LoggingPayment)(nil) für denselben Zweck verwenden.
8. Name-Konflikte und häufige Fehler
8.1. Mehrdeutiger Selector
Wenn zwei eingebettete Typen in der gleichen Tiefe den gleichen Feld- oder Methodennamen haben, führt der Zugriff durch die äußere Struct zu einem Compile-Fehler:
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // compile error: ambiguous selector o.Email
fmt.Println(o.Billing.Email) // OK
}
Die Order-Struct ist immer noch gültig. Der Fehler tritt nur auf, wenn Sie o.Email aufrufen. Die Lösung ist expliziter Aufruf oder die Deklaration von Email direkt auf Order, um beide zu verbergen.
8.2. Versehentliches Offenlegen der API: Beispiel sync.Mutex
type Inventory struct {
sync.Mutex
stock map[string]int
}
Dieser Code funktioniert. Aber jetzt sind Lock() und Unlock() öffentliche Methoden von Inventory, weil der eingebettete Feldname Mutex exportiert wird. Code außerhalb des Pakets kann Ihr Inventory sperren. Daher schreiben Sie:
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
Das allgemeine Prinzip: Embedding bringt alle Methoden des eingebetteten Typs in Ihre API. Wenn Sie sie nicht öffentlich machen möchten, verwenden Sie ein benanntes Feld.
8.3. Einbetten eines Nil-Pointers
Das Einbetten von *Address anstelle von Address bedeutet, dass der Nullwert der enthaltenden Struct ein Nil-Pointer ist. Dann wird c.City einen Panic verursachen. Betten Sie einen Pointer nur ein, wenn Sie wirklich die Instanz teilen müssen oder die Methodenmenge von *T benötigen, und denken Sie daran, ihn zu initialisieren.
9. Wann sollte und wann sollte nicht Embedding verwendet werden
Sollte verwendet werden, wenn:
- Sie möchten, dass die äußere Struct wirklich das gesamte Verhalten des eingebetteten Typs offenlegt, z.B.
http.Handleroderio.Readereinbetten. - Sie einen Decorator oder Wrapper schreiben, der nur wenige Methoden eines großen Interfaces ändert.
- Sie häufig verwendete Felder wie
Audit{CreatedAt, UpdatedAt}in viele Entitäten gruppieren.
Sollte nicht verwendet werden, wenn:
- Sie versuchen, einen mehrschichtigen Vererbungsbaum mit “Eltern rufen Kind-Methode auf” nachzubilden. Verwenden Sie stattdessen Interfaces und explizite Abhängigkeiten (Abschnitt 4.1).
- Der eingebettete Typ Methoden hat, die Sie nicht öffentlich machen möchten (Abschnitt 8.2).
- Sie nur eine oder zwei Methoden von diesem Typ benötigen. Ein benanntes Feld mit wenigen von Hand geschriebenen Delegierungsmethoden wird klarer sein.
Hinweis: Embedding erzeugt keine is-a-Beziehung. Wenn Sie Polymorphismus benötigen, definieren Sie ein Interface und lassen mehrere Typen es erfüllen. Verlassen Sie sich nicht darauf, dass “Kindstruct ist Elternstruct”.
10. Fazit
Struct Embedding ist Gos Kompositions-Tool mit ein bisschen syntaktischer Bequemlichkeit: Felder und Methoden des eingebetteten Typs werden auf die äußere Struct gefördert. Es hilft Ihnen, Code wiederzuverwenden, Interfaces zu erfüllen und sehr saubere Decorator zu schreiben.
Allerdings ist Embedding keine Vererbung. Es gibt kein Virtual Dispatch, der eingebettete Typ weiß nichts von der Struct, die ihn enthält, und jede geförderte Methode wird ein Teil Ihrer API. Denken Sie an drei Dinge: Verwenden Sie Interfaces für den Teil des Verhaltens, das sich ändert, initialisieren Sie vollständig alle eingebetteten Interface-Felder oder Pointer, und wechseln Sie zu benannten Feldern, wenn Sie Methoden nicht nach außen hin offenlegen möchten.