Toda medição e toda saída deste post foram produzidas no Node 24.16.0, V8 13.6.233.17. Os layouts de elemento da seção 7 foram inspecionados com node --allow-natives-syntax.
1. O que é um array, e por que a definição importa
Um array, no sentido de estrutura de dados, é um bloco contíguo de memória guardando elementos de tamanho igual e conhecido. Essa única frase já carrega todo o resto:
int xs[5];
// xs[3] não é uma consulta. É aritmética:
// endereço(xs[3]) = endereço(xs) + 3 * sizeof(int)
Como os elementos têm o mesmo tamanho e ficam um ao lado do outro, o endereço do elemento i se calcula com uma multiplicação e uma soma. Indexar em tempo constante não é um recurso que a estrutura oferece. É consequência do layout, e é inevitável: não há o que procurar, não há o que hashear, não há o que percorrer.
O mesmo layout força o resto do contrato. O comprimento é fixo, porque o bloco foi alocado uma vez. Todo slot tem o mesmo tamanho, porque a aritmética precisa de um passo constante. Isso em geral significa tipo uniforme, embora um array de ponteiros satisfaça a exigência apontando para coisas diferentes. Não existem vãos, porque todo byte do bloco faz parte de algum elemento, tendo você escrito nele ou não.
Guarde essas quatro propriedades: contíguo, slots de tamanho fixo, comprimento fixo, sem vãos. É isso que a palavra significa. O resto que usa o nome está usando emprestado.
2. O que o JavaScript te dá no lugar
Na especificação do ECMAScript, um Array é um objeto exótico: um objeto comum com um [[DefineOwnProperty]] customizado e uma propriedade length que se mantém sozinha. Repare no substantivo. É um objeto.
Isso não é metáfora. Chaves de propriedade em JavaScript são de dois tipos, strings e symbols, e índices de array são sempre do primeiro tipo:
const a = [10, 20, 30];
Object.keys(a); // ["0", "1", "2"]
typeof Object.keys(a)[0]; // "string"
a['1'] === a[1]; // true
Reflect.ownKeys(a); // ["0", "1", "2", "length"]
a[1] não é um deslocamento. É uma consulta de propriedade cuja chave por acaso é a string "1", e o número que você escreveu virou essa string no caminho. Não existe aritmética de endereço em lugar nenhum dessa abstração. Se alguma aritmética acontece por baixo, isso é decisão que o motor toma depois, e a seção 7 é sobre como ele decide.
3. O índice não é um número, é uma string bem específica
Se índices são chaves string, alguma coisa precisa decidir quais strings são índices. A especificação decide, e a regra é estreita: uma chave é índice de array quando ela é a string decimal canônica de um inteiro não negativo abaixo de 232 menos 1. Canônica quer dizer que a ida e volta sobrevive, ou seja, String(Number(chave)) === chave. Não negativo e inteiro são exigências separadas, e cada uma delas exclui um caso que parece que deveria funcionar.
Todo o resto é propriedade comum que por acaso mora num array:
const c = [1, 2, 3];
c[-1] = 'negativo';
c[1.5] = 'fracionário';
c['01'] = 'zero à esquerda';
c.length; // 3, inalterado pelas três
Object.keys(c); // ["0", "1", "2", "-1", "1.5", "01"]
Três escritas com cara de indexação, nenhuma delas indexou coisa alguma, e cada uma falha numa cláusula diferente da regra. -1 não é não negativo. 1.5 não é inteiro. '01' não é canônica, porque String(Number('01')) é '1', não '01'.
O limite de cima é tão real quanto:
const b = [];
b[4294967294] = 'último índice válido'; // 2**32 - 2
b[4294967295] = 'não é índice'; // 2**32 - 1
b.length; // 4294967295
Object.prototype.hasOwnProperty.call(b, '4294967295'); // true, como propriedade comum
const big = [];
big.length = 2 ** 32; // RangeError: Invalid array length
Num array de verdade o índice é um deslocamento, limitado pela alocação. Aqui ele é uma string, limitada por uma regra de especificação, e passar um a mais do limite não falha. Ele silenciosamente deixa de ser índice e vira propriedade, que é um modo de falhar bem mais estranho que um segmentation fault.
4. A estrutura admite ausência
Um array de verdade não pode ter vão. Todo slot é byte, e byte é sempre alguma coisa, mesmo que essa coisa seja lixo não inicializado.
Um array de JavaScript pode estar sem um elemento inteiro, porque propriedade faltando é algo que objeto pode ter:
const d = [1, 2, 3];
delete d[1];
d.length; // 3
1 in d; // false
d[1]; // undefined
Object.keys(d); // ["0", "2"]
JSON.stringify(d); // "[1,null,3]"
O length continua dizendo três. A chave "1" sumiu. Ler devolve undefined, que é exatamente o que você obteria de uma propriedade que nunca existiu, porque é isso que ela virou.
Repare que um buraco e um undefined guardado são estados diferentes que se leem igual:
const e = [1, undefined, 3];
1 in e; // true, a propriedade existe e guarda undefined
E, como buraco é propriedade ausente, ler um buraco faz o que ler qualquer propriedade ausente faz: sobe a cadeia de protótipos.
const f = [1, , 3];
Array.prototype[1] = 'veio do protótipo';
f[1]; // "veio do protótipo"
Esse é o argumento inteiro numa linha. Nenhum array no sentido de estrutura de dados consegue resolver uma leitura consultando um objeto pai, porque não há consulta a interceptar, só aritmética.
5. length é um ajuste, não uma medição
Num array de verdade o comprimento é propriedade da alocação. Você não atribui a ele. Em JavaScript é propriedade gravável, e escrever nela é o jeito de redimensionar:
const g = [1, 2, 3, 4, 5];
g.length = 2; // [1, 2] os três últimos se foram
g.length = 5; // [1, 2, <3 empty items>]
3 in g; // false
Truncar destrói dados. Estender fabrica buraco. E length não é a quantidade de elementos, porque nada conta coisa alguma. A regra é mais estreita: escrever um índice de array igual ou acima do length atual faz o length virar esse índice mais um, e escrever direto no length o deixa com o valor que você pediu.
As duas metades importam, porque qualquer uma delas sozinha seria uma história mais bonita que a verdade:
const a = [];
a.length = 100;
Object.keys(a).length; // 0, cem elementos que ele nunca teve
const b = [1, 2, 3];
b.length = 1; // agora 1, mesmo tendo o índice 2 sido atribuído
É por isso que Object.keys(d).length e d.length discordam no instante em que existe um buraco.
6. É objeto, então carrega o que você pendurar
const h = [1, 2];
h.autor = 'arthur';
h.length; // 2
Object.keys(h); // ["0", "1", "autor"]
JSON.stringify(h); // "[1,2]"
A propriedade existe, o length ignora, e o serializador descarta. Três subsistemas, três opiniões diferentes sobre se aquilo faz parte do array.
E o sistema de tipos, no pouco que ele é, não concorda com nenhum dos três:
typeof [1, 2, 3]; // "object"
Array.isArray([1,2,3]); // true
Array.isArray existe justamente porque typeof não sabe responder. Não existe tipo array para reportar.
7. Então por que é rápido? Porque o motor mente por você
Tudo acima descreve um dicionário com regras. Se os motores implementassem isso ao pé da letra, código com array seria inutilizável de tão lento. Eles não implementam. O V8 rastreia o que ele chama de elements kind de cada array e escolhe uma representação compatível.
Os kinds formam uma escada do mais específico para o mais geral, e as transições são visíveis:
const a = [1, 2, 3]; // PACKED_SMI_ELEMENTS
a.push(4.5); // PACKED_DOUBLE_ELEMENTS
a.push('texto'); // PACKED_ELEMENTS
Cada degrau amplia o que um slot pode guardar, e cada degrau custa algo. O caso de inteiro pequeno empacotado guarda inteiros pequenos com tag direto num armazenamento contíguo, que é um array de verdade no sentido da seção 1. O caso de double empacotado guarda doubles sem boxing. O caso geral usa slots com tag que aceitam os dois: um inteiro pequeno continua cabendo no próprio slot, e qualquer outra coisa vira referência para o heap.
Um segundo eixo rastreia se buracos são possíveis:
| o que você escreveu | kind resultante |
|---|---|
[1, 2, 3] | PACKED_SMI |
[1.1, 2.2] | PACKED_DOUBLE |
[] | PACKED_SMI |
new Array(3) | HOLEY_SMI |
[1,2,3] e depois delete a[1] | HOLEY_SMI |
[1,2,3] e depois a[10] = 4 | HOLEY_SMI |
[1,2,3] e depois a[100000] = 1 | DICTIONARY_ELEMENTS |
Representações com buraco precisam checar, a cada leitura, se o slot é um buraco, e, se for, recorrer à cadeia de protótipos da seção 4. O modo dicionário abandona o buffer de vez e guarda os elementos numa tabela hash, que é bem mais próximo da consulta de propriedade que a especificação de fato descreve.
As transições não se revertem sozinhas. Remover o elemento que causou a mudança não restaura a representação anterior:
const h = [1.5, 2.5, 3.5]; // PACKED_DOUBLE
h.push('x'); // PACKED_ELEMENTS
h.pop(); // continua PACKED_ELEMENTS
Só que elas não são estritamente de mão única. Uma operação que reescreve todos os slots pode produzir um kind mais estreito, e o fill é o caso mais claro no V8 atual. Ele leva um array com buraco de volta para empacotado, e na minha medição levou um PACKED_ELEMENTS de volta até PACKED_SMI:
const a = [1, 2, 3];
delete a[1]; // HOLEY_SMI
a.fill(0); // PACKED_SMI, descendo a escada de novo
Então a formulação útil não é que a escada é de mão única, é que você nunca desce de volta por acidente. Alargar acontece como efeito colateral de escritas comuns; estreitar exige uma operação que reescreve o armazenamento inteiro.
Essa é a resposta de verdade para o título. O array que você acha que está usando é uma representação que o motor te concede enquanto seu uso fica dentro de um conjunto de suposições, e revoga em silêncio quando não fica. A especificação promete um objeto com length que se mantém sozinho. O buffer contíguo é otimização, não garantia.
8. O precipício, medido
Três arrays de 100.000 inteiros, conteúdo idêntico e comprimento idêntico, diferindo só no elements kind. Somando cada um com um for indexado simples, 100 rodadas de aquecimento, mediana de 51 rodadas medidas:
| elements kind | mediana | relativo |
|---|---|---|
PACKED_SMI | 0,053 ms | 1x |
HOLEY_SMI | 0,080 ms | cerca de 1,2x a 1,5x entre execuções |
DICTIONARY | 2,94 ms | cerca de 43x a 56x entre execuções |
A penalidade do buraco é real, mas modesta, e variou o bastante entre execuções para eu não cravar um multiplicador. A penalidade do dicionário não é modesta e não variou: é a diferença entre aritmética e hash, nos mesmos dados, no mesmo laço.
O que produziu o caso dicionário merece ser encarado. Foi uma atribuição:
const a = [];
for (let i = 0; i < 100000; i++) a.push(i);
a[5000000] = 1; // um índice bem além do fim
a.length = 100000; // desfazer não restaura a representação
Uma única escrita num índice distante converteu um buffer contíguo em tabela hash, e o array seguiu com toda a aparência de array rodando cinquenta vezes mais devagar.
9. O array de verdade se escreve diferente
O JavaScript tem sim a estrutura da seção 1. Ela tem outro nome:
const t = new Int32Array(3);
t.BYTES_PER_ELEMENT; // 4
t.buffer.byteLength; // 12, exatamente 3 * 4
t[5] = 99; // fora dos limites: descartado
t[5]; // undefined
Object.prototype.hasOwnProperty.call(t, '5'); // false, nenhuma propriedade foi criada
t.length = 10; // TypeError em modo estrito: length só tem getter
Comprimento fixo, tipo de elemento uniforme, buffer contíguo por trás, e escrita fora dos limites é descartada em vez de virar propriedade. Doze bytes, três inteiros, aritmética de endereço. Isso é um array.
Uma ressalva sobre “fixo”. Desde o ArrayBuffer redimensionável, um typed array construído sobre um buffer redimensionável acompanha esse buffer, e o comprimento dele muda quando o buffer muda:
const rab = new ArrayBuffer(8, { maxByteLength: 64 });
const t = new Int32Array(rab); // t.length é 2
rab.resize(32); // t.length agora é 8
O new Int32Array(3) do exemplo acima não faz isso. O buffer dele não é redimensionável, então o comprimento fica decidido na construção.
Continua sendo objeto, e continua aceitando o que você pendurar (t.foo = 'bar' funciona). Mas a parte indexada deixou de ser negociável, e é aí que mora toda a diferença.
10. O que decorre disso na prática
Tudo abaixo foi medido do mesmo jeito, e é o que o argumento deixa de prático:
| construção | kind resultante |
|---|---|
[] e push num laço | PACKED_SMI |
new Array(3) | HOLEY_SMI |
new Array(3).fill(0) | PACKED_SMI |
Array.from({length: 3}, (_, i) => i) | PACKED_SMI |
[1,2,3] e depois splice(1, 1) | PACKED_SMI |
[1,2,3] e depois delete a[1] | HOLEY_SMI |
map sobre array com buraco | HOLEY_SMI |
filter sobre array com buraco | HOLEY_SMI |
Quatro coisas decorrem daí, em ordem decrescente de importância:
Nunca escreva muito além do fim. É a única com modo de falha catastrófico, e a seção 8 mediu. Todo o resto desta lista custa uma fração; essa custa uma ordem de grandeza, e sobrevive à remoção do que a causou.
Remova com splice ou pop, não com delete. delete abre buraco por definição: ele remove uma propriedade. splice reindexa e mantém o array empacotado.
Se for pré-dimensionar, preencha. new Array(n) já nasce com buraco. new Array(n).fill(0) e Array.from({length: n}, fn) não.
Buraco se propaga. map e filter sobre array com buraco devolvem array com buraco, mesmo o filter descartando os buracos. Se um buraco entra cedo nos seus dados, ele viaja.
E uma calibragem, porque esse assunto atrai folclore: na medição acima, buraco custou algo entre vinte e cinquenta por cento num laço de soma apertado. Vale evitar, não vale reescrever código em pânico. Modo dicionário é o que se deve temer de fato.
11. Conclusão
O nome é o problema. “Array” importa uma expectativa de memória contígua, indexação em tempo constante por aritmética, slots de tamanho fixo e comprimento fixo, e o JavaScript nunca prometeu nenhuma das quatro. O que a linguagem define é um objeto cujas chaves string com cara de inteiro recebem tratamento especial e cujo length se mantém sozinho.
O fato de ele se comportar como array mesmo assim é trabalho do motor, não da linguagem, e o motor faz isso condicionalmente. Cada esquisitice das seções 3 a 6 não é uma verruga num array. É o objeto aparecendo por baixo, que é o que estava ali desde sempre.
O valor prático de saber disso não é microotimização. É que os comportamentos deixam de ser surpresa. Um buraco resolvido pela cadeia de protótipos, um length que discorda do Object.keys, um índice que silenciosamente vira propriedade, uma lentidão de cinquenta vezes sem causa visível: nada disso é anomalia para decorar depois que você sabe que está segurando um objeto que finge, com competência e condicionalmente, ser um array.
