Article original : Learn How to Use Pointers in Go – With Example Code
Les pointeurs sont un concept fondamental mais souvent redouté dans chaque langage de programmation qui les supporte. Heureusement pour nous, Go rend le travail avec les pointeurs simple et sûr.
Dans cet article, nous allons démystifier les pointeurs en Go. Vous apprendrez :
Ce que sont les pointeurs et comment les utiliser
Comment déclarer des pointeurs et les déréférencer
Les pièges courants, comme les pointeurs nil et les types de référence
Les récepteurs de pointeur dans les structs (une raison clé pour laquelle les pointeurs sont si utiles en Go)
Un aperçu bonus des pointeurs faibles (Go 1.24+) pour la gestion avancée de la mémoire
À la fin, vous aurez une solide compréhension des pointeurs et serez confiant pour les utiliser dans vos propres programmes Go.
Ce que nous allons couvrir :
Prérequis
Cet article suppose que vous avez une compréhension de base de Go, incluant :
Les variables et les types de base (
int,string, etc.)Les fonctions et les appels de fonctions
Les structs et les méthodes
Une familiarité avec les concepts de mémoire (comme la copie vs le référencement de valeurs) peut être utile, mais n'est pas requise. J'expliquerai tous les exemples de manière accessible aux débutants.
Qu'est-ce qu'un pointeur ?
Comprendre la mémoire
La mémoire d'un ordinateur est une large séquence d'octets, chacun ayant une adresse unique. Chaque variable d'un programme occupe un ou plusieurs octets contigus en mémoire, selon son type :
Un
int32occupe typiquement 4 octets.Un
int64occupe typiquement 8 octets.Un
booloccupe généralement 1 octet.
Les structs, les tableaux et les slices occupent la somme des tailles de leurs champs, plus un éventuel rembourrage (padding) pour l'alignement (pour un accès rapide). Chaque variable possède une adresse mémoire unique, qui est l'endroit où ses données sont stockées.
Par exemple, considérons :
var a int32 = 100
var b bool = true
Cela ressemblera à quelque chose comme ceci en mémoire :

a occupe 4 octets et contient la valeur 100. b occupe 1 octet et contient true. L'adresse d'une variable est simplement l'emplacement en mémoire où ses données commencent (0x0202 pour a, et 0x0207 pour b dans cet exemple).
Pile vs Tas (Stack vs Heap)
En Go, les variables peuvent être allouées sur la pile (stack) ou sur le tas (heap). La pile est une région de mémoire qui stocke les variables locales et les informations d'appel de fonction. Elle est rapide à allouer et à désallouer, car elle fonctionne selon le principe du dernier entré, premier sorti (LIFO).
Le tas est un pool de mémoire plus important utilisé pour l'allocation dynamique. Les variables allouées sur le tas peuvent survivre à la fonction qui les a créées, ce qui les rend adaptées aux données qui doivent être partagées ou modifiées entre différentes parties d'un programme.
Le runtime Go gère automatiquement l'allocation de mémoire et le Garbage Collector, vous n'avez donc pas besoin de vous soucier de libérer manuellement la mémoire comme dans certains autres langages.
La pile et le tas ne sont que des détails d'implémentation. En tant que programmeur Go, vous n'avez généralement pas besoin de vous soucier de l'endroit où une variable est allouée. Le compilateur et le runtime Go s'en occupent pour vous. Vous n'avez certainement pas à vous en soucier dans cet article – sachez simplement qu'ils existent et que les pointeurs peuvent pointer vers des valeurs dans l'un ou l'autre emplacement.
Le pointeur
Un pointeur est simplement une variable qui stocke l'adresse mémoire d'une autre variable. D'après le diagramme ci-dessus, vous pouvez voir qu'une adresse est essentiellement une valeur entière (qui se trouve représenter un emplacement en mémoire). Sur un système 64 bits, les adresses font généralement 8 octets (64 bits) de long, donc une variable pointeur occupera également 8 octets.
En Go, vous déclarez un pointeur en utilisant l'opérateur *. Les pointeurs ont également un type, qui est le type de la variable vers laquelle ils pointent. Par exemple :
var p *int32 // un pointeur vers un int32
You pouvez obtenir l'adresse d'une variable en utilisant l'opérateur & :
var a int32 = 100
var p *int32 = &a // p contient maintenant l'adresse de a
En mémoire :

a contient la valeur 100 à l'adresse 0x0202. p contient l'adresse de a (0x0202), et p lui-même est stocké à sa propre adresse (0x0207).
La raison pour laquelle les pointeurs portent des informations de type est que vous pouvez les déréférencer : suivre l'adresse pour accéder à la valeur sous-jacente. Cela se fait également en utilisant l'opérateur * :
var a int32 = 100
var p *int32 = &a // p contient maintenant l'adresse de a
fmt.Println(*p) // affiche la valeur à l'adresse pointée par p, qui est la valeur de a : 100
Ce double usage de * est une source courante de confusion, clarifions donc :
Dans une déclaration de type (comme
var p *int32),*indique quepest un pointeur vers unint32.Dans une expression (comme
*p),*déréférence le pointeur, vous donnant accès à la valeur vers laquelle il pointe.
Ensuite, voyons comment déclarer et utiliser des pointeurs en pratique, afin que vous puissiez voir comment & et * fonctionnent ensemble.
Déclarer et utiliser des pointeurs
Maintenant que nous savons ce que sont les pointeurs conceptuellement, voyons à quoi ils ressemblent dans du code Go réel.
Pointeurs vers des types de base
Comme nous l'avons vu précédemment, vous pouvez déclarer une variable pointeur avec l'opérateur * dans le type :
var p *int // p est un pointeur vers un int, mais actuellement nil
fmt.Println(p) // <nil>
Comme chaque variable en Go, si vous ne l'initialisez pas, elle prend par défaut la valeur zéro pour son type. Pour les pointeurs, la valeur zéro est nil, ce qui signifie qu'il ne pointe vers aucune adresse mémoire valide.
You pouvez également utiliser la fonction intégrée new pour allouer une valeur et obtenir son pointeur :
p := new(int) // p est un pointeur vers un int avec une valeur zéro (0 pour int)
fmt.Println(*p) // affiche 0, la valeur zéro pour int
Obtenir une adresse avec &
L'opérateur & récupère l'adresse d'une variable existante :
x := 42
p := &x // p contient maintenant l'adresse de x
fmt.Println(*p) // 42
*p = 99 // change la valeur à l'adresse pointée par p (qui est x)
fmt.Println(x) // 99
fmt.Println(p) // affiche l'adresse mémoire de x, ex: 0xc0000140b8
fmt.Println(&x) // affiche la même adresse que p
Pointeurs vers des structs
Les pointeurs peuvent pointer vers n'importe quel type. Ils sont particulièrement courants avec les structs :
type User struct {
Name string
Age int
}
func main() {
u := User{"Alice", 30}
p := &u // pointeur vers User
fmt.Println((*p).Age) // 30
fmt.Println(p.Name) // Alice - raccourci pour (*p).Name
}
You pouvez accéder aux champs avec soit (*p).Name ou simplement p.Name. Go déréférence automatiquement les pointeurs de struct par commodité.
Nous explorerons davantage les pointeurs de struct dans la section "Récepteurs de pointeur".
Pointeurs vers d'autres types utilisateur
Vous pouvez créer des pointeurs vers n'importe quel type nommé :
type Point struct {
X, Y int
}
p := &Point{X: 1, Y: 2} // pointeur vers Point
fmt.Println(p.X, p.Y) // 1 2 - raccourci pour (*p).X et (*p).Y
La syntaxe fonctionne exactement de la même manière pour les types définis par l'utilisateur que pour les types intégrés.
Pourquoi utiliser des pointeurs ?
À première vue, les pointeurs peuvent ressembler à de la gymnastique mentale. Pourquoi ne pas simplement utiliser les valeurs directement ? Voici quelques raisons clés d'utiliser des pointeurs en Go :
Éviter les copies
Lorsque vous assignez une valeur en Go, elle est copiée :
type User struct {
Name string
Age int
}
u1 := User{"Alice", 30}
u2 := u1 // copie
u2.Age = 40
fmt.Println(u1.Age) // 30
fmt.Println(u2.Age) // 40
Lorsque le struct est petit avec seulement quelques champs (comme cet exemple), la copie est peu coûteuse. Mais s'il est volumineux (des centaines de champs ou des données imbriquées), la copie peut être inefficace. Passer un pointeur évite de faire une copie complète :
func Birthday(u *User) {
u.Age++
}
u := User{"Bob", 29}
Birthday(&u)
fmt.Println(u.Age) // 30
Partager et modifier l'état
Parfois, vous voulez que plusieurs parties de votre programme travaillent avec le même objet. Avec les valeurs, chaque affectation crée une copie :
type Counter struct {
value int32
}
c1 := Counter{value: 0} // c1 est un Counter
c2 := c1 // c2 est une copie - un autre Counter
c2.value++
fmt.Println(c1.value) // 0
fmt.Println(c2.value) // 1
L'utilisation de pointeurs garantit que les deux variables se réfèrent aux mêmes données sous-jacentes :
pc1 := &Counter{value: 0} // pc1 est un pointeur vers un Counter
pc2 := pc1 // copie du pointeur - les deux pointent vers le même Counter
pc2.value++
fmt.Println(pc1.value) // 1
fmt.Println(pc2.value) // 1
Ce diagramme illustre les deux dispositions de mémoire :

c1 et c2 sont stockés séparément à 0x0202 et 0x0207, chacun avec son propre champ value de 4 octets. Dans le second exemple, pc1 et pc2 sont stockés respectivement à 0x0202 et 0x020a, et tous deux contiennent la même adresse (0x1002) pointant vers une instance unique de Counter dans le tas, ayant son propre champ value de 4 octets.
Récepteurs de méthodes
Les méthodes Go peuvent avoir des récepteurs de valeur ou des récepteurs de pointeur. Les récepteurs de pointeur sont nécessaires lorsque :
La méthode doit modifier le struct
Le struct est volumineux et la copie serait coûteuse
Vous voulez de la cohérence (il est courant de faire de tous les récepteurs des pointeurs si certains doivent l'être)
Nous couvrirons cela en détail dans la section "Récepteurs de pointeur".
Interfaçage avec des API de bas niveau
Certaines bibliothèques et appels système exigent que vous passiez des adresses mémoire, pas des copies. Les pointeurs rendent cela possible, tout en restant sûrs en Go.
Pièges courants et malentendus
Pointeurs Nil
Si vous déclarez un pointeur sans l'initialiser, il sera nil. Déréférencer un pointeur nil provoquera immédiatement un panic à l'exécution :
var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference
C'est la façon de Go de vous dire que vous avez essayé de suivre une adresse qui n'existe pas. Pour utiliser les pointeurs en toute sécurité, vous devez toujours leur donner une cible valide avant de les déréférencer :
x := 42
p := &x // p pointe vers x
fmt.Println(*p) // 42
q := new(int) // alloue de la mémoire pour un int, l'initialise à 0
fmt.Println(*q) // 0
& et new garantissent tous deux que le pointeur pointe vers une mémoire valide.
Types de référence
En Go, tout est passé par valeur. Mais cette valeur dépend de la représentation interne du type. Par exemple, une slice est stockée en mémoire comme ceci :
struct {
ptr *ElementType // pointeur vers le tableau sous-jacent
len int // longueur de la slice
cap int // capacité de la slice
}
Lorsque vous passez une slice à une fonction, vous passez une copie de cette structure. Le champ ptr pointe toujours vers le même tableau sous-jacent, donc les modifications apportées aux éléments de la slice à l'intérieur de la fonction affecteront la slice originale.
En raison de ce comportement, les slices sont souvent appelées types de référence : vous n'avez pas besoin d'utiliser des pointeurs avec elles pour partager ou modifier des données. Les autres types de référence en Go incluent les maps et les channels. Les strings sont également considérées comme des types de référence, mais elles sont immuables, vous ne pouvez donc pas modifier leur contenu.
Notez que les pointeurs eux-mêmes ne sont pas des types de référence : ce sont simplement des variables qui contiennent des adresses mémoire.
(Pour compliquer un peu plus les choses, si vous passez une slice à une fonction puis que vous la re-slicez ou que vous y ajoutez des éléments avec append, vous modifiez la copie de la structure de la slice. La slice originale à l'extérieur de la fonction ne verra pas ces changements !)
Récepteurs de pointeur
Lors de la définition de méthodes sur des structs en Go, vous pouvez choisir entre des récepteurs de valeur et des récepteurs de pointeur. Comprendre la différence est essentiel pour écrire du code Go correct et efficace.
Récepteurs de valeur vs Récepteurs de pointeur
Récepteur de valeur : la méthode reçoit une copie du struct. Toute modification à l'intérieur de la méthode n'affecte pas le struct original.
Récepteur de pointeur : la méthode reçoit une copie du pointeur, qui pointe toujours vers le struct original. Les modifications à l'intérieur de la méthode affectent le struct original.
Exemple :
type Counter struct {
value int
}
Si vous essayez d'incrémenter le compteur en utilisant un récepteur de valeur, cela ne fonctionnera pas comme prévu :
func (c Counter) Inc() {
c.value++ // INCORRECT : modifie la copie, pas l'original
}
c := Counter{value: 5}
c.Inc()
fmt.Println(c.value) // toujours 5
Avec un récepteur de pointeur, cela fonctionne correctement :
func (c *Counter) Inc() {
// notez la syntaxe raccourcie pour (*c).value
c.value++ // CORRECT : modifie l'original via le pointeur
}
c := Counter{value: 5}
c.Inc()
fmt.Println(c.value) // maintenant 6
Même si la méthode reçoit une copie du pointeur, la copie et le pointeur d'origine pointent vers le même struct en mémoire (dans le tas), de sorte que les changements à l'intérieur de la méthode affectent l'original.
Go idiomatique
Les petits structs peuvent utiliser des récepteurs de valeur si la mutation n'est pas nécessaire.
Les grands structs ou tout struct qui doit être modifié doivent utiliser des récepteurs de pointeur.
Si certaines méthodes ont besoin de récepteurs de pointeur, il est courant que toutes les méthodes utilisent des récepteurs de pointeur par souci de cohérence.
Les récepteurs de pointeur sont sans doute l'utilisation la plus courante et la plus pratique des pointeurs en Go. Ils permettent aux méthodes de modifier l'état en toute sécurité sans copies inutiles.
Exercices pour le lecteur
Pour consolider votre compréhension des pointeurs, essayez les exercices suivants :
Écrivez une fonction qui échange deux entiers en utilisant des pointeurs.
Créez un struct représentant un
Rectangleavec une largeur (width) et une hauteur (height). Écrivez des méthodes pour calculer l'aire et pour redimensionner le rectangle par un facteur, en utilisant des récepteurs de pointeur.
Bonus : Pointeurs faibles (Go 1.24+)
Go 1.24 a introduit les références faibles, qui vous permettent de détenir une référence vers une valeur sans empêcher son ramassage par le Garbage Collector. Ceci est utile lorsque vous voulez un cache ou une structure de données auxiliaire sans prolonger la durée de vie des objets.
Que sont les pointeurs faibles ?
Un pointeur faible est un pointeur qui ne compte pas pour maintenir l'objet référencé en vie. Si les seules références à un objet sont faibles, le Garbage Collector peut toujours le libérer.
Les pointeurs faibles sont fournis par le package runtime/weak :
import "runtime/weak"
type Cache struct {
data weak.Map[string, *User]
}
func main() {
c := Cache{data: weak.MakeMap[string, *User]()}
u := &User{"Alice", 30}
c.data.Set("alice", u) // référence faible stockée
}
// ...plus tard
if user, ok := c.data.Get("alice"); ok {
fmt.Println(user.Name) // Alice
} else {
fmt.Println("L'utilisateur a été collecté par le Garbage Collector")
}
Si u n'est plus référencé ailleurs, le Garbage Collector peut le récupérer, même s'il existe dans la weak.Map.
Essentiellement, un pointeur faible peut se transformer en un pointeur nil si l'objet vers lequel il pointe a été collecté. Vous devriez toujours vérifier s'il est nil avant de le déréférencer.
Quand utiliser des pointeurs faibles
Caches : Gardez les objets s'ils sont encore utilisés, mais n'empêchez pas le GC s'ils ne le sont plus.
Éviter les fuites de mémoire : Surtout dans les services à longue durée de vie où les objets temporaires pourraient autrement s'accumuler.
Indexation auxiliaire : Comme mapper des IDs à des objets sans contrôler leur durée de vie.
Les pointeurs faibles sont une fonctionnalité avancée et doivent être utilisés avec discernement. La plupart des programmes Go n'en auront jamais besoin, mais dans certains scénarios, ils peuvent être très utiles.
Résumé et bonnes pratiques
Les pointeurs stockent les adresses des variables et vous permettent de partager et de modifier les données efficacement.
Utilisez
&pour obtenir une adresse,*pour déréférencer et déclarer.Les récepteurs de pointeur permettent aux méthodes de modifier les structs sans copies inutiles.
Soyez prudent avec les pointeurs nil pour éviter les panics.
Les types de référence (slices, maps, channels) partagent déjà les données sous-jacentes.
Les pointeurs faibles (Go 1.24+) offrent une gestion de mémoire avancée pour les caches ou les structures auxiliaires.
Bien qu'ils ne soient pas aussi puissants que dans des langages comme C/C++, les pointeurs Go sont sûrs et faciles à utiliser. Expérimentez avec eux dans de petits programmes, et vous verrez rapidement comment ils peuvent vous aider à écrire du code Go plus efficace et facile à lire.
Solutions des exercices
- Échanger deux entiers en utilisant des pointeurs :
func swap(a, b *int) {
*a, *b = *b, *a
}
x, y := 1, 2
swap(&x, &y)
fmt.Println(x, y) // 2 1
Ici, swap prend deux pointeurs vers des entiers et échange leurs valeurs en les déréférençant. Sans pointeurs, vous n'échangeriez que des copies des entiers, laissant les originaux inchangés.
- Struct Rectangle avec méthodes :
type Rectangle struct {
Width, Height float64
}
func (r *Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
rect := Rectangle{Width: 3, Height: 4}
fmt.Println(rect.Area()) // 12
rect.Scale(2)
fmt.Println(rect.Area()) // 48
Parce que Scale modifie le rectangle, il utilise un récepteur de pointeur. La méthode Area pourrait utiliser soit un récepteur de valeur, soit un récepteur de pointeur puisqu'elle ne modifie pas le struct, mais l'utilisation d'un récepteur de pointeur est cohérente et évite de copier le struct.