Что такое table и metatable в Lua?

API7.ai

October 11, 2022

OpenResty (NGINX + Lua)

Сегодня мы узнаем о единственной структуре данных в LuaJIT: table.

В отличие от других языков сценариев с богатыми структурами данных, LuaJIT имеет только одну структуру данных — table, которая не разделяется на массивы, хэши, коллекции и т.д., а представляет собой нечто смешанное. Давайте рассмотрим один из примеров, упомянутых ранее.

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

В этом примере таблица color содержит как массив, так и хэш, и доступ к ним осуществляется без взаимного вмешательства. Например, вы можете использовать функцию ipairs для итерации только по массиву таблицы.

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

Операции с table настолько важны, что LuaJIT расширяет стандартную библиотеку таблиц Lua 5.1, а OpenResty расширяет библиотеку таблиц LuaJIT еще дальше. Давайте рассмотрим каждую из этих функций библиотеки.

Функции библиотеки таблиц

Начнем со стандартных функций библиотеки таблиц. В Lua 5.1 не так много функций библиотеки таблиц, поэтому мы можем быстро их просмотреть.

table.getn Получение количества элементов

Как мы упоминали в главе Standard Lua and LuaJIT, получение правильного количества всех элементов таблицы — это большая проблема в LuaJIT.

Для последовательностей вы можете использовать table.getn или унарный оператор #, чтобы вернуть правильное количество элементов. Следующий пример возвращает ожидаемое число 3.

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

Для таблиц, которые не являются последовательными, правильное значение не может быть возвращено. Во втором примере возвращается значение 1.

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

К счастью, такие сложные для понимания функции были заменены расширениями LuaJIT, о которых мы упомянем позже. Поэтому в контексте OpenResty не используйте функцию table.getn и унарный оператор #, если вы точно не знаете, что получаете длину последовательности.

Кроме того, table.getn и унарный оператор # имеют временную сложность не O(1), а O(n), что является еще одной причиной избегать их, если это возможно.

table.remove Удаление указанного элемента

Вторая функция — это table.remove, которая удаляет элементы в таблице на основе индексов, то есть удаляются только элементы массива таблицы. Давайте снова рассмотрим пример 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'

Этот код удалит элемент blue с индексом 1. Вы можете спросить, как удалить хэш-часть таблицы? Это так же просто, как установить значение, соответствующее ключу, в nil. Таким образом, в примере color, элемент green, соответствующий 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 Функция объединения элементов

Третья функция — это table.concat, функция объединения элементов. Она объединяет элементы таблицы в соответствии с индексами. Поскольку это снова основано на индексах, она работает только с массивной частью таблицы. Снова пример с color.

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

После использования функции table.concat вывод будет blue, yellow, а хэш-часть будет пропущена.

Кроме того, эта функция также может указывать начальную позицию индекса для объединения; например, она может быть записана следующим образом:

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

На этот раз вывод будет yellow, orange, пропуская blue.

Не стоит недооценивать эту, казалось бы, бесполезную функцию, но она может иметь неожиданные эффекты при оптимизации производительности и является одним из главных героев в наших последующих главах об оптимизации производительности.

table.insert Вставка элемента

Наконец, давайте рассмотрим функцию table.insert. Она вставляет новый элемент в указанный индекс, что влияет на массивную часть таблицы. Для иллюстрации снова используем пример color.

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

Вы можете видеть, что первый элемент color становится orange, но, конечно, вы можете не указывать индекс, и тогда элемент будет вставлен в конец очереди по умолчанию.

Следует отметить, что table.insert — это распространенная операция, но ее производительность не очень хорошая. Если вы не вставляете элементы на основе указанного индекса, то каждый раз вам нужно будет вызывать lj_tab_len LuaJIT, чтобы получить длину массива для вставки в конец очереди. Как и table.getn, временная сложность получения длины таблицы составляет O(n).

Поэтому для операции table.insert мы должны стараться избегать ее использования в горячем коде. Например:

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

Расширенные функции таблиц LuaJIT

Далее рассмотрим расширенные функции таблиц LuaJIT. LuaJIT расширяет стандартный Lua двумя полезными функциями для создания и очистки таблиц, которые я опишу ниже.

table.new(narray, nhash) Создание новой таблицы

Первая функция — это table.new(narray, nhash). Вместо того чтобы увеличивать себя при вставке элементов, эта функция предварительно выделяет пространство для указанного массива и хэша, что и означают ее два параметра narray и nhash. Самоувеличение — это дорогостоящая операция, которая включает выделение пространства, resize и rehash, и ее следует избегать любой ценой.

Обратите внимание, что документация для table.new находится не на сайте LuaJIT, а глубоко в расширенной документации проекта на GitHub, поэтому ее трудно найти даже через Google, и не многие инженеры знают о ней.

Вот простой пример, и я покажу вам, как она работает. Прежде всего, эта функция является расширенной, поэтому перед использованием ее нужно require.

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

Как видите, этот код создает новую таблицу с 100 элементами массива и 0 элементами хэша. Конечно, вы можете создать новую таблицу с 100 элементами массива и 50 элементами хэша по мере необходимости, что также допустимо.

local t = new_tab(100, 50)

Или, если вы выйдете за пределы предустановленного размера пространства, вы все равно сможете использовать ее, но производительность ухудшится, и смысл использования table.new будет потерян.

В следующем примере у нас предустановленный размер 100, но мы используем 200.

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

Вам нужно предустановить размер массива и хэш-пространства в table.new в соответствии с реальным сценарием, чтобы найти баланс между производительностью и использованием памяти.

table.clear() Очистка таблицы

Вторая функция — это функция очистки table.clear(). Она очищает все данные в таблице, но не освобождает память, занимаемую массивной и хэш-частями. Поэтому она полезна при переработке Lua-таблиц, чтобы избежать накладных расходов на повторное создание и уничтожение таблиц.

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

Однако сценариев использования этой функции не так много, и в большинстве случаев мы должны оставить эту задачу сборщику мусора LuaJIT.

Расширенные функции таблиц OpenResty

Как я упоминал в начале, OpenResty поддерживает свою собственную ветку LuaJIT, которая также расширяет таблицы, с несколькими новыми API: table.isempty, table.isarray, table.nkeys и table.clone.

Перед использованием этих новых API, пожалуйста, проверьте версию OpenResty, так как большинство из них можно использовать только в версиях OpenResty после 1.15.8.1. Это связано с тем, что OpenResty не выпускал новых версий около года до версии 1.15.8.1, и эти API были добавлены в этот промежуток времени.

Я включил ссылку на статью, поэтому я использую table.nkeys в качестве примера. Остальные три API легко понять с точки зрения именования, поэтому просмотрите документацию на GitHub, и вы поймете. Должен сказать, что документация OpenResty очень высокого качества, включая примеры кода, можно ли JIT, на что обратить внимание и т.д. На несколько порядков лучше, чем документация Lua и LuaJIT.

Хорошо, вернемся к функции table.nkeys. Ее именование может вас запутать, но это функция, которая получает длину таблицы и возвращает количество элементов таблицы, включая элементы массива и хэш-части. Поэтому мы можем использовать ее вместо table.getn, например, следующим образом.

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, производную от table. Метатаблица — это уникальная концепция в Lua, которая широко используется в реальных проектах. Не будет преувеличением сказать, что вы можете найти ее практически в любой библиотеке lua-resty-*.

Metatable ведет себя как перегрузка операторов; например, мы можем перегрузить __add для вычисления конкатенации двух Lua-массивов или __tostring для определения функций преобразования в строки.

Lua, с другой стороны, предоставляет две функции для работы с метатаблицами.

  • Первая — это setmetatable(table, metatable), которая устанавливает метатаблицу для таблицы.
  • Вторая — это getmetatable(table), которая получает метатаблицу таблицы.

После всего этого вас может больше интересовать, что она делает, поэтому давайте посмотрим, для чего конкретно используется метатаблица. Вот фрагмент кода из реального проекта.

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

Сначала мы определяем таблицу с именем version, и, как видите, цель этого кода — вывести номер версии из version. Однако мы не можем просто напечатать version. Попробуйте сделать это, и вы увидите, что печать напрямую выведет только адрес таблицы.

print(tostring(version))

Поэтому нам нужно настроить функцию преобразования строк для этой таблицы, которая и есть __tostring, и здесь на помощь приходит метатаблица. Мы используем setmetatable, чтобы переопределить метод __tostring таблицы version, чтобы вывести номер версии: 1.1.1.

Помимо __tostring, мы часто переопределяем следующие два метаметода в метатаблице в реальных проектах.

Один из них — __index. Когда мы ищем элемент в таблице, мы сначала ищем его непосредственно в таблице, и если не находим, то переходим к __index метатаблицы.

В следующем примере мы удаляем patch из таблицы 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)) '

В этом случае t.patch не получает значение, поэтому он переходит к функции __index, которая выводит 1.1.2.

__index может быть не только функцией, но и таблицей, и если вы попробуете выполнить следующий код, вы увидите, что они достигают того же результата.

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

Другой метаметод — __call. Он похож на функтор, который позволяет вызывать таблицу.

Давайте продолжим на основе кода выше, который выводит номер версии, и посмотрим, как вызвать таблицу.

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

В этом коде мы используем setmetatable, чтобы добавить метатаблицу к таблице version, и метаметод __call внутри нее указывает на функцию print_version. Поэтому, если мы попытаемся вызвать version как функцию, здесь будет выполнена функция print_version.

И getmetatable — это операция, парная с setmetatable, чтобы получить установленную метатаблицу, как в следующем коде.

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

Помимо этих трех метаметодов, о которых мы говорили сегодня, есть некоторые редко используемые метаметоды, которые вы можете изучить в документации, когда столкнетесь с ними.

Объектно-ориентированное программирование

Наконец, давайте поговорим об объектно-ориентированном программировании. Как вы, возможно, знаете, Lua не является объектно-ориентированным языком, но мы можем использовать метатаблицы для реализации ОО.

Давайте рассмотрим практический пример. lua-resty-mysql — это официальный клиент MySQL для OpenResty, и он использует метатаблицы для симуляции классов и методов классов, которые используются следующим образом.

$ resty -e 'local mysql = require "resty.mysql" -- сначала ссылаемся на библиотеку lua-resty local db, err = mysql:new() -- Создаем новый экземпляр класса db:set_timeout(1000) -- Вызов методов класса

Вы можете выполнить этот код напрямую с помощью команды resty. Эти строки кода легко понять; единственное, что может вызвать у вас затруднения, это:

Почему при вызове метода класса используется двоеточие вместо точки?

На самом деле, и двоеточие, и точка здесь допустимы, и db:set_timeout(1000) и db.set_timeout(db, 1000) абсолютно эквивалентны. Двоеточие — это синтаксический сахар в Lua, который позволяет опускать первый аргумент self функции.

Как мы все знаем, перед исходным кодом нет секретов, поэтому давайте посмотрим на конкретную реализацию, соответствующую этим строкам кода, чтобы вы могли лучше понять, как моделировать объектно-ориентированное программирование с помощью метатаблиц.

local _M = { _VERSION = '0.21' } -- Используем таблицу для симуляции класса local mt = { __index = _M } -- mt — это сокращение от metatable, __index ссылается на сам класс -- Конструктор класса function _M.new(self) local sock, err = tcp() if not sock then return nil, err end return setmetatable({ sock = sock }, mt) -- пример симуляции классов с использованием таблицы и метатаблицы end -- Методы класса function _M.set_timeout(self, timeout) -- Используем аргумент self для получения экземпляра класса, с которым хотим работать local sock = self.sock if not sock then return nil, "not initialized" end return sock:settimeout(timeout) end

Таблица _M симулирует класс, инициализированный одной переменной-членом _VERSION, и впоследствии определяет методы-члены, такие как _M.set_timeout. В конструкторе _M.new(self) мы возвращаем таблицу, метатаблица которой — mt, а метаметод __index mt указывает на _M, так что возвращаемая таблица симулирует экземпляр класса _M.

Итог

Ну что ж, это завершает основное содержание на сегодня. Таблицы и метатаблицы широко используются в библиотеках lua-resty-* OpenResty и проектах на основе OpenResty. Я надеюсь, что этот урок облегчит вам чтение и понимание исходного кода.

В Lua есть и другие стандартные функции, которые мы изучим вместе в следующем уроке.

Наконец, я хотел бы оставить вам вопрос для размышления. Почему библиотека lua-resty-mysql моделирует ОО как слой обертки? Добро пожаловать обсудить этот вопрос в разделе комментариев, и приглашаю вас поделиться этой статьей с вашими коллегами и друзьями, чтобы мы могли общаться и прогрессировать вместе.