Leçon 6/11 10 min

Méthodes et interfaces

Attachez du comportement à vos types avec les méthodes, et le polymorphisme sans héritage avec les interfaces Go.

Les méthodes : attacher un comportement à un type

Jusqu'ici vos struct ne faisaient que stocker des données. Une méthode leur attache un comportement : c'est une fonction avec un récepteur, déclaré entre func et le nom.

type Compte struct {
    solde float64
}

// Récepteur VALEUR : lecture seule (travaille sur une copie)
func (c Compte) Solde() float64 {
    return c.solde
}

// Récepteur POINTEUR : peut modifier le Compte d'origine
func (c *Compte) Deposer(montant float64) {
    c.solde += montant
}

func main() {
    c := Compte{}
    c.Deposer(50)            // Go prend l'adresse automatiquement
    fmt.Println(c.Solde())   // 50
}

La distinction est cruciale : un récepteur valeur (c Compte) reçoit une copie, donc il ne peut pas modifier l'original. Un récepteur pointeur (c *Compte) reçoit l'adresse, donc il peut muter le type.

Quel récepteur choisir ? Pointeur (*T) dès que la méthode doit modifier le type, ou si le type est gros (éviter la copie). Valeur (T) pour de la lecture seule sur de petits types. Règle pratique : sois cohérent, ne mélange pas récepteurs valeur et pointeur sur un même type.

Les interfaces : un contrat, pas une hiérarchie

Une interface décrit un comportement attendu : une liste de méthodes. Tout type qui possède ces méthodes satisfait l'interface, automatiquement, sans aucun mot-clé implements. C'est la grande différence avec Java ou PHP.

Prédisez avant de lire

Imaginez une interface type Parleur interface { Parler() string } et un type Chien qui possède une méthode Parler() string. Avant de dérouler : faut-il écrire explicitement quelque part, avec un mot-clé du genre implements Parleur, que Chien satisfait l'interface ? Comment Go décide-t-il qu'un type satisfait une interface ?

Voir la réponse

Non, il n'y a aucun mot-clé implements en Go. La satisfaction d'une interface est implicite (typage structurel, souvent appelé « duck typing ») : dès qu'un type possède toutes les méthodes exigées par une interface (mêmes noms, mêmes signatures), il satisfait cette interface automatiquement, sans aucune déclaration. Chien, qui a une méthode Parler() string, est donc un Parleur sans qu'on l'écrive nulle part. Conséquence forte : le type ne « connaît » pas l'interface, et on peut définir une interface après coup pour des types existants, même ceux d'une autre bibliothèque. Les interfaces décrivent un comportement attendu, pas une hiérarchie d'héritage.

type Forme interface {
    Aire() float64
}

type Rectangle struct{ L, H float64 }
func (r Rectangle) Aire() float64 { return r.L * r.H }

type Cercle struct{ R float64 }
func (c Cercle) Aire() float64 { return 3.14159 * c.R * c.R }

Rectangle et Cercle n'ont jamais déclaré « je suis une Forme ». Mais comme ils ont tous les deux une méthode Aire() float64, ils satisfont l'interface Forme. Le compilateur le vérifie tout seul.

Les types Rectangle et Cercle possèdent chacun une méthode Aire() float64, donc ils satisfont automatiquement l'interface Forme, sans mot-clé implements. Rectangle Aire() float64 Cercle Aire() float64 interface Forme { Aire() float64 } un contrat satisfont
Aucun mot-clé implements : un type satisfait une interface dès qu'il en possède les méthodes.

Le polymorphisme sans héritage

L'intérêt : une fonction qui prend une Forme accepte n'importe quel type qui la satisfait. Pas besoin de classe parente, pas d'arbre d'héritage.

func decrire(f Forme) {
    fmt.Printf("Aire : %.2f\n", f.Aire())
}

func main() {
    decrire(Rectangle{L: 3, H: 4})  // Aire : 12.00
    decrire(Cercle{R: 5})           // Aire : 78.54
}

Ajoutez demain un Triangle avec une méthode Aire() : decrire fonctionnera sans changer une ligne. C'est le polymorphisme à la Go : on programme contre un comportement, pas contre une hiérarchie de types.

Le conseil idiomatique. Garde tes interfaces petites (souvent une seule méthode) et définis-les côté consommateur, pas côté producteur. La bibliothèque standard en est pleine : io.Reader, io.Writer, fmt.Stringer n'ont qu'une méthode chacune.

Révélation : error est une interface

Souvenez-vous du error de la leçon précédente. Ce n'est pas un type magique : c'est une interface, définie par la bibliothèque standard en trois lignes :

type error interface {
    Error() string
}

N'importe quel type qui possède une méthode Error() string est donc un error valide. C'est ainsi qu'on crée des erreurs riches, qui portent des données :

type ErrSolde struct {
    Manque float64
}

func (e ErrSolde) Error() string {
    return fmt.Sprintf("solde insuffisant : il manque %.2f €", e.Manque)
}

func retirer(solde, montant float64) error {
    if montant > solde {
        return ErrSolde{Manque: montant - solde}  // satisfait error
    }
    return nil
}

Le code appelant peut alors vérifier if err != nil comme d'habitude, mais aussi récupérer la donnée (le montant manquant) avec un type assertion. Les interfaces, c'est exactement ce qui rend le système d'erreurs de Go à la fois simple et extensible.

Le piège de l'interface nil qui n'est pas nil

Une variable d'interface, sous le capot, c'est une boîte à deux cases : le type concret qu'elle contient, et la valeur elle-même. Et la comparaison err == nil regarde les deux cases. C'est là que Go tend son piège le plus célèbre :

Le piège se déclenche presque toujours de la même façon : une fonction qui déclare retourner error mais renvoie un pointeur typé qui vaut nil :

func retirer(solde, montant float64) error {
    var e *ErrSolde               // pointeur nil pour l'instant
    if montant > solde {
        e = &ErrSolde{Manque: montant - solde}
    }
    return e  // ⚠ même si e vaut nil, l'interface ne le sera pas !
}

err := retirer(100, 50)
fmt.Println(err == nil)  // false… alors qu'il n'y a aucune erreur

Le remède : ne stockez jamais une erreur dans une variable pointeur avant de la retourner. Retournez nil explicitement dans le chemin sans erreur (if montant > solde { return &ErrSolde{...} } ; return nil). Le nil littéral laisse les deux cases vides ; un pointeur nil typé remplit la case type.

À toi de jouer

Complète ce programme : ajoute un type Carre avec une méthode Aire() pour qu'il satisfasse l'interface Forme et passe dans decrire. Puis exécute :

package main

import "fmt"

type Forme interface {
    Aire() float64
}

type Rectangle struct{ L, H float64 }

func (r Rectangle) Aire() float64 { return r.L * r.H }

// TODO : ajoute ici un type Carre avec une méthode Aire()

func decrire(f Forme) {
    fmt.Printf("Aire : %.2f\n", f.Aire())
}

func main() {
    decrire(Rectangle{L: 3, H: 4})
    // decrire(Carre{Cote: 5})  // décommente après avoir créé Carre
}

La solution. type Carre struct{ Cote float64 } puis func (c Carre) Aire() float64 { return c.Cote * c.Cote }. Dès que Carre a la méthode Aire() float64, il satisfait Forme : decrire(Carre{Cote: 5}) affiche Aire : 25.00, sans toucher à decrire.

Methods: attaching behavior to a type

So far your structs only stored data. A method attaches behavior to them: it's a function with a receiver, declared between func and the name.

type Account struct {
    balance float64
}

// VALUE receiver: read-only (works on a copy)
func (a Account) Balance() float64 {
    return a.balance
}

// POINTER receiver: can modify the original Account
func (a *Account) Deposit(amount float64) {
    a.balance += amount
}

func main() {
    a := Account{}
    a.Deposit(50)            // Go takes the address automatically
    fmt.Println(a.Balance()) // 50
}

The distinction is crucial: a value receiver (a Account) gets a copy, so it can't modify the original. A pointer receiver (a *Account) gets the address, so it can mutate the type.

Which receiver? Pointer (*T) whenever the method must modify the type, or if the type is large (avoid the copy). Value (T) for read-only on small types. Rule of thumb: be consistent, don't mix value and pointer receivers on the same type.

Interfaces: a contract, not a hierarchy

An interface describes expected behavior: a list of methods. Any type that has those methods satisfies the interface — automatically, with no implements keyword. That's the big difference from Java or PHP.

Predict before reading

Imagine an interface type Speaker interface { Speak() string } and a type Dog that has a Speak() string method. Before scrolling: do you need to write anywhere, with a keyword like implements Speaker, that Dog satisfies the interface? How does Go decide that a type satisfies an interface?

See the answer

No, there is no implements keyword in Go. Interface satisfaction is implicit (structural typing, often called "duck typing"): as soon as a type has all the methods required by an interface (same names, same signatures), it satisfies that interface automatically, with no declaration needed. Dog, which has a Speak() string method, is therefore a Speaker without writing it anywhere. Strong consequence: the type doesn't "know" the interface, and you can define an interface after the fact for existing types, even those from another package. Interfaces describe expected behavior, not an inheritance hierarchy.

type Shape interface {
    Area() float64
}

type Rectangle struct{ W, H float64 }
func (r Rectangle) Area() float64 { return r.W * r.H }

type Circle struct{ R float64 }
func (c Circle) Area() float64 { return 3.14159 * c.R * c.R }

Rectangle and Circle never declared "I am a Shape". But because both have an Area() float64 method, they satisfy the Shape interface. The compiler checks it for you.

The Rectangle and Circle types each have an Area() float64 method, so they automatically satisfy the Shape interface, with no implements keyword. Rectangle Area() float64 Circle Area() float64 interface Shape { Area() float64 } a contract satisfy
No implements keyword: a type satisfies an interface as soon as it has its methods.

Polymorphism without inheritance

The payoff: a function taking a Shape accepts any type that satisfies it. No parent class, no inheritance tree.

func describe(s Shape) {
    fmt.Printf("Area: %.2f\n", s.Area())
}

func main() {
    describe(Rectangle{W: 3, H: 4})  // Area: 12.00
    describe(Circle{R: 5})           // Area: 78.54
}

Add a Triangle tomorrow with an Area() method: describe will work without changing a line. That's Go-style polymorphism: you program against a behavior, not against a type hierarchy.

The idiomatic advice. Keep your interfaces small (often a single method) and define them on the consumer side, not the producer side. The standard library is full of them: io.Reader, io.Writer, fmt.Stringer each have just one method.

Plot twist: error is an interface

Remember error from the previous lesson. It's not a magic type: it's an interface, defined by the standard library in three lines:

type error interface {
    Error() string
}

So any type with an Error() string method is a valid error. That's how you build rich errors that carry data:

type ErrBalance struct {
    Missing float64
}

func (e ErrBalance) Error() string {
    return fmt.Sprintf("insufficient balance: %.2f short", e.Missing)
}

func withdraw(balance, amount float64) error {
    if amount > balance {
        return ErrBalance{Missing: amount - balance}  // satisfies error
    }
    return nil
}

The calling code can still check if err != nil as usual, but also recover the data (the missing amount) with a type assertion. Interfaces are exactly what makes Go's error system both simple and extensible.

The trap: the nil interface that isn't nil

Under the hood, an interface variable is a box with two slots: the concrete type it holds, and the value itself. And the err == nil comparison checks both slots. That's where Go sets its most famous trap:

The trap almost always springs the same way: a function that declares it returns error but actually returns a typed pointer that happens to be nil:

func withdraw(balance, amount float64) error {
    var e *ErrBalance             // nil pointer for now
    if amount > balance {
        e = &ErrBalance{Missing: amount - balance}
    }
    return e  // ⚠ even if e is nil, the interface won't be!
}

err := withdraw(100, 50)
fmt.Println(err == nil)  // false… even though there is no error

The cure: never park an error in a pointer variable before returning it. Return a literal nil on the no-error path (if amount > balance { return &ErrBalance{...} } ; return nil). The literal nil leaves both slots empty; a typed nil pointer fills the type slot.

Your turn

Complete this program: add a Square type with an Area() method so it satisfies the Shape interface and passes to describe. Then run it:

package main

import "fmt"

type Shape interface {
    Area() float64
}

type Rectangle struct{ W, H float64 }

func (r Rectangle) Area() float64 { return r.W * r.H }

// TODO: add a Square type with an Area() method here

func describe(s Shape) {
    fmt.Printf("Area: %.2f\n", s.Area())
}

func main() {
    describe(Rectangle{W: 3, H: 4})
    // describe(Square{Side: 5})  // uncomment after creating Square
}

The solution. type Square struct{ Side float64 } then func (s Square) Area() float64 { return s.Side * s.Side }. As soon as Square has the Area() float64 method, it satisfies Shape: describe(Square{Side: 5}) prints Area: 25.00, without touching describe.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

Prompt IA
Avec l'IA

Copiez ce prompt dans Claude ou ChatGPT :

Montre-moi en Go une interface Notificateur avec une méthode Envoyer(message string) error, deux types qui la satisfont (Email et SMS), et une fonction qui envoie via n'importe quel Notificateur. Explique pourquoi il n'y a pas de mot-clé implements.
💬 Ré-explique sans regarder
Ré-explique sans regarder

L'IA te rend deux types Email et SMS qui « sont » des Notificateur. Explique avec tes mots : comment Go sait-il qu'ils satisfont l'interface, alors qu'ils ne l'ont jamais déclaré ?

Une bonne explication dit : en Go la satisfaction d'interface est implicite et structurelle. Le compilateur regarde si le type possède toutes les méthodes de l'interface (mêmes noms, mêmes signatures) ; si oui, il satisfait l'interface, sans implements ni héritage. Email et SMS ont chacun Envoyer(string) error, donc ils sont des Notificateur valides. On programme contre le comportement, pas contre une hiérarchie.
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

L'IA propose ce code pour qu'un compteur s'incrémente. L'accepter ou le rejeter, et pourquoi ?

type Compteur struct{ n int }

func (c Compteur) Incr() { c.n++ }

func main() {
    c := Compteur{}
    c.Incr()
    c.Incr()
    fmt.Println(c.n)  // ?
}
À rejeter : ça affiche 0, pas 2. La méthode Incr a un récepteur valeur (c Compteur) : elle travaille sur une copie, donc l'incrément est perdu à chaque appel. Pour modifier le compteur d'origine, il faut un récepteur pointeur : func (c *Compteur) Incr() { c.n++ }. C'est l'erreur n°1 sur les méthodes en Go.
🧠 Rappel libre
Rappel libre

Sans remonter : pour qu'une méthode puisse modifier le type sur lequel elle est appelée, quel type de récepteur faut-il, et pourquoi ?

Il faut un récepteur pointeur (func (c *T)). Un récepteur valeur (func (c T)) reçoit une copie du type : toute modification reste sur la copie et est perdue au retour. Le récepteur pointeur reçoit l'adresse de l'original, donc les modifications (c.champ = …) portent sur la vraie valeur. Go ajoute/enlève le & automatiquement à l'appel.
Pour qu'une méthode modifie le struct sur lequel elle est appelée, son récepteur doit être…
Comment un type satisfait-il une interface en Go ?
Pourquoi peut-on créer son propre type d'erreur en Go (ex. un struct qui porte des données) ?
Prochaine étape

Vos types ont un comportement et se plient à des contrats. Maintenant, écrivons du code qui marche pour plusieurs types à la fois, sans le dupliquer : les génériques.

Leçon 7 : Les génériques →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement