Les tranches (slices) sont flexibles et efficaces, mais leur implémentation produit parfois des résultats inattendus.
Voyons comment elles fonctionnent réellement et comment éviter les problèmes lors de leur utilisation.
Comprendre les tranches
Il y a quelques jours, cette réponse est apparue sur mon flux X :
https://x.com/likely_a_DFS/status/1793399293312966777
Cela m’a amené à me demander si le fonctionnement interne des tranches est réellement bien connu de nombreux développeurs Go, en particulier les débutants. Je me suis dit que cela donnerait une explication intéressante, alors c’est parti, ligne par ligne.
Qu’est-ce qu’une tranche, au juste ?
L’implémentation des tranches est visible dans le fichier runtime/slice.go
:
|
|
Une variante runtime.notInHeapSlice
existe également,
pour des usages limités hors contrôle du ramasse-miettes.
Ces deux types équivalents, qui représentent les tranches à l’exécution, n’ont pas de
méthodes et contiennent simplement trois valeurs :
- un pointeur, du type public
unsafe.Pointer
ou du type interne au runtimenotInHeap
- la longueur
len
de la tranche, c’est à dire le nombre d’éléments (et non d’octets) visibles du tableau - la capacité de la tranche, elle aussi en éléments et non en octets. Elle est toujours supérieure ou égale à
len
.
L’attendu
Nous allons parcourir ce fragment de code, une instruction à la fois,
et voir comment l’allocation mémoire évolue à chaque instruction.
L’explication est tirée à la fois du traçage du code d’exécution et
de la vérification du code assembleur généré par Go 1.22.4 pour les architectures arm64
, amd64
, wasm
, loong64
et ibm390
, pour des raisons de cohérence.
Créer une tranche à partir d’un littéral
sliceA := []int{1, 2, 3}
- construit la tranche manuellement au moment de la compilation :
- alloue un objet :
runtime.newobject(typ)
- où
typ.Size == 24
ettyp.Kind_ == abi.Array
- c’est donc un “tableau” pour 24 octets = 3 entiers de 64 bits,
- initialise une structure
runtime.slice
avec :- le résultat alloué, pour le champ
array unsafe.Pointer
len == 3
cap == 3
- le résultat alloué, pour le champ
- et les 3 entiers littéraux 1, 2, et 3
Ajouter un élément unique à une tranche complète
Notre tranche « sliceA » est complète, ce qui signifie que sa len
est égale à sa cap
.
Nous créons sliceB
en ajoutant un seul int
à sliceA
:
sliceB := append(sliceA, 4)
- agrandit la tranche en invoquant
runtime.growslice
en lui passantoldPtr
, le pointeur de la tranche existantenewLen == 4
, car nous ajoutons une valeur à la trancheoldCap == 3
, est la capacité actuelle de la tranchenum == 1
, qui est le nombre d’éléments ajoutés
- en interne,
growslice
- invoque
runtime.nextslicecap
pour obtenir la capacité dont la tranche ajoutée a besoin, obtenant le double de la capacité existante, car notre tranche est suffisamment petite - fait quelques vérifications et conclut que le bon
newCap
est bien 6 entiers de 64 bits, donc 48 octets - en conséquence, alloue un nouvel espace de 48 octets
- efface la partie de cet espace qui ne sera pas copiée de l’ancienne tranche en utilisant
runtime.memclrNoHeapPointers
- copie les valeurs de l’ancien tableau dans le nouvel espace en utilisant
runtime.memmove
- renvoie une nouvelle instance
runtime.slice
allouée à partir de ce nouvel espace, lenewLen
reçu et lenewCap
ajusté.
- invoque
sliceB
est également pleine etsliceA
est inchangée.
Ajouter un seul élément à une tranche complète - bis
Si nous répétons l’opération pour ajouter un élément différent à sliceA
, le même mécanisme se reproduit :
sliceC := append(sliceA, 5)
- effectue les mêmes opérations, en allouant également un nouvel espace pour le tableau de stockage de la tranche
sliceC
est également pleine etsliceA
est toujours inchangée.
Mettre à jour un élément dans la tranche étendue par append
trancheC[0] = 0
- modifie le premier
int
dans le (nouveau) tableau de stockage desliceC
- comme ce tableau n’est pas partagé, rien ne se reporte sur « sliceA » et « sliceB ».
Ce qui nous donne le premier ensemble de résultats, auxquels presque tout développeur s’attendrait.
|
|
L’inattendu
Pour le deuxième ensemble, les résultats sont un peu différents en raison de la manière dont l’initialisation est effectuée.
Créer une tranche en ajoutant un élément à un littéral de tranche
Cette fois, au lieu d’utiliser un littéral tranche à trois éléments, nous allons initialiser notre tranche en ajoutant un élément à un littéral de tranche à deux éléments.
sliceD := append([]int{1, 2}, 3)
- construit la tranche
[]int{1, 2}
manuellement au moment de la compilation, en suivant les mêmes étapes que pour l’initialisation desliceA
- à ce stade, la tranche est pleine:
cap == len == 2
- l’ajout se produit de la même manière que pour construire
sliceB
etsliceC
, ce qui donne à la tranche[]int{1, 2, 3}
un tableau de sauvegarde de taille double, donc 4 octets, et non seulement trois commesliceA
Ajouter un seul élément à une tranche non complète
Notre tranche sliceD
n’est pas pleine, ce qui signifie que sa len
est strictement inférieure à sa cap
(3 contre 4).
Nous créons sliceE
en ajoutant un seul int
à sliceD
:
sliceE := append(sliceD, 4)
- ici, cependant, le compilateur est suffisamment intelligent pour générer du code vérifiant si la taille du tableau de stockage de
sliceD
est inférieure à 4 avant d’invoquergrowslice
- comme le tableau est en réalité de taille 4, cet appel est omis et le tableau existant est réutilisé, le code définissant uniquement la valeur 4 à la dernière position de ce tableau
- la nouvelle tranche réutilise ce tableau comme stockage
sliceD
etsliceE
tutilisent désormais le même tableau avec le contenu[1 2 3 4]
Ajouter un seul élément à une tranche non complète - bis
Si nous réitérons l’appel pour ajouter un élément différent à sliceD
, le même mécanisme se reproduit :
sliceF := append(sliceD, 5)
- la même vérification du compilateur remarque à nouveau que le tableau de stockage a une longueur de 4,
donc un appel à
growslice
n’est pas nécessaire ici non plus - la nouvelle tranche réutilise ce tableau comme stockage,
tout comme
sliceD
etsliceE
- le code affecte ensuite la valeur 5 à la dernière cellule de ce tableau au lieu de 4,
ce qui signifie que les trois tranches sont sauvegardées avec le tableau
[1 2 3 5]
Mettre à jour un élément dans la tranche étendue par append
sliceF[0] = 0
- comme il ne s’agit que d’une mise à jour en place, le code affecte 0 à la cellule 0 du le tableau supportant
sliceF
, qui contient dès lors[0 2 3 5]
- mais comme ce tableau est également le stockage commun de
sliceD
etsliceE
, elles reçoivent également la même mise à jour.
Et c’est ainsi que nous obtenons ces résultats quelque peu surprenants :
|
|
Prudence est mère de sûreté
La fonction slices.Clip
Si vous ne voulez pas vous soucier de ce qui se passe réellement, et préférez que votre code fasse un tout petit peu plus de travail, il existe des solutions, et l’une d’entre elles est mentionnée par l’auteur du message initial :
https://x.com/likely_a_DFS/status/1793629501911609691
Alors, comment slices.Clip
contourne-t-il ce problème, exactement ?
Comme l’indique sa documentation, Clip supprime la capacité inutilisée de la tranche
.
Dans cet exemple, notre problème vient du fait que nous avons initialisé la tranche sliceD
avec une capacité additionnelle,
permettant ainsi aux appels append()
suivants de réutiliser le tableau de sauvegarde pour sliceD
sans avoir à un allouer un nouveau.
Nous aurions pu éviter cette capacité supplémentaire en construisant la tranche comme nous l’avons fait pour « trancheA ». Mais parfois, les tranches à étendre sont des paramètres, ce qui ne permet pas de contrôler leur allocation préexistante.
Dans un tel cas, on pourrait écrire quelque chose comme
|
|
Avec cet appel slices.Clip
supplémentaire, notre modèle de mémoire change :
Et de la sorte, nous revenons exactement à la même situation que pour « sliceA » et nous obtenons les résultats attendus :
sliceD
, sliceE
et sliceF
ont toutes trois des tableaux de sauvegarde indépendants.
L’“expression de tranche complète”
Aurions-nous pu nous passer de cet appel de fonction supplémentaire,
d’autant que slices.Clip
utilise des génériques ?
slices.Clip
est en fait une petite fonction. Tel qu’elle est implémentée dans Go 1.22.4 :
|
|
Cette syntaxe de tranchage avec deux deux-points est ce que la spécification Go appelle une expression de tranche complète . Elle effectue à la fois le tranchage et la troncature du tableau de support.
Nous aurions donc tout aussi bien pu rédiger nous-mêmes notre tranchage :
|
|
Et c’est tout : pas d’importation supplémentaire, pas d’appel de fonction générique.
Mais comme cette forme complète d’opérateur de découpage semble si peu connue parmi les Gophers, notamment débutants,
l’usage de slices.Clip
peut être meilleur pour l’intelligibilité du code.