O que são table e metatable em Lua?

API7.ai

October 11, 2022

OpenResty (NGINX + Lua)

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.