Мемоизация в Lua

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

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

Потом когда функция вызывается с аргументом, который уже использовался ранее, то тело функции не исполняется, а результат ее работы при данном аргументе берется из ранее созданной и постепенно наполняемой структуры данных.

Механизм такого кэширования реализуется через создание функции-обертки. Рассмотрим простейший пример мемоизации на Lua:

-- Функция высшего порядка для мемоизации
function memoize(f)
    local cache = {} -- Локальный кэш, доступный через замыкание
    return function(arg)
        if cache[arg] == nil then -- Если записи нет,
            cache[arg] = f(arg) -- вычисляем и сохраняем
        end
        return cache[arg]
    end
end

-- Будем считать, что это тяжелая по вычислениям функция 
local function heavy_function(n)
    print("Вычисляю для " .. n) -- Выведется единожды для каждого n
    return n * 2
end

local fast_function = memoize(heavy_function)

print(fast_function(10)) -- Выведет сначала "Вычисляю для 10", с новой строки "20"
print(fast_function(10)) -- Выведет только "20", т.к. данные возьмутся из таблицы

Когда функция memoize(heavy_function) возвращает анонимную функцию, которую мы связываем с идентификатором fast_function, то в эту анонимную функцию "вшито" два замыкания — ссылка на таблицу cache и ссылка на функцию heavy_function, которая здесь доступна через f. Потом, когда мы несколько раз вызываем fast_function() происходит либо взятие данных из cache, либо вызов heavy_function().

Если тяжелая функция принимает несколько аргументов, можно использовать вложенные таблицы, например, cache[a][b]. Однако бывает проще, особенно если аргументов не два, для формирования ключа выполнять их конкатенацию:

function memoize(f)
    local cache = {}
    return function(a, b)
        local key = a .. '_' .. b
        if cache[key] == nil then
            cache[key] = f(a, b)
        end
        return cache[key]
    end
end

local function heavy_function(m, n)
    return m * n
end

local fast_function = memoize(heavy_function)

print(fast_function(10, 5))  -- 50

Пример мемоизации для функции с переменным числом аргументов:

function memoize(f)
    local cache = {}
    return function(...)
        local key = table.concat(table.pack(...), ',')
        if cache[key] == nil then
            cache[key] = f(...)
        end
        return cache[key]
    end
end

local function heavy_function(...)
    io.write('Выполняюсь... ')
    local args = table.pack(...)
    local total = 1
    for i = 1, args.n do
        total = total * (args[i] or 1)
    end
    return total
end

local fast_function = memoize(heavy_function)

print(fast_function(10, 5, 3))
print(fast_function(3, 4, 2, 1, 2))
print(fast_function(10, 5, 3))  -- результат будет взят из кэша
Выполняюсь... 150
Выполняюсь... 48
150

Если в качестве ключей или значений выступают ссылки (когда передается любой изменяемый объект, всегда передается ссылка на него), то кэш-таблицы имеет смысл делать слабыми (weak tables). Для этого используются метатаблицы, в которых через свойство __mode = "k" (слабые ключи) или __mode = "v" (слабые значения) указывают сборщику мусора, что данные из кэша можно удалить, если в программе на них нет других ссылок. Это предотвращает бесконечное разрастание таблиц, которое может привести к утечке памяти.

local function memoize_safe(f)
    local cache = {}
    -- Делаем ключи таблицы слабыми
    setmetatable(cache, { __mode = "k" }) 
    
    return function(obj)
        if cache[obj] == nil then
            cache[obj] = f(obj)
        end
        return cache[obj]
    end
end

-- Тяжелая функция, принимающая объект-таблицу в качестве аргумента
local function calculate_stats(character)
    print("-> Долгий расчет характеристик для: " .. character.name)
    -- Симулируем какие-то вычисления на основе данных персонажа
    return {
        power = character.level * 15,
        speed = character.level * 5
    }
end

-- Создаем мемоизированную версию функции
local get_stats = memoize_safe(calculate_stats)

-- Создаем двух персонажей
local warrior = { name = "Воин Гром", level = 5 }
local mage = { name = "Маг Антос", level = 3 }

print("--- Вызовы в первый раз ---") -- Вызовет calculate_stats
print("Сила воина: " .. get_stats(warrior).power) 
print("Сила мага: " .. get_stats(mage).power) 

print("\n--- Повторные вызовы (данные берутся мгновенно из кэша)---")
print("Сила воина: " .. get_stats(warrior).power) 
print("Сила мага: " .. get_stats(mage).power) 

print("\n--- СОДЕРЖИМОЕ КЕША ---")
local _, cache_table = debug.getupvalue(get_stats, 1)
for character_obj, result_table in pairs(cache_table) do
    print(character_obj.name, result_table.power, result_table.speed)
end

print("\n--- Удаляем мага из игры ---")
mage = nil -- Стираем единственную сильную ссылку на объект мага.
           -- Теперь объект живет только как слабый ключ внутри кэша.

-- Принудительно запускаем сборщик мусора Lua
collectgarbage("collect") 

print("\n--- СОДЕРЖИМОЕ КЕША ---")
for character_obj, result_table in pairs(cache_table) do
    print(character_obj.name, result_table.power, result_table.speed)
end
--- Вызовы в первый раз ---
-> Долгий расчет характеристик для: Воин Гром
Сила воина: 75
-> Долгий расчет характеристик для: Маг Антос
Сила мага: 45

--- Повторные вызовы (данные берутся мгновенно из кэша)---
Сила воина: 75
Сила мага: 45

--- СОДЕРЖИМОЕ КЕША ---
Воин Гром       75      25
Маг Антос       45      15

--- Удаляем мага из игры ---

--- СОДЕРЖИМОЕ КЕША ---
Воин Гром       75      25

Следует иметь в виду, мемоизация подходит далеко не всегда. Главное требование — является ли функция чистой, то есть результат ее работы должен зависеть только от входных аргументов. Также она должна долго выполняться, быть требовательной к ресурсам или часто вызываться (например, вычисление сложных математических алгоритмов, парсинг конфигураций). Если же функция зависит от внешних факторов (текущего времени, глобальных переменных) или если затраты памяти под кэш превышают выгоду от скорости, то мемоизация в таких случаях не подходит.

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

Мемоизация важна в играх, где скорость кадра (время) — это главный приоритет. Если игра "задумается" на сложных расчетах, игрок увидит лаг. Поэтому лучше выделить лишние мегабайты памяти под таблицы-кэши (мемоизацию), чтобы гарантировать стабильные 60+ кадров в секунду без задержек процессора.

Мемоизация в Lua используется в играх для менеджмента ресурсов. Так если надо отрисовать множество одинаковых врагов, условная функция draw(monster_image) не должна каждый раз читать файл с диска. Поэтому функция загрузки изображений мемоизируется:

function memoize(f)
    local cache = {}
    return function(url)
        if cache[url] == nil then
            cache[url] = f(url)
        end
        return cache[url]
    end
end

local load_image = memoize(function(path)
    print('Загружаю изображение по адресу '..path)
    return 'img:'..path -- Здесь должна быть тяжелая операция движка
                        -- Например: love.graphics.newImage(path) 
end)

-- Внутри игрового цикла (60 кадров в секунду):
for i = 1, 5 do
    -- Файл прочитается ОДИН раз, в остальные разы вернется ссылка на готовую текстуру в памяти
    local img = load_image('assets/orc.png') 
    print(img, math.random(100), math.random(80))
    -- Вместо print должно быть что-то вроде love.graphics.draw()
end
Загружаю изображение по адресу assets/orc.png
img:assets/orc.png      42      19
img:assets/orc.png      34      2
img:assets/orc.png      35      50
img:assets/orc.png      62      20
img:assets/orc.png      47      7

При подготовке к рендеру часто нужно передавать цвет или координаты в виде таблиц: set_color({r=255, g=0, b=0}). В Lua создание таблицы {} на каждом кадре генерирует мусор (garbage) и тормозит игру. С помощью мемоизации цвета уникализируют:

local function memoize3(f)
    local cache = {}
    
    return function(a, b, c)
        -- Пошагово проверяем и строим ветки дерева кэша
        local res1 = cache[a]
        if not res1 then
            res1 = {}
            cache[a] = res1
        end
        
        local res2 = res1[b]
        if not res2 then
            res2 = {}
            res1[b] = res2
        end
        
        local val = res2[c]
        if val == nil then
            val = f(a, b, c)
            res2[c] = val
        end
        
        return val
    end
end

-- Функция создания цвета (вызовется 1 раз для каждого цвета)
local get_color = memoize3(function(r, g, b)
    print(string.format(">> [Память] Создана таблица для (%d, %d, %d)", r, g, b))
    return {r = r, g = g, b = b}
end)

-- Функция рендера
local function set_color(color_table)
    -- Имитация работы движка: передаем координаты цвета в API рендера
    -- (Здесь просто выводим адрес таблицы в памяти, чтобы убедиться, что он не меняется)
    print(string.format("   [Рендер] Цвет: %s (r:%d, g:%d, b:%d)", 
        tostring(color_table), color_table.r, color_table.g, color_table.b))
end

-- --- Игровой цикл ---
print("=== Кадр 1 ===")
set_color(get_color(255, 0, 0)) -- Создание

print("=== Кадр 2 ===")
set_color(get_color(255, 0, 0)) -- Из кэша (адрес тот же, принта создания нет)

print("=== Кадр 3 ===")
set_color(get_color(0, 255, 0)) -- Создание другого цвета
=== Кадр 1 ===
>> [Память] Создана таблица для (255, 0, 0)
   [Рендер] Цвет: table: 0x5f8968d62db0 (r:255, g:0, b:0)
=== Кадр 2 ===
   [Рендер] Цвет: table: 0x5f8968d62db0 (r:255, g:0, b:0)
=== Кадр 3 ===
>> [Память] Создана таблица для (0, 255, 0)
   [Рендер] Цвет: table: 0x5f8968d62700 (r:0, g:255, b:0)