Перейти к основному содержимому

5.15. Рекомендации по разработке на Lua

Разработчику Архитектору

Рекомендации по разработке на Lua

Введение в культуру кода на Lua

Качественный код на Lua сочетает лаконичность языка с предсказуемостью поведения. Язык предоставляет минималистичный набор конструкций, что делает ответственность за читаемость и структуру полностью лежащей на разработчике. Основные опорные точки культуры кода на Lua — последовательность в именовании, явное управление состоянием таблиц, бережное отношение к производительности в циклах и чёткое разделение ответственностей между модулями.

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

Именование сущностей

Общие принципы именования

Имена переменных, функций и модулей пишутся в стиле snake_case. Этот стиль устоялся в сообществе как стандарт де-факто и используется в официальной документации, стандартной библиотеке и большинстве популярных фреймворков.

Примеры корректных имён:

local player_health = 100
local max_inventory_slots = 20
local calculate_damage = function(attacker, target)
-- реализация
end

Константы объявляются заглавными буквами с подчёркиваниями:

local MAX_PLAYERS = 64
local DEFAULT_TIMEOUT = 30
local PHYSICS_GRAVITY = 9.81

Имена модулей соответствуют пути к файлу в файловой системе с заменой разделителей пути на точки:

-- Файл: game/entities/player.lua
local Player = require("game.entities.player")

Специфика именования таблиц и объектов

Таблицы, представляющие объекты с поведением, именуются с заглавной буквы в стиле PascalCase:

local Vector2 = {}
local GameObject = {}
local NetworkManager = {}

Таблицы, используемые как простые контейнеры данных или конфигурации, именуются в snake_case:

local player_stats = { health = 100, mana = 50 }
local ui_config = { font_size = 14, padding = 8 }

Имена итераторов в циклах отражают содержимое коллекции:

for index, item in ipairs(inventory_items) do
-- обработка предмета
end

for entity_id, entity in pairs(active_entities) do
-- обработка сущности
end

Избегайте однобуквенных имён за исключением стандартных математических обозначений или коротких циклов:

-- допустимо в узком контексте
for i = 1, 10 do
sum = sum + i * i
end

-- недопустимо для бизнес-логики
local x = get_user_data()
process(x) -- непонятно, что представляет x

Оформление кода

Отступы и пробелы

Используйте два пробела для отступов. Табуляция не применяется. Открывающая скобка блока размещается на той же строке, что и управляющая конструкция. Закрывающая скобка выравнивается по отступу управляющей конструкции.

if player.health <= 0 then
player.is_alive = false
trigger_death_animation(player)
end

for i = 1, #enemies do
if enemies[i]:is_visible() then
enemies[i]:update_position(delta_time)
end
end

Добавляйте пробелы вокруг бинарных операторов и после запятых:

local damage = base_damage * (1 + crit_multiplier)
local position = { x = 10.5, y = 20.3, z = 0.0 }

Не добавляйте пробелы внутри скобок вызова функции или индексации таблицы:

-- правильно
local result = calculate(10, 20)
local value = config["timeout"]

-- неправильно
local result = calculate( 10, 20 )
local value = config[ "timeout" ]

Длинные строки и переносы

Переносите длинные строки вызовов функций или выражений на новую строку с дополнительным отступом в два пробела. Запятые размещайте в конце строки:

local success, result = network.request(
"POST",
"/api/v1/users",
{ username = "player1", password = "secret" },
{ timeout = 5000, headers = { ["Content-Type"] = "application/json" } }
)

Для таблиц с множеством элементов используйте вертикальное оформление:

local character_config = {
name = "Aeliana",
class = "Ranger",
level = 24,
attributes = {
strength = 12,
agility = 18,
intelligence = 15
},
equipment = {
weapon = "Elven Bow",
armor = "Leather Tunic",
accessory = "Amulet of Swiftness"
}
}

Условные конструкции и циклы

Всегда используйте блоки then/end даже для однострочных условий. Это предотвращает ошибки при расширении логики и улучшает читаемость:

-- правильно
if is_debug_mode then
log_debug_message("Entering combat zone")
end

-- неправильно
if is_debug_mode then log_debug_message("Entering combat zone") end

Для цепочек условий применяйте последовательное выравнивание:

if player.stamina > 50 then
perform_sprint()
elseif player.stamina > 20 then
perform_jog()
else
perform_walk()
end

Структура проекта и организация файлов

Иерархия каталогов

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

project/
├── main.lua -- точка входа приложения
├── config/ -- конфигурационные файлы
│ ├── default.lua
│ └── production.lua
├── core/ -- ядро движка или фреймворка
│ ├── event.lua
│ ├── timer.lua
│ └── math.lua
├── entities/ -- игровые сущности или бизнес-объекты
│ ├── player.lua
│ ├── enemy.lua
│ └── projectile.lua
├── systems/ -- системы обработки (ECS-стиль)
│ ├── physics.lua
│ ├── rendering.lua
│ └── audio.lua
├── ui/ -- пользовательский интерфейс
│ ├── hud.lua
│ ├── menu.lua
│ └── inventory.lua
├── utils/ -- утилитарные функции
│ ├── string.lua
│ ├── table.lua
│ └── validation.lua
└── tests/ -- тесты
├── unit/
└── integration/

Каждый файл представляет собой самостоятельный модуль, возвращающий таблицу с публичным интерфейсом через return.

Модульная система

Модули объявляются через локальную таблицу, которая возвращается в конце файла:

-- файл: utils/string.lua
local String = {}

function String.trim(text)
return text:match("^%s*(.-)%s*$")
end

function String.split(text, delimiter)
local result = {}
for match in (text .. delimiter):gmatch("(.-)" .. delimiter) do
table.insert(result, match)
end
return result
end

return String

Импорт модулей выполняется через require с указанием пути относительно корня проекта:

local String = require("utils.string")
local Player = require("entities.player")

local cleaned = String.trim(" hello world ")
local words = String.split(cleaned, " ")

Избегайте глобальных переменных. Все сущности должны быть либо локальными, либо явно экспортированы через модульную систему. Глобальное пространство имён зарезервировано для стандартной библиотеки Lua и критически важных системных компонентов.

Работа с таблицами

Инициализация и манипуляции

Пустые таблицы объявляются через {}. Для таблиц, используемых как массивы, применяйте функции table.insert, table.remove и # для получения длины:

local items = {}
table.insert(items, "Sword")
table.insert(items, "Shield")
table.insert(items, "Potion")

for i = 1, #items do
print(items[i])
end

Для таблиц-словарей используйте прямую индексацию строковыми ключами:

local settings = {
volume = 0.75,
fullscreen = true,
language = "en"
}

settings.resolution = "1920x1080"

Избегайте смешивания числовых и строковых ключей в одной таблице без явной необходимости. Такая практика затрудняет понимание семантики таблицы и приводит к ошибкам при итерации.

Копирование таблиц

Глубокое копирование таблиц требует рекурсивной реализации. Для поверхностного копирования используйте итерацию:

function table_shallow_copy(original)
local copy = {}
for key, value in pairs(original) do
copy[key] = value
end
return copy
end

function table_deep_copy(original)
local copy = {}
for key, value in pairs(original) do
if type(value) == "table" then
copy[key] = table_deep_copy(value)
else
copy[key] = value
end
end
return copy
end

В производительных критических участках избегайте частого создания таблиц внутри циклов. Предварительно аллоцируйте рабочие таблицы или используйте пулы объектов.

Функции и замыкания

Объявление и сигнатуры

Функции объявляются через ключевое слово function или как значения таблицы:

-- глобальная функция (избегать)
function global_helper()
-- реализация
end

-- локальная функция (предпочтительно)
local function calculate_score(kills, assists, deaths)
return kills * 10 + assists * 5 - deaths * 3
end

-- метод таблицы
local Player = {}
function Player:take_damage(amount)
self.health = self.health - amount
if self.health <= 0 then
self:die()
end
end

Синтаксис function table:name() автоматически добавляет параметр self в начало списка аргументов. Это эквивалентно function table.name(self, ...).

Параметры и возврат значений

Функции в Lua могут принимать переменное число аргументов через ... и возвращать несколько значений:

local function min_max(...)
local args = { ... }
local min, max = args[1], args[1]
for i = 2, #args do
if args[i] < min then min = args[i] end
if args[i] > max then max = args[i] end
end
return min, max
end

local lowest, highest = min_max(3, 7, 2, 9, 1)
-- lowest = 1, highest = 9

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

local function create_entity(config)
local entity = {
position = config.position or { x = 0, y = 0 },
velocity = config.velocity or { x = 0, y = 0 },
health = config.health or 100,
tags = config.tags or {}
}
return entity
end

local enemy = create_entity({
position = { x = 100, y = 200 },
health = 150,
tags = { "hostile", "ranged" }
})

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

Замыкания и состояние

Замыкания позволяют сохранять локальное состояние между вызовами функции:

local function create_counter(initial)
local count = initial or 0
return function()
count = count + 1
return count
end
end

local counter = create_counter(10)
print(counter()) -- 11
print(counter()) -- 12

Используйте замыкания для создания фабрик объектов, генераторов последовательностей и обработчиков событий с сохранением контекста. Избегайте чрезмерного использования замыканий для хранения крупных структур данных — это усложняет отладку и управление памятью.

Объектно-ориентированное программирование

Прототипное наследование

Lua реализует ООП через таблицы и метатаблицы. Базовый шаблон класса:

local Class = {}
Class.__index = Class

function Class.new(initial_value)
local self = setmetatable({}, Class)
self.value = initial_value or 0
return self
end

function Class:increment(amount)
self.value = self.value + (amount or 1)
return self.value
end

function Class:get_value()
return self.value
end

-- использование
local instance = Class.new(10)
instance:increment(5)
print(instance:get_value()) -- 15

Наследование через метатаблицы

Наследование реализуется через установку __index дочернего класса на родительский:

local Animal = {}
Animal.__index = Animal

function Animal.new(name)
local self = setmetatable({}, Animal)
self.name = name
return self
end

function Animal:speak()
return "..."
end

local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog

function Dog.new(name, breed)
local self = Animal.new(name)
return setmetatable(self, Dog)
end

function Dog:speak()
return "Woof!"
end

local my_dog = Dog.new("Rex", "Shepherd")
print(my_dog:speak()) -- Woof!
print(my_dog.name) -- Rex

Конструктор дочернего класса вызывает конструктор родителя для инициализации базовых полей. Переопределённые методы вызывают родительские реализации через Animal.speak(self) при необходимости.

Приватные поля через замыкания

Для эмуляции приватных полей используйте замыкания:

local function create_bank_account(initial_balance)
local balance = initial_balance or 0

return {
deposit = function(amount)
if amount > 0 then
balance = balance + amount
end
end,

withdraw = function(amount)
if amount > 0 and balance >= amount then
balance = balance - amount
return true
end
return false
end,

get_balance = function()
return balance
end
}
end

local account = create_bank_account(100)
account.deposit(50)
account.withdraw(30)
print(account.get_balance()) -- 120
-- поле `balance` недоступно напрямую извне

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

Обработка ошибок

Защищённые вызовы

Используйте pcall для перехвата ошибок выполнения:

local success, result = pcall(function()
return risky_operation()
end)

if success then
process(result)
else
log_error("Operation failed: " .. result)
end

Для получения стека вызовов при ошибке применяйте xpcall с обработчиком ошибок:

local function error_handler(err)
return debug.traceback(err, 2)
end

local success, result = xpcall(risky_operation, error_handler)
if not success then
log_error(result) -- содержит стек вызовов
end

Утверждения и валидация

Функция assert используется для проверки предусловий и инвариантов:

function divide(a, b)
assert(type(a) == "number", "First argument must be a number")
assert(type(b) == "number", "Second argument must be a number")
assert(b ~= 0, "Division by zero")
return a / b
end

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

function parse_user_input(text)
if not text or text == "" then
return nil, "Input cannot be empty"
end

local number = tonumber(text)
if not number then
return nil, "Input must be a valid number"
end

return number, nil
end

local value, err = parse_user_input(user_text)
if err then
show_error_message(err)
else
process_value(value)
end

Такой подход позволяет гибко обрабатывать ошибки на разных уровнях приложения без прерывания выполнения.

Комментарии и документация

Виды комментариев

Однострочные комментарии начинаются с --. Многострочные комментарии оформляются через --[[ ... ]]:

-- Расчёт урона с учётом брони цели
local damage = calculate_damage(attacker, target)

--[[
Система квестов обрабатывает:
- активацию триггеров
- отслеживание прогресса
- начисление наград
]]
quest_system:update(delta_time)

Комментарии размещаются на отдельной строке перед кодом, который они описывают. Избегайте комментариев в конце строк кода — они затрудняют чтение.

Документирование модулей

Модули сопровождаются заголовочным комментарием с описанием назначения и примерами использования:

--[[
Модуль векторной математики для двумерного пространства.

Примеры:
local v1 = Vector2.new(3, 4)
local v2 = Vector2.new(1, 2)
local sum = v1:add(v2) -- Vector2(4, 6)
local length = v1:length() -- 5.0
local normalized = v1:normalize() -- Vector2(0.6, 0.8)
]]
local Vector2 = {}
Vector2.__index = Vector2

function Vector2.new(x, y)
return setmetatable({ x = x or 0, y = y or 0 }, Vector2)
end

-- ... реализация методов

Для публичных функций указывайте типы параметров и возвращаемых значений в комментариях:

--[[
Создаёт новую частицу с указанными параметрами.

@param position table {x: number, y: number} Начальная позиция
@param velocity table {x: number, y: number} Начальная скорость
@param lifetime number Время жизни в секундах
@param color table {r: number, g: number, b: number, a: number} Цвет
@return table Частица с методами обновления и отрисовки
]]
function ParticleSystem:create_particle(position, velocity, lifetime, color)
-- реализация
end

Такой подход упрощает навигацию по кодовой базе и интеграцию с инструментами автодополнения.

Производительность и оптимизация

Аллокации памяти

Частое создание таблиц и строк внутри циклов приводит к нагрузке на сборщик мусора. Предварительно аллоцируйте рабочие структуры:

-- неэффективно
for i = 1, 1000 do
local temp = { x = i * 0.1, y = math.sin(i) }
process(temp)
end

-- эффективно
local temp = {}
for i = 1, 1000 do
temp.x = i * 0.1
temp.y = math.sin(i)
process(temp)
end

Для временных объектов используйте пулы:

local Vector2Pool = {
_pool = {},
_index = 0
}

function Vector2Pool.acquire(x, y)
local vec
if self._index > 0 then
vec = self._pool[self._index]
self._index = self._index - 1
else
vec = { x = 0, y = 0 }
end
vec.x = x or 0
vec.y = y or 0
return vec
end

function Vector2Pool.release(vec)
self._index = self._index + 1
self._pool[self._index] = vec
end

Локальные переменные

Локальные переменные быстрее глобальных и полей таблиц. Кэшируйте часто используемые функции и значения:

-- медленно
for i = 1, 1000000 do
table.insert(results, math.sin(i) * math.cos(i))
end

-- быстро
local sin, cos, insert = math.sin, math.cos, table.insert
for i = 1, 1000000 do
insert(results, sin(i) * cos(i))
end

Особенно критично кэширование для вложенных циклов и функций, вызываемых тысячи раз в секунду.

Строковые операции

Конкатенация строк через .. создаёт новые объекты. Для сборки длинных строк используйте таблицу с последующим table.concat:

-- неэффективно
local result = ""
for i = 1, 1000 do
result = result .. items[i] .. "\n"
end

-- эффективно
local parts = {}
for i = 1, 1000 do
table.insert(parts, items[i])
table.insert(parts, "\n")
end
local result = table.concat(parts)

Этот подход снижает количество аллокаций и ускоряет обработку текста в циклах.

Тестирование кода на Lua

Структура тестов

Тесты организуются параллельно основной кодовой базе с сохранением иерархии:

project/
├── src/
│ └── utils/
│ └── string.lua
└── tests/
└── utils/
└── string_spec.lua

Каждый тестовый файл содержит набор проверок для соответствующего модуля:

-- tests/utils/string_spec.lua
local String = require("utils.string")
local assert = require("luassert")

describe("String utilities", function()
describe("trim", function()
it("removes whitespace from both ends", function()
assert.equals("hello", String.trim(" hello "))
end)

it("handles empty strings", function()
assert.equals("", String.trim(""))
assert.equals("", String.trim(" "))
end)
end)

describe("split", function()
it("splits string by delimiter", function()
local parts = String.split("a,b,c", ",")
assert.equals(3, #parts)
assert.equals("a", parts[1])
assert.equals("b", parts[2])
assert.equals("c", parts[3])
end)
end)
end)

Тестирование асинхронного кода

Для проверки асинхронных операций используйте таймауты и колбэки:

it("loads resource asynchronously", function()
local completed = false
local result = nil

ResourceManager.load("texture.png", function(res)
result = res
completed = true
end)

-- ожидание завершения (в реальных тестах используйте фреймворк)
while not completed do
coroutine.yield()
end

assert.truthy(result)
assert.equals("texture.png", result.name)
end)

В производственных проектах применяйте специализированные тестовые фреймворки с поддержкой асинхронных проверок, такие как busted с расширениями для кооперативной многозадачности.

Инструменты разработки

Статический анализ

Luacheck анализирует код на предмет неиспользуемых переменных, глобальных утечек и стилевых нарушений. Конфигурация размещается в .luacheckrc:

std = "lua51"
globals = { "love", "ngx" } -- разрешённые глобальные для конкретных сред
max_line_length = 100
unused_args = false

Запуск анализа:

luacheck src/ --globals love,ngx

Форматирование кода

LuaFormatter автоматизирует применение стилевых правил. Конфигурация в .lua-format:

indent_width = 2
use_tab = false
column_limit = 100
continuation_indent_width = 2

Форматирование проекта:

lua-format -i src/**/*.lua

Отладка

Встроенный отладчик через debug модуль позволяет устанавливать точки останова и инспектировать стек вызовов:

debug.sethook(function(event, line)
if event == "line" and line == 42 then
print("Reached line 42")
print(debug.traceback())
end
end, "l")

Для сложных проектов применяйте интегрированные отладчики в средах разработки: ZeroBrane Studio, Visual Studio Code с расширением Lua Debug или среды, предоставляемые игровыми движками (LÖVE2D, Defold, Roblox Studio).