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
-
Go Blog: Go Slices — usage and internals — Artigo oficial e definitivo sobre como slices funcionam internamente. https://go.dev/blog/slices-intro
-
Go Blog: Arrays, slices and strings — the mechanics of append — Aprofundamento técnico sobre append e realocação. https://go.dev/blog/slices
-
A Tour of Go — More types — Seção interativa sobre arrays, slices e maps. https://go.dev/tour/moretypes/6
-
Go by Example: Slices — Exemplos práticos e comentados de operações com slices. https://gobyexample.com/slices
-
Especificação da linguagem Go — Slice types — Definição formal do tipo slice. https://go.dev/ref/spec#Slice_types
-
Documentação de funções embutidas: append, copy, make — Referência das funções embutidas para slices. https://pkg.go.dev/builtin
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.