Как одна забытая local ломает Lua‑код и почему эту ошибку так сложно найти

Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах, пишу статьи и книги.
Скриптовый язык Lua, (Луна по‑португальски), получил широкую популярность среди разработчиков, благодаря своей легкости, гибкости и простому синтаксису.
Его можно встретить не только на различных устройствах, но даже в скриптах для Wireshark (это будет тема отдельной статьи).

Однако даже в таком простом языке легко столкнуться с проблемами, особенно когда дело касается архитектуры кода и взаимодействия модулей.
Сегодня поговорим об одной из таких проблем — глобальных переменных в Lua — и разберёмся, как их надо и не надо использовать.
Когда одна буква решает всё
Наш разбор проблем мы начнём с самой простой и при этом наиболее часто встречающейся.
В Lua переменная становится глобальной, если вы не написали local. Вроде бы звучит банально, и кажется, что каждый джун это знает, но посмотрите на этот код:
local function calculate_bonus(score)
multiplier = 1.5 -- забыли local
return score * multiplier
end
calculate_bonus(100)
print(multiplier) -- 1.5, глобальная переменная созданаС точки зрения выполнения функция работает, тесты проходят. Но теперь представьте, что рядом есть другая функция:
local function apply_penalty(score)
multiplier = 0.5 -- та же опечатка
return score * multiplier
endИ в итоге мы получаем, что multiplier стала общей глобальной переменной. Вызовите calculate_bonus, потом apply_penalty, затем снова calculate_bonus — и в итоге вы получите разные результаты.
Это классическая ошибка, которая проявляется только при определённой последовательности вызовов, и её не всегда можно поймать в тестах.
Здесь в качестве решения необходимо приучить себя всегда писать local, даже если кажется, что переменная нужна «везде».
Если действительно нужна глобальная, то создавайте её явно через модуль или таблицу-конфиг.

Например, так:
local Config = {}
Config.multiplier = 1.5
local function calculate_bonus(score)
return score * Config.multiplier
endПри таком написании кода вы всегда видите, откуда приходят данные, и разночтения практически исключены.
_G или швейцарский нож, который режет пальцы
В Lua есть специальная таблица _G, в которой лежат все глобальные переменные, и через неё можно читать и писать что угодно:
G.myvariable = 42
print(my_variable) -- напишет 42
_G.print = function() end
print("hello") -- не напишет ничегоИ второй пример не является шуткой, хотя кажется, что никто в здравом уме не будет переопределять print.
Но если вы случайно перезапишете print, pairs, ipairs или require через _G, то весь код после этого начнёт вести себя непредсказуемо. Особенно опасно это делать в средах, где несколько модулей или плагинов работают в одном пространстве имён.
Например, вот реальный кейс из игрового движка.
Плагин A для отладки переопределил
G.pairsсвоей версией с логированием. А плагин B использовалpairsв горячем цикле, из-за чего производительность упала в десять раз, и никто не понимал почему, пока не нашли строчкуG.pairs = my_pairsв чужом коде.
Здесь правилом хорошего тона является отказ от использования _G напрямую (если, конечно, вы не разрабатываете какую-нибудь песочницу или другую среду эмуляции). Читать оттуда тоже стоит осторожно, так как полученное значение тоже может измениться в любой момент.
Если вам нужно изолировать модуль, используйте локальную таблицу как пространство имён:
local M = {}
function M.do_something()
-- здесь только M и стандартные функции
end
return MЛомаем замыкания
В Lua 5.1 для изоляции окружения использовался setfenv, но начиная с версии 5.2+ его заменили на _ENV.
Обе конструкции действительно мощные, и обе легко ломают код, если не понимать, как работают замыкания.
Взгляните на классическую ловушку с setfenv:
-- Lua 5.1
local function make_counter()
local count = 0
return function()
count = count + 1
return count
end
end
local counter = make_counter()
setfenv(counter, {}) -- изолировали окружение
print(counter()) -- ошибка: attempt to perform arithmetic on upvalue 'count'Здесь проблема заключается в том, что setfenv меняет окружение функции, но count на самом деле — это локальная переменная, определённая вне функции, но используемая внутри неё. Однако если внутри замыкания была глобальная переменная, она внезапно исчезнет:
С ENV в Lua 5.2+ ситуация стала несколько тоньше, так как ENV — это обычная локальная переменная, которая определяет, где искать глобальные имена. Но если вы захватите _ENV в замыкании, а потом измените его снаружи, замыкание продолжит использовать старое окружение:
Для этой проблемы лучшим решением будет отказ от использования ENV вообще, так как это инструмент для фреймворков, а не для повседневной разработки.
Но если вы пишете песочницу, используйте load с явным окружением вместо манипуляций с ENV в рантайме.
Как глобальные переменные загрязняют пространство имён между модулями
А теперь давайте представим два модуля, которые «немного» используют глобальные переменные. Например, вот так:
-- module_a.lua
cache = {}
function get_cached(key)
return cache[key]
end
return { get_cached = get_cached }
-- module_b.lua
cache = {} -- перезаписали чужой cache
function set_cached(key, value)
cache[key] = value
end
return { set_cached = set_cached }И вот модуль module_a.get_cached начинает читать из того же cache, что и module_b.set_cached. Если модули загружаются в разном порядке или один из них сбрасывает cache, данные теряются.
При этом, когда мы тестируем каждый модуль отдельно, у нас всё зелёное, но в интеграции начинается хаос.
Здесь каждый модуль должен возвращать таблицу и хранить состояние внутри неё, и никаких глобальных cache, config, state.
Например:
-- module_a.lua
local M = {}
local cache = {}
function M.get_cached(key)
return cache[key]
end
return MПрактический итог
Для того чтобы выявить ошибки в своём коде, прежде всего включите проверку на глобальные переменные.
В Lua есть
debug.getinfoиdebug.sethook, но проще использовать статический анализатор вродеluacheck. Он найдётx = 10безlocalбыстрее, чем вы напишете следующий баг.Также, если вам нужна конфигурация, доступная везде, передавайте её явно. Например, через аргументы функции, замыкание или через модуль.
В средах с плагинами (OpenResty, Redis, игровые движки) всегда проверяйте, не пишет ли чужой код в
G. Одна строкаG.something = ...может обрушить всю систему.И наконец, не используйте
setfenvи_ENVдля «быстрого» решения задач изоляции. Это низкоуровневые инструменты с неочевидными побочными эффектами на замыкания. Если вам нужна песочница, то лучше воспользоватьсяload(code, name, "t", env).
Важно понимать, что глобальные переменные в Lua сами по себе не являются злом. Это инструмент, который слишком легко можно использовать неправильно.
Одна забытая local — и вам придётся отлаживать поведение, которое зависит от порядка загрузки модулей. Не давайте вашему коду такого шанса вас обмануть.

Даже небольшая ошибка в коде может привести к неожиданным последствиям, особенно если её не обнаружили до релиза. О том, как выявлять подобные проблемы и какие инструменты помогают повысить качество и безопасность разработки, поговорим на бесплатных открытых уроках:
11 ноября, 20:00. «Ошибки до релиза: автоматическая проверка кода 1С». Записаться.
16 ноября, 20:00. «Швейцарский нож РБПО: карта инструментов безопасной разработки на практике». Записаться.
Другие темы для разработчиков и не только собрали в дайджесте вебинаров октября.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.