Pular para o conteúdo principal

Arrays: Go x JavaScript

16 min de leitura•Arquivado emEstruturas de Dadosem

Por que o Go exige o tamanho do array e o JavaScript não. Compare o consumo de memória com Uint32Array e veja o que o JavaScript troca por conveniência.

No artigo sobre Arrays, um array foi descrito como um bloco de slots do mesmo tamanho, lado a lado na memória. Se você escreve Go, essa descrição bate com o que você digita todo dia. Se você escreve JavaScript, provavelmente não: você nunca diz a um array em JavaScript qual o tamanho dele, o que ele guarda ou quando ele deve crescer.

Aqui está a mesma lista de quatro números nas duas linguagens:

As duas linguagens rodam no mesmo hardware, e o hardware só sabe fazer um tipo de array. Este artigo mostra o que cada linguagem faz com isso, usando o Uint32Array para medir quanto um array em JavaScript custa de verdade em memória.

Por que o Go pede o tamanho

No Go, o tamanho do array faz parte do tipo. [4]uint32 e [5]uint32 são tipos diferentes, tão diferentes quanto int e string:

Como o tipo carrega o tamanho, o compilador sabe exatamente quantos bytes cada array ocupa antes do programa rodar. Um [4]uint32 tem 4 elementos × 4 bytes = 16 bytes, nem um a mais:

Não existe header, campo de capacity nem tag de tipo guardados junto com os dados. O acesso por índice é o cálculo início + índice × 4 do artigo anterior, e o compilador pode colocar o array inteiro na stack ou direto dentro de uma struct, porque sabe quantos bytes reservar.

O preço é a rigidez que apareceu no snippet em Go: o tamanho não muda e precisa ser conhecido quando você escreve o código. Para o resto, o Go tem os slices, um header pequeno (ponteiro, length e capacity: 24 bytes em 64 bits) que aponta para um array e troca ele por um maior quando enche. O artigo de Arrays e slices explica como eles crescem. O que importa aqui é que o Go deixa a escolha explícita: você escolhe entre um array fixo e um slice que cresce, e o tipo dos elementos é sempre fixo.

O que um Array em JavaScript realmente é

A especificação do ECMAScript não define Array como um bloco de memória. Ela define como um exotic object: um objeto comum, com chaves do tipo string, que trata de forma especial as chaves que parecem índices (inteiros de 0 a 2³² − 2) e mantém uma propriedade length sempre uma unidade acima do maior índice.

Essa definição explica tudo que o JavaScript deixa você fazer com um array:

Nada disso se encaixa na definição do artigo anterior. Não tem tamanho fixo por elemento, não tem garantia de contiguidade e não tem tamanho fixo. Por isso alguns livros e cursos nem chamam o Array do JavaScript de array: tratam como um objeto parecido com lista, ou um hash map com chaves inteiras. Do ponto de vista da especificação, eles estão certos.

Como o V8 deixa isso rápido mesmo assim

Se as engines de JavaScript implementassem essa especificação ao pé da letra, todo arr[i] seria uma busca numa hash table. Elas não fazem isso. O V8 (a engine por trás do Chrome e do Node.js) acompanha o que cada array contém e, sempre que dá, guarda os elementos num array contíguo de verdade por baixo dos panos.

Ele faz isso atribuindo a cada array um elements kind. Os três principais são:

Elements kindGuardaArmazenado como
PACKED_SMI_ELEMENTSSó inteiros pequenos (SMIs)Inteiros com tag, um por slot
PACKED_DOUBLE_ELEMENTSNúmeros, incluindo frações e inteiros altosFloats de 64 bits crus, um por slot
PACKED_ELEMENTSQualquer coisa (strings, objetos, misturas)Um ponteiro por slot

Cada um tem também uma variante HOLEY_ para arrays com buracos, e um array esparso demais cai para DICTIONARY_ELEMENTS, que é uma hash table de fato. Dá para ver as transições no Node com a flag --allow-natives-syntax:

As transições só andam num sentido. Depois que a recebe uma string, remover a string não faz ele voltar para PACKED_DOUBLE_ELEMENTS, e um array que ganhou um buraco continua HOLEY_ mesmo depois que você preenche o buraco.

O crescimento funciona como nos arrays dinâmicos do artigo anterior, com outro fator. Quando o array fica sem espaço, o V8 aloca new_length + new_length / 2 + 16 slots e copia os elementos. Fazer push de um milhão de inteiros, um por vez, causa 26 realocações e deixa o array com espaço para 1.304.209 elementos.

Se quiser ir mais fundo, o post Elements kinds in V8 (abre em nova janela), do próprio time do V8, cobre todos os kinds e transições. Os kinds estão declarados em src/objects/elements-kind.h (abre em nova janela), e os métodos de Array ficam em src/builtins (abre em nova janela).

Uint32Array: um array de verdade em JavaScript

O JavaScript tem, sim, arrays no sentido do artigo anterior: os typed arrays. Um Uint32Array é uma view sobre um ArrayBuffer, um bloco cru de bytes, e se comporta muito mais como o [N]uint32 do Go do que como o Array:

O tamanho é fixo, todo elemento é um inteiro de 32 bits sem sinal, e não tem push, buracos nem tipos misturados. Em troca, o layout na memória é exatamente o do artigo anterior:

new Uint32Array([85, 92, 78, 95])
0x1000
85
índice 0
0x1004
92
índice 1
0x1008
78
índice 2
0x100C
95
índice 3

4 bytes por elemento, 16 bytes no total

Compare com um Array que passou para PACKED_ELEMENTS. Os slots continuam contíguos, mas cada um tem 8 bytes, e os valores que não são SMIs moram em outro lugar do heap:

[85, 3000000000, 'x', 95] no Node.js
0x2000
SMI 85
índice 0
0x2008
→ 3e9
índice 1
0x2010
→ 'x'
índice 2
0x2018
SMI 95
índice 3

Slots de 8 bytes; os índices 1 e 2 apontam para objetos separados no heap

Um buffer, várias views

Um Uint32Array é um jeito de ler um ArrayBuffer, não o buffer em si. O buffer é só um monte de bytes, sem tipo de elemento, e dá para colocar várias views sobre os mesmos bytes ao mesmo tempo. Oito bytes podem ser lidos como oito uint8, quatro uint16, dois uint32 ou um único float64:

Cada view aplica a fórmula início + índice × tamanho com o seu próprio tamanho de elemento, então o mesmo índice cai em bytes diferentes:

ViewTamanho do elementoElementosÍndice i cobre os bytes
Uint8Array1 byte8i
Uint16Array2 bytes42i a 2i + 1
Uint32Array4 bytes24i a 4i + 3
Float64Array8 bytes10 a 7

Como elas dividem a mesma memória, escrever por uma view muda o que as outras leem:

shorts[0] vale 772 porque lê os bytes 0 e 1 (0x0304), e shorts[1] vale 258 (0x0102). Depois de doubles[0] = 1, os mesmos oito bytes guardam a codificação IEEE 754 de 1.0, que o Uint32Array lê como 0 e 1.072.693.248. Nada nos bytes diz qual leitura é a certa: quem decide é a view. É o mesmo motivo pelo qual C e Go pedem o tipo do elemento, visto pelo outro lado. A memória não sabe o que guarda, e é o tipo que diz ao programa quantos bytes pular por índice e como interpretá-los.

As views também precisam estar alinhadas com o tamanho do elemento. new Uint32Array(buffer, 2) lança um RangeError, porque um elemento de 4 bytes não pode começar no byte 2, e um Uint32Array também não consegue cobrir um buffer de 6 bytes.

Medindo o consumo de memória

Para colocar números nisso, aloquei um milhão de elementos em cada formato e medi o heap (e, no caso do typed array, a memória de ArrayBuffer) antes e depois, forçando o garbage collector dos dois lados:

No Node.js 24 (V8 13.6, Linux 64 bits), com o equivalente em Go via unsafe.Sizeof como referência:

ContainerElements kindBytes por elemento
Go [1_000_000]uint32n/a4,00
Uint32Arrayn/a4,00
Array.from com inteiros pequenosPACKED_SMI_ELEMENTS8,00
push com inteiros pequenosPACKED_SMI_ELEMENTS10,44
push com números fracionáriosPACKED_DOUBLE_ELEMENTS10,44
push com valores misturadosPACKED_ELEMENTS18,44

Os números batem com os layouts acima:

  • 4 bytes são os dados e mais nada, tanto no Go quanto no typed array.
  • 8 bytes é um slot com tag por SMI, ou um double cru por número. Sem pointer compression, cada slot tem 8 bytes, então inteiros pequenos que caberiam em 4 bytes ocupam 8.
  • 10,44 bytes é o mesmo slot de 8 bytes somado à capacity sobrando que a fórmula de crescimento deixa: 1.304.209 slots × 8 bytes divididos por um milhão de elementos.
  • 18,44 bytes inclui os números em caixa. Metade dos valores não cabe num SMI, e cada um deles vira um objeto separado de 16 bytes no heap, atrás de um ponteiro.

O array misturado usa 4,6 vezes a memória do Uint32Array para o mesmo um milhão de números, e todos eles cabem em 32 bits. No Chrome, a pointer compression corta o slot pela metade, então a diferença é menor, mas o formato do resultado é o mesmo.

O que a gente perde em troca de developer experience

O Array do JavaScript foi pensado para você nunca precisar pensar em memória. Você não escolhe tamanho, não escolhe tipo, e o array nunca fica "cheio". A engine faz isso funcionar adivinhando, acompanhando e convertendo por baixo dos panos, e você paga em alguns lugares:

  • Memória: no mínimo 2× os bytes de um typed array para inteiros no Node.js, mais a sobra do crescimento, mais um objeto no heap para cada número que não é SMI.
  • Previsibilidade: se arr[i] é um cálculo direto de offset, um ponteiro a seguir ou uma busca num dicionário depende do histórico do array, não da declaração dele. Um único arr.push('') ou arr[1e6] = 1 muda a representação para sempre.
  • Localidade: em PACKED_ELEMENTS, os slots são contíguos mas os valores não. Percorrer o array significa seguir ponteiros para objetos espalhados, o que perde a vantagem de cache descrita no artigo anterior.
  • Garantia de tipo: nada impede uma string de parar num array de preços.

O que você ganha em troca é real: código mais curto, nenhum erro de índice fora do limite em tempo de compilação para brigar, e um array que se adapta ao que o programa fizer com ele. Para a maior parte do código de aplicação (uma lista de usuários, os itens de um carrinho) a diferença de bytes não importa, e o Array é a escolha certa.

Os typed arrays compensam a rigidez quando os dados são grandes, numéricos e uniformes:

  • Dados binários: arquivos, pacotes de rede, pixels de imagem (o ImageData do Canvas usa um Uint8ClampedArray).
  • Amostras de áudio e vertex buffers de WebGL, onde as APIs esperam typed arrays diretamente.
  • Trabalho numérico em milhões de valores, onde 4 ou 8 bytes previsíveis por elemento ganham de 10 a 18.
  • Compartilhar memória com WebAssembly, cuja memória linear é um ArrayBuffer.

Resumo

ContainerTamanho definidoTipo dos elementosCresceBytes por valor do tamanho de um uint32 (Node.js)
Go [N]uint32Em tempo de compilaçãoFixoNão4
Go []uint32Em tempo de execuçãoFixoSim (append)4 + capacity sobrando
JS Uint32ArrayNa criaçãoFixo (uint32)Não4
JS ArrayNunca, cresce sob demandaQualquer umSimDe 8 a 18+, conforme o elements kind
  • No Go, o tamanho do array faz parte do tipo, então o compilador sabe exatamente quantos bytes ele ocupa e o layout é o do artigo anterior: os dados e mais nada.
  • O Array do JavaScript é especificado como um objeto com chaves string e um length especial. Por isso alguns autores nem consideram ele um array.
  • O V8 esconde isso acompanhando elements kinds e guardando os elementos de forma contígua quando dá, mas a representação depende do que o array já guardou e só fica mais genérica.
  • O Uint32Array é o array de verdade do JavaScript: tamanho fixo, tipo fixo, exatamente 4 bytes por elemento sobre um ArrayBuffer.
  • Um ArrayBuffer não tem tipo de elemento. Cada view de typed array traz o seu próprio tamanho de elemento, então os mesmos 8 bytes podem ser 8, 4, 2 ou 1 elemento, e escrever por uma view muda o que as outras leem.
  • No Node.js 24, o mesmo um milhão de inteiros ocupou 4 bytes cada num Uint32Array e entre 8 e 18,44 bytes cada num Array, dependendo de como ele foi construído e do que mais ele guardava.