Топ-советы: выявление уникальных концепций и подводных камней в Lua
API7.ai
October 12, 2022
В предыдущей статье мы изучили библиотечные функции, связанные с таблицами в LuaJIT. В дополнение к этим распространённым функциям, сегодня я познакомлю вас с некоторыми уникальными или менее известными концепциями Lua, а также с распространёнными ошибками в OpenResty.
Слабая таблица (Weak Table)
Первая концепция — это слабая таблица, уникальная для Lua, которая связана с сборкой мусора. Как и в других высокоуровневых языках, в Lua сборка мусора происходит автоматически, вам не нужно заботиться о её реализации или явно вызывать GC. Сборщик мусора автоматически освобождает память, на которую больше нет ссылок.
Однако простого подсчёта ссылок недостаточно, и иногда нам нужен более гибкий механизм. Например, если мы вставим объект Lua Foo (таблицу или функцию) в таблицу tb, это создаст ссылку на этот объект Foo. Даже если больше нет других ссылок на Foo, ссылка на него в tb будет существовать всегда, и GC не сможет освободить память, занимаемую Foo. В этом случае у нас есть только два варианта.
- Первый — вручную освободить
Foo. - Второй — оставить его в памяти навсегда.
Например, рассмотрим следующий код.
$ resty -e 'local tb = {} tb[1] = {red} tb[2] = function() print("func") end print(#tb) -- 2 collectgarbage() print(#tb) -- 2 table.remove(tb, 1) print(#tb) -- 1
Однако, я думаю, вы не хотите, чтобы память занималась объектами, которые вы не используете, особенно учитывая, что LuaJIT имеет ограничение в 2 ГБ памяти. Время для ручного освобождения определить непросто, и это добавляет сложности вашему коду.
Тут на помощь приходит слабая таблица. Посмотрите на её название — слабая таблица. Во-первых, это таблица, и все элементы в этой таблице являются слабыми ссылками. Концепция всегда абстрактна, поэтому давайте начнём с немного изменённого кода.
$ resty -e 'local tb = {} tb[1] = {red} tb[2] = function() print("func") end setmetatable(tb, {__mode = "v"}) print(#tb) -- 2 collectgarbage() print(#tb) -- 0 '
Как видите, объекты, которые не используются, освобождаются. Самая важная строка в этом коде — следующая.
setmetatable(tb, {__mode = "v"})
Знакомо? Это же операция с метатаблицей? Да, таблица становится слабой таблицей, когда в её метатаблице есть поле __mode.
- Если значение
__modeравноk, ключи таблицы являются слабыми ссылками. - Если значение
__modeравноv, значения таблицы являются слабыми ссылками. - Конечно, вы также можете установить его в
kv, что означает, что и ключи, и значения этой таблицы являются слабыми ссылками.
Любая из этих трёх слабых таблиц будет иметь свои ключи или значения освобождены, как только они будут освобождены.
В примере кода выше значение __mode равно v, tb — это массив, и значения массива — это таблица и функция, которые могут быть автоматически освобождены. Однако, если вы измените значение __mode на k, они не будут освобождены, как видно из следующего кода.
$ resty -e 'local tb = {} tb[1] = {red} tb[2] = function() print("func") end setmetatable(tb, {__mode = "k"}) print(#tb) -- 2 collectgarbage() print(#tb) -- 2 '
Мы продемонстрировали только слабые таблицы, где значение является слабой ссылкой, то есть слабые таблицы типа массива. Естественно, вы также можете создать слабую таблицу типа хэш-таблицы, используя объект в качестве ключа, например, как показано ниже.
$ resty -e 'local tb = {} tb[{color = red}] = "red" local fc = function() print("func") end tb[fc] = "func" fc = nil setmetatable(tb, {__mode = "k"}) for k,v in pairs(tb) do print(v) end collectgarbage() print("----------") for k,v in pairs(tb) do print(v) end '
После ручного вызова collectgarbage() для принудительной сборки мусора все элементы в таблице tb будут освобождены. Конечно, в реальном коде нам не нужно вызывать collectgarbage() вручную, она будет запускаться автоматически в фоновом режиме, и нам не нужно об этом беспокоиться.
Однако, поскольку мы упомянули функцию collectgarbage(), я скажу о ней ещё несколько слов. Эта функция может принимать несколько различных опций и по умолчанию использует collect, что означает полную сборку мусора. Ещё одна полезная опция — count, которая возвращает количество памяти, занимаемой Lua. Эта статистика помогает вам увидеть, есть ли утечка памяти, и напоминает нам не приближаться к верхнему пределу в 2 ГБ.
Код, связанный со слабыми таблицами, сложнее писать на практике, он менее понятен и, соответственно, содержит больше скрытых ошибок. Не стоит торопиться. Позже я представлю проект с открытым исходным кодом, использующий слабые таблицы, который приводит к проблеме утечки памяти.
Замыкания и upvalue
Перейдём к замыканиям и upvalue. Как я уже подчеркивал ранее, все значения в Lua являются объектами первого класса, включая функции. Это означает, что функции могут храниться в переменных, передаваться в качестве аргументов и возвращаться как значения другой функции. Например, в примере кода выше, связанном со слабой таблицей, есть следующий фрагмент.
tb[2] = function() print("func") end
Это анонимная функция, которая хранится как значение таблицы.
В Lua определение двух функций в следующем коде эквивалентно. Однако обратите внимание, что последний вариант присваивает функцию переменной, что является методом, который мы часто используем.
local function foo() print("foo") end local foo = fuction() print("foo") end
Кроме того, Lua поддерживает написание функции внутри другой функции, то есть вложенные функции, как в следующем примере кода.
$ resty -e ' local function foo() local i = 1 local function bar() i = i + 1 print(i) end return bar end local fn = foo() print(fn()) -- 2 '
Вы можете видеть, что функция bar может читать локальную переменную i внутри функции foo и изменять её значение, даже если эта переменная не определена внутри bar. Эта особенность называется лексической областью видимости.
Эти особенности Lua являются основой для замыканий. Замыкание — это просто функция, которая имеет доступ к переменной в лексической области видимости другой функции.
По определению, все функции в Lua фактически являются замыканиями, даже если вы их не вкладываете. Это связано с тем, что компилятор Lua выносит код за пределы скрипта Lua и оборачивает его в другую функцию, например, main. Например, следующие простые строки кода.
local foo, bar local function fn() foo = 1 bar = 2 end
После компиляции это будет выглядеть так.
function main(...) local foo, bar local function fn() foo = 1 bar = 2 end end
И функция fn захватывает две локальные переменные функции main, поэтому она также является замыканием.
Конечно, мы знаем, что концепция замыканий существует во многих языках, и она не уникальна для Lua, поэтому вы можете сравнить и сопоставить, чтобы лучше понять её. Только когда вы поймёте замыкания, вы сможете понять то, что мы собираемся сказать об upvalue.
upvalue — это концепция, уникальная для Lua, которая представляет собой переменную вне лексической области видимости, захваченную в замыкании. Давайте продолжим с кодом выше.
local foo, bar local function fn() foo = 1 bar = 2 end
Вы можете видеть, что функция fn захватывает две локальные переменные, foo и bar, которые не находятся в их собственной лексической области видимости, и эти две переменные фактически являются upvalue функции fn.
Распространённые ошибки
После введения нескольких концепций Lua я расскажу о связанных с Lua ошибках, с которыми я столкнулся при разработке на OpenResty.
В предыдущем разделе мы упомянули некоторые различия между Lua и другими языками разработки, такие как индексация, начинающаяся с 1, глобальные переменные по умолчанию и т.д. В реальной разработке кода на OpenResty мы сталкиваемся с большим количеством проблем, связанных с Lua и LuaJIT, и я расскажу о некоторых из наиболее распространённых.
Напоминаю, что даже если вы знаете все ошибки, вам всё равно придётся пройти через них самостоятельно, чтобы они запомнились. Разница, конечно, в том, что вы сможете выбраться из ямы и найти суть проблемы гораздо лучше.
Индексация начинается с 0 или 1?
Первая ошибка заключается в том, что индексация в Lua начинается с 1, как мы уже неоднократно упоминали.
Но я должен сказать, что это не вся правда. Потому что в LuaJIT массивы, созданные с помощью ffi.new, индексируются с 0:
local buf = ffi_new("char[?]", 128)
Поэтому, если вы хотите получить доступ к buf cdata в приведённом выше коде, помните, что индексация начинается с 0, а не с 1. Обязательно обратите на это особое внимание, когда вы используете FFI для взаимодействия с C.
Регулярные выражения
Вторая ошибка связана с регулярными выражениями, и в OpenResty есть два набора методов для сопоставления строк: библиотека строк Lua и API OpenResty ngx.re.*.
Регулярные выражения Lua имеют уникальный формат и записываются иначе, чем PCRE. Вот простой пример.
resty -e 'print(string.match("foo 123 bar", "%d%d%d"))' — 123
Этот код извлекает числовую часть из строки, и вы заметите, что он полностью отличается от знакомых нам регулярных выражений. Библиотека регулярных выражений Lua дорога в обслуживании и имеет низкую производительность — JIT не может её оптимизировать, а скомпилированные один раз шаблоны не кэшируются.
Поэтому, когда вы используете встроенную библиотеку строк Lua для find, match и т.д., не стесняйтесь использовать ngx.re OpenResty, если вам нужно что-то вроде регулярного выражения. При поиске фиксированной строки мы рассматриваем использование простого режима для вызова библиотеки строк.
Вот совет: в OpenResty мы всегда отдаём предпочтение API OpenResty, затем API LuaJIT и используем библиотеки Lua с осторожностью.
Кодирование JSON не различает массивы и словари
Третья ошибка заключается в том, что кодирование JSON не различает массивы и словари; поскольку в Lua есть только одна структура данных, таблица, при кодировании пустой таблицы в JSON невозможно определить, является ли она массивом или словарём.
resty -e 'local cjson = require "cjson" local t = {} print(cjson.encode(t)) '
Например, приведённый выше код выводит {}, что показывает, что библиотека cjson OpenResty по умолчанию кодирует пустую таблицу как словарь. Конечно, мы можем изменить это глобальное значение по умолчанию с помощью функции encode_empty_table_as_object.
resty -e 'local cjson = require "cjson" cjson.encode_empty_table_as_object(false) local t = {} print(cjson.encode(t)) '
На этот раз пустая таблица кодируется как массив [].
Однако это глобальное изменение имеет значительное влияние, поэтому можем ли мы указать правила кодирования для конкретной таблицы? Ответ, естественно, да, и есть два способа сделать это.
Первый способ — присвоить userdata cjson.empty_array указанной таблице, чтобы она рассматривалась как пустой массив при кодировании в JSON.
$ resty -e 'local cjson = require "cjson" local t = cjson.empty_array print(cjson.encode(t)) '
Однако иногда мы не уверены, всегда ли указанная таблица пуста. Мы хотим кодировать её как массив, когда она пуста, поэтому мы используем функцию cjson.empty_array_mt, что является нашим вторым методом.
Она пометит указанную таблицу и будет кодировать её как массив, когда таблица пуста. Как видно из названия cjson.empty_array_mt, она устанавливается с помощью метатаблицы, как в следующем коде.
$ resty -e 'local cjson = require "cjson" local t = {} setmetatable(t, cjson.empty_array_mt) print(cjson.encode(t)) t = {123} print(cjson.encode(t)) '
Ограничение на количество переменных
Рассмотрим четвёртую ошибку — ограничение на количество переменных. В Lua есть верхний предел на количество локальных переменных и количество upvalue в функции, как видно из исходного кода Lua.
/* @@ LUAI_MAXVARS is the maximum number of local variables per function @* (must be smaller than 250). */ #define LUAI_MAXVARS 200 /* @@ LUAI_MAXUPVALUES is the maximum number of upvalues per function @* (must be smaller than 250). */ #define LUAI_MAXUPVALUES 60
Эти два порога жёстко заданы как 200 и 60 соответственно, и хотя вы можете вручную изменить исходный код, чтобы настроить эти значения, они могут быть установлены только до максимума в 250.
Обычно мы не превышаем этот порог. Однако при написании кода на OpenResty следует быть осторожным, чтобы не злоупотреблять локальными переменными и upvalue, а использовать do ... end как можно чаще, чтобы уменьшить количество локальных переменных и upvalue.
Например, рассмотрим следующий псевдокод.
local re_find = ngx.re.find function foo() ... end function bar() ... end function fn() ... end
Если только функция foo использует re_find, то мы можем изменить её следующим образом:
do local re_find = ngx.re.find function foo() ... end end function bar() ... end function fn() ... end
Итог
С точки зрения "задавать больше вопросов", откуда взялся порог в 250 в Lua? Это наш вопрос для размышления на сегодня. Вы можете оставить свои комментарии и поделиться этой статьёй с коллегами и друзьями. Мы будем общаться и улучшать вместе.