Retour au blog

Le mot-clé interface en Go : interfaces implicites et pièges du nil

Le fonctionnement du mot-clé interface en Go : satisfaction implicite, récepteurs pointeurs, petites interfaces, any, assertions de type, le piège de l'erreur nil et les contraintes.

Le mot-clé interface en Go : interfaces implicites et pièges du nil

Le mot-clé interface déclare un ensemble de méthodes. Tout type qui possède ces méthodes satisfait automatiquement l'interface. Il n'existe ni mot-clé implements ni liste d'interfaces sur le type, si bien qu'un struct écrit des années avant l'interface peut tout de même la satisfaire. Depuis Go 1.18, une interface peut aussi lister des types en plus des méthodes, ce qui en fait une contrainte pour le code générique (Go spec).

En bref

  • type Notifier interface { Notify(...) error } déclare une interface. Tout type doté d'une méthode Notify correspondante est un Notifier.
  • La satisfaction est implicite. var _ Notifier = (*SlackNotifier)(nil) demande au compilateur de la vérifier au moment du build.
  • Une méthode avec un récepteur pointeur appartient à *T, pas à T. Affecter un T échoue avec method Notify has pointer receiver.
  • Gardez des interfaces petites et déclarez-les dans le package qui les utilise. Acceptez des interfaces, renvoyez des types concrets.
  • any est un alias de interface{} et contient une valeur de n'importe quel type. Récupérez la valeur avec v, ok := x.(T) ou un type switch.
  • Une interface ne vaut nil que si son type et sa valeur sont tous deux non définis. Renvoyer un *ValidationError nil en tant qu'error vous donne un err != nil qui vaut true.
  • Les interfaces avec des listes de types comme ~int64 | ~float64 sont des contraintes. Elles fonctionnent dans les listes de paramètres de type et nulle part ailleurs.

Comment déclarer une interface en Go ?

Écrivez type, un nom et interface suivi des signatures de méthodes. Voici un système d'alertes qui envoie le même message sur Slack et par e-mail :

example.gogo
package main

import (
	"context"
	"fmt"
)

type Notifier interface {
	Notify(ctx context.Context, msg string) error
}

type SlackNotifier struct {
	Channel string
}

func (s SlackNotifier) Notify(ctx context.Context, msg string) error {
	fmt.Printf("slack #%s: %s\n", s.Channel, msg)
	return nil
}

type EmailNotifier struct {
	To string
}

func (e EmailNotifier) Notify(ctx context.Context, msg string) error {
	fmt.Printf("email %s: %s\n", e.To, msg)
	return nil
}

func alertAll(ctx context.Context, notifiers []Notifier, msg string) {
	for _, n := range notifiers {
		if err := n.Notify(ctx, msg); err != nil {
			fmt.Println("notify failed:", err)
		}
	}
}

func main() {
	notifiers := []Notifier{
		SlackNotifier{Channel: "oncall"},
		EmailNotifier{To: "[email protected]"},
	}
	alertAll(context.Background(), notifiers, "payment API p99 above 2s")
}
example.texttext
slack #oncall: payment API p99 above 2s
email [email protected]: payment API p99 above 2s

alertAll ne connaît que la méthode Notify. Peu lui importe de s'adresser à Slack, à l'e-mail ou à un client PagerDuty que quelqu'un ajoutera le mois prochain. Chaque valeur de la slice transporte son type concret avec elle, et l'appel part vers la méthode de ce type à l'exécution.

Une interface peut en embarquer d'autres. Les ensembles de méthodes embarqués sont fusionnés, et c'est ainsi que la bibliothèque standard construit io.ReadWriter à partir de io.Reader et io.Writer :

example.gogo
type ReadWriter interface {
	Reader
	Writer
}

Un type satisfait io.ReadWriter lorsqu'il possède à la fois Read et Write. C'est le cas de *os.File et net.Conn, mais pas de *strings.Reader, qui n'a pas de méthode Write.

Comment un type implémente-t-il une interface en Go ?

En ayant chaque méthode de l'interface dans son ensemble de méthodes, avec exactement la même signature. Go appelle cela la satisfaction implicite, et c'est la principale différence avec Java ou C#, où une classe doit nommer chaque interface qu'elle implémente.

Les packages restent ainsi découplés. SlackNotifier n'importe pas le package qui déclare Notifier, et n'a même pas besoin de savoir que Notifier existe. Vous pouvez écrire aujourd'hui une interface que des types de la bibliothèque standard ou d'un package tiers satisfont déjà.

Quand une méthode manque, le compilateur vous le signale à l'endroit où la valeur est utilisée comme interface :

example.gogo
type PagerDutyNotifier struct {
	RoutingKey string
}

func (p PagerDutyNotifier) Send(ctx context.Context, msg string) error {
	return nil
}

var _ Notifier = PagerDutyNotifier{}
example.texttext
./main.go:20:18: cannot use PagerDutyNotifier{} (value of struct type PagerDutyNotifier) as Notifier value in variable declaration: PagerDutyNotifier does not implement Notifier (missing method Notify)

Si la méthode existe mais que la signature diffère, l'erreur affiche les deux versions côte à côte :

example.texttext
./main.go:16:18: cannot use PagerDutyNotifier{} (value of struct type PagerDutyNotifier) as Notifier value in variable declaration: PagerDutyNotifier does not implement Notifier (wrong type for method Notify)
		have Notify(string) error
		want Notify(context.Context, string) error

Comment vérifier à la compilation qu'un type implémente une interface ?

Déclarez une variable au niveau du package avec l'identifiant blanc :

example.gogo
var _ Notifier = (*SlackNotifier)(nil)

Cette ligne n'alloue rien et n'exécute jamais de code. Elle demande seulement au compilateur si un *SlackNotifier peut être affecté à un Notifier, si bien que le build casse dès que quelqu'un renomme ou modifie Notify. Sans elle, vous ne le découvrez qu'à la première utilisation du type en tant que Notifier, qui peut se trouver dans un autre package. Le mot-clé var en Go présente la même astuce avec http.Handler.

Pourquoi un récepteur pointeur empêche-t-il de satisfaire une interface ?

Parce que la méthode appartient au type pointeur. Chaque type possède un ensemble de méthodes, et les règles sont les suivantes (Go spec) :

  • L'ensemble de méthodes de T contient les méthodes déclarées avec un récepteur valeur (t T).
  • L'ensemble de méthodes de *T contient celles-ci ainsi que les méthodes déclarées avec un récepteur pointeur (t *T).

Un notifier qui regroupe les messages par lots doit se modifier lui-même, il utilise donc un récepteur pointeur :

example.gogo
type BatchNotifier struct {
	mu      sync.Mutex
	pending []string
}

func (b *BatchNotifier) Notify(ctx context.Context, msg string) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.pending = append(b.pending, msg)
	return nil
}

func main() {
	var n Notifier = BatchNotifier{}
	fmt.Println(n)
}
example.texttext
./main.go:26:19: cannot use BatchNotifier{} (value of struct type BatchNotifier) as Notifier value in variable declaration: BatchNotifier does not implement Notifier (method Notify has pointer receiver)

La correction est var n Notifier = &BatchNotifier{}. Go refuse volontairement la version valeur. Une interface contenant un BatchNotifier contiendrait une copie, et Notify ajouterait des éléments à la slice pending de la copie sans que l'original ne change jamais. Passer Notify en récepteur valeur n'aide pas non plus. Cela copierait le sync.Mutex à chaque appel, et go vet le signale par Notify passes lock by value.

Un type doté de méthodes à récepteur pointeur doit être vérifié via son pointeur, comme dans var _ Notifier = (*BatchNotifier)(nil). La forme pointeur fonctionne aussi pour les types à récepteur valeur comme SlackNotifier, car un *T possède toutes les méthodes de T. C'est pourquoi (*T)(nil) est la façon habituelle d'écrire cette vérification.

Comment concevoir des interfaces en Go ?

Le proverbe Go dit : « plus l'interface est grande, plus l'abstraction est faible » (Go Proverbs). Les interfaces les plus utilisées de la bibliothèque standard ont une ou deux méthodes :

InterfaceMéthodeImplémentée par
io.ReaderReadfichiers, connexions réseau, corps HTTP, lecteurs gzip, bytes.Buffer
io.WriterWritefichiers, http.ResponseWriter, fonctions de hachage, bytes.Buffer
errorError*fs.PathError, *json.SyntaxError, vos propres types d'erreur
fmt.StringerStringtout ce qui contrôle la façon dont fmt l'affiche
http.HandlerServeHTTProuteurs, middlewares, http.HandlerFunc

Une interface à une seule méthode est facile à satisfaire, donc beaucoup de types la satisfont. io.Copy fonctionne avec n'importe quelle source et n'importe quelle destination parce qu'il exige très peu. Une interface Storage à douze méthodes n'a généralement qu'une seule vraie implémentation, plus un mock à modifier chaque fois que l'interface change. http.Handler fonctionne de la même manière. Un middleware en prend un et en renvoie un, si bien que les middlewares Go de n'importe quel package s'empilent autour de n'importe quel routeur.

fmt.Stringer montre combien il en faut peu à un type pour s'intégrer à la bibliothèque standard. Donnez une méthode String à une énumération, et chaque verbe de fmt l'utilise :

example.gogo
type OrderStatus int

const (
	Pending OrderStatus = iota
	Paid
	Refunded
)

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "pending"
	case Paid:
		return "paid"
	case Refunded:
		return "refunded"
	}
	return fmt.Sprintf("OrderStatus(%d)", int(s))
}

func main() {
	fmt.Println(Paid)
	fmt.Printf("order 1001 is %v\n", Refunded)
}
example.texttext
paid
order 1001 is refunded

Où déclarer une interface Go ?

Dans le package qui l'utilise, pas dans celui qui l'implémente. Comme la satisfaction est implicite, le consommateur peut ne lister que les méthodes qu'il appelle.

Un service de remboursement n'a besoin que de charger une commande. Il déclare pour cela une interface à une méthode et la reçoit dans son constructeur :

example.gogo
type orderGetter interface {
	GetOrder(ctx context.Context, id int64) (Order, error)
}

type RefundService struct {
	orders orderGetter
}

func NewRefundService(orders orderGetter) *RefundService {
	return &RefundService{orders: orders}
}

func (s *RefundService) CanRefund(ctx context.Context, id int64) (bool, error) {
	o, err := s.orders.GetOrder(ctx, id)
	if err != nil {
		return false, fmt.Errorf("load order %d: %w", id, err)
	}
	return o.Status == "paid", nil
}

En production, vous passez le store Postgres, qui possède vingt autres méthodes que le service de remboursement ne voit jamais. Dans un test, une map suffit :

example.gogo
type fakeOrders map[int64]Order

func (f fakeOrders) GetOrder(ctx context.Context, id int64) (Order, error) {
	o, ok := f[id]
	if !ok {
		return Order{}, ErrNotFound
	}
	return o, nil
}

func main() {
	svc := NewRefundService(fakeOrders{1001: {ID: 1001, Status: "paid"}})
	fmt.Println(svc.CanRefund(context.Background(), 1001))
	fmt.Println(svc.CanRefund(context.Background(), 1002))
}
example.texttext
true <nil>
false load order 1002: order not found

fakeOrders est un type map défini avec une méthode (voir le mot-clé type en Go), le test n'a donc pas besoin de bibliothèque de mocks.

Que signifie « acceptez des interfaces, renvoyez des structs » ?

C'est l'autre moitié de la même idée. Les fonctions prennent des interfaces en paramètre, pour que les appelants puissent passer tout ce qui convient. Les constructeurs renvoient des types concrets comme *RefundService ou *PostgresStore, pour que les appelants obtiennent toutes les méthodes et tous les champs exportés, et puissent décider eux-mêmes de quelle petite interface ils ont besoin.

Renvoyer une interface depuis un constructeur masque les méthodes que l'interface ne liste pas, et oblige chaque appelant à utiliser l'unique abstraction que vous avez choisie. La principale exception est error. Les fonctions renvoient error plutôt que *ValidationError, et la section sur nil plus bas explique pourquoi.

Qu'est-ce que any en Go ?

any est un alias de interface{}, l'interface sans aucune méthode. Tout type possède au moins zéro méthode, donc une variable de type any peut contenir n'importe quelle valeur. any a été ajouté dans Go 1.18, et les deux écritures sont interchangeables (Go spec).

any apparaît là où le type n'est connu qu'à l'exécution. fmt.Println(a ...any) accepte des arguments de n'importe quel type, et json.Unmarshal vers une map[string]any produit des valeurs dont le type dépend du JSON.

Le prix à payer, c'est que le compilateur cesse de vérifier. Chaque lecture depuis un any exige une assertion de type, et une mauvaise supposition devient une erreur à l'exécution au lieu d'une erreur de compilation. Quand une fonction travaille sur plusieurs types mais que les mêmes opérations s'appliquent à tous, utilisez plutôt un paramètre de type. func Sum[T Amount](values []T) T, présenté dans la section sur les contraintes plus bas, conserve toute la vérification de types, alors que func Sum(values []any) any repousse chaque vérification à l'exécution.

Comment fonctionnent les assertions de type et les type switches ?

Une assertion de type extrait la valeur concrète d'une interface. La forme à deux valeurs ne panique jamais :

example.gogo
var payload map[string]any
body := `{"user_id": 42, "email": "[email protected]", "roles": ["admin", "billing"], "manager": null}`
if err := json.Unmarshal([]byte(body), &payload); err != nil {
	log.Fatal(err)
}

email, ok := payload["email"].(string)
fmt.Println(email, ok)

id, ok := payload["user_id"].(int)
fmt.Println(id, ok)
fmt.Printf("%T\n", payload["user_id"])
example.texttext
[email protected] true
0 false
float64

La seconde assertion échoue parce que encoding/json décode chaque nombre JSON en float64 lorsque la cible est any. Avec ok, l'échec se traduit simplement par false et la valeur zéro. La forme à une valeur, payload["user_id"].(int), panique à la place :

example.texttext
panic: interface conversion: interface {} is float64, not int

Les assertions fonctionnent aussi avec des interfaces. r.(io.Writer) demande si la valeur contenue dans r possède une méthode Write, et la bibliothèque standard s'en sert pour détecter un comportement optionnel. io.Copy vérifie si la source est un io.WriterTo et, le cas échéant, emprunte son chemin plus rapide.

Un type switch enchaîne plusieurs assertions en une seule instruction. Il convient bien au même payload JSON, où une valeur peut être de l'un d'une poignée de types :

example.gogo
func describe(v any) string {
	switch v := v.(type) {
	case nil:
		return "null"
	case float64:
		return fmt.Sprintf("number %g", v)
	case string:
		return fmt.Sprintf("string of %d bytes", len(v))
	case []any:
		return fmt.Sprintf("array of %d items", len(v))
	case map[string]any:
		return fmt.Sprintf("object with %d keys", len(v))
	default:
		return fmt.Sprintf("other %T", v)
	}
}

func main() {
	// payload decoded as above
	for _, key := range []string{"user_id", "email", "roles", "manager"} {
		fmt.Println(key+":", describe(payload[key]))
	}
}
example.texttext
user_id: number 42
email: string of 15 bytes
roles: array of 2 items
manager: null

Dans chaque case, v a le type de ce case, donc len(v) compile pour la string, la slice et la map. Les type switches sur des erreurs fonctionnent de la même façon, mais ils ne voient que l'erreur la plus externe. Le mot-clé type en Go montre ce cas et explique pourquoi errors.As est généralement le meilleur outil pour les erreurs enveloppées.

Pourquoi mon erreur nil n'est-elle pas nil en Go ?

Une valeur d'interface a deux parties, un type dynamique et une valeur dynamique. Elle n'est égale à nil que si les deux sont non définis. Si vous stockez un pointeur nil dans une interface, la partie type est définie, donc l'interface n'est pas nil (Go FAQ).

Le piège se referme le plus souvent avec les types d'erreur personnalisés. Ici, la fonction garde une variable *ValidationError et la renvoie, qu'elle ait été affectée ou non :

example.gogo
package main

import (
	"fmt"
	"strings"
)

type ValidationError struct {
	Field string
}

func (e *ValidationError) Error() string {
	return "invalid " + e.Field
}

func validate(email string) error {
	var verr *ValidationError
	if !strings.Contains(email, "@") {
		verr = &ValidationError{Field: "email"}
	}
	return verr
}

func main() {
	err := validate("[email protected]")
	if err != nil {
		fmt.Printf("validation failed: %v (%T)\n", err, err)
		return
	}
	fmt.Println("ok")
}
example.texttext
validation failed: <nil> (*main.ValidationError)

L'e-mail est valide et verr vaut nil, mais l'error renvoyée contient le type *main.ValidationError avec un pointeur nil à l'intérieur. err != nil compare l'interface entière à une interface sans type, donc le résultat est true. L'appelant rejette une inscription valide, et la ligne de log affiche <nil>, ce qui rend le bug difficile à repérer.

La correction consiste à renvoyer un nil littéral sur le chemin de succès et à ne jamais faire transiter un pointeur typé par un retour error :

example.gogo
func validate(email string) error {
	if !strings.Contains(email, "@") {
		return &ValidationError{Field: "email"}
	}
	return nil
}

Déclarez le type de retour comme error, pas *ValidationError, et renvoyez nil directement. Les appelants qui ont besoin du champ l'obtiennent toujours avec errors.As, qui retrouve aussi l'erreur après qu'elle a été enveloppée :

example.gogo
err := fmt.Errorf("signup: %w", validate("ana.example.com"))
var verr *ValidationError
if errors.As(err, &verr) {
	fmt.Println("bad field:", verr.Field)
}

Ce code affiche bad field: email. Le même piège existe pour n'importe quelle interface. Une variable var store *PostgresStore jamais initialisée, passée là où un orderGetter est attendu, produit elle aussi une interface non nil.

Le cas inverse est une interface réellement nil, comme un champ de struct de type Notifier que personne n'a renseigné. Appeler une méthode dessus panique avec invalid memory address or nil pointer dereference, car il n'y a aucun type sur lequel trouver la méthode. Un constructeur qui exige la dépendance, comme NewRefundService(orders), évite ce problème.

Comment utiliser les interfaces comme contraintes génériques ?

Depuis Go 1.18, une interface peut lister des types en plus des méthodes. Cette liste s'appelle un ensemble de types (type set), et ~T signifie « tout type dont le type sous-jacent est T » :

example.gogo
type Cents int64

type Amount interface {
	~int64 | ~float64
}

func Sum[T Amount](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

func Dedupe[T comparable](ids []T) []T {
	seen := make(map[T]struct{}, len(ids))
	out := make([]T, 0, len(ids))
	for _, id := range ids {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func main() {
	fmt.Println(Sum([]Cents{4999, 1250, 300}))
	fmt.Println(Dedupe([]string{"u_42", "u_7", "u_42"}))
}
example.texttext
6549
[u_42 u_7]

Amount autorise + parce que chaque type de son ensemble le prend en charge. comparable est une contrainte prédéclarée pour les types compatibles avec ==, ce dont une clé de map a besoin. Le compilateur rejette un type hors de l'ensemble et nomme le problème :

example.texttext
./main.go:27:17: string does not satisfy Amount (string missing in ~int64 | ~float64)

Une interface avec une liste de types ne peut servir que de contrainte. Déclarer une variable de ce type échoue :

example.texttext
./main.go:10:8: cannot use type Amount outside a type constraint: interface contains type constraints

Go a donc deux sortes d'interfaces qui partagent un même mot-clé. Les interfaces composées uniquement de méthodes fonctionnent partout, comme types de variables, paramètres et contraintes. Les interfaces avec des listes de types n'apparaissent qu'entre crochets. L'introduction aux generics du blog Go décrit comment les ensembles de types unifient les deux.

La place de LevelUpGo

LevelUpGo enseigne Go à travers des exercices qui exécutent du vrai code Go dans le navigateur. Interfaces & Polymorphism couvre la satisfaction implicite, les ensembles de méthodes, any, les assertions de type et la composition d'interfaces. Interface Design couvre les petites interfaces, leur déclaration là où elles sont utilisées et la façon d'éviter les interfaces qui n'existent que pour les mocks. Go Generics Masterclass va plus loin sur les contraintes et les ensembles de types. Le Training Ground propose de courts exercices autonomes sur les interfaces pour s'entraîner en dehors d'un cours. Pour les 24 autres mots réservés, consultez Les mots-clés Go : les 25 expliqués.

Questions fréquentes

interface est-il un mot-clé en Go ?

Oui. interface fait partie des 25 mots-clés réservés de Go, vous ne pouvez donc pas l'utiliser comme nom de variable ou de fonction. Il apparaît dans les déclarations de types interface comme type Notifier interface { ... }, dans le littéral interface{} et dans les contraintes en ligne comme [T interface{ ~int | ~string }].

Go a-t-il un mot-clé implements ?

Non. Un type satisfait une interface simplement en possédant toutes ses méthodes avec des signatures correspondantes. Pour que le compilateur vérifie qu'un type satisfait une interface, ajoutez var _ Notifier = (*SlackNotifier)(nil) à côté du type.

Quelle est la différence entre any et interface{} en Go ?

Aucune. any est un alias prédéclaré de interface{}, ajouté dans Go 1.18, les deux sont donc le même type. Le code récent utilise any parce qu'il est plus court, et gofmt -r 'interface{} -> any' peut réécrire le code plus ancien.

Pourquoi Go affiche-t-il « method has pointer receiver » ?

La méthode est déclarée sur *T, donc seul un *T la possède. Vous avez tenté d'utiliser une valeur T comme interface. Passez plutôt un pointeur, comme &BatchNotifier{}, ou changez la méthode en récepteur valeur si elle ne modifie pas le récepteur.

Pourquoi mon erreur n'est-elle pas nil alors que j'ai renvoyé un pointeur nil ?

Une interface ne vaut nil que si son type et sa valeur sont tous deux non définis. Renvoyer un *ValidationError nil depuis une fonction dont le type de retour est error fixe le type à *ValidationError, donc err != nil vaut true. Renvoyez un nil littéral sur le chemin de succès au lieu d'une variable pointeur typée.

Les interfaces Go peuvent-elles avoir des champs ?

Non. Les interfaces ne décrivent que des méthodes, ou des ensembles de types lorsqu'elles servent de contraintes. Si les appelants ont besoin d'une valeur, ajoutez une méthode getter comme ID() string, ou acceptez le struct concret au lieu d'une interface.

Sources

Écrivez du Go comme un ingénieur senior

Des leçons interactives dans votre navigateur. Les premières sont gratuites.

Essayer une leçon gratuiteOu créer un compte gratuit