Blog

Arrays y slices en Go: len, cap y cómo funciona append

Un slice de Go es una pequeña ventana sobre un array que guarda los valores reales. Cuando ves la ventana, len, cap, append y el bug de dos slices que se cambian entre sí dejan de ser un misterio.

Los slices son el tipo de Go que más vas a usar. También son el que más sorprende, porque un slice parece una lista y casi siempre se comporta como una. Pero no lo es.

Este post empieza con los arrays, porque un slice está construido sobre uno. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.

Arrays: tamaño fijo, y se copian completos

Un array tiene una longitud que forma parte de su tipo. [3]int y [4]int son tipos distintos, y ninguno puede crecer.

package main

import "fmt"

func main() {
	a := [3]int{1, 2, 3}
	b := a // copies all three values
	b[0] = 99
	fmt.Println(a, b)
}

Imprime:

[1 2 3] [99 2 3]

Asignar un array copia cada elemento. b es un conjunto nuevo de tres cajas, así que cambiarlo no toca a.

Eso hace que los arrays sean predecibles. También hace que sean raros en el código de todos los días, porque casi nunca sabes el tamaño exacto de antemano. Lo que usas en su lugar es un slice.

Un slice es una ventana sobre un array

package main

import "fmt"

func main() {
	shelf := [6]string{"a", "b", "c", "d", "e", "f"}
	s := shelf[1:4]
	fmt.Println(s, len(s), cap(s))

	s[0] = "B"
	fmt.Println(shelf)
}

Imprime:

[b c d] 3 5
[a B c d e f]

shelf[1:4] no copia nada. Crea un slice que mira los elementos 1, 2 y 3 del array que ya existe. Escribir s[0] = "B" cambió shelf, porque hay un solo conjunto de cajas.

Explicado como si tuvieras diez años

Imagina un estante largo de cajas numeradas. Ese es el array. Ahí es donde están las cosas de verdad.

Un slice es una ventana de cartón que pones delante del estante. La ventana tiene tres cosas escritas:

  • dónde empieza en el estante
  • cuántas cajas muestra. Eso es len.
  • cuántas cajas hay desde donde empieza hasta el final del estante. Eso es cap.

Si metes la mano por la ventana y cambias un juguete, el juguete del estante cambia. Cualquier otra persona que tenga una ventana sobre las mismas cajas también ve el juguete nuevo.

La versión precisa

Un valor slice son tres palabras: un puntero a un elemento de un array subyacente, una longitud y una capacidad. Copiar un slice, o pasarlo a una función, copia esas tres palabras. Nunca copia los elementos.

len(s) es cuántos elementos puedes indexar. cap(s) es cuántos elementos existen desde el inicio del slice hasta el final del array subyacente. En el ejemplo, s empieza en el elemento 1 de un array de 6 elementos, así que su capacidad es 5.

Dónde falla la analogía: una ventana de verdad no puede alargar el estante. append puede hacer algo parecido, y la siguiente sección muestra cómo.

append: el mismo estante si hay espacio, un estante nuevo si no

append agrega valores a un slice, y que reutilice el array que ya existe o reserve uno nuevo decide si tu programa tiene un bug. Primero míralo, y después lee las reglas.

array A 10 20 30 40 array viejo: ya nada apunta aquí array B 1020 3040 50 s: len 3, cap 4, apunta al array A s: len 4, cap 4, apunta al array A s: len 5, cap 8, apunta al array B s := []int{10, 20, 30}, con espacio para 4 s = append(s, 40): hay espacio, se usa el mismo array s = append(s, 50): sin espacio, Go crea un array más grande se copian los valores, se agrega 50 y s pasa a B

append sobre un slice con longitud 3 y capacidad 4. El primer append cabe en la caja libre, así que se usa el mismo array. El segundo no encuentra espacio: Go reserva un array más grande, copia los valores, agrega 50, y el slice ahora apunta al array nuevo. Ya nada apunta al array viejo, así que el recolector de basura lo va a liberar.

Estos son los mismos pasos en palabras, por si la animación no se reproduce:

  1. s guarda tres valores en un array con espacio para cuatro.
  2. append(s, 40) encuentra una caja libre. Pone 40 ahí y devuelve un slice con longitud 4 sobre el mismo array.
  3. append(s, 50) no encuentra ninguna caja libre. Reserva un array nuevo y más grande.
  4. Copia los cuatro valores, agrega 50 y devuelve un slice sobre el array nuevo. El array viejo queda atrás.

Por eso siempre escribes s = append(s, x). append puede devolverte un slice sobre otro array, y si no guardas el resultado, te quedas con la ventana vieja.

¿Cuánto más grande?

La especificación de Go no lo dice. Es un detalle del runtime, así que es mejor observarlo que memorizarlo:

package main

import "fmt"

func main() {
	var s []int
	prev := -1
	for i := range 2000 {
		s = append(s, i)
		if cap(s) != prev {
			fmt.Printf("len %4d  cap %4d\n", len(s), cap(s))
			prev = cap(s)
		}
	}
}

Imprime:

len    1  cap    4
len    5  cap    8
len    9  cap   16
len   17  cap   32
len   33  cap   64
len   65  cap  128
len  129  cap  256
len  257  cap  512
len  513  cap  848
len  849  cap 1280
len 1281  cap 1792
len 1793  cap 2560

Vale la pena notar dos cosas. El primer append ya reserva espacio para cuatro, y después los slices pequeños se duplican. Pasados unos cientos de elementos, el crecimiento se frena, para que un slice enorme no desperdicie cantidades enormes de memoria. Los números exactos pueden cambiar entre versiones de Go. Lo que no cambia es la regla: solo se reserva un array nuevo cuando el viejo está lleno.

Si sabes más o menos cuántos elementos vienen, dilo desde el principio y te ahorras volver a crecer:

package main

import "fmt"

func main() {
	ids := make([]int, 0, 1000)
	for i := range 1000 {
		ids = append(ids, i)
	}
	fmt.Println(len(ids), cap(ids))
}

Imprime:

1000 1000

make([]int, 0, 1000) da longitud 0 y capacidad 1000. Los mil appends caben, y no se copia nada.

El bug: dos slices que se cambian entre sí

Junta el array compartido con la regla de «el mismo estante si hay espacio», y obtienes el bug de slices más común en Go:

package main

import "fmt"

func main() {
	base := make([]int, 3, 4)
	a := append(base, 1)
	b := append(base, 2)
	fmt.Println(a, b)
}

Imprime:

[0 0 0 2] [0 0 0 2]

Probablemente esperabas [0 0 0 1] [0 0 0 2]. Esto es lo que pasó. base tiene una caja libre. El primer append escribió 1 en ella. El segundo append también tenía espacio, así que escribió 2 en la misma caja. a y b son dos ventanas sobre un solo array, y gana la última escritura.

Nada falló, y nada te avisó. Por eso este bug sobrevive a la revisión de código.

La solución: limitar la capacidad

Una expresión de slice completa, s[low:high:max], también fija la capacidad. Sin espacio libre, append tiene que reservar uno nuevo:

package main

import "fmt"

func main() {
	base := make([]int, 3, 4)
	a := append(base[:3:3], 1)
	b := append(base[:3:3], 2)
	fmt.Println(a, b)
}

Imprime:

[0 0 0 1] [0 0 0 2]

base[:3:3] significa «los primeros tres elementos, con capacidad 3». Ahora cada append encuentra la ventana llena, así que cada uno recibe su propio array nuevo.

Cuando solo quieres una copia independiente, slices.Clone lo dice de forma más directa. Vuelve a aparecer más abajo.

Pasar slices a funciones

Pasar un slice a una función de Go copia el encabezado del slice, de tres palabras, y esa copia apunta al mismo array:

package main

import "fmt"

func setFirst(s []int) {
	s[0] = 100
}

func addOne(s []int) {
	s = append(s, 1)
}

func main() {
	nums := []int{1, 2, 3}
	setFirst(nums)
	addOne(nums)
	fmt.Println(nums)
}

Imprime:

[100 2 3]

setFirst cambió el array compartido, así que quien llama ve 100. addOne hizo append, pero solo a su propia copia del encabezado. El nums de quien llama sigue teniendo longitud 3, así que el elemento extra nunca aparece.

Una función que necesita hacer crecer un slice devuelve el nuevo, igual que append: nums = addOne(nums).

Slices nil y slices vacíos

Un slice nil y un slice vacío tienen longitud 0, pero no son el mismo valor:

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var nilSlice []int
	empty := []int{}
	fmt.Println(nilSlice == nil, empty == nil)
	fmt.Println(len(nilSlice), len(empty))

	a, _ := json.Marshal(nilSlice)
	b, _ := json.Marshal(empty)
	fmt.Println(string(a), string(b))
}

Imprime:

true false
0 0
null []

Los dos tienen longitud 0. Puedes recorrer los dos con range y hacer append a los dos. Un slice nil es un slice vacío perfectamente válido, así que var s []int es la forma normal de empezar uno.

La diferencia se nota en los bordes. Un slice nil se codifica en JSON como null, y uno vacío como []. Eso importa cuando construyas una API REST más adelante en esta serie. Un cliente que espera un array puede fallar con null.

Pasarse del final

Leer un slice más allá de su longitud es un panic en tiempo de ejecución en Go, no una lectura silenciosa de la memoria que venga después:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	fmt.Println(s[:cap(s)])
	fmt.Println(s[3])
}

Imprime la primera línea y se detiene:

[1 2 3]
panic: runtime error: index out of range [3] with length 3

El indexado se verifica contra len, y el rebanado contra cap. Go nunca lee en silencio la memoria que hay después del final. Detiene el programa con un panic, que vas a conocer bien en la parte sobre errores.

El paquete slices

Desde Go 1.21 la biblioteca estándar tiene un paquete slices para las tareas para las que antes escribías bucles:

package main

import (
	"fmt"
	"slices"
)

func main() {
	scores := []int{42, 7, 19, 7}
	clone := slices.Clone(scores)
	slices.Sort(clone)

	fmt.Println(scores, clone)
	fmt.Println(slices.Contains(scores, 19), slices.Index(scores, 7))
	fmt.Println(slices.Compact(clone))
}

Imprime:

[42 7 19 7] [7 7 19 42]
true 1
[7 19 42]

slices.Clone crea un slice sobre un array nuevo, así que ordenar el clon dejó scores intacto. slices.Compact elimina los duplicados consecutivos, y por eso se usa después de Sort.

Qué recordar

  • Un array tiene un tamaño fijo que forma parte de su tipo, y asignar uno copia cada elemento.
  • Un slice es una ventana sobre un array: un puntero, len y cap. Copiar un slice copia la ventana, no los valores.
  • append reutiliza el array mientras haya capacidad, y reserva uno más grande cuando no la hay. Escribe siempre s = append(s, x).
  • Dos slices sobre un mismo array pueden sobrescribirse entre sí a través de append. Usa s[low:high:max] o slices.Clone cuando necesiten ser independientes.
  • Una función puede cambiar los elementos de un slice, pero no su longitud. Devuelve el slice nuevo.
  • Un slice nil es un buen slice vacío, pero se codifica en JSON como null.

Un slice no guarda tus valores. Te dice dónde encontrarlos.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.