Comprendre les tranches

Des tranches partageant un même tableau de stockage
Crédit image: fgm@osinet.fr

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 :

Une conséquence inattendue de la sémantique de append() en Go 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 :

15
16
17
18
19
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

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 runtime notInHeap
  • 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)
    • typ.Size == 24 et typ.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
  • et les 3 entiers littéraux 1, 2, et 3

sliceA avec len et cap tous deux égaux à 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 passant
    • oldPtr, le pointeur de la tranche existante
    • newLen == 4, car nous ajoutons une valeur à la tranche
    • oldCap == 3, est la capacité actuelle de la tranche
    • num == 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, le newLen reçu et le newCap ajusté.
  • sliceB est également pleine et sliceA est inchangée.

l’ajout d’un élément alloue un nouveau tableau de quatre éléments comme stockage pour sliceB

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 et sliceA est toujours inchangée.

l’ajout d’un élément alloue un nouveau de quatre éléments comme stockage pour sliceC

Mettre à jour un élément dans la tranche étendue par append

trancheC[0] = 0

  • modifie le premier int dans le (nouveau) tableau de stockage de sliceC
  • comme ce tableau n’est pas partagé, rien ne se reporte sur « sliceA » et « sliceB ».

l’écrasement de l’élément 0 dans sliceC ne modifie pas sliceA et sliceB

Ce qui nous donne le premier ensemble de résultats, auxquels presque tout développeur s’attendrait.

1
2
3
fmt.Println(sliceA) // imprime [1 2 3]
fmt.Println(sliceB) // imprime [1 2 3 4]
fmt.Println(sliceC) // imprime [0 2 3 5]

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 de sliceA
  • à ce stade, la tranche est pleine: cap == len == 2
  • l’ajout se produit de la même manière que pour construire sliceB et sliceC, ce qui donne à la tranche []int{1, 2, 3} un tableau de sauvegarde de taille double, donc 4 octets, et non seulement trois comme sliceA

l’ajout d’un élément à une tranche de deux éléments double la taille de son tableau de support à quatre, laissant un emplacement inutilisé dans sliceD

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’invoquer growslice
  • 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 et sliceE tutilisent désormais le même tableau avec le contenu [1 2 3 4]

l’ajout à sliceD utilise l’emplacement restant dans le tableau de stockage de sliceD, sans allouer de nouveau tableau de support pour sliceE

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 et sliceE
  • 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]

l’ajout à sliceD utilise l’emplacement restant, sans allouer non plus de nouveau tableau de support pour sliceF

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 et sliceE, elles reçoivent également la même mise à jour.

l’écrasement de l’élément 0 dans sliceF modifie le tableau de sauvegarde partagé avec sliceD et sliceE

Et c’est ainsi que nous obtenons ces résultats quelque peu surprenants :

1
2
3
fmt.Println(sliceD) // imprime [0 2 3]
fmt.Println(sliceE) // imprime [0 2 3 5]
fmt.Println(sliceF) // imprime [0 2 3 5]

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 :

l’utilisation de slices.Clip supprime la capacité inutilisée dans les tranches 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.

ajouter un élément à une tranche littérale à deux éléments étend sa capacité à quatre au lieu de trois

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
  "fmt"
	"slices"
)

func main() {
  sliceD := append([]int{1, 2}, 3)
	sliceD = slices.Clip(sliceD)
	sliceE := append(sliceD, 4)
	sliceF := append(sliceD, 5)
	sliceF[0] = 0

fmt.Println(sliceD) // imprime [1 2 3]
fmt.Println(sliceE) // imprime [1 2 3 4]
fmt.Println(sliceF) // imprime [0 2 3 5]
}

Avec cet appel slices.Clip supplémentaire, notre modèle de mémoire change :

ajouter un élément à une tranche de deux éléments double sa capacité, mais appeler slices.Clip le tronque à la longueur trois

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 :

1
2
3
4
// Clip removes unused capacity from the slice, returning s[:len(s):len(s)].
func Clip[S ~[]E, E any](s S) S {
	return s[:len(s):len(s)]
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
)

func main() {
sliceD := append([]int{1, 2}, 3)
	sliceD = sliceD[:len(sliceD):len(sliceD)] // Full slice expression
	sliceE := append(sliceD, 4)
	sliceF := append(sliceD, 5)
	sliceF[0] = 0

  fmt.Println(sliceD) // imprime [1 2 3]
  fmt.Println(sliceE) // imprime [1 2 3 4]
  fmt.Println(sliceF) // imprime [0 2 3 5]
}

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.