Lua — прекрасный язык программирования. Прежде всего благодаря своей предельной простоте. Но даже в Lua есть свои нюансы.
Допустим, мы хотим создать свой Lua REPL. REPL — Read–Eval–Print Loop — также называется оболочкой (shell) или интерпретатором (interpreter). Из аббриевиатуры должно быть понятно, что эта прога будет делать:
- читать ввод
- интерпретировать его
- принтить выхлоп
Программа и так несложно выглядит, а в Lua ещё есть функция load
, которая нам
поможет невероятно.
while true do local input = io.read("*l") local chunk, reason = load(input, "=stdin", "t") if not chunk then io.stderr:write("Syntax error: " .. reason .. "\n") else local success, result = xpcall(chunk, debug.traceback) if not success then io.stderr:write("Runtime error: " .. result .. "\n") else print(result) end end end
Попробуем запустить:
$ lua5.3 repl.lua asdf Syntax error: stdin:1: syntax error near <eof> return asdf, 5 nil printtr() Runtime error: stdin:1: attempt to call a nil value (global 'printtr') stack traceback: stdin:1: in main chunk [C]: in function 'xpcall' repl.lua:9: in main chunk [C]: in ? print("hi!") hi! nil
К нашей проге есть замечания:
- Непонятно, где ввод, а где вывод.
asdf
он считает за синтаксическую ошибку. Нет, это, конечно, верно, но лучше бы он это воспринял как команду показать содержимое переменнойasdf
. Чтобы не приходилось каждый раз писатьreturn
для этого.- Если мы сделаем ретурн двух значений, он покажет только первое.
- После
print("hi!")
пишетсяnil
, что выглядит странно.
Сначала починим первые два пункта:
while true do -- пишем строку io.write("lua> ") local input = io.read("*l") -- сначала попробуем выполнить с return local chunk, reason = load("return " .. input, "=stdin", "t") -- если это было не выражение, то будет ошибка; -- в таком случае попробуем выполнить без return if not chunk then chunk, reason = load(input, "=stdin", "t") end if not chunk then -- если и теперь не получилось, то в данном коде 100% синтаксическая ошибка io.stderr:write("Syntax error: " .. reason .. "\n") else local success, result = xpcall(chunk, debug.traceback) if not success then io.stderr:write("Runtime error: " .. result .. "\n") else print(result) end end end
$ lua5.3 repl.lua lua> 1, 2 1 lua> return 1, 2 1 lua> print("hi") hi nil lua> os.exit()
У нас остались две последние проблемы. Давайте снова посмотрим, как работает pcall
:
local function test() return 1, 2, 3 end print(pcall(test)) --> true 1 2 3 local success, r1, r2, r3 = pcall(test) print(success, r1, r2, r3) --> true 1 2 3
Ага. То есть pcall
всё же ничего не съедает и отдаёт всё, что возвращает наша функция. Хорошо.
Самый логичный путь — это просто сделать кучу переменных и надеяться, что в них всё влезет. Но это жутко неудобно. Если вы начинали программировать с Lua, наверняка эта ситуация вам знакома. Ведь избавиться от этой лапши стало возможным, когда вы узнали про таблицы в Lua. Гм!
Значит, складывается вот такая ситуация: мы хотим запихать весь вывод pcall
в одну таблицу, а потом просто обращаться к ней
по индексам. Задача решается двумя способами: один похуже, один покруче. Начнём с первого, разумеется.
local tbl = {pcall(test)} print(tbl[1], tbl[2], tbl[3], tbl[4]) --> true 1 2 3
Как можно заметить, вокруг вызова pcall
я поставил фигурные скобочки, как при объявлении таблицы.
Это означает следующее: создать таблицу и заполнить её всем, что вернёт pcall(test)
. Круто же!
А чтобы не приходилось нам вручную распаковывать таблицу, мы воспользуемся table.unpack
. Работает она предельно просто:
local tbl = {pcall(test)} print(table.unpack(tbl)) --> true 1 2 3
Сравните с предыдущим куском кода. Удобно же! Модифицируем наш REPL, чтобы он возвращал все значения.
while true do -- пишем строку io.write("lua> ") local input = io.read("*l") -- сначала попробуем выполнить с return local chunk, reason = load("return " .. input, "=stdin", "t") -- если это было не выражение, то будет ошибка; -- в таком случае попробуем выполнить без return if not chunk then chunk, reason = load(input, "=stdin", "t") end if not chunk then -- если и теперь не получилось, то в данном коде 100% синтаксическая ошибка io.stderr:write("Syntax error: " .. reason .. "\n") else local result = {xpcall(chunk, debug.traceback)} local success = table.remove(result, 1) if not success then io.stderr:write("Runtime error: " .. result[1] .. "\n") else print(table.unpack(result)) end end end
Запускаем:
$ lua5.3 repl.lua lua> 1, 2 1 2 lua> 1, 2, 3, 4 1 2 3 4 lua> print("test") test lua> nil, 2 lua> os.exit()
Мы замечаем две вещи:
- Во-первых, у нас исчез
nil
послеprint("test")
. - Во-вторых, мы запросили
nil, 2
, но нам ничего не вывелось.
Если первое — это то, что мы как раз хотели получить, то второе — весьма странная вещь.
Да и первое-то тоже странное. Почему тогда писался nil
, а теперь не пишется? Каким образом мы это починили?
Оказывается, обе вещи связаны с оператором #
. В мануале
прописано, что #seq
возвращает длину таблицы seq
и предназначен для определения длины последовательности.
Последовательность — это таблица, в которой элементы (любые, кроме nil
) идут по порядку, начиная с единицы.
Пример:
{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} -- последовательность из 10 элементов
А теперь — внимание — определение. Длина таблицы (в том числе и последовательности) — это любое целое неотрицательное число k
,
при котором k == 0 or tbl[k] ~= nil
и tbl[k + 1] == nil
. Прочитайте это внимательно.
- Во-первых, k равен нулю, или же в
tbl[k]
есть какое-то значение, отличающееся отnil
. - Во-вторых, следующий элемент —
nil
.
Посмотрите на последовательность выше. Здесь k
явно должно быть равно десяти, ведь tbl[10] ~= nil
, а tbl[11] == nil
.
А что про 0
? Это возможно, например, в таком случае:
{} -- последовательность из 0 элементов
Здесь совершенно нет элементов. Тем не менее, мы можем найти значение для k
— оно равно нулю. Действительно:
- Первое условие удовлетворено:
0 == 0
, всё-таки. - Второе условие тоже выполняется:
tbl[1] == nil
.
Как я уже сказал, #
нужен для нахождения длины последовательности. Тем не менее, на самом деле он может искать длину любой
таблицы. Работает он точно по определению выше.
#{[-1] = -1, 1} == 1 #{test = 23, 4, 5} == 2 #{[-2] = -2, [-1] = -1, [0] = 0} == 0 #{[-2] = -2, [-1] = -1} == 0 #{foo = 1, bar = 2} == 0
Обратите внимание, что наше k
— обязательно целое неотрицательное число.
В последних трёх примерах единственное значение k
, которые мы сможем найти, равно нулю, что мы и имеем.
Как видно, математическая точность здесь невероятно важна для понимания настоящего принципа работы оператора #
.
И я продемонстрирую это ещё раз.
#{1, nil, 3, nil, 5, nil, 7} == 7 #{nil, nil, 3, nil, 5, nil} == 3 #{nil, nil, 3, nil, 5} == 5 #{nil, nil, 3, nil} == 0
Что за бред тут творится? Ещё раз обратимся к определению:
- Пример 1. Подходящих чисел
k
у нас целых 4: 1, 3, 5, 7. - Пример 2. Подходящих чисел
k
теперь 3: 0, 3, 5. - Пример 3. Здесь также три варианта для
k
: 0, 3, 5. - Пример 4. А тут их два: 0, 3.
Какое из них выберет Lua? Спешу разочаровать: любое. В определении так и написано. То же вы сможете найти и в официальной документации к Lua.
Тут можно ещё больше сломать мозг.
local tbl = {} a[1] = nil a[2] = nil a[3] = 3 a[4] = nil a[5] = 5
Содержимое таблицы тут точно такое же, как в примере 3 выше. Тем не менее:
print(#tbl) --> 0
Ноль! Даже не три, не пять — ноль!! Надеюсь, теперь вы представляете весь ужас ситуации. Использовать #
нормально мы можем
только с последовательностями, иначе же...
print(table.unpack(tbl)) -->
...мы рискуем недосчитаться всех значений в таблице.
table.unpack
, например, по умолчанию распаковывает се элементы от первого до... #tbl
.
Я думаю, теперь должно быть ясно, почему, когда мы ввели nil, 2
в наш REPL, нам ничего не вывелось.
Но на этом приколы Lua не заканчиваются, нет-нет. Вот код:
local function a() return end local function b() return nil end print(a()) --> print(b())
Что выведет второй принт? Казалось бы, функция b
ничем не отличается от функции a
, и тогда вывод должен был быть таким же,
как и у первого принта, то есть . Но нет:
print(b()) --> nil
Обратите внимание на этот nil
. Обычно привыкли мы считать, что nil
— это ничего. Отсутствие значения.
Но между настоящим отсутствием значения и присутствуем nil
есть разница:
print() --> print(nil) --> nil
Но ведь внутри Lua вызовы func()
и func(nil)
считаются эквивалентными (ещё об этом — в конце статьи).
Как принт их может различить?
Наверняка вы знаете, что Lua позиционируется как встраиваемый язык программирования. Это достигается за счёт предоставления
C API к луа. Вызывая функции этого API, код может в том числе добавлять свои функции в окружение. Причём эти функции могут быть
написаны не только на Lua, но и на C или другом языке программирования. Всё с помощью этого же C API.
Более того. Как раз с помощью него и написаны все встроенные функции Lua: от print
до debug.traceback
.
Почему это важно? Дело в том, что для C API есть огромное различие между func()
и func(nil)
.
В первом случае функция увидит 0 аргументов, во втором — 1 аргумент. Обычно функции недостаток аргументов обрабатывают,
как в Lua, заменяя всё nil
ами. Но иногда они этого не делают, например print
. Или вот ещё пример:
> pcall() stdin:1: bad argument #1 to 'pcall' (value expected) stack traceback: [C]: in function 'pcall' stdin:1: in main chunk [C]: in ? > pcall(nil) false attempt to call a nil value
Тут ещё круче: без аргументов полноценная ошибка, а с ним просто false
, "attempt to call a nil value"
.
Итак. К чему это я рассказываю. Представляю вам функцию table.pack
. Эта функция так же написана с помощью C API и
намеренно умеет отличать пустоту от nil
. Она является весьма продвинутым аналогом конструкции вида {...}
. Она пакует
все переданные ей значения в одну большую таблицу:
local tbl = table.pack(pcall(function() return 1, 2, 3 end)) print(table.unpack(tbl)) --> true 1 2 3
Но у неё есть отличия. Причём колоссальные. Дело в том, что table.pack
в возвращаемую таблицу добавляет ещё одно поле — n
.
В ней находится реальное количество переданных аргументов.
print(table.pack().n, table.pack(nil).n, table.pack(nil, nil, nil, 4, nil, nil).n) --> 0 1 6
Кроме того, table.unpack
позволяет указывать промежуток таблицы, который следует распаковать:
local tbl = table.pack(nil, nil, nil, 4, nil, nil) print(table.unpack(tbl, 1, tbl.n)) --> nil nil nil 4 nil nil local tbl = table.pack(nil, 2) print(table.unpack(tbl, 1, tbl.n)) --> nil 2
А теперь — магия:
local tbl = table.pack(print("Hello there!")) --> Hello there! print(tbl.n) --> 0 print(table.unpack(tbl, 1, tbl.n)) -->
Опять же благодаря тому, что print
и table.pack
— это функции, которые используют C API, они отличают пустоту от nil
.
print("Hello there!")
возвращает пустоту, и table.pack
это замечает. table.unpack
возвращает пустоту, и print
это тоже
замечает. Поэтому мы в программе сможем писать nil
в этом случае:
local tbl = table.pack(foo) print(table.unpack(tbl, 1, tbl.n)) --> nil
...и не писать его, если запакуем выхлоп print("Hello there!")
.
Последний элемент паззла: pcall
также не будет засорять выхлоп, если ему передать функцию, которая ничего не возвращает:
print(table.pack(pcall(function() end)).n) --> 1
Фух! Надеюсь, я вас убедил, что в нашем REPL необходимо использовать table.pack
. Давайте, наконец, допилим нашу программу:
while true do -- пишем строку io.write("lua> ") local input = io.read("*l") if not input then -- например, если мы нажали ^D os.exit() end -- сначала попробуем выполнить с return local chunk, reason = load("return " .. input, "=stdin", "t") -- если это было не выражение, то будет ошибка; -- в таком случае попробуем выполнить без return if not chunk then chunk, reason = load(input, "=stdin", "t") end if not chunk then -- если и теперь не получилось, то в данном коде 100% синтаксическая ошибка io.stderr:write("Syntax error: " .. reason .. "\n") else local result = table.pack(xpcall(chunk, debug.traceback)) local success = table.remove(result, 1) result.n = result.n - 1 if not success then io.stderr:write("Runtime error: " .. result[1] .. "\n") elseif result.n > 0 then -- что-то пишем, только если у нас есть что, собственно, писать print(table.unpack(result, 1, result.n)) end end end
Запускаем:
$ lua5.3 repl.lua lua> 1, 2 1 2 lua> nil, 2 nil 2 lua> nil, nil, nil, 4, nil, nil nil nil nil nil 4 nil lua> print("Hello!") Hello! lua> blah nil lua> synt@x 3rr0r Syntax error: stdin:1: syntax error near '@' lua> runtimeError() Runtime error: stdin:1: attempt to call a nil value (global 'runtimeError') stack traceback: stdin:1: in main chunk [C]: in function 'xpcall' repl.lua:24: in main chunk [C]: in ? lua> os.exit()
Изюмительно.
Бонусная часть. Теперь, когда мы знаем о таких тонкостях, мы можем их использовать, чтобы внутри Lua различать число действительно переданных функции аргументов:
local function argCount(...) return table.pack(...).n end print(argCount()) --> 0 print(argCount(nil)) --> 1 print(argCount(1, 2, 3, nil, nil, nil)) --> 6
Таким образом, здесь argCount() ~= argCount(nil)
. Впрочем, не знаю, зачем это может быть кому-то нужно.