Background Image
TECHNOLOGIE

Ce comportement de Golang est inattendu... Ou l'est-il ?

Headshot - Xander Apponi
Xander Apponi
Consultant principal

July 11, 2023 | 6 Lecture minute

J'ai vu cet exemple de Go à plusieurs reprises, mais je n'ai jamais eu l'impression de bien comprendre ce qui se passait. J'ai donc fait quelques dessins pour comprendre. Voici l'exemple Go :

https://go.dev/play/p/tRKXqPzvLOz 

package main 
 
import "fmt" 
 
func appendAndChange(numbers []int) { 
	newNumbers := append(numbers, 42) 
	newNumbers[0] = 66 
	fmt.Println("inside", newNumbers) 
} 
func main() { 
	slice := []int{1, 2, 3} 
	fmt.Println("before", slice) 
	appendAndChange(slice) 
	fmt.Println("after", slice) 
 
	fmt.Println("original slice is intact") 
	fmt.Println("-------") 
 
	slice = append(slice, 5) 
	fmt.Println("before", slice) 
	appendAndChange(slice) 
	fmt.Println("after", slice) 
	fmt.Println("original slice is modified") 
}  
Output:  
before [1 2 3] 
inside [66 2 3 42] 
after [1 2 3] 
original slice is intact 
------- 
before [1 2 3 5] 
inside [66 2 3 5 42] 
after [66 2 3 5] 
original slice is modified  

Alors, pourquoi deux appels à appendAndChange se comportent-ils différemment ??????

Les tranches de Go ont une longueur et une capacité. La longueur d'une tranche est le nombre d'éléments qu'elle contient. La capacité d'une tranche est le nombre d'éléments du tableau sous-jacent, en comptant à partir du premier élément de la tranche. La longueur et la capacité d'une tranche s peuvent être obtenues à l'aide des expressions len(s) et cap(s).

La clé pour comprendre la raison de ce comportement se trouve dans la documentation de append :

La valeur résultante de append est une tranche contenant tous les éléments de la tranche originale plus les valeurs fournies. Si le tableau de sauvegarde de s est trop petit pour contenir toutes les valeurs fournies, un tableau plus grand sera alloué. La tranche renvoyée pointera vers le tableau nouvellement alloué.

Passons en revue chaque étape.

Premièrement, tranche := []int{1, 2, 3}

Une tranche est créée avec len=3 et capacity=3. slice pointe vers un tableau sous-jacent où les données sont effectivement stockées :

Graphic #1 - This Golang Behavior is Unexpected... Or is it?

Next,

appendAndChange(slice)

newNumbers := append(numbers, 42)

newNumbers[0] = 66

où nombres = tranche. Nous essayons d'ajouter 42 à la tranche, mais la tranche n'a qu'une capacité de 3, et le tableau d'appui est trop petit pour contenir toutes les valeurs, donc un tableau plus grand est alloué, et la tranche retournée pointe vers le tableau nouvellement alloué.

Graphic #2 - This Golang Behavior is Unexpected... Or is it?

Note : Pour ce code, la capacité de la tranche retournée semble doubler pour atteindre 6, mais la stratégie de croissance n'est pas documentée.

Nous disposons maintenant d'un nouveau tableau d'appui que nouveauxNombres pointe vers lui. Après avoir retourné la fonction appendAndChange() la fonction newNumbers sort du champ d'application, et notre tranche originale reste inchangée.

Nous appelons maintenant

slice = append(slice, 5)

Graphic #3 - This Golang Behavior is Unexpected... Or is it?

Un nouveau tableau d'appui est créé et la variable slice est mise à jour pour pointer vers lui. L'ancien tableau d'accompagnement n'a plus de pointeurs vers lui et sort de la portée. Cette fois, lorsque appendAndChange est appelée, la fonction interne append() n'a pas besoin de créer un nouveau tableau d'accompagnement, car la capacité est suffisante.

newNumbers := append(numbers, 42)

newNumbers[0] = 66

où nombres = tranche

Graphic #4 - This Golang Behavior is Unexpected... Or is it?

Maintenant, la tranche et les nombres pointent vers le nouveau tableau d'appui, mais la tranche a une longueur de 4 et les nombres de nouveauxNombres a une longueur de 5. Cela conduit à :

nouveauxNuméros = [66, 2, 3, 5, 42]

tranche = [66, 2, 3, 5]

Correct Go

Il est vrai que la résolution de ce problème est très simple et que la réponse se trouve dans la documentation elle-même :

Append renvoie la tranche mise à jour. Il est donc nécessaire de stocker le résultat de append, souvent dans la variable contenant la tranche elle-même :

https://pkg.go.dev/builtin#append 

Cela signifie que nous pouvons simplement renvoyer nouveauxNombres dans appendAndChangeet nous assurer que nous définissons la tranche à cette valeur lorsque nous l'appelons, comme suit :

https://go.dev/play/p/Vkm72dXxpnx 

package main 
 
import "fmt" 
 
func appendAndChange(numbers []int) []int { 
	newNumbers := append(numbers, 42) 
	newNumbers[0] = 66 
	fmt.Println("inside", newNumbers) 
    return newNumbers 
} 
func main() { 
 
	slice := []int{1, 2, 3} 
	fmt.Println("before", slice) 
	slice = appendAndChange(slice) 
	fmt.Println("after", slice) 
 
 
	fmt.Println("original slice is modified") 
	fmt.Println("-------") 
 
	slice = append(slice, 5) 
	fmt.Println("before", slice) 
	slice = appendAndChange(slice) 
	fmt.Println("after", slice) 
	fmt.Println("original slice is modified") 
}  
Output 
before [1 2 3] 
inside [66 2 3 42] 
after [66 2 3 42] 
original slice is modified 
------- 
before [66 2 3 42 5] 
inside [66 2 3 42 5 42] 
after [66 2 3 42 5 42] 
original slice is modified  
This works, and technically is the correct way to use append according to the documentation, but developers should not ha

Cela fonctionne, et c'est techniquement la bonne façon d'utiliser append selon la documentation, mais les développeurs ne devraient pas avoir à lire la documentation de la bibliothèque de base pour rechercher des cas particuliers.

Présentation de Rust

A titre de comparaison, essayons de reproduire ce comportement en Rust, qui dispose de plus de contrôles de sécurité à la compilation.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=010437e4280da7883dbdd41ae55e9327 

#[allow(non_snake_case)] 
fn appendAndChange(mut numbers: Vec<i32>) -> () { 
    numbers.push(24); 
    numbers[0] = 66;  
    println!("inside {:?}", numbers); 
} 
 
fn main() { 
    let mut slice = vec![1, 2, 3];  
    println!("before: {:?}", slice); 
    appendAndChange(slice); 
    println!("after: {:?}", slice); 
     
    println!("original slice is modified"); 
    println!("-------"); 
     
    slice.push(5); 
    println!("before: {:?}", slice);  
    appendAndChange(slice);  
    println!("after: {:?}", slice); 
    println!("original slice is modified"); 
}  
Output: (just the first error...) 
error[E0382]: borrow of moved value: `slice` 
  --> src/main.rs:12:29 
   | 
9  |     let mut slice = vec![1, 2, 3];  
   |         --------- move occurs because `slice` has type `Vec<i32>`, which does not implement the `Copy` trait 
10 |     println!("before: {:?}", slice); 
11 |     appendAndChange(slice); 
   |                     ----- value moved here 
12 |     println!("after: {:?}", slice); 
   |                             ^^^^^ value borrowed here after move 
   | 
note: consider changing this parameter type in function appendAndChange to borrow instead if owning the value isn't necessary  

Les vecs de Rust sont très similaires aux slices de Go. Cependant, au lieu que push soit une fonction, c'est une méthode, qui doit prendre une référence mutable à self, et qui retourne (). https://doc.rust-lang.org/std/vec/struct.Vec.html#method.push Cette différence subtile mais puissante devrait suffire à éviter toute confusion sur la manière d'utiliser la bibliothèque standard.

Cette méthode échoue parce que la valeur de slice est déplacée dans appendAndChange, mais qu'elle est à nouveau référencée après le déplacement. Voici quelques images (très simplifiées) pour expliquer ce qui se passe :

Nous commençons par :

Graphic #5 - This Golang Behavior is Unexpected... Or is it?

Puis appendAndChange arrive et prend toute la valeur. Notre message d'erreur nous le fait savoir :

--------- move occurs because `slice` has type `Vec<i32>`, which does not implement the `Copy` trait appendAndChange(slice) ; | La valeur ----- est déplacée ici

Graphic #6 - This Golang Behavior is Unexpected... Or is it?

Rust ne permet pas au tableau d'avoir 2 pointeurs vers lui, donc déplace les données du tableau vers les nombres, en enlevant le pointeur de la tranche.

Ensuite, des modifications sont apportées aux nombres, de sorte que les nombres sont maintenant [66, 2, 3, 24]et appendAndChange() retourne à la fonction principale.

Jusqu'à présent, tout s'est déroulé avec succès, et si nous n'avions plus de lignes de code, le programme se compilerait et fonctionnerait correctement. Mais nous voulons que la nouvelle valeur des nombres soit réintégrée dans notre fonction principale

println !("after : {:?}", slice) ;

Voici maintenant notre véritable erreur :

println !("after : {:?}", slice) ; | ^^^^^ valeur empruntée ici après le déplacement

Le compilateur nous dit : "Attendez, vous essayez d'imprimer 'slice' ? Vous avez déplacé la valeur loin de slice. Je ne vais pas pouvoir le faire parce que tranche n'a plus de valeur maintenant" Heureusement pour nous, cela donne aussi une suggestion :

note : envisagez de modifier ce type de paramètre dans la fonction `appendAndChange` pour emprunter à la place si posséder la valeur n'est pas nécessaire.  

Rust nous dit que nous essayons de faire quelque chose qui n'est pas sûr, et il nous arrête. Essayons tout de même de le contourner. Ils suggèrent d'emprunter le paramètre pour appendAndChange. Passons donc une référence mutable :

https://play.rust-lang.org/?version=stable&amp;mode=debug&amp;edition=2021&amp;gist=ed5e97a3660782dfc50fb3e4c51cabf4 

#[allow(non_snake_case)] 
fn appendAndChange(numbers: &mut Vec<i32>) -> () { 
    numbers.push(24); 
    numbers[0] = 66;  
    println!("inside {:?}", numbers); 
} 
 
fn main() { 
    let mut slice = vec![1, 2, 3];  
    println!("before: {:?}", slice); 
    appendAndChange(&mut slice); 
    println!("after: {:?}", slice); 
     
    println!("original slice is modified"); 
    println!("-------"); 
     
    slice.push(5); 
    println!("before: {:?}", slice);  
    appendAndChange(&mut slice);  
    println!("after: {:?}", slice); 
    println!("original slice is modified"); 
}  
Output:  
before: [1, 2, 3] 
inside [66, 2, 3, 24] 
after: [66, 2, 3, 24] 
original slice is modified 
------- 
before: [66, 2, 3, 24, 5] 
inside [66, 2, 3, 24, 5, 24] 
after: [66, 2, 3, 24, 5, 24] 
original slice is modified 

Et voilà ! Comme ça, ça fonctionne maintenant et il n'y a pas de cas étranges à craindre. Voici comment cela fonctionne. Lorsque nous passons une référence mutable de la tranche à appendAndChangeil crée un nouveau pointeur :

Graphic #7 - This Golang Behavior is Unexpected... Or is it?

Maintenant, les nombres peuvent modifier les valeurs sous-jacentes du tableau. Lorsque appendAndChange retourne à la fonction main()les nombres sortent de la portée, mais la tranche reste un pointeur vers les nouvelles valeurs.

Conclusion

Fondamentalement, le problème en Go provenait du fait que deux pointeurs avec des attributs différents pointaient vers la même valeur :

Graphic #8 - This Golang Behavior is Unexpected... Or is it?

Le compilateur Rust ne permet pas que cela se produise, ce qui évite de nombreux bogues potentiels au moment de la compilation !

Technologie

Dernières réflexions

Explorez nos articles de blog et laissez-vous inspirer par les leaders d'opinion de nos entreprises.
Tomorrow Technology Today Blog Thumbnail
DONNÉES

Comment savoir si votre plateforme de données est réellement moderne ?

Voyons ce qui définit une plateforme de données moderne et comment en exploiter tout le potentiel pour votre entreprise.