Arrays e Slices: a espinha dorsal das coleções em Go

Arrays e Slices: a espinha dorsal das coleções em Go

Arrays e slices em Go: tamanho no tipo, len e cap, fatiamento que compartilha o array, append que ora realoca e ora sobrescreve, remoção com slices.Delete, o pedaço pequeno que segura 100 MB na memória e por que uma fatia nil vira null no JSON enquanto a vazia vira [].
Golang

• • 13 min de leitura

Toda linguagem de programação precisa de uma forma de armazenar sequências de valores. Go oferece duas estruturas para isso: arrays e slices. À primeira vista parecem similares, mas têm naturezas fundamentalmente diferentes. Arrays são rígidos e raramente usados diretamente. Slices são flexíveis, poderosos e estão em praticamente todo código Go real. Entender a diferença entre os dois — e como um é construído sobre o outro — é essencial para escrever Go idiomático.

Arrays: tamanho fixo em tempo de compilação

Um array em Go tem tamanho fixo, definido na declaração e imutável para sempre. O tamanho faz parte do tipo — [3]int e [5]int são tipos completamente diferentes e incompatíveis:

package main

import "fmt"

func main() {
    var notas [5]float64
    notas[0] = 7.5
    notas[1] = 8.0
    notas[2] = 9.5
    notas[3] = 6.0
    notas[4] = 8.5

    fmt.Println(notas)        // [7.5 8 9.5 6 8.5]
    fmt.Println(len(notas))   // 5
}

É possível declarar e inicializar em uma única linha com um literal de array:

primos := [5]int{2, 3, 5, 7, 11}

Ou deixar o compilador contar os elementos automaticamente com ...:

primos := [...]int{2, 3, 5, 7, 11}
fmt.Println(len(primos)) // 5

Arrays em Go são passados por valor. Quando um array é atribuído a outra variável ou passado para uma função, uma cópia completa de todos os elementos é feita. Para um array de um milhão de inteiros, isso significa copiar um milhão de inteiros. Esse comportamento é uma das razões pelas quais arrays diretos são pouco usados em Go — slices resolvem esse problema com elegância.

Slices: a visão dinâmica sobre um array

Um slice é uma estrutura leve que descreve uma sequência de elementos de um array subjacente. Internamente, um slice é composto por três campos:

  • Um ponteiro para o primeiro elemento do array que o slice enxerga
  • Um comprimento (len) — quantos elementos o slice contém atualmente
  • Uma capacidade (cap) — quantos elementos existem no array subjacente a partir do ponteiro

Essa estrutura interna tem tamanho fixo e pequeno, independentemente de quantos elementos o slice referencia. Por isso, slices são passados por valor de forma eficiente — a cópia é apenas do cabeçalho de três campos, não dos dados.

Criando slices

A partir de um literal:

linguagens := []string{"Go", "Rust", "Python", "Java"}
fmt.Println(linguagens)        // [Go Rust Python Java]
fmt.Println(len(linguagens))   // 4
fmt.Println(cap(linguagens))   // 4

Note a ausência de tamanho entre os colchetes — isso distingue um slice []string de um array [4]string.

Com a função make:

make cria um slice com comprimento e capacidade definidos, todos os elementos inicializados com o valor zero do tipo:

numeros := make([]int, 5)       // len=5, cap=5
fmt.Println(numeros)            // [0 0 0 0 0]

buffer := make([]byte, 0, 100)  // len=0, cap=100
fmt.Println(len(buffer), cap(buffer)) // 0 100

A distinção entre len e cap em make é importante: len define quantos elementos estão disponíveis para uso imediato, enquanto cap reserva memória para crescimento futuro sem realocação.

Fatiamento: slices de slices

A operação de fatiamento cria um novo slice que aponta para uma região do array subjacente. A sintaxe é s[baixo:alto], onde baixo é inclusivo e alto é exclusivo:

numeros := []int{10, 20, 30, 40, 50}

a := numeros[1:4]   // [20 30 40]
b := numeros[:3]    // [10 20 30] — baixo omitido assume 0
c := numeros[2:]    // [30 40 50] — alto omitido assume len
d := numeros[:]     // [10 20 30 40 50] — cópia do cabeçalho

fmt.Println(a, b, c, d)

Atenção crítica: slices criados por fatiamento compartilham o array subjacente. Modificar elementos de um slice modifica os dados visíveis pelo outro:

original := []int{1, 2, 3, 4, 5}
fatia := original[1:4]  // [2 3 4]

fatia[0] = 99

fmt.Println(original)   // [1 99 3 4 5] — original foi afetado!
fmt.Println(fatia)       // [99 3 4]

Para obter um slice verdadeiramente independente, use copy:

original := []int{1, 2, 3, 4, 5}
independente := make([]int, len(original))
copy(independente, original)

independente[0] = 99
fmt.Println(original)     // [1 2 3 4 5] — inalterado
fmt.Println(independente) // [99 2 3 4 5]

Desde o Go 1.21, slices.Clone(original) faz as duas linhas de uma vez.

Append: adicionando elementos

A função embutida append adiciona elementos ao final de um slice e retorna o slice resultante. Sempre atribua o retorno de append — o slice original não é modificado diretamente:

s := []int{1, 2, 3}
s = append(s, 4)
s = append(s, 5, 6, 7)

fmt.Println(s) // [1 2 3 4 5 6 7]

Para concatenar dois slices, use o operador ... para expandir o segundo:

a := []int{1, 2, 3}
b := []int{4, 5, 6}
c := append(a, b...)

fmt.Println(c) // [1 2 3 4 5 6]

Como o append gerencia memória

Quando append precisa adicionar elementos além da capacidade atual do slice, Go aloca um novo array subjacente com capacidade maior, copia os dados existentes e retorna um slice apontando para o novo array. O array antigo é descartado pelo garbage collector se não houver mais referências a ele.

A estratégia de crescimento dobra a capacidade para slices pequenos e cresce de forma mais conservadora para slices maiores. O ponto importante é que após um append que causou realocação, o novo slice não compartilha mais o array com o original:

original := make([]int, 3, 3)  // len=3, cap=3
original[0], original[1], original[2] = 1, 2, 3

expandido := append(original, 4)  // causa realocação

expandido[0] = 99

fmt.Println(original)   // [1 2 3]  — não afetado
fmt.Println(expandido)  // [99 2 3 4]

Para evitar realocações frequentes quando o tamanho final é conhecido, pré-aloque a capacidade com make:

// Ineficiente: múltiplas realocações
s := []int{}
for i := 0; i < 10000; i++ {
    s = append(s, i)
}

// Eficiente: nenhuma realocação
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    s = append(s, i)
}

Iterando sobre slices

A forma idiomática de iterar é com for range:

frutas := []string{"maçã", "banana", "laranja", "uva"}

for i, fruta := range frutas {
    fmt.Printf("%d: %s\n", i, fruta)
}

Quando apenas o valor importa:

for _, fruta := range frutas {
    fmt.Println(fruta)
}

Quando apenas o índice importa:

for i := range frutas {
    fmt.Println(i)
}

Removendo elementos de um slice

Go não possui uma função embutida de remoção. O padrão idiomático usa fatiamento e append:

s := []int{10, 20, 30, 40, 50}
i := 2  // índice do elemento a remover (30)

s = append(s[:i], s[i+1:]...)

fmt.Println(s) // [10 20 40 50]

Essa operação preserva a ordem dos elementos, mas trabalha dentro do mesmo array: qualquer outra fatia que ainda aponte para ele passa a ver os elementos deslocados e o último repetido — [10 20 40 50 50], no exemplo acima. Desde o Go 1.21, slices.Delete(s, i, i+1) faz a mesma remoção e, desde o 1.22, ainda zera a posição que sobrou no fim, o que deixa o problema visível e libera para o coletor o que ela referenciava. Se a ordem não importa, trocar o elemento a remover pelo último e reduzir o comprimento é mais eficiente por evitar o deslocamento de elementos:

s[i] = s[len(s)-1]
s = s[:len(s)-1]

fmt.Println(s) // [10 20 50 40] — ordem não preservada, mas mais rápido

Slices multidimensionais

Quando as dimensões são fixas, Go tem arrays de arrays: var tabuleiro [3][3]int é um bloco único de nove inteiros, e tabuleiro[1][1] = 5 funciona como se espera. Quando o tamanho só é conhecido em execução, ou cada linha pode ter um comprimento diferente, slices de slices cumprem o papel:

matriz := [][]int{
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9},
}

for _, linha := range matriz {
    for _, val := range linha {
        fmt.Printf("%d ", val)
    }
    fmt.Println()
}

Slices de structs: o caso mais comum na prática

Em código Go real, slices de structs são onipresentes — listas de usuários, produtos, registros de banco de dados, resultados de API:

package main

import "fmt"

type Produto struct {
    Nome  string
    Preco float64
}

func main() {
    produtos := []Produto{
        {"Teclado", 250.00},
        {"Mouse", 120.00},
        {"Monitor", 1800.00},
    }

    total := 0.0
    for _, p := range produtos {
        total += p.Preco
        fmt.Printf("%-10s R$ %.2f\n", p.Nome, p.Preco)
    }
    fmt.Printf("%-10s R$ %.2f\n", "Total", total)
}

Quase todo o comportamento das fatias sai de uma frase só: uma fatia é uma janela sobre um array que ela não possui. Daí vêm a leveza de passá-las a funções, o compartilhamento que o fatiamento cria e o append que ora escreve no mesmo array, ora troca de array em silêncio. Quando o compartilhamento não é o que se quer — ao guardar um pedaço pequeno de algo grande, ao montar duas listas a partir da mesma base —, slices.Clone é a saída explícita; o resto do pacote slices, com Delete, Sort e Contains, poupa boa parte dos laços escritos à mão.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma função monta dois pedidos a partir da mesma lista base: base := make([]int, 3, 10), depois a := append(base, 1) e b := append(base, 2). Ao imprimir, os dois pedidos aparecem terminando em 2. Explique o resultado e dê duas formas de corrigir.

Ver resposta

✓ Resposta: A base tem tamanho 3 e capacidade 10, então nenhum dos dois append precisa realocar: os dois escrevem na mesma posição 3 do mesmo array. O primeiro grava 1 e devolve uma fatia de tamanho 4; o segundo grava 2 por cima e devolve outra fatia de tamanho 4 sobre o mesmo array. No fim, a e b imprimem [0 0 0 2]. É o outro lado do exemplo do artigo, em que a capacidade cheia forçava a realocação e deixava o original intacto: com capacidade sobrando, o append compartilha. A primeira correção é copiar antes de acrescentar, com a := append(slices.Clone(base), 1). A segunda é limitar a capacidade da base com a expressão de fatiamento de três índices, base[:3:3], o que obriga todo append sobre ela a alocar um array novo. O sinal de alerta é sempre o mesmo: dois append sobre a mesma fatia, guardados em variáveis diferentes.

Exercício 2

Um serviço lê um arquivo de log de 100 MB para um []byte, extrai os 10 primeiros bytes (um cabeçalho) com fatiamento e guarda esse cabeçalho numa variável global. O arquivo original não é mais usado, mas o consumo de memória não cai. Por quê, e como resolver?

Ver resposta

✓ Resposta: Porque a fatia de 10 bytes aponta para o mesmo array de 100 MB, e o coletor não libera parte de um array: enquanto alguém referenciar qualquer pedaço dele, ele inteiro fica vivo. Num teste que aloca 100 MB, guarda grande[:10] numa variável global e descarta a referência ao original, o HeapAlloc medido depois de um runtime.GC() continua em 100 MB. Trocando a variável por slices.Clone(guardado), que copia os 10 bytes para um array próprio, o mesmo teste cai para 0 MB. A regra é que fatiar é barato justamente porque não copia, e esse benefício vira vazamento quando o pedaço pequeno vive muito mais que o todo. Vale o mesmo para strings: uma substring também aponta para o texto original, e strings.Clone, desde o Go 1.20, resolve o caso equivalente.

Exercício 3

Num jogo de tabuleiro, alguém quer usar a posição de cada peça como chave de um map. A primeira tentativa, map[[]int]string, não compila, e comparar duas posições com == também falha. Um colega sugere converter a posição para string, como em "3,4". Existe algo melhor?

Ver resposta

✓ Resposta: Existe: usar um array, que é exatamente o caso em que ele brilha. Fatias não são comparáveis, e o compilador recusa a == b entre duas delas com slice can only be compared to nil; por isso também não servem de chave. Arrays são valores comparáveis elemento a elemento: [2]int{1, 2} == [2]int{1, 2} dá true, e map[[2]int]string{{0, 0}: "origem"} funciona, com a busca por pos[[2]int{0, 0}] devolvendo origem. Uma struct pequena, como type Pos struct{ L, C int }, funciona igual e deixa o código mais legível. A string "3,4" também funcionaria, mas custa formatar e alocar a cada busca e abre espaço para erros de formato. O fato de o tamanho fazer parte do tipo, que o artigo apresenta como rigidez, é justamente o que permite ao array ser comparável.

Exercício 4

Um endpoint devolve a lista de pedidos de um cliente em JSON. Para quem não tem pedidos, o front-end quebra ao chamar .length, porque recebe null em vez de []. O código faz var pedidos []Pedido e acrescenta os resultados com append. Onde está a origem do null, e qual é a correção?

Ver resposta

✓ Resposta: A origem é a diferença entre fatia nil e fatia vazia. var pedidos []Pedido declara uma fatia nil e, se nenhum append acontecer, ela continua nil. Para quase todo o Go, isso não importa: len dá 0, o range não executa nenhuma volta e o append funciona. Mas o encoding/json faz a distinção: json.Marshal de uma fatia nil produz null, e de []int{} produz []. Num teste, as duas fatias têm o mesmo tamanho 0, mas só a primeira é nil, e as saídas JSON são null e []. A correção é inicializar a fatia vazia quando ela vai virar resposta, com pedidos := []Pedido{} ou make([]Pedido, 0), e deixar a forma nil para o uso interno. Vale combinar a regra com o front-end, mas o contrato mais seguro para uma API é que listas sejam sempre listas.

Exercício 5

Um importador acrescenta um milhão de registros a uma fatia com append, sem pré-alocar, e alguém propõe calcular o tamanho antes "porque senão o Go copia tudo a cada inserção". A afirmação procede? Como a capacidade cresce, e quando vale mesmo a pena pré-alocar?

Ver resposta

✓ Resposta: Não procede no "a cada inserção". O append só realoca quando a capacidade acaba, e cada realocação cresce em proporção ao tamanho atual. Medido no Go 1.27, a capacidade dobra enquanto a fatia é pequena (de 8 vai para 16, de 256 vai para 512) e depois cresce num fator menor (de 512 vai para 848), caindo aos poucos para perto de 1,25. O número de cópias é, portanto, logarítmico no tamanho, e o custo médio por inserção continua constante. Mesmo assim, pré-alocar tem vantagem real quando o tamanho é conhecido: make([]Registro, 0, n) faz uma alocação só, sem as cópias intermediárias e sem deixar os arrays abandonados para o coletor, o que conta com um milhão de structs. Quando o tamanho não é conhecido, uma estimativa razoável já elimina a maior parte das realocações. E o ponto do artigo sobre o array ser trocado continua valendo: depois de uma realocação, quem guardava a fatia antiga está olhando para outro array.

Comentários

Mais em Golang

O tipo `error` e tratamento idiomático de falhas
O tipo `error` e tratamento idiomático de falhas

Erros em Go: a interface error, errors.New e fmt.Errorf, tipos próprios de…

Design Patterns em Go: Repository, Factory e Strategy
Design Patterns em Go: Repository, Factory e Strategy

Design Patterns em Go medidos: Repository, Factory com registro que não perde…

Consumindo APIs externas com http.Client
Consumindo APIs externas com http.Client

Consumindo APIs em Go: http.Client com timeout, o Transport que perde o proxy…