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)
. Впрочем, не знаю, зачем это может быть кому-то нужно.