Les interfaces Go : petites, implicites et bien utilisées
1. Aperçu général
Les interfaces Go diffèrent de Java ou C# sur un point fondamental : il n’y a pas le mot-clé implements. Un type n’a qu’à avoir les bonnes méthodes pour satisfaire automatiquement une interface. Cette particularité mène à une approche de conception distinctive : des interfaces petites, déclarées où elles sont utilisées, et non où elles sont implémentées.
Après avoir lu cet article, vous saurez comment écrire des interfaces à la Go, les utiliser pour des tests sans bibliothèque de mock, et éviter les pièges courants comme les interfaces « nil mais pas vraiment nil ».
2. Les interfaces sont satisfaites implicitement
Commençons par une interface pour envoyer des notifications aux clients :
package main
import "fmt"
type Notifier interface {
Notify(customer, message string) error
}
type EmailNotifier struct {
From string
}
func (e EmailNotifier) Notify(customer, message string) error {
fmt.Printf("[email de %s] à %s: %s\n", e.From, customer, message)
return nil
}
type SMSNotifier struct{}
func (SMSNotifier) Notify(customer, message string) error {
fmt.Printf("[sms] à %s: %s\n", customer, message)
return nil
}
func main() {
notifiers := []Notifier{
EmailNotifier{From: "shop@nonla.dev"},
SMSNotifier{},
}
for _, n := range notifiers {
_ = n.Notify("An", "La commande #42 a été confirmée")
}
}
Résultat :
[email de shop@nonla.dev] à An: La commande #42 a été confirmée
[sms] à An: La commande #42 a été confirmée
Nulle part dans EmailNotifier ou SMSNotifier nous ne mentionnons Notifier. Le compilateur vérifie automatiquement : tout type ayant une méthode Notify(string, string) error peut être assigné à une variable de type Notifier.
Une conséquence importante est que le côté implémentation n’a pas besoin de savoir que l’interface existe. Vous pouvez déclarer une nouvelle interface pour un type provenant de la stdlib ou d’une tiers-partie sans modifier leur code.
3. Garder les interfaces petites
La stdlib Go en est le meilleur exemple. io.Reader et io.Writer n’ont chacun qu’une seule méthode :
type Writer interface {
Write(p []byte) (n int, err error)
}
Grâce à cette petite taille, de nombreux types la satisfont : *os.File, *bytes.Buffer, *strings.Builder, http.ResponseWriter, *gzip.Writer… Une fonction acceptant io.Writer fonctionne avec tous ces types :
package main
import (
"fmt"
"io"
"os"
"strings"
)
type Order struct {
ID int
Customer string
Total int64
}
func WriteReceipt(w io.Writer, o Order) error {
_, err := fmt.Fprintf(w, "Commande #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
return err
}
func main() {
order := Order{ID: 42, Customer: "An", Total: 350000}
_ = WriteReceipt(os.Stdout, order) // afficher sur stdout
var sb strings.Builder
_ = WriteReceipt(&sb, order) // écrire en mémoire
fmt.Printf("Longueur du reçu : %d octets\n", sb.Len())
}
Commande #42 - An - 350000 VND
Longueur du reçu : 26 octets
WriteReceipt n’a besoin que de la capacité à « écrire des octets », elle ne demande donc que ça. Si vous voulez écrire dans un fichier, une réponse HTTP ou un fichier compressé, vous n’avez pas besoin de modifier la fonction.
Quand vous avez besoin de plusieurs comportements, composez des interfaces petites au lieu d’écrire une grosse interface dès le départ :
type OrderFinder interface {
FindOrder(id int) (Order, error)
}
type OrderSaver interface {
SaveOrder(o Order) error
}
type OrderStore interface {
OrderFinder
OrderSaver
}
La fonction affichant une commande accepte seulement OrderFinder. La fonction mettant à jour le statut accepte OrderStore. Chaque fonction déclare clairement ce dont elle a besoin, et écrire des fakes pour les tests est beaucoup plus léger.
4. Déclarer les interfaces côté utilisation
En Java, vous écrivez généralement PaymentGateway puis StripePaymentGateway implements PaymentGateway, tous deux côte à côte. En Go, l’approche habituelle est l’inverse : le package d’implémentation retourne une struct concrète, le package consommateur déclare l’interface dont il a besoin.
Supposons que le package payment fournit un client avec de nombreuses méthodes :
// package payment
type Client struct {
apiKey string
}
func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }
func (c *Client) Charge(customer string, amount int64) (string, error) {
return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}
func (c *Client) Refund(txnID string) error { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }
Le package checkout a uniquement besoin de débiter, donc il déclare une interface avec une seule méthode :
// package checkout
var ErrInvalidTotal = errors.New("le total doit être supérieur à 0")
type PaymentGateway interface {
Charge(customer string, amount int64) (string, error)
}
type Service struct {
gateway PaymentGateway
}
func NewService(gw PaymentGateway) *Service {
return &Service{gateway: gw}
}
func (s *Service) Checkout(o Order) (string, error) {
if o.Total <= 0 {
return "", ErrInvalidTotal
}
txnID, err := s.gateway.Charge(o.Customer, o.Total)
if err != nil {
return "", fmt.Errorf("paiement de la commande #%d: %w", o.ID, err)
}
return txnID, nil
}
Lors de l’instanciation dans main :
svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>
*payment.Client satisfait checkout.PaymentGateway implicitement. Le package payment n’importe pas checkout et n’a pas besoin de savoir qu’il existe.
Ce principe est souvent résumé par « accept interfaces, return structs » : les fonctions acceptent des interfaces pour la flexibilité, mais retournent des types concrets pour que l’appelant puisse utiliser toutes les méthodes et tous les champs.
5. Tester avec des fakes, sans bibliothèque de mock
Les petites interfaces côté consommateur rendent les tests très simples. Vous n’avez qu’à écrire une struct avec une seule méthode :
package checkout
import (
"errors"
"testing"
)
type fakeGateway struct {
charged []int64
err error
}
func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
if f.err != nil {
return "", f.err
}
f.charged = append(f.charged, amount)
return "txn-test", nil
}
func TestCheckout_ChargesOrderTotal(t *testing.T) {
gw := &fakeGateway{}
svc := NewService(gw)
txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})
if err != nil {
t.Fatalf("erreur inattendue : %v", err)
}
if txn != "txn-test" {
t.Errorf("txn = %q, attendu %q", txn, "txn-test")
}
if len(gw.charged) != 1 || gw.charged[0] != 200000 {
t.Errorf("charged = %v, attendu [200000]", gw.charged)
}
}
func TestCheckout_RejectsZeroTotal(t *testing.T) {
gw := &fakeGateway{}
svc := NewService(gw)
_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})
if !errors.Is(err, ErrInvalidTotal) {
t.Fatalf("err = %v, attendu ErrInvalidTotal", err)
}
if len(gw.charged) != 0 {
t.Errorf("Charge ne devrait pas être appelée, mais charged = %v", gw.charged)
}
}
func TestCheckout_WrapsGatewayError(t *testing.T) {
gwErr := errors.New("carte refusée")
svc := NewService(&fakeGateway{err: gwErr})
_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})
if !errors.Is(err, gwErr) {
t.Fatalf("err = %v, attendu de wrapper %v", err, gwErr)
}
}
Exécuter les tests :
go test ./checkout -v
=== RUN TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS
Si PaymentGateway avait dix méthodes, le fake devrait aussi en implémenter dix. C’est le signe le plus clair qu’une interface est trop grande.
6. Type assertion, type switch et vérification à la compilation
6.1. Interface optionnelle via type assertion
Parfois, vous voulez utiliser une capacité supplémentaire si le type concret la supporte. Go utilise un type assertion à deux valeurs :
type Refunder interface {
Refund(txnID string) error
}
func (s *Service) Cancel(txnID string) error {
r, ok := s.gateway.(Refunder)
if !ok {
return errors.New("la passerelle de paiement ne prend pas en charge les remboursements")
}
return r.Refund(txnID)
}
Avec *payment.Client, ok est true car elle a la méthode Refund. Avec fakeGateway, ok est false et la fonction retourne une erreur au lieu de faire un panic. La stdlib utilise beaucoup cette technique, par exemple io.Copy vérifie si le paramètre est un io.WriterTo pour choisir un chemin plus rapide.
Note : La forme à une seule valeur
r := s.gateway.(Refunder)fera un panic si le type ne correspond pas. Utilisez toujours la former, ok := …sauf si vous êtes absolument certain.
6.2. Type switch
Quand vous avez besoin de traiter plusieurs types différents, utilisez type switch :
func describe(n Notifier) string {
switch v := n.(type) {
case EmailNotifier:
return "email de " + v.From
case SMSNotifier:
return "sms"
default:
return fmt.Sprintf("inconnu : %T", v)
}
}
Si vous vous retrouvez à écrire des type switch sur des types métier à plusieurs endroits, c’est peut-être qu’il vous manque une méthode dans l’interface.
6.3. Garantir la satisfaction de l’interface à la compilation
Parce que la satisfaction est implicite, si quelqu’un change une signature de méthode, l’erreur n’apparaît qu’au point d’assignation. Pour capturer l’erreur tôt directement dans le fichier d’implémentation, ajoutez cette ligne :
var _ checkout.PaymentGateway = (*payment.Client)(nil)
Cette ligne n’assigne aucune valeur à l’exécution. Elle force simplement le compilateur à vérifier si *payment.Client satisfait PaymentGateway ou non. À utiliser seulement si le package d’implémentation veut vraiment s’engager envers cette interface. Sinon, cela créerait une dépendance circulaire inutile.
7. Erreurs courantes
7.1. Une interface contenant un pointeur nil n’est pas nil
C’est l’erreur la plus classique de Go :
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string {
return "champ invalide : " + e.Field
}
func validate(o Order) error {
var verr *ValidationError
if o.Total <= 0 {
verr = &ValidationError{Field: "Total"}
}
return verr // FAUX
}
func main() {
err := validate(Order{Total: 100})
fmt.Println(err == nil)
}
false
Une valeur d’interface se compose de deux parties : le type et la valeur. Une interface ne vaut nil que si les deux sont vides. Ici le type est *ValidationError, tandis que la valeur est un pointeur nil, donc err != nil. La correction est de retourner nil explicitement :
func validate(o Order) error {
if o.Total <= 0 {
return &ValidationError{Field: "Total"}
}
return nil
}
7.2. Pointer receiver et method set
type AuditNotifier struct{ count int }
func (a *AuditNotifier) Notify(customer, message string) error {
a.count++
return nil
}
var n Notifier = AuditNotifier{} // erreur compile
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)
Les méthodes avec pointer receiver ne font partie que du method set de *AuditNotifier. Vous devez assigner &AuditNotifier{}. Inversement, les méthodes avec value receiver fonctionnent pour la value comme pour le pointer.
7.3. Écrire des interfaces avant d’en avoir besoin
Le code avec des habitudes Java ressemble généralement à ceci :
type OrderService interface {
Create(o Order) error
Cancel(id int) error
Find(id int) (Order, error)
// … 10 autres méthodes
}
type orderServiceImpl struct{ /* … */ }
func NewOrderService() OrderService { return &orderServiceImpl{} }
Cette approche a trois problèmes. L’interface n’a qu’une seule implémentation. Elle se trouve du côté implémentation au lieu du côté consommation. Le constructeur retourne une interface, donc l’appelant perd accès au type concret. Retournez plutôt la struct *OrderService, et laissez chaque package consommateur déclarer lui-même une petite interface quand il a vraiment besoin de substitution, par exemple pour les tests.
7.4. Abuser de any
any (alias de interface{}) accepte tout type, donc le compilateur ne peut plus vous aider. À partir de Go 1.18, si vous avez besoin d’une fonction fonctionnant sur plusieurs types, préférez les génériques :
func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
var sum int64
for _, it := range items {
sum += it.GetTotal()
}
return sum
}
N’utilisez any que quand les données ne peuvent vraiment pas être connues à l’avance, par exemple lors du décodage JSON arbitraire.
8. Conclusion
Les interfaces Go sont puissantes précisément parce qu’elles sont simples. Points à retenir :
- Les types satisfont les interfaces implicitement, pas besoin de
implements. - Les interfaces devraient être petites, idéalement une ou deux méthodes. Quand vous en avez besoin de plus, composez des petites interfaces.
- Déclarez les interfaces côté utilisation. Les fonctions acceptent des interfaces, les constructeurs retournent des structs.
- Les petites interfaces permettent de tester avec des fakes écrits à la main, sans bibliothèque de mock.
- Attention aux interfaces contenant un pointeur nil et au method set des pointer receivers.
La prochaine fois que vous vous apprêtez à écrire une interface, demandez-vous : « Qui l’utilisera, et de quelles méthodes ont-ils vraiment besoin ? » La réponse est généralement moins que ce que vous pensiez.