Slices en Go: len, cap y trampas con append
1. Introducción
Slice es el tipo de dato más utilizado en Go, pero también causa los bugs más difíciles de detectar. La razón es que varios slices pueden compartir un array subyacente. En ese caso, append puede sobrescribir datos de otro slice sin reportar ningún error.
Este artículo explica len y cap, cómo append decide si asignar un nuevo array o escribir en el actual, y técnicas para evitar errores: full slice expression, copy, slices.Clone. Después de leerlo, podrás predecir correctamente el resultado de los fragmentos de código con append y sabrás cómo escribir pruebas para detectar errores de aliasing.
Los ejemplos utilizan Go 1.21 o superior, ya que el paquete slices está disponible desde esa versión.
2. ¿Qué es un slice realmente?
Un slice es una pequeña estructura que contiene tres campos:
- pointer: apunta al primer elemento del array subyacente (backing array).
- len: el número de elementos a los que puedes acceder.
- cap: el número de elementos desde el pointer hasta el final del array subyacente.
Cuando pasas un slice a una función o lo asignas a otra variable, Go solo copia esta estructura de tres campos. El array subyacente no se copia.
2.1. Observando len y cap
package main
import "fmt"
func main() {
orders := []string{"A01", "A02", "A03", "A04", "A05"}
recent := orders[1:3]
fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4
orders[1:3] obtiene los elementos desde el índice 1 hasta antes del índice 3, por lo que len es 2. cap es 4 porque desde la posición orders[1] hasta el final del array hay 4 espacios: A02, A03, A04, A05.
2.2. Dos slices, un array
Dado que recent apunta al mismo array que orders, modificar un elemento de este slice se verá inmediatamente en el otro:
recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]
Este es el comportamiento intencionado. Permite hacer slicing sin costo de copia. Pero también es la raíz de todas las trampas que veremos después.
3. Cómo funciona append
append(s, x) hace una de dos cosas:
- Si
len(s) < cap(s): escribexen la posicións[len(s)]en el array actual, luego devuelve un nuevo slice conlen + 1. - Si
len(s) == cap(s): asigna un array nuevo más grande, copia los datos antiguos, escribex, luego devuelve un slice que apunta al nuevo array.
Precisamente porque el caso 2 puede ocurrir, siempre debes reasignar el resultado: s = append(s, x).
3.1. Observando cap incrementarse gradualmente
package main
import "fmt"
func main() {
var ids []int
for i := 0; i < 10; i++ {
ids = append(ids, i)
fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
Con slices pequeños, cap usualmente se duplica cada vez que se llena. Con slices grandes, el runtime aumenta más lentamente, y el resultado se redondea según la clase de tamaño del asignador.
Nota: La fórmula para incrementar
capes un detalle de implementación del runtime y ha cambiado en diferentes versiones de Go. No escribas código que dependa de un valor específico decapdespués de unappend.
4. Trampa 1: append sobrescribe datos del slice original
Este es el error más común:
package main
import "fmt"
func main() {
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
}
firstTwo: [10 20 99]
prices: [10 20 99 40 50]
firstTwo tiene len = 2 pero cap = 5. Hay espacio en el array, así que append no asigna un nuevo array sino que escribe 99 en la tercera posición del array compartido. El resultado es que prices[2] cambia de 30 a 99.
Este bug es muy difícil de encontrar. El código no entra en pánico, y el valor sobrescrito puede aparecer solo en otro módulo, mucho tiempo después.
5. Trampa 2: dos append del mismo slice
package main
import "fmt"
func main() {
base := make([]string, 3, 4)
base[0], base[1], base[2] = "pen", "book", "bag"
cartA := append(base, "laptop")
cartB := append(base, "phone")
fmt.Println("cartA:", cartA)
fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]
base tiene exactamente un espacio vacío. El primer append escribe "laptop" en ese espacio. El segundo append escribe "phone" en el mismo espacio. Tanto cartA como cartB apuntan al mismo array, así que "laptop" ha desaparecido.
Si base tuviera cap = 3, ambos append asignarían arrays separados y el resultado sería el esperado. El mismo código, pero el comportamiento cambia según cap. Por eso este bug solo aparece ocasionalmente.
6. Trampa 3: append dentro de una función no cambia el slice del llamador
package main
import "fmt"
func addItem(items []string, item string) {
items = append(items, item)
}
func main() {
cart := make([]string, 0, 10)
addItem(cart, "pen")
fmt.Println(len(cart), cart)
fmt.Println(cart[:1])
}
0 []
[pen]
La función addItem recibe una copia de la estructura del slice. append escribió "pen" en el array compartido porque hay cap. Pero el nuevo len solo se asignó a la variable local items. La variable cart en main sigue teniendo len = 0. Los datos están en el array, pero solo los ves cuando deliberadamente haces cart[:1].
La solución es devolver el nuevo slice:
func addItem(items []string, item string) []string {
return append(items, item)
}
// usar
cart = addItem(cart, "pen")
Esta es también la razón por la que la biblioteca estándar diseña append como una función que devuelve un valor, no una que modifica el slice en su lugar.
7. Trampa 4: confundir make con len y make con cap
package main
import "fmt"
func main() {
names := []string{"An", "Binh", "Chi"}
upper := make([]string, len(names)) // intención incorrecta
for _, n := range names {
upper = append(upper, n+"!")
}
fmt.Println(len(upper), upper)
}
6 [ An! Binh! Chi!]
make([]string, 3) crea un slice con 3 elementos vacíos, no un slice vacío con capacidad 3. append agrega después de esas 3 cadenas vacías.
Tienes dos formas de arreglarlo. Elige una, no las mezcles:
// Forma 1: len = 0, cap = n, luego append
upper := make([]string, 0, len(names))
for _, n := range names {
upper = append(upper, n+"!")
}
// Forma 2: len = n, asignar por índice
upper := make([]string, len(names))
for i, n := range names {
upper[i] = n + "!"
}
Ambas formas asignan exactamente una vez, más rápido que dejar que append aumente cap muchas veces.
8. Cómo prevenir
8.1. Full slice expression: limitar cap
La sintaxis s[low:high:max] establece cap = max - low. Cuando cap iguala len, el siguiente append debe asignar un nuevo array:
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
firstTwo: [10 20 99]
prices: [10 20 30 40 50]
prices no se ve afectado. Esta técnica es útil cuando devuelves una parte de un slice interno y quieres que el llamador pueda hacer append libremente sin dañar tus datos.
8.2. slices.Clone y copy: separar completamente el array
Cuando necesites una copia completamente independiente, clona:
import "slices"
orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)
snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X
Con Go anterior a 1.21, usa copy:
snapshot := make([]string, len(orders))
copy(snapshot, orders)
copy devuelve el número de elementos copiados, igual al menor entre len(dst) y len(src). Por eso dst debe tener len suficientemente grande, no solo cap. Un error común es escribir copy(make([]string, 0, n), src) y obtener un slice vacío.
8.3. Trampa: retener memoria
Hacer slicing de un slice pequeño de un array grande mantendrá todo el array grande en memoria:
func firstLine(data []byte) []byte {
for i, b := range data {
if b == '\n' {
return slices.Clone(data[:i]) // no retiene todo data
}
}
return slices.Clone(data)
}
Si data es un archivo de 100 MB y devuelves data[:i] sin clonar, el garbage collector no puede liberar esos 100 MB mientras el slice resultante sea referenciado.
9. Escribir pruebas para detectar errores de aliasing
Supongamos que tienes un tipo Inventory que devuelve una lista de productos. Quieres estar seguro de que si el llamador hace append al resultado no dañará tus datos internos:
// inventory.go
package inventory
type Inventory struct {
items []string
}
func New(items ...string) *Inventory {
return &Inventory{items: items}
}
func (inv *Inventory) Items() []string {
return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory
import (
"slices"
"testing"
)
func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
inv := New("pen", "book")
inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // cap excesivo a propósito
got := inv.Items()
got = append(got, "laptop")
want := []string{"pen", "book"}
if !slices.Equal(inv.Items(), want) {
t.Fatalf("internal items = %v, want %v", inv.Items(), want)
}
if inv.items[:3][2] != "bag" {
t.Fatalf("backing array sobrescrito: %v", inv.items[:3])
}
if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
t.Fatalf("got = %v", got)
}
}
La prueba deliberadamente crea un slice interno con cap mayor que len, porque esa es la condición para que el bug aparezca. Si cambias Items() a return inv.items, el comando append(got, "laptop") sobrescribirá "bag" y la prueba fallará en la segunda aserción.
go test ./...
ok example.com/inventory 0.002s
Nota: Las pruebas de aliasing siempre deben crear la situación
len < cap. Si el slice en la prueba se crea con un literal como[]string{"pen", "book"}, entonceslen == cap,appendsiempre asigna un nuevo array, y la prueba pasará aunque el código tenga un bug.
10. Cuándo NO necesitas preocuparte
No todos los slices necesitan ser clonados. Copiar innecesariamente consume memoria y CPU. Puedes compartir un array de forma segura cuando:
- El slice solo se lee, nadie hace
appendo modifica elementos. - El slice es una variable local, no se comparte fuera de la función.
- Deliberadamente quieres modificar en su lugar, por ejemplo filtrar elementos usando
s[:0]para reutilizar el array:
active := orders[:0]
for _, o := range orders {
if o != "CANCELLED" {
active = append(active, o)
}
}
Esta técnica es correcta porque active nunca escribe más allá de donde orders está siendo leído. Después del bucle, solo debes usar active y descartar orders.
11. Conclusión
Un slice es una estructura con un pointer, len y cap que apunta a un array que puede ser compartido. append solo asigna un nuevo array cuando len == cap. En todos los demás casos, escribe directamente en el array compartido. Esa es la raíz de las cuatro trampas que hemos visto: sobrescribir el slice original, dos append sobrescribiendo uno al otro, append dentro de una función no cambiar al llamador, y confundir len con cap en make.
Para ser seguro, recuerda estas reglas:
- Siempre escribe
s = append(s, ...). - Una función que necesita agregar elementos debe devolver el nuevo slice.
- Usa
s[low:high:high]cuando devuelvas un slice interno hacia afuera. - Usa
slices.Clonecuando necesites una copia independiente o quieras liberar un array grande.
Cuando escribas pruebas, recuerda crear la condición len < cap para dar al bug la oportunidad de aparecer.