Функции pcall, xpcall, error и assert: обработка и генерация ошибок в Lua

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

В Lua нет оператора для перехвата исключений как, например, try-except в Python. Вместо этого используется функциональный подход — встроенные pcall и xpcall. Поэтому, чтобы запустить код в защищенном режиме, его нужно обернуть в функцию, которую передать в pcall или xpcall, то есть выполнить так называемый изолированный вызов (protected call).
В статье сначала дается краткий обзор типичных ошибок в Lua, далее объясняется работа pcall и xpcall, рассказывается как принудительно создавать ошибки (error — безусловная генерация, assert — условная), в конце приводится пример перехвата сгенерированных ошибок с помощью pcall.

В Lua обычно говорят об ошибках, а не исключениях, так как в этом языке для обработки сбоев нет специальных классов и объектов-исключений. В Lua любая внештатная ситуация — это либо ошибка времени выполнения (runtime error), либо ошибка синтаксиса (syntax error). Ошибка в Lua — это, как правило, обычная строка текста, которая сообщает, что пошло не так.

Одной из наиболее частых является обращение к полю несуществующей таблицы, когда вместо нее в переменной содержится nil:

local t = nil
print(t.field)
attempt to index a nil value (local 't')

В приведенном выше коде ошибка очевидна на момент написания. Однако если данные переменная должна получать извне в процессе выполнения программы, есть вероятность, что в итоге она получит nil.

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

local player = {x=10, y=10, jump=100}
print(player.jump())
attempt to call a number value (field 'jump')

В примере в выводе информации об ошибке вместо "a number value" было бы "a nil value", если бы идентификатора jump в принципе не было бы в коде.

В Lua если в конкатенации участвует nil или таблица, не имеющая метаметода __tostring, возникает ошибка:

print(nil .. 'Bob')
attempt to concatenate a nil value

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

local a = 'Ten'
a = a + 10
attempt to add a 'string' with a 'number'
local a = io.read('n')
print(a + 10)
Ten
lua: file.lua:2: attempt to perform arithmetic on a nil value (local 'a')

Бывают ошибки передачи неверного типа в функции (type mismatch). Так многие встроенные функции Lua строго относятся к типам аргументов. Например, функция math.sin() ожидает число, а table.insert() — таблицу на первом месте.

local t = {10, 30}
table.insert(t, 0)  -- работает
local p = 10
table.insert(p, 0)  -- не работает
bad argument #1 to 'insert' (table expected, got number)

Посмотрим, как в Lua можно обработать такие ошибки с помощью встроенной функции pcall, которая специально для этого предназначена:

-- Имитируем ситуацию: данные не загрузились (переменная равна nil)
local user = nil
-- local user = {name='Bob', age=30}

-- Опасная функция, которая гарантированно упадет без защиты
local function print_name(profile)
    -- Если profile равен nil, здесь произойдет ошибка
    return profile.name
end

-- ВЫЗОВ ПОД ЗАЩИТОЙ (pcall)
-- Передаем саму функцию и аргумент для нее через запятую
local success, result = pcall(print_name, user)

-- Анализируем результат работы pcall
if success then
    print('Имя пользователя: ' .. result)
else
    print('🚨 Не удалось прочитать имя пользователя.')
    print('Текст ошибки из консоли: ' .. result)
end

-- Этот код выполнится в любом случае, т.к. pcall изолировал сбой
print('Выполнение скрипта продолжается дальше...')
🚨 Не удалось прочитать имя пользователя.
Текст ошибки из консоли: 2.lua:8: attempt to index a nil value (local 'profile')
Выполнение скрипта продолжается дальше...

Если раскомментировать строку, где user связан с таблицей, то вывод будет такой:

Имя пользователя: Bob
Выполнение скрипта продолжается дальше...

Функция pcall всегда возвращает как минимум одно значение — "флаг успешности". Второе возвращаемое значение — это либо то, что возвращает функция, либо строка с текстом ошибки (системный трейс). Если функция возвращает несколько значений, то переменных до знака равенства надо указывать больше. В случае ошибки лишним присвоится nil.

Обычный защищенный вызов с помощью pcall сообщает только, в каком месте произошла ошибка. Однако если функция глубоко вложена, вы не узнаете, какой именно путь к ней привел, так как стек вызовов к моменту возврата из pcall уже уничтожен. Поэтому для отслеживания трека вызовов используется функция xpcall:

-- Функция обработки ошибок (Error Handler). 
-- Вызывается ДО того, как Lua развернет стек, поэтому видит всю цепочку вызовов.
local function error_handler(err)
    -- debug.traceback() сам собирает лог вызовов
    return debug.traceback('🚨 Произошла ошибка: ' .. tostring(err), 2)
end

-- Имитируем глубокую вложенность вызовов
local function database_query(profile)  -- Последний вызов
    -- Падение здесь: profile равен nil
    return profile.name
end

local function get_user_data(profile)
    return database_query(profile) .. '!' -- Вызов второго уровня
end

local function render_profile_page(profile)
    return get_user_data(profile) .. '!'  -- Вызов первого уровня
end

-- ВЫЗОВ ПОД РАСШИРЕННОЙ ЗАЩИТОЙ (xpcall)
-- Передаем: 1. Целевую функцию, 2. Обработчик ошибок, 3. Аргументы
local success, result = xpcall(render_profile_page, error_handler, nil)

if not success then
    print(result) -- Выводим подробный отчет со стеком вызовов
end

print('Скрипт успешно работает дальше...')
🚨 Произошла ошибка: file.lua:11: attempt to index a nil value (local 'profile')
stack traceback:
        file.lua:11: in upvalue 'database_query'
        file.lua:15: in upvalue 'get_user_data'
        file.lua:19: in function <file.lua:18>
        [C]: in function 'xpcall'
        file.lua:24: in main chunk
        [C]: in ?
Скрипт успешно работает дальше...

В реальных проектах (например, в логировании на сервере или в игровом движке) в качестве второго аргумента xpcall почти всегда просто передают встроенную функцию debug.traceback:

local success, result = xpcall(render_profile_page, debug.traceback, nil)

В самописном обработчике error_handler мы передавали в debug.traceback() аргумент 2, чтобы скрыть сам обработчик ошибок из отчета и начать лог сразу с той строки кода, где произошла ошибка.

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

-- print( 10 / 0)  -- Вывод: inf

local function divide(a, b)
    if b == 0 then
        -- Говорим, что ошибка произошла на уровне 2 (у того, кто вызвал divide)
        error('Ошибка: Деление на ноль невозможно!', 2)
    end
    return a / b
end

print(divide(10, 0))
lua: 4.lua:11: Ошибка: Деление на ноль невозможно!
stack traceback:
        [C]: in function 'error'
        4.lua:6: in local 'divide'
        4.lua:11: in main chunk
        [C]: in ?

Когда вызывается error, Lua останавливает текущую функцию и начинает "разворачивать" стек вызовов вверх, пока не встретит защищенный вызов pcall или xpcall. Если их нет, скрипт полностью завершает работу с выводом ошибки.

Кроме этого, в Lua есть функция assert, которая в простых случаях удобнее error, так как избавляет от необходимости использования условий. Две функции ниже работают одинаково:

local function set_age1(age)
    if not age then
        error('Возраст не указан!')
    end
    print('Возраст установлен: ' .. age)
end

local function set_age2(age)
    -- Если age равен nil или false, выбросится ошибка
    assert(age, 'Возраст не указан!')
    print('Возраст установлен: ' .. age)
end

-- set_age1(false)
set_age2(false)
lua: 5_assert.lua:10: Возраст не указан!
stack traceback:
        [C]: in function 'assert'
        5_assert.lua:10: in local 'set_age2'
        5_assert.lua:15: in main chunk
        [C]: in ?

Однако первая более универсальная, так как вторая не подойдет, если проверять age надо на попадание в диапазон. Функция assert может быть удобна в комбинации io.open, которая в случае неудачи открытия файла не роняет скрипт, а возвращает nil и текст ошибки. В примере ниже ошибка могла бы появиться только на этапе чтения файла, если бы не условный оператор:

local file = io.open('test.txt', 'r')
if file then
    local content = file:read('*a')
    file:close()
    print(content)
else
    print('Не удалось открыть файл')
end

Когда же ошибка должна генерироваться на этапе открытия, используют assert:

-- Если файл не откроется, assert получит nil и сообщение об ошибке от io.open
local file = assert(io.open('test.txt', 'r'))

Функции error и assert полезны, когда пишется внутренняя логика функции (валидация, вычисления), чтобы сразу выйти из нее при неподходящих данных. Однако даже самостоятельно созданную ошибку будет правильным перехватывать с помощью pcall или xpcall. Пример:

-- Функция с проверками (может выбросить ошибку)
local function load_player_data(nickname)
    -- 1. Быстрая проверка через assert
    assert(nickname, 'Никнейм не может быть пустым!')
    
    -- Имитируем, что игрока 'Admin' нельзя загрузить по соображениям безопасности
    if nickname == 'Admin' then
        -- 2. Сложная логика с явным error
        error('Доступ к Admin заблокирован!', 2)
    end
    
    return { name = nickname, level = 80 }
end

-- ========================================================
-- ЗАЩИЩЕННЫЙ ВЫЗОВ (pcall)
-- ========================================================

-- Сценарий А: Передаем nil (сработает assert)
local success, result = pcall(load_player_data, nil)
print('Статус А:', success) -- false
print('Ошибка А:', result)  -- 'file.lua:4: Никнейм не может быть пустым!'

-- Сценарий Б: Передаем 'Admin' (сработает error)
local success, result = pcall(load_player_data, 'Admin')
print('Статус Б:', success) -- false
print('Ошибка Б:', result)  -- 'Доступ к Admin заблокирован!'

-- Сценарий В: Все хорошо
local success, data = pcall(load_player_data, 'Bob')
print('Статус В:', success) -- true
print('Имя игрока:', data.name) -- Bob