Aller au contenu principal

Cours 3 — Pointeurs, Struct et Interfaces

Pointeurs​

Go a des pointeurs. Ils sont légèrement parsemés dans le langage. Un pointeur garde en mémoire l'adresse de la valeur.

Le type *T est un pointeur vers la valeur de T. Sa valeur zéro est nil.

var p *int

Deux opérateurs sont utilisés avec les pointeurs :

  • & génère un pointeur vers son opérande — &x retourne l'adresse de x (un *int si x est un int).
  • * indique la valeur sous-jacente du pointeur — c'est le déréférencement, qui permet de lire ou d'écrire la valeur pointée.
i := 42
p = &i // p pointe maintenant vers i

fmt.Println(*p) // lit i à travers le pointeur p : 42
*p = 21 // assigne i à travers le pointeur p, donc i vaut maintenant 21

Lorsque nous écrivons *p = 21, cela signifie qu'on stocke la valeur 21 dans l'adresse mémoire pointée par p. Si l'on essaie de faire p = 21, nous obtenons une erreur de compilation car p n'est pas un int, mais un *int, pour lequel on ne peut assigner qu'une valeur *int.

Contrairement à C, Go ne fait pas d'arithmétique avec les pointeurs. En C, p++ avance l'adresse de la taille du type pointé, ce qui permet de parcourir un tableau à la main. En Go, cette opération n'existe tout simplement pas : le compilateur refuse même de compiler, car un pointeur n'est pas considéré comme un type numérique.

var c *int
c++ // erreur de compilation : invalid operation: c++ (non-numeric type *int)

Ce choix est délibéré, pour deux raisons :

  1. Sécurité mémoire — l'arithmétique de pointeur est une source classique de bugs en C (débordement de tableau, lecture/écriture hors limites), puisque rien n'empêche p++ de sortir de la zone mémoire valide.
  2. Compatibilité avec le garbage collector — le GC de Go doit savoir en tout temps où se trouvent les objets vivants en mémoire. Une arithmétique de pointeur arbitraire empêcherait le GC de garantir qu'une adresse calculée pointe encore vers un objet valide.

Pour parcourir une structure de données en Go, on utilise plutôt l'indexation (arr[i]) ou range sur un slice, jamais l'incrémentation d'un pointeur.

Pourquoi passer un pointeur en paramètre?​

En Go, les paramètres sont toujours copiés lorsqu'on les passe à une fonction. Modifier la copie à l'intérieur de la fonction n'a donc aucun effet sur la variable d'origine :

func incrementer(x int) {
x++ // ne modifie que la copie locale
}

func main() {
n := 5
incrementer(n)
fmt.Println(n) // 5, inchangé
}

En passant un pointeur, la fonction reçoit l'adresse de la variable d'origine et peut la modifier via le déréférencement (*x) :

func incrementer(x *int) {
*x++ // modifie la valeur pointée par x, donc n directement
}

func main() {
n := 5
incrementer(&n)
fmt.Println(n) // 6
}

C'est la seule façon en Go de faire en sorte qu'une fonction modifie une variable qui lui a été passée en argument.

Quand l'utiliser​

Quand la fonction doit modifier la variable d'origine, ou quand la donnée est volumineuse et qu'on veut éviter de la copier à chaque appel.

Quand l'éviter​

Pour un petit type simple (int, bool, ...) qu'on ne fait que lire — la copie est moins coûteuse qu'un déréférencement, et le code reste plus simple à suivre sans avoir à se soucier d'un pointeur nil.

new​

Une autre façon d'obtenir un pointeur est d'utiliser la fonction new. new prend comme argument un Type, alloue de la mémoire pour affecter une valeur au type et retourne un pointeur à cet élément.

Dans quelques langages de programmation, il y a une grande différence entre new et & et nous devons éventuellement libérer la mémoire affectée par new. Go n'est pas comme ça, il y a un garbage collector qui nettoie les objets lorsque ceux-ci ne sont plus référencés.

Quand l'utiliser​

Rarement en pratique — surtout utile quand on veut un pointeur vers un type sans avoir de valeur nommée sous la main (ex. p := new(int) en une seule ligne), ou dans du code générique.

Quand l'éviter​

Dans la grande majorité des cas en Go idiomatique, où l'on préfère &MonType{...}, qui permet d'initialiser les champs en même temps que d'obtenir le pointeur — new() retourne toujours une valeur zéro qu'il faut ensuite remplir champ par champ.

Les pointeurs sont peu utilisés avec les types par défaut de Go, mais nous allons voir qu'ils peuvent être utiles avec les structs.

Exemples

Struct​

Imaginons la situation suivante: vous avez à calculer l'aire de différents objets:

func rectangleAire(x1, y1, x2, y2 float64) float64 {
b := x2 - x1
h := y2 - y1
return b * h
}

func cercleAire(x, y, r float64) float64 {
return math.Pi * r * r
}

func main() {
var rx1, ry1 float64 = 0, 0
var rx2, ry2 float64 = 10, 10
var cx, cy, cr float64 = 0, 0, 5
fmt.Println(rectangleAire(rx1, ry1, rx2, ry2))
fmt.Println(cercleAire(cx, cy, cr))
}

Exemple

Bien que ceci soit fonctionnel, il est difficile d'avoir une certaine homogénéité, surtout si l'on commence à ajouter des structures différentes (tel que triangle et trapèze).

On va premièrement se créer des types d'objets qui nous permettront alors d'avoir une plus grande précision dans nos objets (pas seulement par le nom des méthodes):

type Cercle struct {
x, y, r float64
}

func cercleAire(c Cercle) float64 {
return math.Pi * c.r * c.r
}

func main() {
c := Cercle{0, 0, 5}
fmt.Println(cercleAire(c))
}

On peut désormais accéder directement aux champs de la structure Cercle (c.r) à l'intérieur d'une fonction qui la reçoit en paramètre.

Comme vu précédemment, les paramètres sont toujours copiés en Go — il est donc généralement recommandé de passer un pointeur vers l'objet plutôt que l'objet lui-même :

func cercleAire(c *Cercle) float64 {
return math.Pi * c.r * c.r
}

func main() {
c := Cercle{0, 0, 5}
fmt.Println(cercleAire(&c))
}

Exemple

Une autre particularité du code est de voir la création de l'objet cercle. Il y a 4 façons possibles de créer cet objet:

  • var c Cercle : permet d'obtenir un objet Cercle avec les champs égaux à 0 (x, y, r)
  • c := new(Cercle) : permet d'obtenir un pointeur vers un objet Cercle avec les champs égaux à 0 (x, y, r)
  • c := Cercle{x: 0, y: 0, r: 5} : permet d'obtenir un objet Cercle avec les champs égaux à (x:0, y:0, r:5)
  • c := Cercle{0, 0, 5} : permet d'obtenir un objet Cercle avec les champs définis en ordre (x, y, r) tel que défini dans le type

Quand l'utiliser​

Dès qu'on regroupe plusieurs données reliées entre elles qui décrivent un même concept (ex. x, y, r pour un Cercle) — ça donne un nom et une structure claire, plutôt que de multiplier les paramètres épars dans les fonctions.

Quand l'éviter​

Pour une collection homogène d'éléments du même type, où un slice ou une map convient mieux (une struct sert à regrouper des champs différents, pas à répéter le même champ plusieurs fois).

Méthode​

Afin de créer une méthode d'une structure, nous devons utiliser la syntaxe suivante:

func (c *Cercle) aire() float64 {
return math.Pi * c.r * c.r
}

func main() {
c := Cercle{0, 0, 5}
fmt.Println(c.aire())
}

Donc désormais nous avons une méthode qui a été rajoutée à l'objet Cercle, faisons de même pour l'objet rectangle:

type Rectangle struct {
x1, y1, x2, y2 float64
}

func (r *Rectangle) aire() float64 {
return (r.x2 - r.x1) * (r.y2 - r.y1)
}

Quand l'utiliser un récepteur pointeur​

Dès que la méthode doit modifier l'état de la struct, ou que la struct est volumineuse (pour éviter de la copier à chaque appel).

Quand l'utiliser un récepteur valeur​

Pour une petite struct qu'on ne fait que lire, comme aire() ci-dessus.

Piège classique en programmation réseau : une méthode censée incrémenter un compteur de requêtes sur une struct Session, mais déclarée avec un récepteur par valeur — elle modifie une copie, jamais l'original :

type Session struct {
ClientID string
Requetes int
}

func (s Session) EnregistrerRequete() { // BUG : récepteur par valeur
s.Requetes++ // ne modifie que la copie locale de "s"
}

func main() {
sess := Session{ClientID: "abc"}
sess.EnregistrerRequete()
sess.EnregistrerRequete()
fmt.Println(sess.Requetes) // 0, alors qu'on s'attendait à 2
}

Correction : utiliser un récepteur pointeur pour que la méthode modifie bel et bien la struct d'origine :

func (s *Session) EnregistrerRequete() {
s.Requetes++
}

func main() {
sess := &Session{ClientID: "abc"} // on manipule désormais un pointeur vers Session
sess.EnregistrerRequete()
sess.EnregistrerRequete()
fmt.Println(sess.Requetes) // 2, comme attendu
}

Embedding (composition)​

Go n'a pas d'héritage comme dans les langages orientés objet classiques. À la place, on utilise la composition : une struct peut en contenir une autre comme champ anonyme (sans nom), ce qui promeut automatiquement ses champs et ses méthodes.

type Animal struct {
Nom string
Age int
}

func (a Animal) Decrire() string {
return fmt.Sprintf("%s (%d ans)", a.Nom, a.Age)
}

type Chien struct {
Animal // champ anonyme : Chien "embarque" Animal
Race string
}

func main() {
c := Chien{
Animal: Animal{Nom: "Rex", Age: 3},
Race: "Labrador",
}

fmt.Println(c.Nom) // accès direct au champ promu (pas besoin de c.Animal.Nom)
fmt.Println(c.Decrire()) // méthode promue elle aussi
}

Le mécanisme derrière c.Nom et c.Decrire() s'appelle la promotion de champ/méthode, et se décrit comme une règle de résolution de noms : quand tu écris c.Nom, le compilateur cherche d'abord un champ Nom déclaré directement sur Chien. Il n'y en a pas — alors il regarde dans les champs anonymes de Chien (ici Animal) et l'y trouve. c.Nom devient donc littéralement un raccourci pour c.Animal.Nom, et c.Decrire() un raccourci pour c.Animal.Decrire(). C'est une règle appliquée à la compilation — le compilateur réécrit l'appel avant même que le programme tourne, ce n'est pas un mécanisme dynamique comme le serait une vtable en C++/Java. On peut aussi redéfinir une méthode promue en la déclarant directement sur Chien — elle prendra alors le dessus (le comportement le plus proche du type gagne).

Cette promotion dépend entièrement de l'anonymat du champ. Si Animal avait plutôt été un champ nommé :

type Chien struct {
A Animal // champ nommé, pas anonyme
Race string
}

alors c.Nom et c.Decrire() ne compileraient plus — il faudrait écrire explicitement c.A.Nom et c.A.Decrire(), comme pour n'importe quel champ struct imbriqué normal. Dans les deux cas, Chien contient un Animal ; seul le fait que le champ soit anonyme déclenche la promotion.

C'est ce mécanisme qui remplace l'héritage en Go : au lieu de "Chien EST-UN Animal", on dit "Chien CONTIENT un Animal" et en hérite le comportement par cette réécriture automatique. La nuance compte : contrairement à un héritage classique, il n'y a aucune relation de type entre Chien et Animal — on ne peut pas passer un Chien à une fonction qui attend un Animal, alors qu'on pourrait avec un vrai sous-type dans un langage orienté objet classique.

Quand l'utiliser​

Quand on veut réutiliser le comportement d'un type existant sans dupliquer son code, et que la relation entre les deux structs est naturellement du type « contient un ».

Quand l'éviter​

Quand l'embedding ne sert qu'à économiser quelques lignes d'écriture sans relation logique claire — ça rend moins évident, à la lecture, d'où viennent les champs et méthodes promus ; si la relation ne se justifie pas naturellement, préférer un champ nommé explicite (Animal Animal) plutôt qu'un champ anonyme.

Exemple

Interface​

Nous avons nommé les deux méthodes des types créés aire, c'est loin d'être un hasard. Une relation comme ça entre deux objets est très fréquente dans la vraie vie. Afin de standardiser ce genre de relation, Go a une façon de faire très simple:

type Forme interface {
aire() float64
}

Ceci indique que Forme est une interface : pour être considéré comme une Forme, un type doit implémenter la méthode aire(). Et c'est tout.

Contrairement à Java ou C#, il n'y a aucun mot-clé à déclarer (pas de implements Forme) — un type satisfait une interface automatiquement dès qu'il possède les méthodes requises. C'est ce qu'on appelle le typage structurel (structural typing, parfois appelé duck typing : « si ça marche comme un canard et ça cancane comme un canard, c'est un canard »). À partir de maintenant, les deux objets créés précédemment sont des Formes, car les deux implémentent la méthode aire() — sans qu'on ait eu à l'indiquer explicitement nulle part.

Quand l'utiliser​

Dès que vous voulez qu'une fonction accepte n'importe quel type qui respecte un certain comportement, sans se soucier du type concret — par exemple une fonction réseau qui écrit sur n'importe quoi qui implémente io.Writer (une connexion TCP, un fichier, un buffer en mémoire, ...), ou qui reçoit n'importe quelle error sans connaître son type exact.

Quand l'éviter​

Quand une seule implémentation concrète existe et qu'aucune autre n'est prévue — extraire une interface "au cas où" complique le code sans bénéfice réel ; attendez d'avoir un deuxième type concret avant de le faire.

Nous pouvons créer une méthode aireTotale, pour calculer, la superficie totale de tous les objets. Nous pourrions faire ceci:

func aireTotale(rectangles []Rectangle, cercles ...Cercle) float64 {
var total float64
for _, c := range cercles {
total += c.aire()
}

for _, r := range rectangles {
total += r.aire()
}
return total
}

Exemple

Est-ce que c'est bon? Oui. Est-ce que c'est maintenable? Non. Pourquoi? Car si vous créez de nouvelles Formes, il faudra modifier cette méthode à chaque fois pour ajouter tous les types possibles...

Mais utilisons la propriété des Formes pour sauver nos vies (vu que oui, on travaille avec plein d'objets supportant les formes (vous vous rappelez les interfaces)) :

func aireTotale(formes ...Forme) float64 {
var total float64
for _, f := range formes {
total += f.aire()
}
return total
}

Et voilà, tant qu'un objet est une Forme, nous pouvons utiliser sa propriété aire, pour calculer son aire. Ainsi en ajoutant de nouveaux objets tant que vous ajoutez les méthodes aires à chacun des objets, vous n'aurez rien d'autre à faire et votre fonction aireTotale fonctionnera.

Nous pouvons utiliser les Interfaces comme des membres d'une structure, ceci permet de créer une liste de formes par exemple.

type MultiForme struct {
formes []Forme
}

Ceci définit donc une structure qui contient des objets implémentant la méthode aire. Nous pourrions aussi créer la méthode aire dans cette structure afin qu'elle soit une Forme en lui ajoutant la fameuse méthode aire. Comme cette méthode ne fait que lire le champ formes sans le modifier (contrairement à EnregistrerRequete() plus haut), un récepteur valeur reste approprié même si la struct contient un slice — la règle « pointeur si la struct est volumineuse » vise surtout à éviter de copier de gros tableaux de valeurs, pas les slices, qui sont déjà de petites structures internes (pointeur + longueur + capacité) peu coûteuses à copier :

func (m MultiForme) aire() float64 {
var total float64
for _, f := range m.formes {
total += f.aire()
}

return total
}

Et dans le main. Notez le & devant Cercle{...} et Rectangle{...} : comme aire() est déclarée avec un récepteur pointeur (*Cercle, *Rectangle), seul un pointeur vers ces types — pas une valeur — satisfait l'interface Forme :

multiForme := MultiForme {
formes : [] Forme {
&Cercle{0, 0, 5},
&Rectangle{0, 0, 10, 10},
},
}

fmt.Println(multiForme.aire())

Exemple

C'est magique!

Piège classique : une interface qui contient un pointeur nil n'est pas nil. C'est l'un des pièges Go les plus connus, et il surgit typiquement dans une fonction réseau qui retourne un type d'erreur personnalisé :

type ErreurReseau struct {
Code int
}

func (e *ErreurReseau) Error() string {
return fmt.Sprintf("erreur réseau %d", e.Code)
}

func verifierConnexion(conn net.Conn) *ErreurReseau {
if conn == nil {
return &ErreurReseau{Code: 500}
}
return nil // pas d'erreur
}

func main() {
var err error
err = verifierConnexion(monConn) // BUG : err reçoit un *ErreurReseau nil,
// mais assigné à une interface "error"
if err != nil {
fmt.Println("il y a une erreur!") // s'affiche même si tout va bien
}
}

Le problème : une interface (error) contient en réalité une paire (type, valeur). Ici, le type vaut *ErreurReseau et la valeur vaut nil — cette paire n'est pas égale à l'interface nil complètement vide, même si le pointeur qu'elle contient l'est. Correction : faire retourner directement error par la fonction (pas *ErreurReseau), pour que le seul cas sans erreur retourne littéralement nil :

func verifierConnexion(conn net.Conn) error {
if conn == nil {
return &ErreurReseau{Code: 500}
}
return nil
}