Array ou slice? Capacidade, crescimento e as armadilhas do append
Como slices funcionam por dentro em Go: o cabeçalho de três palavras, a diferença entre len e cap, como o append faz o slice crescer, e por que dois slices podem enxergar o mesmo array sem você perceber.
No artigo sobre zero values e pointer semantics eu comentei de passagem que copiar um slice copia só o seu cabeçalho, não o array por baixo, e que aquilo rendia um artigo inteiro. É este aqui.
Vamos começar pelo fim, com um bug real. Imagine um router HTTP em que cada grupo de rotas herda os middlewares do grupo pai:
func (r *Router) Group(m Middleware) *Router {
return &Router{middlewares: append(r.middlewares, m)}
}
A função tem uma linha, não tem ponteiro solto, não tem concorrência, e devolve um Router novo em vez de mutar o antigo. Parece o tipo de código que não tem como dar errado. Agora o uso:
admin := root.Group(requireAdmin)
public := root.Group(rateLimit)
root: len=3 cap=4 [logging recover requestID]
admin : [logging recover requestID rateLimit]
public: [logging recover requestID rateLimit]
O router de admin ficou sem o middleware de autenticação. Ninguém sobrescreveu nada de propósito, e o código está sintaticamente correto. O que aconteceu é que os dois append escreveram na mesma posição do mesmo array, e o segundo passou por cima do primeiro.
Repare no cap=4 com len=3. Se fossem iguais, o bug não aconteceria. Ou seja: esse código funciona ou não dependendo de quantos middlewares o pai tinha. Esse é o tipo de bug que passa em teste, passa em code review e aparece em produção seis meses depois, quando alguém adiciona um middleware a mais.
A proposta deste artigo é destrinchar exatamente isso: primeiro a diferença entre array e slice, depois o que é capacidade e por que ela não é um detalhe, como o append faz o slice crescer, e por fim como dois slices acabam enxergando a mesma memória — e o que fazer a respeito.
gc (o compilador oficial). Números concretos de capacidade são detalhes de implementação e já mudaram entre versões; existe uma seção sobre isso mais adiante.Duas coisas diferentes com nomes parecidos
Antes de falar do append, vale separar dois tipos que a sintaxe faz parecer irmãos e que não são a mesma coisa.
Um array é um valor autocontido, e o tamanho faz parte do tipo. A spec é direta:
"O tamanho faz parte do tipo do array; ele precisa ser avaliado como uma constante não negativa representável por um valor do tipo
int."
Isso tem uma consequência que assusta quem vem de C: [3]int e [4]int são tipos diferentes. Não dá para passar um onde se espera o outro, e não dá nem para compará-los.
Um slice, por outro lado, não guarda os dados. Ele descreve um pedaço de um array que mora em outro lugar:
"Um slice é um descritor para um segmento contíguo de um array subjacente e dá acesso a uma sequência numerada de elementos daquele array. (...) Um slice, uma vez inicializado, está sempre associado a um array subjacente que guarda seus elementos. Um slice, portanto, compartilha armazenamento com seu array e com outros slices do mesmo array; por outro lado, arrays distintos sempre representam armazenamentos distintos."
Essa última frase é o artigo inteiro em uma linha. Guarde ela.
A diferença aparece na hora de medir:
package main
import (
"fmt"
"unsafe"
)
func main() {
var arr [1000]int
var sl []int
fmt.Println(unsafe.Sizeof(arr)) // 8000
fmt.Println(unsafe.Sizeof(sl)) // 24
}
O array ocupa os 8000 bytes que ele promete. O slice ocupa 24 bytes em qualquer plataforma de 64 bits, independentemente de descrever três elementos ou três milhões, porque ele é só um ponteiro e dois inteiros.
E isso não é curiosidade acadêmica: array é valor, então passar um array para uma função copia o array inteiro.
package arrbench
type Buffer [4096]byte
//go:noinline
func firstArray(b Buffer) byte { return b[0] } // copia 4 KiB para ler 1 byte
//go:noinline
func firstSlice(b []byte) byte { return b[0] } // copia 24 bytes de cabeçalho
BenchmarkArrayParam-8 23730294 48.16 ns/op 0 B/op 0 allocs/op
BenchmarkSliceParam-8 927475783 1.375 ns/op 0 B/op 0 allocs/op
Os dois corpos são idênticos. A diferença de 47 nanossegundos é a cópia dos 4 KiB que acontece antes de a função começar. Não há alocação em nenhum dos dois casos — o custo é puramente de cópia, e é por isso que arrays grandes quase nunca aparecem como parâmetro em código Go.
Em compensação, ser um valor autocontido dá ao array duas capacidades que o slice não tem:
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true
routes := map[[2]string]int{
{"GET", "/users"}: 1, // array como chave de map
}
Arrays são comparáveis se o tipo dos elementos for comparável, e por isso servem como chave de map. Slices não:
invalid operation: a == b (slice can only be compared to nil)
O motivo é o mesmo compartilhamento de armazenamento citado na spec: dois slices podem apontar para o mesmo array, ou para arrays diferentes com o mesmo conteúdo, e não existe uma resposta óbvia para o que == deveria significar aí. Para comparar conteúdo existe slices.Equal.
[]string para o tipo inteiro deixar de servir como chave de map:invalid operation: x == y (struct containing []string cannot be compared)
Resumindo a diferença:
Array [N]T | Slice []T | |
|---|---|---|
| Tamanho | fixo, faz parte do tipo | variável em tempo de execução |
| O que a variável guarda | os elementos | ponteiro, len e cap |
| Custo de copiar | proporcional ao tamanho | 24 bytes (64 bits) |
Comparável com == | sim, se T for comparável | só contra nil |
| Zero value | todos os elementos zerados | nil |
Parte 1: o cabeçalho de três palavras
Aqueles 24 bytes são três palavras: um ponteiro para o início do segmento, o comprimento e a capacidade. O blog oficial descreve o slice assim:
"Uma slice é uma estrutura de dados que descreve uma seção contígua de um array guardado separadamente da própria variável slice. Uma slice não é um array. Uma slice descreve um pedaço de um array."
— Go Blog, Arrays, slices (and strings): The mechanics of 'append'
E aqui está o ponto que reorganiza o entendimento, e que é a continuação direta do artigo anterior: o cabeçalho também é um valor. Quando você passa um slice para uma função, o cabeçalho é copiado. Os dois cabeçalhos apontam para o mesmo array, mas são cabeçalhos independentes.
Isso explica um comportamento que parece contraditório:
package main
import "fmt"
func mutate(s []int) {
s[0] = 99 // o chamador vê
s = append(s, 4) // o chamador NÃO vê
}
func main() {
s := []int{1, 2, 3}
mutate(s)
fmt.Println(s, len(s)) // [99 2 3] 3
}
As duas linhas parecem mexer no mesmo slice, e só uma tem efeito lá fora. s[0] = 99 escreve no array, que é compartilhado. Já append devolve um cabeçalho novo, com len igual a 4, e atribui esse cabeçalho à cópia local. O main continua com o cabeçalho antigo, de len 3.
É exatamente por isso que append devolve um valor em vez de mutar o slice no lugar, e por que ignorar esse retorno não é um estilo alternativo, é um erro de compilação:
append(s, 2) (value of type []int) is not used
len. Se você precisa disso, devolva o slice (o que é idiomático) ou receba um *[]T (o que raramente é).Parte 2: len não é cap
O comprimento é quantos elementos o slice tem. A capacidade é quantos ele poderia ter sem trocar de array — é a distância entre o começo do slice e o fim do array subjacente.
A distinção parece burocrática até você escrever, quase sempre sem perceber, esta função:
// Errado: make com len, e não com cap.
func orderIDs(orders []Order) []int64 {
out := make([]int64, len(orders))
for _, o := range orders {
out = append(out, o.ID)
}
return out
}
O make([]int64, len(orders)) cria um slice com len(orders) elementos já preenchidos com o zero value. Cada append acrescenta depois deles:
[0 0 0 10 20 30] len=6 cap=6
A intenção era reservar espaço; o que aconteceu foi criar os elementos. A forma correta é make([]int64, 0, len(orders)): comprimento zero, capacidade reservada.
O detalhe é que esse bug não é só de correção, ele é mais lento do que não ter otimizado nada. Coletando 1000 Order (32 bytes cada):
BenchmarkNoPrealloc-8 114913 8935 ns/op 70208 B/op 10 allocs/op
BenchmarkPrealloc-8 290004 3953 ns/op 32768 B/op 1 allocs/op
BenchmarkWrongPrealloc-8 77742 15990 ns/op 155648 B/op 3 allocs/op
A versão sem pré-alocação faz 10 alocações, porque o slice precisou crescer 10 vezes ao longo do caminho. A versão correta faz uma só, e é 2,3× mais rápida.
A versão com o make errado é a mais interessante das três. Ela termina com um slice de 2000 elementos — os 1000 zeros na frente e os 1000 valores reais depois — e, para chegar lá, alocou três vezes: os 1000 elementos iniciais, e mais duas realocações de crescimento (capacidade 1000 → 1536 → 2304). São os 155.648 B/op que o benchmark reporta, para produzir um resultado que, além de tudo, está errado. A tentativa de otimizar saiu quase 2× mais cara do que não ter feito nada.
go vet não pega esse caso. O linter makezero, disponível no golangci-lint, existe justamente para isso: ele acusa slices criados com comprimento diferente de zero que depois recebem append.A capacidade também é um pedaço de array que ainda está lá
Uma consequência menos óbvia: os elementos entre len e cap não desapareceram, eles só não são acessíveis pelo slice atual. Refatiar até a capacidade os traz de volta:
s := []int{1, 2, 3, 4, 5}
t := s[:2]
fmt.Println(t) // [1 2]
fmt.Println(t[:cap(t)]) // [1 2 3 4 5]
t tem len 2 e cap 5. Encurtar um slice não apaga nada; apenas move o len. É por isso que "cortar" dados sensíveis com s = s[:n] não é uma forma de descartá-los.
A expressão de três índices
Para limitar a capacidade existe a full slice expression, a[low:high:max]:
"Adicionalmente, ela controla a capacidade do slice resultante, definindo-a como
max - low."— The Go Programming Language Specification, Full slice expressions
all := []int{1, 2, 3, 4, 5}
head := all[:2] // len=2, cap=5
safe := all[:2:2] // len=2, cap=2
A diferença entre esses dois é a diferença entre o bug da introdução e o código correto, e é para lá que vamos na Parte 4. Antes, precisamos entender o que o append faz quando a capacidade acaba.
Parte 3: como o slice cresce
A spec descreve o comportamento em uma frase, e note o que ela não promete:
"Se a capacidade de
snão for grande o suficiente para caber os valores adicionais,appendaloca um novo array subjacente, suficientemente grande, que caiba tanto os elementos existentes quanto os adicionais. Caso contrário,appendreutiliza o array subjacente."— The Go Programming Language Specification, Appending to and copying slices
"Suficientemente grande" é tudo o que a linguagem garante. Quanto exatamente é decisão do runtime, e ela mudou ao longo das versões.
No Go 1.26, quem decide é nextslicecap, em runtime/slice.go:
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) >> 2
// ...
}
}
São três regras, em ordem:
- Se o que você pediu já passa do dobro da capacidade atual, o runtime usa exatamente o que foi pedido. Adicionar 100 elementos a um slice de capacidade 4 não vai gerar capacidade 8.
- Abaixo de 256 elementos de capacidade, dobra.
- A partir de 256, aplica
newcap += (newcap + 768) / 4, que começa em fator 2 e vai caindo suavemente em direção a 1,25.
O objetivo da terceira regra é evitar o degrau que existia antes: até o Go 1.17, o fator caía de 2 para 1,25 de uma vez em 1024 elementos.
O que você realmente observa
Só que nextslicecap não é a última palavra. O valor que ela devolve vira uma quantidade de bytes, e o alocador do Go arredonda essa quantidade para a size class mais próxima, porque ele não aloca blocos de tamanho arbitrário. A capacidade final é o quanto cabe no bloco arredondado.
Medindo o crescimento de um []int a partir de nil:
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=5 cap=8
...
len=257 cap=512
len=513 cap=848 <- a fórmula diz 832
len=849 cap=1280
len=1281 cap=1792
Em cap=512, a fórmula dá 512 + (512 + 768)/4 = 832. Mas 832 elementos de 8 bytes são 6656 bytes, e a size class imediatamente acima é 6784 — que comporta 848 elementos. O runtime não joga fora os 16 elementos que sobraram no bloco, então cap vira 848.
Por isso a sequência depende do tipo do elemento, não só da quantidade. Crescendo um elemento por vez a partir de nil:
| Tipo | Tamanho do elemento | Capacidades observadas |
|---|---|---|
[]byte | 1 B | 8, 16, 32, 64, 128, 256, 512, 896, 1408, 2048 |
[]int | 8 B | 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 848, 1280, 1792 |
[]Order | 32 B | 1, 2, 4, 8, 16, 35, 71, 151, 303, 575, 1023, 1536 |
Três coisas para reparar nessa tabela. []byte começa em 8, e não em 1, porque a menor size class é de 8 bytes — pedir 1 byte e receber 8 é o piso do alocador. []int segue as potências de dois até 512, onde a fórmula assume e o arredondamento começa a aparecer. E []Order sai de 16 direto para 35, um número que não é potência de dois nem múltiplo de nada óbvio: são 32 elementos pela fórmula, arredondados para cima pelo tamanho do bloco, e ainda descontados de um cabeçalho de 8 bytes que o alocador acrescenta porque Order contém uma string — elementos com ponteiros precisam desse metadado para o GC saber o que varrer.
cap depois de um append é um detalhe de implementação. Se o seu código depende de um valor específico de capacidade, ele vai quebrar em alguma versão futura do Go. O que você pode confiar é no comportamento: o crescimento é geométrico, então uma sequência de N append custa O(N) no total, e não O(N²).Uma surpresa que vem do artigo anterior
Se você rodar o teste acima em uma função onde o slice não escapa, os números mudam:
//go:noinline
func firstCapLocal() int {
var s []int
s = append(s, 1)
return cap(s) // 4
}
Quatro, e não um. E com []byte o primeiro append devolve capacidade 32, não 8.
Isso não é o growslice — é o escape analysis em ação. Desde o Go 1.25, quando o compilador prova que o slice não sobrevive à função, ele reserva um buffer fixo na stack e usa esse buffer como array inicial. O tamanho padrão é de 32 bytes, definido por VariableMakeThreshold em cmd/compile/internal/base/flag.go, e a capacidade resultante é o número de elementos que cabem nele:
K := maxStackSize / et.Size() // cmd/compile/internal/ssagen/ssa.go
32 / 8 = 4 para []int, 32 / 1 = 32 para []byte, 32 / 32 = 1 para []Order. Confere com o observado. Basta fazer o slice escapar — atribuindo a uma variável global, por exemplo — para as capacidades voltarem a 1, 8 e 1.
É o mesmo padrão que apareceu no artigo sobre escape analysis: a mesma linha de código produz resultados diferentes conforme o contexto da chamada. Aqui isso é ainda mais uma razão para não tratar cap como um número previsível.
Parte 4: aliasing, ou quando dois slices enxergam o mesmo array
Agora dá para voltar ao bug da introdução com as ferramentas certas.
func (r *Router) Group(m Middleware) *Router {
return &Router{middlewares: append(r.middlewares, m)}
}
r.middlewares tem len=3 e cap=4. O append encontra espaço sobrando, então não aloca nada: ele escreve m na posição 3 do array existente e devolve um cabeçalho com len=4 apontando para o mesmo array.
Quando Group é chamado uma segunda vez a partir do mesmo pai, o cabeçalho de r.middlewares continua com len=3 — ele nunca mudou. O append escreve de novo na posição 3, por cima do middleware anterior. Os dois routers filhos terminam com cabeçalhos diferentes, mas apontando para o mesmo array, e o array só tem uma posição 3.
A correção é dizer ao append que ele não tem permissão para usar a capacidade sobrando:
func (r *Router) Group(m Middleware) *Router {
base := r.middlewares[:len(r.middlewares):len(r.middlewares)]
return &Router{middlewares: append(base, m)}
}
Com cap == len, o append é obrigado a alocar um array novo e copiar, e cada filho passa a ter o seu. A biblioteca padrão tem um nome para essa operação desde o Go 1.21:
// Clip removes unused capacity from the slice, returning s[:len(s):len(s)].
func Clip[S ~[]E, E any](s S) S
Então a versão que eu escreveria de verdade é:
func (r *Router) Group(m Middleware) *Router {
return &Router{middlewares: append(slices.Clip(r.middlewares), m)}
}
admin : [logging recover requestID requireAdmin]
public: [logging recover requestID rateLimit]
slices.Clip transforma "às vezes compartilha, às vezes não" em "nunca compartilha". A regra que eu uso: se um slice vai ser guardado em uma struct de vida longa e derivado depois, ele precisa ser clipado ou clonado na fronteira. Pagar uma alocação previsível é melhor do que depender de um cap que ninguém está olhando.O mesmo mecanismo, do lado de dentro
O aliasing não é só sobre append acidental. Ele também aparece em um padrão bastante recomendado — o filtro sem alocação:
// activeOnly reaproveita o array de origem para não alocar.
func activeOnly(orders []Order) []Order {
out := orders[:0]
for _, o := range orders {
if o.Status != "cancelled" {
out = append(out, o)
}
}
return out
}
Ele funciona porque out sempre escreve em uma posição que já foi lida. E ele destrói a entrada:
active: [paid paid]
all : [paid paid paid] <- o chamador perdeu o pedido 2
Não é um bug do padrão, é o preço dele. O problema aparece quando essa função mora em um pacote de utilidades, com um nome como FilterActive, e quem chama não faz ideia. Se a função é pública, ou você documenta que ela consome a entrada, ou você não reaproveita o array:
func activeOnly(orders []Order) []Order {
return slices.DeleteFunc(slices.Clone(orders), func(o Order) bool {
return o.Status == "cancelled"
})
}
Segurar um pedaço é segurar o array inteiro
A última forma de aliasing é a que não corrompe nada, só consome memória. Como o slice guarda um ponteiro para o array, o coletor de lixo não pode liberar o array enquanto qualquer slice apontar para ele — mesmo que esse slice descreva 16 bytes de um array de 10 MiB.
O blog oficial já alertava para isso:
"refatiar um slice não faz uma cópia do array subjacente. O array completo será mantido em memória até que ele não seja mais referenciado."
Um caso concreto: uma função que lê um payload e guarda só um identificador dele.
func extractID(payload []byte) []byte {
return payload[:16] // mantém o payload inteiro vivo
}
func extractIDSafe(payload []byte) []byte {
return slices.Clone(payload[:16]) // 16 bytes, e só
}
Guardando 20 desses identificadores, com payloads de 10 MiB:
20 fatias de 16 bytes (sub-slice): heap = 200.2 MiB
20 fatias de 16 bytes (slices.Clone): heap = 0.2 MiB
Não há vazamento no sentido clássico — tudo será liberado quando os 20 slices morrerem. Mas enquanto eles viverem, o processo carrega 200 MiB para guardar 320 bytes de informação útil. É o tipo de coisa que aparece como "uso de memória alto e estável" e não como um leak crescente, o que torna o diagnóstico bem mais chato.
string, que também é um ponteiro mais um comprimento. Fatiar uma string enorme e guardar o pedaço mantém a original viva. A saída é strings.Clone, do Go 1.18.Limitações, exceções e armadilhas
O append compartilha ou copia, e você não controla qual. Esta é a armadilha-mãe, e vale enunciá-la de forma explícita: depois de b := append(a, x), a e b podem ou não compartilhar o array, dependendo de cap(a). Não existe um jeito de perguntar "esse append alocou?" que seja útil em produção. Trate o resultado do append como um slice novo e o original como inválido, ou clipe antes.
Encurtar um slice não libera nada. s = s[:0] zera o len e mantém a capacidade — o que é ótimo para reaproveitar buffers e péssimo se os elementos forem ponteiros, porque o array continua referenciando os objetos. Se o elemento contém ponteiros e o slice tem vida longa, zere a cauda ou use as funções do pacote slices, que fazem isso desde o Go 1.22:
remoção manual com append: slot além do len = &{d} <- ainda referenciado
slices.Delete: slot além do len = <nil> <- zerado
slices.Clone é uma cópia rasa. A documentação diz que "os elementos são copiados por atribuição". Clonar um []*Order te dá um array novo de ponteiros para os mesmos Order. Se o objetivo era isolamento de verdade, o clone raso não resolve.
nil e slice vazio não são a mesma coisa, exceto quando são.
var nilSlice []int
emptySlice := []int{}
nilSlice == nil // true
emptySlice == nil // false
len(nilSlice) == len(emptySlice) // true
slices.Equal(nilSlice, emptySlice) // true
append, len, cap e range tratam os dois igual, então na maior parte do código a diferença não importa. Ela importa em dois lugares: comparações explícitas contra nil e serialização. O encoding/json gera null para um slice nil e [] para um vazio, e essa diferença já quebrou muito cliente. Se a sua API devolve listas, garanta o slice vazio.
range copia o elemento. Não é específico de slices, mas é onde mais aparece:
for _, o := range orders {
o.Status = "paid" // altera a cópia, não o slice
}
for i := range orders {
orders[i].Status = "paid" // altera o slice
}
Para elementos grandes isso também tem custo de cópia, e é um dos poucos casos em que for i := range é preferível por performance, não só por semântica.
Reservar capacidade demais é um custo, não um seguro. make([]T, 0, 1_000_000) aloca o array inteiro na hora, mesmo que você use dez posições. slices.Grow sofre do mesmo, e é por isso que ele é a ferramenta certa só quando você tem uma estimativa razoável. Quando não tem, deixe o append crescer: o algoritmo geométrico já é uma boa aposta.
Não otimize capacidade sem medir. Vale aqui o mesmo critério do artigo sobre escape analysis. allocs/op só importa em caminhos quentes, e a diferença entre 10 alocações e 1 em uma função chamada três vezes por requisição não paga a complexidade de calcular o tamanho certo. Comece pelo -benchmem e pelo pprof, não pelo código.
Mudanças entre versões
Boa parte do que está aqui é comportamento observável, não contrato, e mudou ao longo do tempo:
| Versão | O que mudou |
|---|---|
| Go 1.18 | A fórmula de crescimento passou a ser suave: em vez de dobrar até 1024 e depois multiplicar por 1,25, o fator começa a cair em 256. As release notes dizem apenas que "a nova fórmula é menos propensa a transições bruscas no comportamento de alocação"; os detalhes estão no commit 2dda92f. |
| Go 1.21 | O pacote slices entrou na biblioteca padrão, com Clone, Clip, Grow, Delete, Equal, Insert e companhia. Antes disso, cada projeto tinha a sua versão de Clip. |
| Go 1.22 | Delete, DeleteFunc, Compact, CompactFunc e Replace passaram a zerar os elementos entre o novo e o antigo comprimento, resolvendo a retenção de ponteiros na cauda. Também chegou slices.Concat. |
| Go 1.25 | O compilador passou a alocar o array subjacente de slices na stack em mais situações ("Faster slices" nas release notes). É o que faz o primeiro append em um []int local devolver cap=4. Pode ser desligado com -gcflags=all=-d=variablemakehash=n. |
| Go 1.26 | A mesma otimização foi estendida a mais casos. Nada mudou na semântica; mudou o que você observa em cap. |
Se você mantém código que nasceu antes do Go 1.21, vale uma busca por implementações caseiras de Clip e por remoções feitas com append(s[:i], s[i+1:]...). As primeiras podem ser trocadas por slices.Clip; as segundas provavelmente estão segurando ponteiros na cauda, e slices.Delete resolve.
Juntando tudo: o caminho mental
Se eu tivesse que resumir o raciocínio em uma sequência de perguntas:
- Eu preciso mesmo de um array? Quase sempre não. Array faz sentido quando o tamanho é fixo por natureza (um hash de 32 bytes, um par método/rota como chave de map) ou quando você quer comparabilidade. Fora isso, slice.
- Esse slice vai ser guardado em algum lugar que sobrevive à função? Se vai, decida na fronteira:
slices.Clonese você precisa de dados próprios,slices.Clipse você só quer impedir que umappendfuturo pise em memória alheia. - Eu sei quantos elementos vão entrar? Se sei,
make([]T, 0, n)— com o zero no meio. Se não sei, deixe crescer. - Essa função muta o slice que recebeu? Se muta, isso faz parte do contrato dela e precisa estar documentado. "Recebe
[]Te devolve[]T" não diz se a entrada continua válida. - Estou guardando um pedaço de algo grande? Se estou, clone. O array subjacente inteiro fica vivo enquanto qualquer fatia dele existir.
O que eu levaria deste artigo é que quase todo problema com slices vem de tratar o cabeçalho como se fosse o dado. Ele não é: ele é uma vista sobre um dado que mora em outro lugar e pode ser visto por mais gente. len e cap diferentes não são um detalhe de eficiência, são a informação de que existe memória ali que o seu slice não está mostrando — e que o próximo append pode usar.
Nos zero values o ganho era não precisar lembrar de inicializar. No escape analysis era não precisar decidir onde as coisas moram. Com slices o acordo é diferente: Go te dá uma abstração barata sobre memória compartilhada e, em troca, espera que você saiba que ela é compartilhada. É a única das três em que a linguagem não decide por você.
Referências
- The Go Programming Language Specification — Slice types — a definição de slice como descritor e a frase sobre compartilhamento de armazenamento.
- The Go Programming Language Specification — Array types — o tamanho como parte do tipo.
- The Go Programming Language Specification — Full slice expressions — a sintaxe
a[low:high:max]e como ela define a capacidade. - The Go Programming Language Specification — Appending to and copying slices — o que
appendgarante, e o que ele não garante. - The Go Programming Language Specification — Comparison operators — por que arrays são comparáveis e slices não.
- Go Blog — Arrays, slices (and strings): The mechanics of 'append' — a explicação canônica do cabeçalho de três palavras e de por que
appenddevolve um valor. - Go Blog — Go Slices: usage and internals — o alerta sobre refatiar manter o array inteiro vivo.
runtime/slice.go—growsliceenextslicecap, com othreshold = 256e a fórmula de transição.internal/runtime/gc/sizeclasses.go— a tabela de size classes que explica por que a capacidade observada não bate com a fórmula.cmd/compile/internal/base/flag.go—VariableMakeThreshold = 32, o tamanho do buffer na stack para slices que não escapam.- commit
2dda92f— runtime: make slice growth formula a bit smoother — a mudança da fórmula de crescimento e a justificativa do limite de 256. - Go 1.18 Release Notes — a menção oficial à nova fórmula do
append. - Go 1.21 Release Notes — a entrada do pacote
slicesna biblioteca padrão. - Go 1.22 Release Notes — o zeramento da cauda em
Delete,CompacteReplace, e a chegada deConcat. - Go 1.25 Release Notes — Faster slices — a alocação do array subjacente na stack e a flag
variablemakehash. - Go 1.26 Release Notes — Compiler — a extensão da mesma otimização.
- Documentação do pacote
slices—Clone,Clip,Grow,Deletee o contrato de cada uma, incluindo a cópia rasa. makezero— o linter que pegamake([]T, n)seguido deappend.