Background Image
TECNOLOGÍA

Este comportamiento de Golang es inesperado... ¿O no lo es?

Headshot - Xander Apponi
Xander Apponi
Consultor principal

July 11, 2023 | 6 Minuto(s) de lectura

He visto este ejemplo de Go unas cuantas veces, pero nunca he tenido la sensación de entender del todo lo que ocurría. Así que hice algunos dibujos para entenderlo. Aquí está el ejemplo 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  

Entonces, ¿por qué dos llamadas a appendAndChange se comportan de manera diferente??????

Las rebanadas de Go tienen longitud y capacidad. La longitud de una porción es el número de elementos que contiene. La capacidad de una porción es el número de elementos del array subyacente, contando desde el primer elemento de la porción. La longitud y la capacidad de una porción s pueden obtenerse mediante las expresiones len(s) y cap(s).

La clave para averiguar por qué vemos este comportamiento se encuentra en la documentación de append:

El valor resultante de append es una porción que contiene todos los elementos de la porción original más los valores proporcionados. Si la matriz de respaldo de s es demasiado pequeña para contener todos los valores proporcionados, se asignará una matriz mayor. La porción devuelta apuntará a la nueva matriz asignada.

Veamos cada paso.

Primero, slice := []int{1, 2, 3}

Se crea una porción con len=3 y capacity=3. La porción apunta a una matriz subyacente donde se almacenan los datos:

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

Siguiente,

appendAndChange(slice)

nuevosNúmeros := append(números, 42)

nuevosNúmeros[0] = 66

donde numbers = slice. Estamos intentando añadir 42 a slice, pero slice sólo tiene capacidad para 3, y el array de respaldo es demasiado pequeño para que quepan todos los valores, por lo que se asigna un array mayor, y el slice devuelto apunta al array recién asignado.

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

Nota: Para este código, la capacidad de la rebanada devuelta parece duplicarse a 6, pero la estrategia de crecimiento no está documentada.

Ahora tenemos un nuevo array de respaldo que nuevosNúmeros apunta. Cuando volvamos de la función appendAndChange() la función nuevosNúmeros sale del ámbito, y nuestra porción original permanece sin cambios.

Ahora llamamos a

slice = append(slice, 5)

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

Se crea una nueva matriz de respaldo y la variable slice se actualiza para apuntar a ella. La antigua matriz de respaldo ya no tiene punteros y sale del ámbito. Esta vez, cuando appendAndChange el método append() no necesita crear otro nuevo array de respaldo, porque hay capacidad suficiente.

nuevosNúmeros := append(números, 42)

nuevosNúmeros[0] = 66

donde números = slice

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

Ahora, slice y numbers apuntan al nuevo array de respaldo, pero slice tiene una longitud de 4 y nuevosNúmeros tiene una longitud de 5. Esto conduce a:

nuevosNúmeros = [66, 2, 3, 5, 42]

slice = [66, 2, 3, 5]

Correcto Ir

Hay que reconocer que solucionar este problema es muy sencillo, y la respuesta está en la propia documentación:

Append devuelve la rebanada actualizada. Por lo tanto, es necesario almacenar el resultado de append, a menudo en la variable que contiene la propia rebanada:

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

Esto significa que podemos simplemente devolver nuevosNúmeros en appendAndChangey luego asegurarnos de que establecemos el slice a ese valor cuando lo llamamos, así:

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

Esto funciona, y técnicamente es la forma correcta de usar append según la documentación, pero los desarrolladores no deberían tener que leer la documentación de la librería base para buscar casos extremos.

Introduciendo Rust

Como comparación, intentemos replicar este comportamiento en Rust, que tiene más comprobaciones de seguridad de compilación.

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  

Los vecs de Rust son muy similares a los slices de Go. Sin embargo, en lugar de ser una función, push es un método, que debe tomar una referencia mutable a self, y devuelve (). https://doc.rust-lang.org/std/vec/struct.Vec.html#method.push Esta sutil pero poderosa diferencia por sí sola debería ser suficiente para evitar confusiones sobre cómo usar la biblioteca estándar.

Esto falla porque el valor de slice se mueve a appendAndChange, pero luego se vuelve a referenciar después del movimiento. Aquí hay algunas imágenes (muy simplificadas) para explicar lo que está pasando:

Empezamos con:

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

Luego appendAndChange llega y TOMA todo el valor. Nuestro mensaje de error nos avisa:

--------- movimiento ocurre porque `slice` tiene el tipo `Vec<i32>`, que no implementa el rasgo `Copy appendAndChange(slice); | ----- valor movido aquí

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

Rust no permite que el array tenga 2 punteros, así que mueve los datos del array a números, quitando el puntero de slice.

Entonces, se hacen modificaciones a numbers, así que numbers es ahora [66, 2, 3, 24]y appendAndChange() vuelve a la función principal.

Hasta ahora, todo se ha ejecutado correctamente, y si no tuviéramos más líneas de código, el programa compilaría y estaría bien. Pero, queremos tener ese nuevo valor numérico de vuelta en nuestro main

println!("después: {:?}", slice);

Ahora este es nuestro error real:

println!("after: {:?}", slice); | ^^^^^ valor prestado aquí después de mover

El compilador está diciendo, "Espera, ¿estás intentando imprimir 'slice'? Has movido el valor fuera de 'slice'. No voy a poder hacer eso porque slice no tiene valor ahora" Por suerte para nosotros, también da una sugerencia:

nota: considera cambiar este tipo de parámetro en la función `appendAndChange` a borrow en su lugar si poseer el valor no es necesario.  

Rust está diciendo que estamos intentando hacer algo que no es seguro, y nos lo impide. Intentemos evitarlo. Sugieren tomar prestado el parámetro para appendAndChange. Así que vamos a pasar una referencia 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 

¡Tada! Así de fácil, ya funciona y no hay casos extraños de los que preocuparse. Así es como funciona. Cuando pasamos una referencia mutable de slice a appendAndChangese crea un nuevo puntero:

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

Ahora, los números pueden hacer cambios a los valores subyacentes en la matriz. Cuando appendAndChange vuelve a la función main()numbers sale del ámbito, pero slice permanece como puntero a los nuevos valores.

Conclusión

Fundamentalmente, el problema en Go resultaba de dos punteros con diferentes atributos apuntando al mismo valor:

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

El compilador de Rust no permitirá que esto ocurra, ¡evitando muchos errores potenciales en tiempo de compilación!

Tecnología

Reflexiones más recientes

Explore las entradas de nuestro blog e inspírese con los líderes de opinión de todas nuestras empresas.
What is the Core of Agile - Preview Image
ÁGIL

El plan de Drucker: De Propietario de Producto a Ejecutivo Eficaz Pt. 3

Cómo preparar a la próxima generación de líderes con las habilidades, experiencias y formación necesarias para dirigir empresas con confianza.