Skip to content

Instantly share code, notes, and snippets.

@Fingercomp
Last active January 10, 2024 10:13
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Fingercomp/3fa6e6dfc482a7f7e8bbc1aa53eac8dd to your computer and use it in GitHub Desktop.
Save Fingercomp/3fa6e6dfc482a7f7e8bbc1aa53eac8dd to your computer and use it in GitHub Desktop.
О функции `table.pack` и операторе `#` на примере REPL

Lua — прекрасный язык программирования. Прежде всего благодаря своей предельной простоте. Но даже в Lua есть свои нюансы.

Допустим, мы хотим создать свой Lua REPL. REPL — Read–Eval–Print Loop — также называется оболочкой (shell) или интерпретатором (interpreter). Из аббриевиатуры должно быть понятно, что эта прога будет делать:

  1. читать ввод
  2. интерпретировать его
  3. принтить выхлоп

Программа и так несложно выглядит, а в 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). Впрочем, не знаю, зачем это может быть кому-то нужно.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment