O que são table e metatable em Lua?
API7.ai
October 11, 2022
Hoje vamos aprender sobre a única estrutura de dados no LuaJIT: a table
.
Ao contrário de outras linguagens de script com estruturas de dados ricas, o LuaJIT possui apenas uma estrutura de dados, a table
, que não é distinguida entre arrays, hashes, coleções, etc., mas é uma mistura de tudo. Vamos revisar um dos exemplos mencionados anteriormente.
local color = {first = "red", "blue", third = "green", "yellow"}
print(color["first"]) --> output: red
print(color[1]) --> output: blue
print(color["third"]) --> output: green
print(color[2]) --> output: yellow
print(color[3]) --> output: nil
Neste exemplo, a tabela color
contém um array e um hash e pode ser acessada sem interferência mútua. Por exemplo, você pode usar a função ipairs
para iterar apenas pela parte do array da tabela.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
for k, v in ipairs(color) do
print(k)
end
'
As operações com table
são tão cruciais que o LuaJIT estende a biblioteca padrão de tabelas do Lua 5.1, e o OpenResty estende ainda mais a biblioteca de tabelas do LuaJIT. Vamos dar uma olhada em cada uma dessas funções da biblioteca.
As funções da biblioteca table
Vamos começar com as funções padrão da biblioteca table. O Lua 5.1 não tem muitas funções na biblioteca table, então podemos passar rapidamente por elas.
table.getn
Obter o número de elementos
Como mencionamos no capítulo Lua Padrão e LuaJIT, obter o número correto de todos os elementos da tabela é um grande problema no LuaJIT.
Para sequências, você pode usar table.getn
ou o operador unário #
para retornar o número correto de elementos. O exemplo a seguir retorna o número 3, como esperado.
$ resty -e 'local t = { 1, 2, 3 }
print(table.getn(t))
O valor correto não pode ser retornado para tabelas que não são sequenciais. No segundo exemplo, o valor retornado é 1.
$ resty -e 'local t = { 1, a = 2 }
print(#t) '
Felizmente, tais funções difíceis de entender foram substituídas por extensões do LuaJIT, que mencionaremos mais tarde. Portanto, no contexto do OpenResty, não use a função table.getn
e o operador unário #
, a menos que você saiba explicitamente que está obtendo o comprimento de uma sequência.
Além disso, table.getn
e o operador unário #
não têm complexidade de tempo O(1), mas O(n), o que é outro motivo para evitá-los, se possível.
table.remove
Remove o elemento especificado
A segunda é a função table.remove
, que remove elementos na tabela com base em subscritos, ou seja, apenas os elementos na parte do array da tabela podem ser removidos. Vamos olhar novamente o exemplo color
.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.remove(color, 1)
for k, v in pairs(color) do
print(v)
end'
Este código removerá o blue
com subscrito 1. Você pode perguntar: como eu removo a parte do hash da tabela? É tão simples quanto definir o valor correspondente à chave como nil
. Assim, no exemplo color
, o green
correspondente a third
é removido.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
color.third = nil
for k, v in pairs(color) do
print(v)
end'
table.concat
Função de concatenação de elementos
A terceira é a função de concatenação de elementos table.concat
. Ela concatena os elementos da tabela de acordo com os subscritos. Como isso é novamente baseado em subscritos, ainda é para a parte do array da tabela. Novamente com o exemplo color
.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
print(table.concat(color, ", "))'
Após usar a função table.concat
, ela retorna blue, yellow
e a parte do hash é ignorada.
Além disso, esta função também pode especificar a posição inicial do subscrito para fazer a concatenação; por exemplo, ela é escrita como a seguir:
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow", "orange"}
print(table.concat(color, ", ", 2, 3))'
Desta vez, a saída é yellow, orange
, pulando blue
.
Por favor, não subestime esta função aparentemente inútil, mas ela pode ter efeitos inesperados ao otimizar o desempenho e é uma das principais personagens em nossos capítulos posteriores de otimização de desempenho.
table.insert
Insere um elemento
Finalmente, vamos olhar para a função table.insert
. Ela insere um novo elemento no subscrito especificado, o que afeta a parte do array da tabela. Para ilustrar, novamente usando o exemplo color
.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.insert(color, 1, "orange")
print(color[1])
'
Você pode ver que o primeiro elemento de color
se torna orange
, mas, claro, você pode deixar o subscrito não especificado, de modo que ele será inserido no final da fila por padrão.
Devo observar que table.insert
é uma operação comum, mas o desempenho não é bom. Se você não estiver inserindo elementos com base no subscrito especificado, precisará chamar lj_tab_len
do LuaJIT cada vez para obter o comprimento do array e inserir no final da fila. Como table.getn
, a complexidade de tempo para obter o comprimento da tabela é O(n).
Portanto, para a operação table.insert
, devemos tentar evitar usá-la em código quente. Por exemplo:
local t = {}
for i = 1, 10000 do
table.insert(t, i)
end
Funções de extensão da tabela do LuaJIT
A seguir, vamos olhar para as funções de extensão da tabela do LuaJIT. O LuaJIT estende o Lua padrão com duas funções úteis para criar e esvaziar uma tabela, que descreverei abaixo.
table.new(narray, nhash)
Cria uma nova tabela
A primeira é a função table.new(narray, nhash)
. Em vez de crescer por si só ao inserir elementos, esta função pré-aloca o tamanho do espaço do array e do hash especificados, que é o que seus dois parâmetros narray
e nhash
significam. O crescimento automático é uma operação custosa que envolve alocação de espaço, resize
e rehash
, e deve ser evitado a todo custo.
Observe aqui que a documentação de table.new
não está no site do LuaJIT, mas está profundamente na documentação estendida do projeto no GitHub, então é difícil encontrá-la mesmo no Google, e poucos engenheiros sabem disso.
Aqui está um exemplo simples, e vou mostrar como ela funciona. Primeiro, esta função é estendida, então antes de usá-la, você precisa require
.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
Como você pode ver, este código cria uma nova tabela com 100 elementos de array e 0 elementos de hash. Claro, você pode criar uma nova tabela com 100 elementos de array e 50 elementos de hash, conforme necessário, o que é legal.
local t = new_tab(100, 50)
Alternativamente, se você ultrapassar o tamanho do espaço pré-definido, ainda poderá usá-la normalmente, mas o desempenho será degradado, e o ponto de usar table.new
será perdido.
No exemplo a seguir, temos um tamanho pré-definido de 100, mas estamos usando 200.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 200 do
t[i] = i
end
Você precisa pré-definir o tamanho do espaço do array e do hash em table.new
de acordo com o cenário real, para que possa encontrar um equilíbrio entre desempenho e uso de memória.
table.clear()
Limpa a tabela
A segunda é a função de limpeza table.clear()
. Ela limpa todos os dados de uma tabela, mas não libera a memória ocupada pelas partes do array e do hash. Portanto, é útil ao reciclar tabelas Lua para evitar a sobrecarga de criar e destruir tabelas repetidamente.
$ resty -e 'local clear_tab =require "table.clear"
local color = {first = "red", "blue", third = "green", "yellow"}
clear_tab(color)
for k, v in pairs(color) do
print(k)
end'
No entanto, há poucos cenários onde esta função pode ser usada, e na maioria dos casos, devemos deixar essa tarefa para o GC do LuaJIT.
Funções de extensão da tabela do OpenResty
Como mencionei no início, o OpenResty mantém seu próprio branch do LuaJIT, que também estende a tabela, com várias novas APIs: table.isempty
, table.isarray
, table.nkeys
e table.clone
.
Antes de usar essas novas APIs, verifique a versão do OpenResty, pois a maioria dessas APIs só pode ser usada em versões do OpenResty após 1.15.8.1. Isso ocorre porque o OpenResty não teve um novo lançamento por cerca de um ano antes da versão 1.15.8.1, e essas APIs foram adicionadas nesse intervalo de lançamento.
Incluí um link para o artigo, então vou usar table.nkeys
como exemplo. As outras três APIs são fáceis de entender a partir da nomenclatura, então dê uma olhada na documentação do GitHub, e você entenderá. Devo dizer que a documentação do OpenResty é de alta qualidade, incluindo exemplos de código, se pode ser JIT, o que observar, etc. Várias ordens de magnitude melhor do que a documentação do Lua e do LuaJIT.
Ok, de volta à função table.nkeys
. Sua nomenclatura pode confundir você, mas é uma função que obtém o comprimento da tabela e retorna o número de elementos da tabela, incluindo os elementos do array e da parte do hash. Portanto, podemos usá-la em vez de table.getn
, por exemplo, como a seguir.
local nkeys = require "table.nkeys"
print(nkeys({})) -- 0
print(nkeys({ "a", nil, "b" })) -- 2
print(nkeys({ dog = 3, cat = 4, bird = nil })) -- 2
print(nkeys({ "a", dog = 3, cat = 4 })) -- 3
Metatable
Depois de falar sobre a função da tabela, vamos olhar para a metatable
derivada da table
. A metatable é um conceito único no Lua e é amplamente usada em projetos reais. Não é exagero dizer que você pode encontrá-la em quase qualquer biblioteca lua-resty-*
.
A metatable
se comporta como sobrecarga de operadores; por exemplo, podemos sobrecarregar __add
para calcular a concatenação de dois arrays Lua ou __tostring
para definir funções que convertem para strings.
O Lua, por outro lado, fornece duas funções para lidar com metatable.
- A primeira é
setmetatable(table, metatable)
, que configura uma metatable para uma tabela. - A segunda é
getmetatable(table)
, que obtém a metatable da tabela.
Depois de tudo isso, você pode estar mais interessado no que ela faz, então vamos ver para que a metatable é usada especificamente. Aqui está um trecho de código de um projeto real.
$ resty -e ' local version = {
major = 1,
minor = 1,
patch = 1
}
version = setmetatable(version, {
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
Primeiro definimos uma tabela chamada version
, e como você pode ver, o propósito deste código é imprimir o número da versão em version
. No entanto, não podemos imprimir version
diretamente. Você pode tentar fazer isso e verá que imprimir diretamente só retornará o endereço da tabela.
print(tostring(version))
Portanto, precisamos personalizar a função de conversão de string para esta tabela, que é __tostring
, e é aqui que a metatable entra. Usamos setmetatable
para redefinir o método __tostring
da tabela version
para imprimir o número da versão: 1.1.1.
Além de __tostring
, frequentemente sobrecarregamos os seguintes dois metamétodos na metatable em projetos reais.
Um deles é __index. Quando procuramos um elemento em uma tabela, primeiro o procuramos diretamente na tabela, e se não o encontrarmos, vamos para o __index
da metatable.
No exemplo a seguir, removemos o patch
da tabela version
.
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = function(t, key)
if key == "patch" then
return 2
end
end,
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
Neste caso, t.patch
não obtém o valor, então ele vai para a função __index
, que imprime 1.1.2.
__index
pode ser não apenas uma função, mas também uma tabela, e se você tentar executar o seguinte código, verá que eles alcançam o mesmo resultado.
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = {patch = 2},
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
Outro metamétodo é __call. Ele é semelhante a um functor que permite que uma tabela seja chamada.
Vamos construir sobre o código acima que imprime o número da versão e ver como chamar uma tabela.
$ resty -e '
local version = {
major = 1,
minor = 1,
patch = 1
}
local function print_version(t)
print(string.format("%d.%d.%d", t.major, t.minor, t.patch))
end
version = setmetatable(version,
{__call = print_version})
version()
'
Neste código, usamos setmetatable
para adicionar uma metatable à tabela version
, e o metamétodo __call
dentro dela aponta para a função print_version
. Portanto, se tentarmos chamar version
como uma função, a função print_version
será executada aqui.
E getmetatable
é a operação emparelhada com setmetatable
para obter a metatable que foi configurada, como no seguinte código.
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = {patch = 2},
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(getmetatable(version).__index.patch)
'
Além desses três metamétodos que discutimos hoje, há alguns metamétodos menos usados que você pode consultar na documentação para aprender mais quando os encontrar.
Orientação a Objetos
Finalmente, vamos falar sobre orientação a objetos. Como você deve saber, o Lua não é uma linguagem de Orientação a Objetos, mas podemos usar metatable para implementar OO.
Vamos ver um exemplo prático. lua-resty-mysql é o cliente MySQL oficial do OpenResty, e ele usa metatables para simular classes e métodos de classe, que são usados da seguinte forma.
$ resty -e 'local mysql = require "resty.mysql" -- primeiro referencie a biblioteca lua-resty
local db, err = mysql:new() -- Cria uma nova instância da classe
db:set_timeout(1000) -- Chamando métodos de uma classe
Você pode executar o código acima diretamente com o comando resty
. Essas linhas de código são fáceis de entender; a única coisa que pode causar problemas é:
Por que, ao chamar um método de classe, é usado dois pontos em vez de um ponto?
Na verdade, tanto dois pontos quanto pontos são aceitáveis aqui, e db:set_timeout(1000)
e db.set_timeout(db, 1000)
são exatamente equivalentes. Os dois pontos são um açúcar sintático no Lua que permite omitir o primeiro argumento self
de uma função.
Como todos sabemos, não há segredos diante do código-fonte, então vamos ver a implementação concreta correspondente às linhas de código acima para que você possa entender melhor como modelar orientação a objetos com metatables.
local _M = { _VERSION = '0.21' } -- Usando a tabela para simular uma classe
local mt = { __index = _M } -- mt é abreviação de metatable, __index se refere à própria classe
-- Construtor da classe
function _M.new(self)
local sock, err = tcp()
if not sock then
return nil, err
end
return setmetatable({ sock = sock }, mt) -- exemplo de simulação de classes usando tabela e metatable
end
-- Funções membro de uma classe
function _M.set_timeout(self, timeout) -- Use o argumento self para obter uma instância da classe que deseja operar
local sock = self.sock
if not sock then
return nil, "not initialized"
end
return sock:settimeout(timeout)
end
A tabela _M
simula uma classe inicializada com uma única variável membro _VERSION
e posteriormente define funções membro como _M.set_timeout
. No construtor _M.new(self)
, retornamos uma tabela cuja metatable é mt
, e o metamétodo __index
de mt
aponta para _M
, de modo que a tabela retornada simula uma instância da classe _M
.
Resumo
Bem, isso conclui o conteúdo principal de hoje. Tabela e metatable são amplamente usadas na biblioteca lua-resty-*
do OpenResty e em projetos de código aberto baseados no OpenResty. Espero que esta lição facilite a leitura e compreensão do código-fonte.
Há outras funções padrão no Lua além da tabela, que aprenderemos juntos na próxima lição.
Finalmente, gostaria de deixar uma pergunta para reflexão. Por que a biblioteca lua-resty-mysql
simula OO como uma camada de encapsulamento? Sinta-se à vontade para discutir esta questão na seção de comentários, e compartilhe este artigo com seus colegas e amigos para que possamos nos comunicar e progredir juntos.