Article original : Common Slice Mistakes in Go and How to Avoid Them
Les slices sont l'une des structures de données les plus fondamentales et les plus puissantes de Go. Elles fournissent une interface de type tableau dynamique qui est à la fois flexible et efficace. Cependant, elles peuvent être très délicates lors de l'implémentation. Et si elles ne sont pas implémentées correctement, elles peuvent provoquer des bugs subtils qui seraient très difficiles à traquer.
Vous penserez probablement qu'il s'agit d'un problème avec votre algorithme ou votre logique, passant des heures à déboguer des flux de travail complexes. En revanche, le véritable problème provient d'une simple incompréhension du comportement des slices sous le capot. La partie la plus frustrante ? Votre code pourrait fonctionner parfaitement en développement avec de petits ensembles de données, pour échouer mystérieusement en production avec des données plus volumineuses ou sous un accès concurrent.
Dans cet article, nous explorerons sept erreurs courantes que les développeurs commettent lorsqu'ils travaillent avec des slices en Go et fournirons des solutions pratiques pour les prévenir.
Table des matières
Passer des slices par valeur et s'attendre à des changements structurels
Utilisation incorrecte des variables de boucle avec des slices de pointeurs
Passer des slices par valeur et s'attendre à des changements structurels
Une incompréhension courante consiste à s'attendre à ce que les modifications de la structure d'une slice (changements de longueur/capacité) dans une fonction affectent la slice originale à l'extérieur de la fonction.
Bien que les éléments d'une slice puissent être modifiés via les paramètres de fonction (car les slices contiennent un pointeur vers les données sous-jacentes), l'en-tête de la slice lui-même (contenant la longueur et la capacité) est passé par valeur.
Voici un exemple de cette idée fausse :
func appendToSlice(s []int) {
s = append(s, 4) // Ceci crée un nouvel en-tête de slice
fmt.Println("À l'intérieur de la fonction :", s) // [1, 2, 3, 4]
}
func main() {
slice := []int{1, 2, 3}
appendToSlice(slice)
fmt.Println("Après l'appel de la fonction :", slice) // Toujours [1, 2, 3]
}
Dans ce code, l'opération append à l'intérieur de la fonction crée un nouvel en-tête de slice, mais ce changement n'affecte pas la slice originale dans la fonction appelante.
Comment le prévenir
Pour modifier la structure d'une slice depuis l'intérieur d'une fonction, retournez la slice modifiée ou utilisez un pointeur vers la slice :
// Méthode 1 : Retourner la slice modifiée
func appendToSlice(s []int) []int {
return append(s, 4)
}
// Méthode 2 : Utiliser un pointeur vers la slice
func appendToSlicePtr(s *[]int) {
*s = append(*s, 4)
}
func main() {
// Utilisation de la méthode 1
slice1 := []int{1, 2, 3}
slice1 = appendToSlice(slice1)
fmt.Println("Méthode 1 :", slice1) // [1, 2, 3, 4]
// Utilisation de la méthode 2
slice2 := []int{1, 2, 3}
appendToSlicePtr(&slice2)
fmt.Println("Méthode 2 :", slice2) // [1, 2, 3, 4]
}
Les deux approches garantissent que les modifications de la structure de la slice sont visibles pour l'appelant.
Partage d'en-tête de slice et mutations involontaires
Une autre erreur courante est de ne pas réaliser que les slices créées à partir du même tableau sous-jacent partagent les données. Ne pas le savoir peut provoquer des mutations inattendues lorsque vous modifiez une slice.
Les slices en Go sont des types référence qui contiennent un pointeur vers le tableau sous-jacent, ainsi que des informations sur la longueur et la capacité. Lorsque vous créez une slice à partir d'une autre slice, elles pointent toutes deux vers les mêmes données sous-jacentes.
Voici un exemple de la façon dont cela peut conduire à un comportement surprenant :
func main() {
original := []int{1, 2, 3, 4, 5}
subset := original[1:4] // Crée [2, 3, 4]
fmt.Println("Original :", original) // [1, 2, 3, 4, 5]
fmt.Println("Subset :", subset) // [2, 3, 4]
subset[0] = 99 // Modifier le premier élément de subset
fmt.Println("Original après modification :", original) // [1, 99, 3, 4, 5]
fmt.Println("Subset après modification :", subset) // [99, 3, 4]
}
Dans ce code, la modification de la slice subset modifie également la slice original car elles partagent le même tableau sous-jacent.
Comment le prévenir
Pour éviter les mutations involontaires, utilisez la fonction copy() pour créer des slices indépendantes :
func main() {
original := []int{1, 2, 3, 4, 5}
// Créer une copie indépendante
subset := make([]int, 3)
copy(subset, original[1:4])
fmt.Println("Original :", original) // [1, 2, 3, 4, 5]
fmt.Println("Subset :", subset) // [2, 3, 4]
subset[0] = 99
fmt.Println("Original après modification :", original) // [1, 2, 3, 4, 5] - inchangé
fmt.Println("Subset après modification :", subset) // [99, 3, 4]
}
La fonction copy() garantit que les données sont dupliquées plutôt que partagées, évitant ainsi les effets secondaires indésirables.
Fuites de mémoire avec des références à de grandes slices
Conserver des références à de petites slices dérivées de grandes slices est considéré comme une erreur mineure mais sérieuse. En effet, cela empêche le garbage collector de libérer le grand tableau sous-jacent, ce qui entraîne des fuites de mémoire.
Lorsque vous créez une slice à partir d'une slice plus grande, la nouvelle slice référence toujours l'intégralité du tableau d'origine, même si elle n'en utilise qu'une petite partie.
Voici un exemple de la façon dont cette fuite de mémoire peut se produire :
func processLargeData() []byte {
largeData := make([]byte, 1<<30) // Allouer 1 Go
// ... remplir largeData avec des informations importantes ...
// Extraire juste les 100 premiers octets
return largeData[:100] // Fuite de mémoire : l'intégralité du Go reste en mémoire
}
func main() {
result := processLargeData()
// Même si result ne fait que 100 octets, 1 Go reste alloué
fmt.Printf("Longueur du résultat : %d\n", len(result))
}
Dans ce code, même si nous n'avons besoin que des 100 premiers octets, l'intégralité du tableau de 1 Go reste en mémoire car notre slice retournée le référence toujours.
Comment le prévenir
Pour éviter les fuites de mémoire, copiez les données nécessaires dans une nouvelle slice lorsque vous travaillez avec de grands ensembles de données :
func processLargeData() []byte {
largeData := make([]byte, 1<<30) // Allouer 1 Go
// ... remplir largeData avec des informations importantes ...
// Créer une copie indépendante des données nécessaires
result := make([]byte, 100)
copy(result, largeData[:100])
return result // largeData peut maintenant être collecté par le garbage collector
}
func main() {
result := processLargeData()
// Seuls 100 octets restent en mémoire
fmt.Printf("Longueur du résultat : %d\n", len(result))
}
En copiant les données dans une nouvelle slice, vous permettez au garbage collector de libérer le grand tableau lorsqu'il n'est plus nécessaire.
Utilisation incorrecte des variables de boucle avec des slices de pointeurs
Il existe des scénarios ou des instances où vous créez ce qui ressemble à une boucle parfaitement raisonnable pour collecter des pointeurs, mais d'une manière ou d'une autre, tous vos pointeurs finissent par pointer vers la même valeur. C'est parce que Go réutilise la même variable de boucle à travers toutes les itérations, donc prendre son adresse aboutit toujours au même emplacement mémoire.
Voici un exemple de la façon dont cette erreur se manifeste :
func main() {
var ptrs []*int
for i := 0; i < 3; i++ {
ptrs = append(ptrs, &i) // Erreur : tous les pointeurs référencent la même variable
}
// Afficher les valeurs
for j, ptr := range ptrs {
fmt.Printf("ptrs[%d] = %d\n", j, *ptr)
}
// Sortie : Tous les pointeurs affichent la même valeur (3)
}
Dans ce code, tous les pointeurs de la slice pointent vers la même variable de boucle i, qui a la valeur finale de 3 une fois la boucle terminée.
Comment le prévenir
Pour résoudre ce problème, créez une nouvelle variable à chaque itération ou utilisez l'indexation de slice :
func main() {
// Méthode 1 : Créer une nouvelle variable à chaque itération
var ptrs1 []*int
for i := 0; i < 3; i++ {
j := i // Créer une nouvelle variable
ptrs1 = append(ptrs1, &j)
}
// Méthode 2 : Utiliser une slice et l'indexer
values := []int{0, 1, 2}
var ptrs2 []*int
for i := range values {
ptrs2 = append(ptrs2, &values[i])
}
// Méthode 3 : Utiliser make et l'assignation directe
values2 := make([]int, 3)
var ptrs3 []*int
for i := 0; i < 3; i++ {
values2[i] = i
ptrs3 = append(ptrs3, &values2[i])
}
// Toutes les méthodes fonctionnent maintenant correctement
fmt.Println("Méthode 1 :", *ptrs1[0], *ptrs1[1], *ptrs1[2]) // 0 1 2
fmt.Println("Méthode 2 :", *ptrs2[0], *ptrs2[1], *ptrs2[2]) // 0 1 2
fmt.Println("Méthode 3 :", *ptrs3[0], *ptrs3[1], *ptrs3[2]) // 0 1 2
}
Ces approches garantissent que chaque pointeur référence un emplacement mémoire unique avec la valeur correcte.
Modifier une slice pendant une itération range
Modifier une slice tout en l'parcourant avec une boucle range peut entraîner des problèmes tels que des éléments ignorés, des boucles infinies ou le traitement de mauvaises données, selon le type de modification.
Lorsque vous utilisez range sur une slice, Go évalue la longueur de la slice au début de la boucle. Cependant, si vous modifiez la slice pendant l'itération, la longueur réelle de la slice peut changer alors que la boucle continue sur la base de la longueur d'origine.
Voici un exemple de la façon dont cela peut causer des problèmes :
func removeEvenNumbers() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8}
fmt.Println("Original :", numbers)
// Dangereux : modification de la slice pendant l'itération range
for i, num := range numbers {
if num%2 == 0 {
// Supprimer le nombre pair par découpage (slicing)
numbers = append(numbers[:i], numbers[i+1:]...)
}
}
fmt.Println("Après suppression :", numbers) // Résultat inattendu !
}
func main() {
removeEvenNumbers()
// La sortie pourrait être : [1 3 5 7 8] - remarquez que le 8 n'a pas été supprimé !
}
Dans ce code, la suppression d'éléments pendant l'itération provoque un décalage des indices, ce qui conduit à l'omission de certains éléments. Le nombre 8 reste car lorsque 6 est supprimé, 8 se déplace vers une position qui a déjà été traitée par la boucle.
Comment le prévenir
Pour modifier en toute sécurité des slices pendant l'itération, itérez dans l'ordre inverse, utilisez une slice de résultat séparée ou collectez d'abord les indices :
// Méthode 1 : Itérer en sens inverse pour éviter les problèmes de décalage d'index
func removeEvenNumbersReverse() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8}
fmt.Println("Original :", numbers)
for i := len(numbers) - 1; i >= 0; i-- {
if numbers[i]%2 == 0 {
numbers = append(numbers[:i], numbers[i+1:]...)
}
}
fmt.Println("Après suppression :", numbers) // [1, 3, 5, 7]
}
// Méthode 2 : Construire une nouvelle slice avec les éléments souhaités
func filterOddNumbers() []int {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8}
var result []int
for _, num := range numbers {
if num%2 != 0 { // Garder les nombres impairs
result = append(result, num)
}
}
return result // [1, 3, 5, 7]
}
// Méthode 3 : Collecter d'abord les indices, puis modifier
func removeEvenNumbersByIndex() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8}
var toRemove []int
// Première passe : collecter les indices des nombres pairs
for i, num := range numbers {
if num%2 == 0 {
toRemove = append(toRemove, i)
}
}
// Seconde passe : supprimer dans l'ordre inverse
for i := len(toRemove) - 1; i >= 0; i-- {
idx := toRemove[i]
numbers = append(numbers[:idx], numbers[idx+1:]...)
}
fmt.Println("Résultat :", numbers) // [1, 3, 5, 7]
}
Ces approches garantissent que vos modifications n'interfèrent pas avec le processus d'itération, vous donnant des résultats prévisibles et corrects.
Confusion entre Slice Nil et Slice Vide
Une autre source de confusion est de ne pas comprendre la différence entre les slices nil et les slices vides, ce qui peut entraîner un comportement incohérent dans vos applications.
Une slice nil n'a pas de tableau sous-jacent, tandis qu'une slice vide a un tableau sous-jacent mais ne contient aucun élément.
Voici un exemple qui démontre les différences :
func main() {
var nilSlice []int
emptySlice := []int{}
emptySlice2 := make([]int, 0)
fmt.Printf("nilSlice == nil : %t\n", nilSlice == nil) // true
fmt.Printf("emptySlice == nil : %t\n", emptySlice == nil) // false
fmt.Printf("emptySlice2 == nil : %t\n", emptySlice2 == nil) // false
// Le marshaling JSON se comporte différemment
nilJSON, _ := json.Marshal(nilSlice)
emptyJSON, _ := json.Marshal(emptySlice)
fmt.Printf("JSON de slice Nil : %s\n", nilJSON) // null
fmt.Printf("JSON de slice vide : %s\n", emptyJSON) // []
}
Cette différence peut causer des problèmes lors de l'utilisation d'API JSON ou lorsque des fonctions attendent des états de slice spécifiques.
Comment le prévenir
Soyez explicite sur vos intentions et gérez les deux cas de manière cohérente. Une bonne pratique consisterait à vérifier la longueur au lieu de nil lorsque cela est important :
func processSlice(s []int) {
if len(s) == 0 { // Fonctionne pour les slices nil et vides
fmt.Println("La slice est vide")
return
}
fmt.Printf("Traitement de %d éléments\n", len(s))
}
// Initialiser les slices nil si nécessaire
func ensureSliceInitialized(s []int) []int {
if s == nil {
return make([]int, 0) // ou []int{} si vous préférez une slice vide non-nil
}
return s
}
func main() {
var nilSlice []int
emptySlice := []int{}
processSlice(nilSlice) // Fonctionne de manière cohérente
processSlice(emptySlice) // Fonctionne de manière cohérente
nilSlice = ensureSliceInitialized(nilSlice)
fmt.Printf("Après initialisation : %t\n", nilSlice == nil) // false
}
Cette approche garantit un comportement cohérent, que vous travailliez avec des slices nil ou vides.
Limites de slice et erreurs de panique
La dernière erreur courante est de ne pas valider les limites de la slice avant d'accéder aux éléments. Lorsque vous ne validez pas les limites d'une slice, vous la rendez sujette à des paniques à l'exécution qui peuvent faire planter votre application.
Go ne fournit pas de vérification automatique des limites pour les opérations sur les slices, il est donc de votre responsabilité de vous assurer que les indices se situent dans des plages valides.
Voici un exemple d'opérations de slice dangereuses :
func dangerousSliceOperations(s []int, index int, start int, end int) {
// Dangereux : peut paniquer si l'index est hors limites
value := s[index]
fmt.Printf("Valeur à l'index %d : %d\n", index, value)
// Également dangereux : peut paniquer si les limites sont invalides
subset := s[start:end]
fmt.Printf("Sous-ensemble [%d:%d] : %v\n", start, end, subset)
}
func main() {
slice := []int{1, 2, 3, 4, 5}
// Ceux-ci provoqueront des paniques
// dangerousSliceOperations(slice, 10, 2, 8) // index hors limites
// dangerousSliceOperations(slice, 0, -1, 3) // index négatif
}
Ces opérations provoqueront des paniques à l'exécution lorsque les limites sont invalides, ce qui pourrait faire planter votre application.
Comment le prévenir
Pour éviter cela, vous devez toujours valider les limites avant d'accéder aux éléments d'une slice :
// Accès sécurisé aux éléments avec gestion des erreurs
func safeGetElement(s []int, index int) (int, error) {
if index < 0 || index >= len(s) {
return 0, fmt.Errorf("index %d hors limites pour une slice de longueur %d", index, len(s))
}
return s[index], nil
}
// Opérations de slice sécurisées avec gestion des erreurs
func safeGetSubslice(s []int, start, end int) ([]int, error) {
if start < 0 || end > len(s) || start > end {
return nil, fmt.Errorf("limites de slice invalides [%d:%d] pour une slice de longueur %d", start, end, len(s))
}
return s[start:end], nil
}
// Aide à la vérification des limites qui restreint les valeurs
func clampedSlice(s []int, start, end int) []int {
if start < 0 {
start = 0
}
if end > len(s) {
end = len(s)
}
if start > end {
start = end
}
return s[start:end]
}
func main() {
slice := []int{1, 2, 3, 4, 5}
// Accès sécurisé avec gestion des erreurs
if value, err := safeGetElement(slice, 2); err == nil {
fmt.Printf("Élément à l'index 2 : %d\n", value)
}
if subset, err := safeGetSubslice(slice, 1, 4); err == nil {
fmt.Printf("Sous-ensemble [1:4] : %v\n", subset)
}
// Accès avec limites restreintes (ne panique jamais)
clamped := clampedSlice(slice, -1, 10)
fmt.Printf("Slice restreinte : %v\n", clamped) // [1, 2, 3, 4, 5]
}
Ces approches offrent des alternatives sûres qui soit gèrent les erreurs avec élégance, soit garantissent que les opérations ne dépassent jamais les limites valides.
Conclusion
Dans cet article, nous avons examiné sept problèmes fréquents qui peuvent survenir lors de l'utilisation des slices en Go. Ces problèmes découlent souvent du comportement subtil de l'implémentation des slices de Go, en particulier autour du partage de mémoire, de la distinction entre les en-têtes de slice et les tableaux sous-jacent, et de la sémantique de référence des slices.
En comprenant ces pièges et en mettant en œuvre les stratégies de prévention dont nous avons discuté, vous pouvez écrire des applications Go plus robustes et efficaces. N'oubliez pas de toujours prendre en compte la capacité par rapport à la longueur de la slice, d'être attentif aux données sous-jacentes partagées, de valider les limites avant d'accéder aux éléments et de comprendre les implications du passage de slices aux fonctions.
Maîtriser ces concepts vous aidera à exploiter toute la puissance des slices de Go tout en évitant les pièges courants qui peuvent mener à des bugs et des problèmes de performance dans vos applications.
N'oubliez pas de partager.