О функции `table.pack` и операторе `#` на примере REPL

fingercomp 2019-06-12   10 минут на чтение

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

К нашей проге есть замечания:

Сначала починим первые два пункта:

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, а теперь не пишется? Каким образом мы это починили?

Оказывается, обе вещи связаны с оператором #. В мануале прописано, что #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[10] ~= nil, а tbl[11] == nil.

А что про 0? Это возможно, например, в таком случае:

{}  -- последовательность из 0 элементов

Здесь совершенно нет элементов. Тем не менее, мы можем найти значение для k — оно равно нулю. Действительно:

Как я уже сказал, # нужен для нахождения длины последовательности. Тем не менее, на самом деле он может искать длину любой таблицы. Работает он точно по определению выше.

#{[-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

Что за бред тут творится? Ещё раз обратимся к определению:

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