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 :

  • Address se trouve dans Customer sans nom de champ. Go nomme automatiquement le champ Address, soit le nom du type.
  • Les champs City et la méthode FullAddress() de Address sont promus jusqu’à Customer. Vous pouvez donc écrire c.City au lieu de c.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 remplir City dans Customer.

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 de OrderService, car elle est à une profondeur plus faible.
  • s.Logger.Log(...) peut toujours appeler l’original. C’est l’équivalent de super.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)
}
  • LoggingPayment ne surcharge que Charge. Refund est automatiquement délégué à PaymentService interne.
  • *LoggingPayment satisfait PaymentService, 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 exemple Refund) causera un panic de déréférence de pointeur nil à l’exécution. Initialisez-le toujours via un constructeur, par exemple NewLoggingPayment(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.Handler ou io.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.

golangstruct embeddingcompositioninterfaceoop