Scripting Lua

Tabelas em Luau: array começa no 1, e é isso que quebra o seu laço

Tabela é o único tipo de coleção do Luau, e serve como array e como dicionário. Duas coisas derrubam quem vem de outra linguagem: o índice começa em 1, e remover item durante o laço reordena a tabela embaixo de você.

por Gustavo Cantino9 min de leituraIniciante

Produzido com IA a partir das fontes citadas abaixo, e conferido por checagens automáticas contra essas mesmas fontes. Nenhuma pessoa leu este texto antes da publicação. Política Editorial

Placa tipográfica com a categoria Scripting Lua em destaque, o nível iniciante abaixo, e um campo de barras verticais verde-limão sobre fundo escuro.

Tabela é o único tipo de coleção do Luau. Não existe lista separada de dicionário, nem conjunto, nem tupla — existe tabela, e ela faz os três papéis dependendo de como você a usa.

Isso é simples de explicar e produz dois erros que todo mundo comete uma vez: o índice que começa em 1, e a remoção durante o laço, que reordena a tabela embaixo de você.

O problema

A definição oficial é generosa: a tabela guarda vários valores de qualquer tipo — booleano, número, texto, função e outras tabelas. Ela se constrói com chaves, {}.

E a mesma tabela serve como array ou como dicionário. A diferença não está no tipo; está no que você usa como índice. Array usa uma lista ordenada de números; dicionário aceita número, texto e objeto.

Essa flexibilidade é a força e a confusão. Uma tabela pode ser as duas coisas ao mesmo tempo, com valores em índices numéricos e outros em chaves de texto — o que funciona e quase sempre é sinal de que duas ideias diferentes foram misturadas no mesmo lugar.

A pergunta que organiza a escolha é uma só: a ordem importa, ou a busca importa? Se você precisa saber quem é o primeiro, quem é o próximo, quem entrou antes — array. Se você precisa saber se um item está lá, ou o valor associado a ele — dicionário.

A diferença não é de gosto, é de custo. Perguntar "este jogador está na lista?" a um array significa percorrer a lista inteira até achar — e se a resposta for não, percorrer tudo. Num dicionário, a mesma pergunta é uma consulta direta, independente do tamanho. Com dez itens ninguém nota; com trezentos, numa checagem que roda a cada quadro, nota-se muito.

E há uma consequência menos óbvia sobre clareza. Um array de jogadores não diz, sozinho, se a ordem dele significa alguma coisa. Quem lê o código depois não sabe se pode reordenar aquilo sem quebrar nada. Um dicionário diz na estrutura que a ordem não importa — e essa informação, escrita no tipo em vez de num comentário, é a que sobrevive.

Passo a passo

Array. Para criar, declaram-se os valores em ordem, separados por vírgula. Para ler, colchetes com o número da posição.

E aqui está o primeiro tropeço, dito literalmente na documentação: diferente de outras linguagens, o Luau indexa array a partir de 1. O primeiro item é o de índice [1], não [0].

Para escrever num índice, declara-se o número entre colchetes seguido de = e do valor. Isso vale tanto para sobrescrever quanto para criar uma posição nova.

Para percorrer, há duas formas. A iteração geral, que entrega índice e valor, e o laço numérico — como o índice é numérico, dá para ir de 1 até o comprimento do array, que se obtém com #.

Inserir. Existem duas formas embutidas de acrescentar no fim: o table.insert(), passando a tabela e o valor; e o índice do comprimento mais um, array[#array+1]. As duas fazem a mesma coisa; a segunda é mais rápida de ler quando já se está manipulando índices.

Para inserir no meio, passa-se a posição como segundo argumento do table.insert(). Isso empurra os itens seguintes uma posição para cima.

Remover. A remoção é pelo table.remove(), que tira o item da posição indicada e move os seguintes uma posição para trás. Guarde essa frase — ela é a próxima seção inteira.

Dicionário. É uma extensão do array: guarda pares de chave e valor, e a chave pode ser número, texto ou objeto. Para criar, define-se cada chave seguida de = e do valor.

O detalhe que mais rende no Roblox: a chave pode ser uma Instance. Um dicionário com o jogador como chave responde "este jogador está na lista?" sem percorrer nada. Para usar objeto como chave, declara-se a chave entre colchetes.

Para ler, colchetes com a chave. E chave de texto sem espaço pode ser indexada com ponto, o que costuma ser mais legível.

Artefato

O módulo abaixo é uma fila de jogadores, e ele existe para mostrar a decisão do começo deste texto aplicada: as duas formas, ao mesmo tempo, para coisas diferentes.

O array guarda a ordem — quem entrou antes sai antes. O dicionário, com a Instance do jogador como chave, responde "está na fila?" numa operação, em vez de percorrer a fila inteira a cada consulta. Manter os dois sincronizados custa duas linhas por operação e paga em toda consulta.

No meio dele estão as duas formas corretas de remover durante um percurso, que é o assunto seguinte.

Erro comum

O índice zero. Quem vem de qualquer linguagem popular escreve lista[0] sem pensar. O resultado é nil, e nil não é um erro — é um valor válido. O código segue com nil, e o erro aparece três linhas depois, apontando para o lugar errado.

O caso mais cruel é o laço for i = 0, #lista do, que roda uma vez a mais, começando num nil. Tratar nil com uma verificação faz o bug sumir e o laço continuar errado.

A remoção durante o laço. Este é o que sobrevive à experiência, porque funciona na maior parte dos testes.

O mecanismo está escrito na documentação: table.remove() tira o item da posição indicada e move os seguintes uma posição para trás. Então, num laço crescente, quando você remove o item da posição i, o item que estava em i+1 passa a estar em i — e o laço, no passo seguinte, vai para i+1, pulando o item que acabou de se mover.

O sintoma é característico e enganoso: com um item para remover, funciona. Com dois seguidos, um dos dois sobrevive. E como raramente se testa com dois itens adjacentes, o bug chega à produção e aparece como "às vezes fica alguém na fila".

Há duas correções, e o artefato traz as duas.

A primeira é percorrer de trás para a frente. O que é reordenado fica sempre atrás de onde o laço já passou, então nada é pulado. É a correção de uma linha.

A segunda é montar uma tabela nova com o que fica, em vez de tirar da antiga. Fica mais claro quando a condição é complicada, e não depende de ninguém lembrar do sentido do laço daqui a seis meses.

A mesma lógica vale para inserir durante o percurso, na direção oposta: a inserção no meio empurra os seguintes para cima, e o laço passa duas vezes pelo mesmo item.

A terceira armadilha é usar o # numa tabela que tem buracos. O operador de comprimento conta a sequência a partir de 1, e uma tabela em que alguém apagou o item do meio com array[3] = nil tem comportamento imprevisível. Remover é table.remove(), não atribuir nil.

Próximo passo

O exercício mais útil é olhar as suas tabelas existentes e perguntar, para cada uma, se a ordem importa.

É comum encontrar arrays que existem só para responder "está aqui?" — e cada consulta dessas é um laço inteiro. Trocar por um dicionário com a Instance ou o UserId como chave transforma uma varredura numa consulta direta, e o código fica mais curto no caminho.

O contrário também aparece: dicionários usados onde a ordem importava, e alguém acrescentou um campo posicao para compensar. Isso é um array pedindo para existir.

Vale também abrir os laços que percorrem tabela e conferir dois pontos: se algum deles remove ou insere no meio do percurso, e se algum começa em zero. Os dois são rápidos de encontrar lendo, e os dois produzem bug que não dá erro.

Um terceiro hábito que paga: quando um array e um dicionário descrevem a mesma coleção — como na fila do artefato —, mantenha os dois numa função só, que altera os dois juntos. Espalhar essa sincronia por vários lugares é como um dos dois fica desatualizado, e aí a fila diz que o jogador está nela enquanto a ordem já o tirou.

Um último hábito, pequeno e que evita retrabalho: quando uma tabela tem chave de texto, prefira nomes sem espaço. Chave sem espaço pode ser indexada com ponto, que é mais curto e mais legível, e ainda evita ter que lembrar quais chaves exigem colchetes e aspas e quais não.

Depois disso, vale conhecer a biblioteca table inteira, que a documentação indica em seguida. table.insert e table.remove são as duas mais usadas, e não são as únicas — e cada função dela que você conhece é um laço que não precisa escrever.

Fontes

Como corrigimos erros

Artefato pronto pra usar

Fila de jogadores: array quando a ordem importa, dicionário quando a busca importa
lua
-- ModuleScript: uma fila de jogadores, mostrando QUANDO usar cada forma
-- da mesma tabela -- e a armadilha da remoção durante o laço.

local Fila = {}

-- ARRAY: a ordem É o dado. Quem entrou antes sai antes.
local ordem = {}

-- DICIONÁRIO com a Instance como chave: responde "este jogador está na
-- fila?" sem percorrer nada. A documentação permite objeto como chave,
-- e é para isto que serve.
local presente = {}

function Fila.entrar(player)
	if presente[player] then
		return false
	end
	ordem[#ordem + 1] = player -- a segunda forma oficial de inserir no fim
	presente[player] = true
	return true
end

function Fila.estaNaFila(player)
	-- O(1). Com array puro isto seria um laço a cada consulta.
	return presente[player] == true
end

function Fila.posicaoDe(player)
	for i, p in ordem do
		if p == player then
			return i -- lembre: a primeira posição é 1, não 0
		end
	end
	return nil
end

function Fila.sair(player)
	local i = Fila.posicaoDe(player)
	if not i then
		return false
	end
	table.remove(ordem, i) -- move os seguintes uma posição para TRÁS
	presente[player] = nil
	return true
end

-- ─────────────────────────────────────────────────────────────────
-- A ARMADILHA: remover DENTRO do laço
-- ─────────────────────────────────────────────────────────────────
-- ERRADO. `table.remove` puxa todo mundo uma casa para trás, e o índice
-- seguinte pula o item que acabou de ocupar a posição removida. Com dois
-- jogadores que saíram lado a lado, um dos dois FICA na fila.
--
--   for i = 1, #ordem do
--       if saiu(ordem[i]) then table.remove(ordem, i) end
--   end
--
-- CERTO, jeito 1: percorrer DE TRÁS PARA A FRENTE. O que é reordenado
-- fica sempre atrás de onde o laço já passou.
function Fila.limparDesconectados()
	for i = #ordem, 1, -1 do
		local player = ordem[i]
		if not player.Parent then
			table.remove(ordem, i)
			presente[player] = nil
		end
	end
end

-- CERTO, jeito 2: montar uma tabela nova. Mais claro quando a condição
-- é complicada, e não depende de lembrar do sentido do laço.
function Fila.somenteVivos()
	local nova = {}
	for _, player in ordem do
		local character = player.Character
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if humanoid and humanoid.Health > 0 then
			nova[#nova + 1] = player
		end
	end
	return nova
end

function Fila.proximo()
	local player = ordem[1]
	if not player then
		return nil
	end
	table.remove(ordem, 1)
	presente[player] = nil
	return player
end

return Fila
Neste artigo

Continue por aqui