Замыкания в Lua: что это, как работают, примеры

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

Почему функции в Lua могут "помнить" переменные даже после завершения работы? Рассмотрим, как механизм замыканий обеспечивает эту возможность, в том числе на уровне памяти. Ответим себе на вопрос, почему разработчик на Lua должен знать о замыканиях, иначе не сможет программировать эффективно. В конце статьи приводятся примеры использования замыканий в пользовательском коде.

Вспомним пример генератора, который приводился в конце статьи про обход таблиц в Lua, упростим его, избавившись от лишних сейчас деталей, и проитерируем без цикла для наглядности:

local function values(t)  -- генератор
    local index = 0
 
    return function()     -- возвращает итератор
        index = index + 1
        if index <= #t then
            return t[index]
        else
            return nil
        end
    end
end

local array = {'apple', 'banana', 'pineapple'}
local it = values(array)
print(it())  -- apple
print(it())  -- banana
print(it())  -- pineapple
print(it())  -- nil

Может остаться непонятным, почему данный код рабочий? Ведь переменные t и index после того, как функция values завершает свое выполнение, должны исчезать. Каким образом они сохраняются в возвращаемой функции, и почему при каждом ее вызове index "помнит" свое предыдущее значение?

Ответы на эти вопросы дает концепция замыканий, которую можно считать одной из базовых в языке Lua. Суть ее в том, что если функция использует внешние для нее локальные переменные (upvalues), то интерпретатор "привязывает" их к этой конкретной функции, сохраняя их и создавая для неё индивидуальный контекст. Можно сказать, замыкание (closure) — это функция вместе со своим окружением, то есть со всеми внешними упомянутыми в ней переменными.

Замыкание возникает, когда вызывается внешняя функция, и поток выполнения доходит до объявления внутренней функции. В этот момент в памяти создается объект функции, который хранит ссылки на ячейки процессорного стека, в которых находятся внешние для этой функции переменные. Но когда внешняя функция завершает работу, её стек должен уничтожиться. "Видя" наличие ссылок, интерпретатор Lua "закрывает" замыкание, копируя переменную со стека в специальную скрытую область памяти (кучу), привязанную к этой внутренней функции. Переменная продолжает жить внутри нее столько, сколько существует сама функция.

Таким образом, замыкание это не совсем функция, а именно конкретный составной объект в памяти, грубо говоря, функция + ее upvalues. Неизменяемый код функции (прототип) компилируется один раз и содержит только инструкции (байт-код). Он один на все будущие замыкания этой функции. Само замыкание является динамическим объектом, так как создается в памяти заново каждый раз, когда выполнение программы доходит до объявления функции. Каждое замыкание содержит ссылку на общий байт-код (инструкции) и список ссылок на внешние переменные.

Если мы вызовем внешнюю функцию из нашего примера дважды, то будут созданы два замыкания на базе внутренней функции. У каждого будет свой индивидуальный контекст, потому что index и t при каждом вызове — это новые переменные.

local function values(t)
    local index = 0 
    return function()
        index = index + 1
        if index <= #t then
            return t[index]
        else
            return nil
        end
    end
end

local fruits = values({'apple', 'banana'})
local colors = values({'red', 'yellow', 'orange'})
print(colors(), fruits())  -- red     apple
print(colors(), fruits())  -- yellow  banana
print(colors(), fruits())  -- orange  nil

В концепции замыканий индивидуальный контекст можно определить как изолированную область памяти, которая создается заново при каждом отдельном вызове внешней функции. Однако в Lua замыкание может работать и по правилам разделяемого контекста (shared context). Так если две функции используют определенную на уровне файла локальную переменную, то ее изменение через одну функцию отразится на другой.

-- Запустился файл. Создался контекст файла.
local count = 0  -- Ячейка памяти в контексте файла

-- Функция 1 захватывает ссылку на ячейку count
local function click()
    count = count + 1
end

-- Функция 2 захватывает ссылку на ТУ ЖЕ САМУЮ ячейку count
local function doubleclick()
    count = count + 2
end

click()
doubleclick()
print(count)  -- 3

Когда файл запускается, Lua оборачивает его целиком в одну большую скрытую функцию. Ее называют чанк-функцией (chunk — крупный кусок) или главной функцией модуля. Получается, что внутри чанка замыкания существуют, но никогда не закрываются, потому что чанк выполняется всего один раз, и его стек живет на протяжении всей работы скрипта. Отсюда у двух внутренних на уровне файла функций не возникает индивидуального контекста.

В памяти создается один общий контекст для файла. В нем выделяется ячейка для локальной переменной. Когда внутри этого файла создаются две функции, интерпретатор упаковывает их и привязывает к ним одну и ту же скрытую ссылку (upvalue) на эту ячейку.

На самом деле в Lua даже "чистые" (не использующие внешние локальные переменные) функции представляют собой замыкания, так как они все равно содержат ссылку на таблицу глобального окружения, можно сказать, являются замыканиями исключительно на уровне виртуальной машины, а не явно для нас. Но когда речь заходит о замыканиях, то обычно имеют в виду "истинные", то есть с точки зрения разработчика функция явно использует внешние для нее локальные переменные.

Итак, замыкание — это фундаментальное свойство функций в Lua, позволяющее им переносить с собой контекст (переменные), в котором они были созданы.

Примеры замыканий

Фабрика функций (конфигурирование поведения):

local function make_multiplier(factor)
    -- Возвращает функцию, которая помнит свой factor
    return function(number)
        return number * factor
    end
end

local double = make_multiplier(2)
local triple = make_multiplier(3)

print(double(5)) -- 10
print(triple(5)) -- 15

С помощью замыканий имитируют объекты с приватными свойствами, к которым нет прямого доступа снаружи (инкапсуляция данных):

local function create_account(initial_balance)
    local balance = initial_balance -- Приватная переменная

    return {
        deposit = function(amount)
            balance = balance + amount
        end,
        withdraw = function(amount)
            if amount <= balance then
                balance = balance - amount
                return true
            end
            return false
        end,
        get_balance = function()
            return balance
        end
    }
end

local tom = create_account(100)
tom.deposit(50)
print(tom.get_balance()) -- 150
-- Напрямую изменить balance нельзя, переменная защищена замыканием

local bob = create_account(250)
bob.withdraw(80)
print(bob.get_balance()) -- 170