Slices en Go : len, cap et pièges avec append
1. Vue d’ensemble
Slice est le type de données le plus utilisé en Go, mais aussi celui qui cause les bugs les plus difficiles à détecter. La raison est que plusieurs slices peuvent partager un même array sous-jacent. Dans ce cas, append peut écraser les données d’une autre slice sans aucune erreur.
Cet article explique len et cap, comment append décide d’allouer un nouvel array ou d’écrire dans l’ancien, et les techniques pour éviter les erreurs : full slice expression, copy, slices.Clone. Après avoir lu, vous serez capable de prédire le résultat correct des codes contenant append et saurez comment écrire des tests pour déterminer les erreurs d’aliasing.
Les exemples utilisent Go 1.21 et plus, car le package slices a été introduit dans cette version.
2. Qu’est-ce qu’un slice en réalité
Un slice est une petite struct composée de trois champs :
- pointer : pointe vers le premier élément de l’array sous-jacent (backing array).
- len : le nombre d’éléments auquel vous pouvez accéder.
- cap : le nombre d’éléments du pointer jusqu’à la fin de l’array sous-jacent.
Quand vous passez un slice à une fonction ou l’assignez à une autre variable, Go ne copie que cette struct à trois champs. L’array sous-jacent n’est pas copié.
2.1. Observer len et cap
package main
import "fmt"
func main() {
orders := []string{"A01", "A02", "A03", "A04", "A05"}
recent := orders[1:3]
fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4
orders[1:3] récupère les éléments de l’index 1 jusqu’avant l’index 3, donc len est égal à 2. cap est égal à 4 car de la position orders[1] jusqu’à la fin du array, il y a 4 places : A02, A03, A04, A05.
2.2. Deux slices, un array
Puisque recent pointe vers le même array que orders, modifier un élément de ce slice sera immédiatement visible dans l’autre slice :
recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]
Ceci est un comportement intentionnel. Il permet la découpe de slices sans surcoût. Mais c’est aussi la source de tous les pièges des sections suivantes.
3. Comment fonctionne append
append(s, x) fait l’une des deux choses :
- Si
len(s) < cap(s): écrit x dans la positions[len(s)]dans l’array actuel, puis retourne un nouveau slice aveclen + 1. - Si
len(s) == cap(s): alloue un nouvel array plus grand, copie les anciennes données dedans, écrit x, puis retourne un slice pointant vers le nouvel array.
C’est précisément parce que le cas 2 peut arriver que vous devez toujours réassigner le résultat : s = append(s, x).
3.1. Observer cap augmenter graduellement
package main
import "fmt"
func main() {
var ids []int
for i := 0; i < 10; i++ {
ids = append(ids, i)
fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
Pour les petits slices, cap double généralement à chaque fois qu’il est plein. Pour les gros slices, le runtime augmente plus lentement, et le résultat est aussi arrondi selon la size class de l’allocateur.
Note : La formule d’augmentation de cap est un détail d’implémentation du runtime et a changé au fil des versions de Go. N’écrivez pas de code qui dépend d’une valeur cap spécifique après append.
4. Piège 1 : append écrase les données du slice original
C’est l’erreur la plus courante :
package main
import "fmt"
func main() {
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
}
firstTwo: [10 20 99]
prices: [10 20 99 40 50]
firstTwo a len = 2 mais cap = 5. Il reste de la place dans l’array, donc append n’alloue pas un nouvel array mais écrit 99 dans la troisième position de l’array partagé. Le résultat est que prices[2] passe de 30 à 99.
Ce bug est très difficile à trouver. Le code ne panique pas, et la valeur écrasée peut ne se manifester que dans un autre module, bien plus tard.
5. Piège 2 : deux appends du même slice
package main
import "fmt"
func main() {
base := make([]string, 3, 4)
base[0], base[1], base[2] = "pen", "book", "bag"
cartA := append(base, "laptop")
cartB := append(base, "phone")
fmt.Println("cartA:", cartA)
fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]
base a exactement une place libre. La première commande append écrit "laptop" dans cette place. La deuxième commande écrit "phone" dans cette même place. Comme cartA et cartB pointent tous les deux vers le même array, "laptop" a disparu.
Si base avait cap = 3, les deux append alloueraient des arrays séparés et le résultat serait correct comme prévu. Le même code, mais le comportement change selon cap. C’est pourquoi ce bug n’apparaît qu’occasionnellement.
6. Piège 3 : append à l’intérieur d’une fonction ne change pas le slice de l’appelant
package main
import "fmt"
func addItem(items []string, item string) {
items = append(items, item)
}
func main() {
cart := make([]string, 0, 10)
addItem(cart, "pen")
fmt.Println(len(cart), cart)
fmt.Println(cart[:1])
}
0 []
[pen]
La fonction addItem reçoit une copie de la struct slice. append a écrit "pen" dans l’array partagé car il restait de la place. Mais le nouveau len n’a été assigné qu’à la variable locale items. La variable cart dans main a toujours len = 0. Les données sont déjà dans l’array, mais vous ne les voyez que si vous coupez délibérément cart[:1].
La correction consiste à retourner un nouveau slice :
func addItem(items []string, item string) []string {
return append(items, item)
}
// utilisation
cart = addItem(cart, "pen")
C’est aussi pourquoi la bibliothèque standard a conçu append comme une fonction qui retourne une valeur, plutôt que de modifier le slice sur place.
7. Piège 4 : confondre make avec len et make avec cap
package main
import "fmt"
func main() {
names := []string{"An", "Binh", "Chi"}
upper := make([]string, len(names)) // mauvaise intention
for _, n := range names {
upper = append(upper, n+"!")
}
fmt.Println(len(upper), upper)
}
6 [ An! Binh! Chi!]
make([]string, 3) crée un slice avec 3 éléments vides, pas un slice vide avec une capacité de 3. append ajoute après ces 3 chaînes vides.
Vous avez deux façons correctes de corriger. Choisissez l’une ou l’autre, ne les mélangez pas :
// Méthode 1 : len = 0, cap = n, puis append
upper := make([]string, 0, len(names))
for _, n := range names {
upper = append(upper, n+"!")
}
// Méthode 2 : len = n, assigner par index
upper := make([]string, len(names))
for i, n := range names {
upper[i] = n + "!"
}
Les deux allouent exactement une fois, plus rapide que de laisser append augmenter cap plusieurs fois.
8. Comment prévenir
8.1. Full slice expression : limiter cap
La syntaxe s[low:high:max] définit cap = max - low. Quand cap est égal à len, le prochain append force l’allocation d’un nouvel array :
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
firstTwo: [10 20 99]
prices: [10 20 30 40 50]
prices n’est pas affecté. Cette technique est utile quand vous retournez une partie d’un slice interne vers l’extérieur et voulez que l’appelant puisse append librement sans détruire vos données.
8.2. slices.Clone et copy : séparer complètement l’array
Quand vous avez besoin d’une copie complètement indépendante, clonez :
import "slices"
orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)
snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X
Avec Go plus ancien que 1.21, utilisez copy :
snapshot := make([]string, len(orders))
copy(snapshot, orders)
copy retourne le nombre d’éléments copiés, égal à la valeur la plus petite entre len(dst) et len(src). Donc dst doit avoir un len suffisamment grand, pas seulement cap. Une erreur courante est d’écrire copy(make([]string, 0, n), src) et de recevoir un slice vide.
8.3. Piège de rétention mémoire
Découper un petit slice d’un grand array retiendra l’ensemble du grand array en mémoire :
func firstLine(data []byte) []byte {
for i, b := range data {
if b == '\n' {
return slices.Clone(data[:i]) // ne retient pas tout data
}
}
return slices.Clone(data)
}
Si data est un fichier de 100 MB et que vous retournez data[:i] sans cloner, le garbage collector ne pourra pas libérer ces 100 MB tant que le slice résultant est référencé.
9. Écrire des tests pour déterminer les erreurs d’aliasing
Supposons que vous ayez un type Inventory qui retourne une liste de produits. Vous voulez être certain que l’appelant qui append au résultat ne détruira pas les données internes :
// inventory.go
package inventory
type Inventory struct {
items []string
}
func New(items ...string) *Inventory {
return &Inventory{items: items}
}
func (inv *Inventory) Items() []string {
return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory
import (
"slices"
"testing"
)
func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
inv := New("pen", "book")
inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // cap en excès intentionnellement
got := inv.Items()
got = append(got, "laptop")
want := []string{"pen", "book"}
if !slices.Equal(inv.Items(), want) {
t.Fatalf("internal items = %v, want %v", inv.Items(), want)
}
if inv.items[:3][2] != "bag" {
t.Fatalf("backing array écrasé : %v", inv.items[:3])
}
if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
t.Fatalf("got = %v", got)
}
}
Le test crée intentionnellement un slice interne avec cap plus grand que len, car c’est la condition pour que le bug apparaisse. Si vous changez Items() en return inv.items, la commande append(got, "laptop") écrasera "bag" et le test échouera au deuxième assertion.
go test ./...
ok example.com/inventory 0.002s
Note : Les tests d’aliasing doivent toujours créer une situation
len < cap. Si le slice dans le test est créé par un literal comme[]string{"pen", "book"}alorslen == cap,appendalloue toujours un nouvel array, et le test passe même si le code a un bug.
10. Quand vous N’AVEZ PAS besoin de vous inquiéter
Tous les slices n’ont pas besoin d’être clonés. Copier indûment gaspille la mémoire et le CPU. Vous pouvez partager un array en toute sécurité quand :
- Le slice n’est que lu, personne n’y ajoute avec
appendni ne modifie les éléments. - Le slice est une variable locale, pas partagée en dehors de la fonction.
- Vous voulez intentionnellement modifier sur place, par exemple filtrer des éléments en utilisant
s[:0]pour réutiliser l’array :
active := orders[:0]
for _, o := range orders {
if o != "CANCELLED" {
active = append(active, o)
}
}
Cette technique est correcte car active n’écrit jamais au-delà de la position en cours de lecture de orders. Après la boucle, vous ne devez utiliser que active et abandonner orders.
11. Conclusion
Un slice est une struct composée d’un pointer, de len et cap, pointant vers un array qui peut être partagé. append n’alloue un nouvel array que quand len == cap. Dans tous les autres cas, il écrit directement dans l’array partagé. C’est l’origine des quatre pièges rencontrés : écraser le slice original, deux append qui se chevauchent, append dans une fonction qui ne change pas l’appelant, et confondre len avec cap lors de make.
Pour être en sécurité, mémorisez quelques règles :
- Écrivez toujours
s = append(s, ...). - Une fonction qui a besoin d’ajouter des éléments doit retourner un nouveau slice.
- Utilisez
s[low:high:high]quand vous retournez un slice interne vers l’extérieur. - Utilisez
slices.Clonequand vous avez besoin d’une copie indépendante ou voulez libérer un grand array.
Quand vous écrivez des tests, créez la condition len < cap pour que le bug ait une chance de se manifester.