Итерирование таблиц в Lua: циклы, ipairs и pairs

Опубликовано:

Поскольку в Lua есть только один составной тип — таблица, решение, как ее перебирать, должен принимать сам программист. В итерирующем for (цикле перебора) обычно вызывают ipairs для обхода таблиц, которые хранятся как массивы с последовательной индексацией от 1. Во всех остальных случаях используют pairs. Но так ли все просто на практике?
В данной статье дается краткий обзор всех циклов в Lua, а затем разбираются нюансы итерации, безопасное удаление элементов и создание кастомного генератора.

В Lua имеется полный арсенал циклов: классический while, с постусловием repeat...until, for числовой и для итерирования.

local sum = 0

while true do
    local input = io.read('n')  -- считываем число
    if input == nil then
        break  -- прерываем цикл, если введено не число
    end
    sum = sum + input
end

print(sum)
local sum = 0
repeat
    local i = io.read('n')
    sum = sum + (i or 0) -- если i равен nil, подставится 0
until i == nil
print(sum)
4 -3 0.5 stop
1.5
local lines = {}

for i = 1, 3 do
    lines[i] = io.read()
end

for i, v in ipairs(lines) do
    print(i .. ': ' .. v)
end
black grey
yellow green
blue
1: black grey
2: yellow green
3: blue

Однако в Lua синтаксис цикла for...in..., который для таблиц используется чаще, ожидает после in наличие функции-итератора. На каждой итерации Lua неявно вызывает эту функцию. Если она возвращает любое значение, кроме nil, цикл продолжается.

В случае переобхода таблиц используются итераторные генераторы ipairs и pairs. Они создают (возвращают) функции-итераторы, которые и использует цикл for...in....

Возврат именно пары из ключа и значения — это специфика создаваемых функциями ipairs и pairs итераторов, которая продиктована особенностями работы с таблицами. На самом деле в Lua количество переменных перед in может быть любым. Если их больше необходимого, лишним присваивается nil. Если меньше, данные, которым не досталось переменной, теряются. Так код ниже выведет только индексы, а не значения элементов или парные кортежи, как это было бы в Python:

local arr = {'one', 'two', 'three'}
for i in ipairs(arr) do
    io.write(i .. ' ')
end
print()
1 2 3

Какое бы количество переменных вы ни написали, Lua всегда принимает решение о продолжении или завершении цикла только по первой. Если она после очередного вызова итератора получает nil, цикл останавливается. Любое другое значение (даже false) заставляет цикл выполнять свое тело.

Если в Python списки и словари — это разные типы данных, и цикл for работает с ними по-разному, то в Lua есть только один составной тип — таблица. И хотя в памяти она может храниться и как массив и как хеш-таблица, решение о способе перебора отдается в руки программисту. Когда вы вызываете ipairs или pairs, вы явно принуждаете интерпретатор использовать один из двух конкретных алгоритмов обхода: перебирать таблицу как упорядоченную последовательность или как неупорядоченный словарь.

Функция ipairs используется для таблиц-последовательностей (массивов, списков) и гарантирует упорядоченное извлечение элементов. Однако циклический обход таблицы останавливается не только после "извлечения" всех ее элементов, также это произойдет при встрече первого nil, то есть "дыры в массиве". Также если у элемента ключом является не целое число, то он просто пропускается.

local arr = {10, t = 50, 20, [5] = 30, 40}
for i, v in ipairs(arr) do
    print(i, v)
end
1       10
2       20
3       40

Здесь число 30 с индексом 5 будет выводиться, если в таблицу добавить еще одно значение без явного ключа или с индексом 4. А в предложенном выше варианте четвертого элемента просто нет, то есть этот элемент имеет значение nil.

Функция pairs обходит все элементы, независимо от типа данных их ключей. В основном используется для таблиц-словарей (записей, объектов, классов). Порядок обхода хэш-ключей может несоответствовать порядку следования элементов в таблице.

local d = {name='Tom', level=4}
for i, v in pairs(d) do
    print(i, v)
end
level   4
name    Tom

В случае последовательностей также можно использовать pairs, это имеет смысл, если есть подозрение на "дыру" в массиве. Однако только ipairs гарантирует упорядоченность, а также работает быстрее, так как здесь не высчитываются хэши и не ищутся ключи внутри структуры таблицы. Итератор просто выполняет инкремент индекса +1 на каждом шаге.

Важно помнить, что когда мы ключу присваиваем nil, элемент просто исчезает из таблицы:

local data = {a=10, b=20, 30, c=40}
data.a = nil
data[1] = nil
for i, v in pairs(data) do
    print(i, v)
end
c       40
b       20

В Lua именно так удаляют элементы из хеш-таблицы. Но так не следует делать для массивов, чтобы не образовывались nil-дыры. Если последовательность должна таковой и оставаться, надо использовать функцию table.remove. Пример ниже удаляет элементы по условию, сохраняя хранение таблицы в памяти как массива:

local t = {10, 4, 12, 7, 20}

-- #t длина массива, -1 шаг назад
for i = #t, 1, -1 do
    if t[i] < 10 then
        table.remove(t, i)
    end
end

for i, v in ipairs(t) do
    print(i, v)
end
1       10
2       12
3       20

Итераторные генераторы, или фабрики итераторов, — это обычные функции в Lua. Единственное, что делает функцию "генератором" — это её архитектурное назначение: она создана для того, чтобы конструировать и возвращать другие функции (итераторы). Рассмотрим пример кастомного итераторного генератора, который работает так же, как ipairs, но перебирает массив с конца:

local function r_ipairs(t)
    local index = #t + 1 -- стартовая позиция (за границей массива)
    
    -- Генератор возвращает итератор (анонимную функцию)
    return function()
        index = index - 1
        if index > 0 then
            return index, t[index] -- возвращаем пару
        else
            return nil -- стоп-сигнал для цикла for
        end
    end
end

local array = {'apple', 'banana', 'pineapple'}

for i, v in r_ipairs(array) do
    print(i, v)
end
3       pineapple
2       banana
1       apple

Интерпретатор Lua в заголовке цикла for вызывает r_ipairs(array) только один раз. Функция r_ipairs при выполнении своего тела создает внутри себя переменную index и возвращает созданную анонимную функцию. Цикл for подхватывает эту анонимную функцию и начинает вызывать уже её на каждом шаге, пока она не вернет nil.