¿Qué es table y metatable en Lua?

API7.ai

October 11, 2022

OpenResty (NGINX + Lua)

Hoy aprenderemos sobre la única estructura de datos en LuaJIT: table.

A diferencia de otros lenguajes de scripting con estructuras de datos ricas, LuaJIT tiene solo una estructura de datos, table, que no se distingue entre arreglos, hashes, colecciones, etc., sino que está algo mezclada. Revisemos uno de los ejemplos 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

En este ejemplo, la tabla color contiene un arreglo y un hash, y se puede acceder a ellos sin interferir entre sí. Por ejemplo, puedes usar la función ipairs para iterar solo la parte del arreglo de la tabla.

$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
for k, v in ipairs(color) do
      print(k)
end
'

Las operaciones con table son tan cruciales que LuaJIT extiende la biblioteca estándar de tablas de Lua 5.1, y OpenResty extiende aún más la biblioteca de tablas de LuaJIT. Veamos cada una de estas funciones de la biblioteca.

Funciones de la biblioteca table

Comencemos con las funciones estándar de la biblioteca table. Lua 5.1 no tiene muchas funciones en la biblioteca table, así que podemos revisarlas rápidamente.

table.getn Obtener el número de elementos

Como mencionamos en el capítulo Lua estándar y LuaJIT, obtener el número correcto de todos los elementos de una tabla es un gran problema en LuaJIT.

Para secuencias, puedes usar table.getn o el operador unario # para devolver el número correcto de elementos. El siguiente ejemplo devuelve el número 3 que esperaríamos.

$ resty -e 'local t = { 1, 2, 3 }
  print(table.getn(t))

No se puede devolver el valor correcto para tablas que no son secuenciales. En el segundo ejemplo, el valor devuelto es 1.

$ resty -e 'local t = { 1, a = 2 }
  print(#t) '

Afortunadamente, estas funciones difíciles de entender han sido reemplazadas por extensiones de LuaJIT, que mencionaremos más adelante. Por lo tanto, en el contexto de OpenResty, no uses la función table.getn ni el operador unario # a menos que sepas explícitamente que estás obteniendo la longitud de una secuencia.

Además, table.getn y el operador unario # no tienen una complejidad de tiempo O(1), sino O(n), lo cual es otra razón para evitarlos si es posible.

table.remove Elimina el elemento especificado

La segunda es la función table.remove, que elimina elementos en la tabla basados en subíndices, es decir, solo se pueden eliminar los elementos en la parte del arreglo de la tabla. Veamos nuevamente el ejemplo de 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 eliminará el blue con el subíndice 1. Puedes preguntarte, ¿cómo elimino la parte del hash de la tabla? Es tan simple como establecer el valor correspondiente a la clave en nil. Por lo tanto, en el ejemplo de color, se elimina el green correspondiente a third.

$ 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 Función de concatenación de elementos

La tercera es la función de concatenación de elementos table.concat. Concatena los elementos de la tabla según los subíndices. Dado que esto se basa nuevamente en subíndices, sigue siendo para la parte del arreglo de la tabla. Nuevamente con el ejemplo de color.

$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
print(table.concat(color, ", "))'

Después de usar la función table.concat, se imprime blue, yellow y se omite la parte del hash.

Además, esta función también puede especificar la posición inicial del subíndice para hacer la concatenación; por ejemplo, se escribe de la siguiente manera:

$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow", "orange"}
print(table.concat(color, ", ", 2, 3))'

Esta vez la salida es yellow, orange, omitiendo blue.

No subestimes esta función aparentemente inútil, ya que puede tener efectos inesperados al optimizar el rendimiento y es uno de los protagonistas en nuestros capítulos posteriores de optimización de rendimiento.

table.insert Inserta un elemento

Finalmente, veamos la función table.insert. Inserta un nuevo elemento en el subíndice especificado, lo que afecta la parte del arreglo de la tabla. Para ilustrar, nuevamente usando el ejemplo de color.

$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.insert(color, 1,  "orange")
print(color[1])
'

Puedes ver que el primer elemento de color se convierte en orange, pero, por supuesto, puedes dejar el subíndice sin especificar para que se inserte al final de la cola por defecto.

Debo señalar que table.insert es una operación omnipresente, pero el rendimiento no es bueno. Si no estás insertando elementos basados en el subíndice especificado, entonces necesitarás llamar a lj_tab_len de LuaJIT cada vez para obtener la longitud del arreglo e insertar al final de la cola. Como table.getn, la complejidad de tiempo para obtener la longitud de la tabla es O(n).

Por lo tanto, para la operación table.insert, debemos tratar de evitar usarla en código caliente. Por ejemplo:

local t = {}
for i = 1, 10000 do
     table.insert(t, i)
end

Funciones de extensión de table en LuaJIT

A continuación, veamos las funciones de extensión de table en LuaJIT. LuaJIT extiende el Lua estándar con dos funciones útiles para crear y vaciar una tabla, que describiré a continuación.

table.new(narray, nhash) Crear una nueva tabla

La primera es la función table.new(narray, nhash). En lugar de crecer por sí misma al insertar elementos, esta función preasignará el tamaño del espacio para el arreglo y el hash especificados, que es lo que significan sus dos parámetros narray y nhash. El crecimiento automático es una operación costosa que implica asignación de espacio, resize y rehash, y debe evitarse a toda costa.

Ten en cuenta que la documentación de table.new no está en el sitio web de LuaJIT, sino en la documentación extendida del proyecto en GitHub, por lo que es difícil encontrarla incluso si la buscas en Google, por lo que no muchos ingenieros la conocen.

Aquí hay un ejemplo simple, y te mostraré cómo funciona. En primer lugar, esta función es una extensión, por lo que antes de usarla, debes requirela.

local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
   t[i] = i
end

Como puedes ver, este código crea una nueva tabla con 100 elementos de arreglo y 0 elementos de hash. Por supuesto, puedes crear una nueva tabla con 100 elementos de arreglo y 50 elementos de hash según sea necesario, lo cual es legal.

local t = new_tab(100, 50)

Alternativamente, si excedes el tamaño del espacio preestablecido, aún puedes usarlo normalmente, pero el rendimiento se degradará, y el punto de usar table.new se perderá.

En el siguiente ejemplo, tenemos un tamaño preestablecido de 100, pero estamos usando 200.

local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 200 do
   t[i] = i
end

Debes preestablecer el tamaño del espacio del arreglo y el hash en table.new según el escenario real para que puedas encontrar un equilibrio entre el rendimiento y el uso de memoria.

table.clear() Limpia la tabla

La segunda es la función de limpieza table.clear(). Limpia todos los datos en una tabla, pero no libera la memoria ocupada por las partes del arreglo y el hash. Por lo tanto, es útil cuando se reciclan tablas Lua para evitar la sobrecarga de crear y destruir tablas 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'

Sin embargo, no hay muchos escenarios donde esta función pueda usarse, y en la mayoría de los casos, debemos dejar esta tarea al GC de LuaJIT.

Funciones de extensión de table en OpenResty

Como mencioné al principio, OpenResty mantiene su propia rama de LuaJIT, que también extiende table, con varias nuevas APIs: table.isempty, table.isarray, table.nkeys y table.clone.

Antes de usar estas nuevas APIs, verifica la versión de OpenResty, ya que la mayoría de estas APIs solo se pueden usar en versiones de OpenResty posteriores a 1.15.8.1. Esto se debe a que OpenResty no tuvo un nuevo lanzamiento durante aproximadamente un año antes de la versión 1.15.8.1, y estas APIs se agregaron en ese intervalo de lanzamiento.

He incluido un enlace al artículo, así que usaré table.nkeys como ejemplo. Las otras tres APIs son bastante fáciles de entender desde una perspectiva de nomenclatura, así que revisa la documentación en GitHub y lo entenderás. Debo decir que la documentación de OpenResty es de muy alta calidad, incluyendo ejemplos de código, si se puede JIT, qué buscar, etc. Varios órdenes de magnitud mejor que la documentación de Lua y LuaJIT.

Bien, volviendo a la función table.nkeys. Su nombre puede confundirte, pero es una función que obtiene la longitud de la tabla y devuelve el número de elementos de la tabla, incluyendo los elementos del arreglo y la parte del hash. Por lo tanto, podemos usarla en lugar de table.getn, por ejemplo, de la siguiente manera.

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

Metatabla

Después de hablar sobre la función table, veamos la metatable derivada de table. La metatabla es un concepto único en Lua y se usa ampliamente en proyectos reales. No es exagerado decir que puedes encontrarla en casi cualquier biblioteca lua-resty-*.

Metatable se comporta como sobrecargas de operadores; por ejemplo, podemos sobrecargar __add para calcular la concatenación de dos arreglos Lua o __tostring para definir funciones que convierten a cadenas.

Lua, por otro lado, proporciona dos funciones para manejar metatablas.

  • La primera es setmetatable(table, metatable), que configura una metatabla para una tabla.
  • La segunda es getmetatable(table), que obtiene la metatabla de la tabla.

Después de todo esto, es posible que estés más interesado en lo que hace, así que veamos para qué se usa específicamente la metatabla. Aquí hay un fragmento de código de un proyecto 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))
'

Primero definimos una tabla llamada version, y como puedes ver, el propósito de este código es imprimir el número de versión en version. Sin embargo, no podemos imprimir version directamente. Puedes intentar hacer esto y ver que imprimir directamente solo mostrará la dirección de la tabla.

print(tostring(version))

Por lo tanto, necesitamos personalizar la función de conversión de cadena para esta tabla, que es __tostring, y aquí es donde entra la metatabla. Usamos setmetatable para restablecer el método __tostring de la tabla version para imprimir el número de versión: 1.1.1.

Además de __tostring, a menudo anulamos los siguientes dos metamétodos en la metatabla en proyectos reales.

Uno de ellos es __index. Cuando buscamos un elemento en una tabla, primero lo buscamos directamente en la tabla, y si no lo encontramos, pasamos al __index de la metatabla.

Eliminamos patch de la tabla version en el siguiente ejemplo.

$ 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))
'

En este caso, t.patch no obtiene el valor, por lo que pasa a la función __index, que imprime 1.1.2.

__index puede ser no solo una función, sino también una tabla, y si intentas ejecutar el siguiente código, verás que logran el mismo 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))
'

Otro metamétodo es __call. Es similar a un functor que permite llamar a una tabla.

Continuemos con el código anterior que imprime el número de versión y veamos cómo llamar a una tabla.

$ 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()
'

En este código, usamos setmetatable para agregar una metatabla a la tabla version, y el metamétodo __call dentro de ella apunta a la función print_version. Por lo tanto, si intentamos llamar a version como una función, la función print_version se ejecutará aquí.

Y getmetatable es la operación emparejada con setmetatable para obtener la metatabla que se ha configurado, como en el siguiente 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)
'

Además de estos tres metamétodos que discutimos hoy, hay algunos metamétodos menos utilizados que puedes consultar en la documentación para aprender más sobre ellos cuando los encuentres.

Orientación a objetos

Finalmente, hablemos de orientación a objetos. Como sabrás, Lua no es un lenguaje orientado a objetos, pero podemos usar metatablas para implementar OO.

Veamos un ejemplo práctico. lua-resty-mysql es el cliente oficial de MySQL de OpenResty, y usa metatablas para simular clases y métodos de clase, que se usan de la siguiente manera.

    $ resty -e 'local mysql = require "resty.mysql" -- primero referencia la biblioteca lua-resty
    local db, err = mysql:new() -- Crea una nueva instancia de la clase
    db:set_timeout(1000) -- Llama a los métodos de una clase

Puedes ejecutar el código anterior directamente con la línea de comandos resty. Estas líneas de código son fáciles de entender; lo único que podría causarte problemas es.

¿Por qué es un dos puntos en lugar de un punto al llamar a un método de clase?

En realidad, tanto los dos puntos como los puntos están bien aquí, y db:set_timeout(1000) y db.set_timeout(db, 1000) son exactamente equivalentes. Los dos puntos son un azúcar sintáctico en Lua que permite omitir el primer argumento self de una función.

Como todos sabemos, no hay secretos frente al código fuente, así que veamos la implementación concreta correspondiente a las líneas de código anteriores para que puedas entender mejor cómo modelar la orientación a objetos con metatablas.

local _M = { _VERSION = '0.21' } -- Usando la tabla para simular una clase
local mt = { __index = _M } -- mt es la abreviatura de metatabla, __index se refiere a la clase misma
-- Constructor de la clase
function _M.new(self)
    local sock, err = tcp()
    if not sock then
          return nil, err
    end
    return setmetatable({ sock = sock }, mt) -- ejemplo de simulación de clases usando tabla y metatabla
end

-- Funciones miembro de una clase
function _M.set_timeout(self, timeout) -- Usa el argumento self para obtener una instancia de la clase que deseas operar
  local sock = self.sock
  if not sock then
      return nil, "not initialized"
  end

  return sock:settimeout(timeout)
end

La tabla _M simula una clase inicializada con una sola variable miembro _VERSION y posteriormente define funciones miembro como _M.set_timeout. En el constructor _M.new(self), devolvemos una tabla cuya metatabla es mt, y el metamétodo __index de mt apunta a _M para que la tabla devuelta simule una instancia de la clase _M.

Resumen

Bueno, eso concluye el contenido principal de hoy. Table y metatable se usan mucho en las bibliotecas lua-resty-* de OpenResty y en proyectos de código abierto basados en OpenResty. Espero que esta lección te facilite la lectura y comprensión del código fuente.

Hay otras funciones estándar en Lua además de table, que aprenderemos juntos en la próxima lección.

Finalmente, me gustaría dejarte con una pregunta para reflexionar. ¿Por qué la biblioteca lua-resty-mysql simula OO como una capa de envoltura? Bienvenido a discutir esta pregunta en la sección de comentarios, y te invito a compartir este artículo con tus colegas y amigos para que podamos comunicarnos y progresar juntos.