Skip to content

Erreur de pointeur nil

Introduction

Lors d'une session de programmation, j'avais besoin d'appeler la méthode Close() pour fermer plusieurs objets, comme dans le code ci-dessous :

go
type A struct {
  b B
  c C
  d D
}

func (a A) Close() error {
  if a.b != nil {
    if err := a.b.Close(); err != nil {
      return err
    }
  }

  if a.c != nil {
    if err := a.c.Close(); err != nil {
      return err
    }
  }

    if a.d != nil {
        if err := a.d.Close(); err != nil {
            return err
        }
    }

  return nil
}

Mais écrire autant de conditions if ne semblait pas très élégant. B, C et D implémentent tous la méthode Close, donc cela devrait pouvoir être simplifié. J'ai donc mis ces objets dans une slice et utilisé une boucle :

go
func (a A) Close() error {
  closers := []io.Closer{
    a.b,
    a.c,
    a.d,
  }

  for _, closer := range closers {
    if closer != nil {
      if err := closer.Close(); err != nil {
        return err
      }
    }
  }
  return nil
}

Cela semblait mieux. Voyons le résultat :

go
func main() {
  var a A
  if err := a.Close(); err != nil {
    panic(err)
  }
  fmt.Println("success")
}

Le résultat a été surprenant, le programme a planté avec le message d'erreur suivant, indiquant qu'on ne peut pas appeler une méthode sur un récepteur nil. La condition if closer != nil dans la boucle ne semblait pas avoir filtré les valeurs nil :

panic: value method main.B.Close called using nil *B pointer

Cet exemple est une version simplifiée d'un bug que j'ai rencontré. Beaucoup de débutants peuvent faire la même erreur. Expliquons ce qui se passe réellement.

Interface

Comme mentionné dans les chapitres précédents, nil est la valeur zéro des types référence : slices, maps, channels, fonctions, pointeurs et interfaces. Pour les slices, maps, channels et fonctions, on peut les considérer comme des pointeurs, tous pointant vers une implémentation concrète.

Mais les interfaces sont différentes. Une interface est composée de deux éléments : un type et une valeur.

Quand on essaie d'assigner nil à une variable, cela ne compile pas et affiche le message suivant :

use of untyped nil in assignment

Le message indique qu'on ne peut pas déclarer une variable avec une valeur untyped nil. S'il existe un untyped nil, il existe aussi un typed nil, et cette situation apparaît souvent avec les interfaces. Voyons un exemple simple :

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(pa)
  fmt.Println(pa == nil)
}

Résultat :

<nil>
true
<nil>
false

Le résultat est étrange. Bien que pa affiche nil, il n'est pas égal à nil. Utilisons la réflexion pour voir ce qu'il est réellement :

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.TypeOf(pa))
  fmt.Println(reflect.ValueOf(pa))
}

Résultat :

<nil>
true
*int
<nil>

On peut voir qu'il s'agit en réalité de (*int)(nil), c'est-à-dire que pa stocke un type *int et sa valeur réelle est nil. Lors d'une comparaison d'égalité sur une valeur d'interface, Go compare d'abord les types. Si les types sont différents, les valeurs sont considérées comme différentes. Ce n'est que si les types sont identiques que Go compare les valeurs. Cette logique de comparaison d'interface se trouve dans la fonction cmd/compile/internal/walk.walkCompare.

Donc, pour qu'une interface soit égale à nil, il faut que sa valeur soit nil ET que son type soit aussi nil, car le type dans une interface est en fait un pointeur :

go
type iface struct {
  tab  *itab
  data unsafe.Pointer
}

Si on veut contourner le type et tester directement si la valeur est nil, on peut utiliser la réflexion. Voici un exemple :

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.ValueOf(pa).IsNil())
}

Avec IsNil(), on peut tester directement si la valeur est nil, ce qui évite le problème mentionné ci-dessus. Donc en pratique, si une fonction retourne un type interface et que vous voulez retourner une valeur zéro, il vaut mieux retourner directement nil plutôt que la valeur zéro d'une implémentation concrète. Même si elle implémente l'interface, elle ne sera jamais égale à nil, ce qui peut causer l'erreur de l'exemple.

Résumé

Maintenant que ce problème est résolu, regardons quelques autres exemples.

Quand le récepteur d'une méthode de structure est un récepteur par pointeur, nil est utilisable. Voici un exemple :

go
type A struct {

}

func (a *A) Do()  {

}

func main() {
  var a *A
  a.Do()
}

Ce code s'exécute normalement sans erreur de pointeur nil.

Quand une slice est nil, on peut accéder à sa longueur et sa capacité, et aussi y ajouter des éléments :

func main() {
  var s []int
  fmt.Println(len(s))
  fmt.Println(cap(s))
  s = append(s, 1)
}

Quand une map est nil, on peut y accéder, mais une map nil est en lecture seule. Toute tentative d'écriture provoquera une panic :

go
func main() {
  var s map[string]int
  i, ok := s[""]
  fmt.Println(i, ok)
  fmt.Println(len(s))

  // Tentative d'écriture, provoque une panic
  s["a"] = 1 // panic: assignment to entry in nil map

}

Ces caractéristiques de nil peuvent être déroutantes, surtout pour les débutants en Go. nil représente la valeur zéro des types mentionnés ci-dessus, c'est-à-dire la valeur par défaut. Une valeur par défaut devrait avoir un comportement par défaut, et c'est exactement ce que les concepteurs de Go voulaient : rendre nil plus utile plutôt que de lancer directement une erreur de pointeur nil. Cette philosophie se retrouve aussi dans la bibliothèque standard. Par exemple, pour démarrer un serveur HTTP :

go
http.ListenAndServe(":8080", nil)

On peut passer directement un nil Handler, et la bibliothèque http utilisera le Handler par défaut pour traiter les requêtes HTTP.

TIP

Pour aller plus loin, vous pouvez regarder cette vidéo Understanding nil - Gopher Conference 2016, qui explique tout très clairement.

Golang by www.golangdev.cn edit