Struct embedding dans Go plutôt que l'héritage
1. Aperçu
Si vous êtes habitué à Java ou C#, la première question quand vous écrivez du Go est généralement : « où est extends ? ». Go n’a pas d’héritage. À la place, Go offre le struct embedding : intégrer un type dans un struct pour réutiliser les champs et les méthodes.
Cet article explique comment l’embedding fonctionne, les différences avec l’héritage, et les pièges courants. Après l’avoir lu, vous saurez utiliser l’embedding pour partager un comportement, écrire des décorateurs propres et éviter les erreurs caractéristiques.
2. Struct embedding basique
L’embedding consiste à déclarer un champ ayant seulement un type, sans nom :
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 // champ intégré
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // champ promu
fmt.Println(c.FullAddress()) // méthode promue
fmt.Println(c.Address.City) // accès explicite
}
Huế
12 Lê Lợi, Huế
Huế
Analyse :
Addressse trouve dansCustomersans nom de champ. Go nomme automatiquement le champAddress, soit le nom du type.- Les champs
Cityet la méthodeFullAddress()deAddresssont promus jusqu’àCustomer. Vous pouvez donc écrirec.Cityau lieu dec.Address.City. - Lors de l’initialisation avec un composite literal, vous devez toujours utiliser le nom du champ
Address:. Il n’y a pas de syntaxe « directe » pour remplirCitydansCustomer.
Essentiellement, Customer contient un Address. C’est une relation has-a, à laquelle le compilateur ajoute juste une syntaxe pratique. Customer n’est pas un Address. Vous ne pouvez pas passer Customer à une fonction qui attend Address :
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // erreur de compilation : impossible d'utiliser c (type Customer) comme Address
printCity(c.Address) // OK
3. « Surcharge » de méthode par shadowing
Si le struct externe déclare une méthode portant le même nom qu’une méthode du type intégré, la méthode du struct externe masque (shadow) celle du type interne :
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) // appeler la méthode du type intégré
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)appelle la méthode deOrderService, car elle est à une profondeur plus faible.s.Logger.Log(...)peut toujours appeler l’original. C’est l’équivalent desuper.log(...)en Java.
À première vue, cela ressemble à une surcharge. Mais c’est précisément l’endroit où beaucoup de gens trébuchent, comme l’explique la section suivante.
4. Le piège majeur : pas de dispatch virtuel
En Java, si la classe parent appelle this.channel() et la classe enfant surcharge channel(), la version de la classe enfant s’exécute. Go ne le fait pas :
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(...) est en réalité s.Notifier.Send(...). À l’intérieur de Send, le receiver n a le type Notifier. Il n’a aucune idée qu’il se trouve à l’intérieur d’un SMSNotifier, donc n.Channel() retourne toujours "email".
C’est la différence fondamentale. Le type intégré n’a pas de référence inverse vers le struct qui le contient. Le motif « template method » à la Java ne s’applique pas directement.
4.1. Solution : extraire la partie variable dans une interface
Si vous avez besoin d’un comportement variable, passez-le explicitement :
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
Maintenant la partie variable (Channel) est une dépendance explicite. C’est plus « Go » que d’essayer de simuler l’override.
5. Embedding et interface
Les interfaces en Go sont satisfaites implicitement. Les méthodes promues sont également comptées dans l’ensemble de méthodes du struct externe. Ainsi, l’embedding aide un struct à satisfaire une interface sans avoir à réécrire la méthode :
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 satisfait Describer grâce à la méthode promue
}
KCN Thăng Long, Hà Nội
5.1. Remarque sur le pointeur receiver
Les règles de l’ensemble de méthodes lors de l’embedding sont les suivantes :
Déclaration dans S |
Ensemble de méthodes de S |
Ensemble de méthodes de *S |
|---|---|---|
intégrer T |
receiver T |
receiver T et *T |
intégrer *T |
receiver T et *T |
receiver T et *T |
Par exemple, si Counter a func (c *Counter) Inc() et vous intégrez Counter (pas un pointeur) dans Stats, seul *Stats satisfait une interface ayant Inc(). La valeur Stats ne le fait pas. L’erreur de compilation du type “Stats does not implement Incrementer (method Inc has pointer receiver)” vient généralement de là.
6. Intégrer une interface dans un struct : décorateurs propres
Vous pouvez intégrer une interface dans un struct. Le struct aura automatiquement toutes les méthodes de l’interface, chaque méthode étant déléguée à la valeur interne. Vous ne devez surcharger que les méthodes que vous souhaitez modifier :
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // déléguer toutes les méthodes
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)
}
LoggingPaymentne surcharge queCharge.Refundest automatiquement délégué àPaymentServiceinterne.*LoggingPaymentsatisfaitPaymentService, vous pouvez donc l’envelopper autour de n’importe quelle implémentation sans que l’appelant ne le sache.
Quand une interface a dix méthodes et que vous n’en avez besoin de modifier qu’une, cette technique économise beaucoup de code répétitif.
Remarque : Si le champ d’interface intégré est
nil, le code compile toujours normalement. Mais appeler une méthode qui n’est pas surchargée (par exempleRefund) causera un panic de déréférence de pointeur nil à l’exécution. Initialisez-le toujours via un constructeur, par exempleNewLoggingPayment(inner PaymentService).
7. Tester le comportement délégué
Go utilise le package testing standard à la place de JUnit. Le test ci-dessous vérifie deux choses : Charge est enregistré et appelle toujours le service d’origine, tandis que Refund est délégué sans enregistrement.
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)
}
}
Exécuter le test :
go test ./...
ok example.com/shop/payment 0.002s
La ligne var svc PaymentService = &LoggingPayment{...} est aussi une vérification à la compilation : si *LoggingPayment ne satisfait pas l’interface, le test ne compilera pas. En code production, vous pouvez utiliser la façon conventionnelle var _ PaymentService = (*LoggingPayment)(nil) pour le même objectif.
8. Conflits de noms et erreurs courantes
8.1. Sélecteur ambigu
Quand deux types intégrés à la même profondeur ont le même nom de champ ou de méthode, accéder par le struct externe causera une erreur de compilation :
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // erreur de compilation : sélecteur ambigu o.Email
fmt.Println(o.Billing.Email) // OK
}
Le struct Order est toujours valide. L’erreur n’apparaît que quand vous accédez à o.Email. La solution est un appel explicite, ou déclarer Email directement sur Order pour masquer les deux.
8.2. Exposition accidentelle d’API : exemple sync.Mutex
type Inventory struct {
sync.Mutex
stock map[string]int
}
Ce code fonctionne. Mais Lock() et Unlock() sont maintenant des méthodes publiques de Inventory, car le nom du champ intégré Mutex est exporté. Le code en dehors du package peut verrouiller votre inventaire. Écrivez plutôt :
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
Règle générale : l’embedding expose toutes les méthodes du type intégré dans l’API de votre package. Si vous ne voulez pas les rendre publiques, utilisez un champ nommé.
8.3. Intégrer un pointeur nul
Intégrer *Address au lieu de Address signifie que la valeur zéro du struct contenant le pointeur est nil. Ensuite c.City causera une panique. N’intégrez un pointeur que si vous avez vraiment besoin de partager une instance ou de la méthode set de *T, et n’oubliez pas de l’initialiser.
9. Quand utiliser et ne pas utiliser l’embedding
À utiliser quand :
- Vous voulez que le struct externe expose vraiment tout le comportement du type intégré, par exemple intégrer
http.Handlerouio.Reader. - Vous écrivez un décorateur ou un wrapper qui ne modifie que quelques méthodes d’une grande interface.
- Vous regroupez des champs communs comme
Audit{CreatedAt, UpdatedAt}dans plusieurs entités.
À ne pas utiliser quand :
- Vous essayez de recréer une hiérarchie d’héritage en plusieurs niveaux avec « la classe parent appelle la méthode de la classe enfant ». Utilisez plutôt des interfaces et des dépendances explicites (section 4.1).
- Le type intégré a des méthodes que vous ne voulez pas exposer publiquement (section 8.2).
- Vous n’avez besoin que d’une ou deux méthodes du type. Un champ nommé avec quelques méthodes de délégation écrites manuellement sera plus clair.
Remarque : L’embedding ne crée pas une relation is-a. Si vous avez besoin du polymorphisme, définissez une interface et faites satisfaire plusieurs types. Ne comptez pas sur « le struct enfant est le struct parent ».
10. Conclusion
Le struct embedding est l’outil de composition de Go, avec une syntaxe pratique : les champs et méthodes du type intégré sont promus au struct externe. Cela vous aide à réutiliser le code, satisfaire les interfaces et écrire des décorateurs très proprement.
Cependant, l’embedding n’est pas l’héritage. Il n’y a pas de dispatch virtuel, le type intégré ne sait rien du struct qui le contient, et chaque méthode promue devient une partie de votre API. Retenez trois choses : utilisez les interfaces pour la partie comportement variable, initialisez complètement les champs d’interface ou de pointeur intégrés, et passez à un champ nommé quand vous ne voulez pas exposer les méthodes vers l’extérieur.