Обработка ошибок: try/catch, throw, exit, error

Когда всё-таки нужно ловить ошибки, а не падать.

try/catch — конструкция перехвата исключений в Erlang, позволяющая обработать сбой локально вместо аварийного завершения процесса.

Философия «let it crash» не означает полного отказа от обработки ошибок. Иногда сбой нужно поймать прямо здесь — например, на границе с внешним миром. Erlang даёт для этого классические исключения, но применяет их сдержанно. Подумайте, где проходит «граница»: это места, где ваша программа соприкасается с тем, что вы не контролируете, — разбор присланного клиентом JSON, чтение файла, который мог оказаться повреждённым, вызов библиотеки, которая по документации бросает исключение. В этих точках падать с непонятным крашем невежливо: лучше поймать сбой, превратить его в осмысленное значение и аккуратно вернуть наверх. А вот внутри вашей собственной логики, где вы контролируете все инварианты, ловить исключения чаще всего вредно — это маскирует баги. Так что try/catch в Erlang — инструмент пограничника, а не штатное средство на каждом шагу.

Три класса исключений

В Erlang исключения делятся на три вида, и важно их различать. Различие не косметическое: класс исключения говорит о намерении, и от него зависит, как код выше по стеку должен реагировать.

КлассКак возникаетСмысл
errorделение на ноль, no match, или error(Reason)программная ошибка
throwthrow(Value)намеренный «выброс» для нелокального выхода
exitexit(Reason)сигнал о завершении процесса

Разберём смысл каждого. error возникает, когда что-то пошло не так на уровне самого выполнения: вы делите на ноль, не подходит ни один образец в case, вызвана функция от аргумента неверного типа. Это, как правило, признак бага. throw — единственный класс, который программист бросает намеренно как часть управления потоком: «я хочу выскочить отсюда вот с этим значением». exit стоит особняком: его причина становится причиной завершения процесса и распространяется по связям как сигнал выхода — то есть exit теснее всего связан с механизмами link и supervisor из предыдущих уроков. Когда вы перехватываете исключение, вы пишете не просто Причина, а пару Класс:Причина — и можете отлавливать строго те классы, что ожидаете, не глотая остальные. Это прямое следствие того, что три класса несут разный смысл.

Конструкция try/catch

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

safe_divide(A, B) ->
    try A / B of
        Result -> {ok, Result}
    catch
        error:badarith -> {error, division_by_zero}
    end.
1> calc:safe_divide(10, 2).
{ok, 5.0}
2> calc:safe_divide(10, 0).
{error, division_by_zero}

В блоке catch шаблон вида Класс:Причина указывает, какие исключения ловить. Можно ловить и по образцу причины, и несколько веток сразу. Часть of с обработкой успешного результата необязательна: если она не нужна, пишут просто try Выражение catch ... end, и значением всего блока становится либо результат выражения, либо то, что вернула сработавшая ветка catch. Полезно знать, что если в шаблоне опустить класс и написать только причину, Erlang по умолчанию подставит класс throw. Поэтому привычка всегда указывать класс явно (error:badarith, а не просто badarith) спасает от сюрпризов: вы точно знаете, какие именно исключения перехватываете, и случайно не проглотите программную ошибку, думая, что ловите свой намеренный throw.

throw для нелокального выхода

throw применяют, когда нужно «выскочить» из глубокой рекурсии с результатом, не таща его через все уровни возврата. Представьте обход большого дерева в поисках первого подходящего элемента: как только вы его нашли, тянуть результат обратно через десятки уровней рекурсии, аккуратно прокидывая «нашёл / не нашёл» на каждом шаге, утомительно и засоряет код. throw позволяет в момент находки просто «выпрыгнуть» к ближайшему обрамляющему try, минуя все промежуточные кадры стека. Это законный приём, но пользоваться им стоит локально и осознанно: throw, который вылетает за пределы модуля и ловится где-то далеко, превращает поток управления в трудночитаемую головоломку. Хорошая практика — чтобы throw и ловящий его try жили рядом, в одной функции или хотя бы в одном модуле.

find_first_negative(List) ->
    try
        lists:foreach(fun(X) ->
            case X < 0 of
                true -> throw({found, X});
                false -> ok
            end
        end, List),
        not_found
    catch
        {found, N} -> N
    end.

after — гарантированная очистка

Блок after выполняется всегда — и при успехе, и при исключении. Его используют для освобождения ресурсов (закрыть файл, соединение).

try
    do_work(File)
after
    file:close(File)
end.

Почему try/catch используют умеренно

Главная стратегия Erlang — не ловить, а падать и восстанавливаться супервизором. Чрезмерный try/catch возвращает нас к оборонительному коду, который Erlang старается избегать. Хорошее правило: ловите исключения на границах (разбор внешнего ввода, вызов чужого кода, освобождение ресурса), а внутреннюю логику оставляйте падать. Есть и более тонкий довод против повсеместной ловли: каждый перехваченный, но толком не обработанный сбой делает систему «тише», чем она есть на самом деле. Баг, который должен был громко уронить процесс и попасть в логи супервизора, оказывается проглочен, программа ковыляет дальше с искажённым состоянием, и настоящая причина проблемы всплывает гораздо позже и совсем в другом месте, где её уже почти невозможно связать с источником. Поэтому в Erlang ценится не «поймать всё», а «поймать ровно то, что ты действительно знаешь как обработать», а остальное доверить дереву супервизоров, которое спроектировано именно для восстановления.

Как работает под капотом

Когда возникает исключение, BEAM разворачивает стек до ближайшего try с подходящей веткой catch. Если такого нет, исключение «выходит» из процесса как сигнал выхода, и процесс умирает — тут и вступает в дело супервизор. Класс exit отличается тем, что его причина становится причиной завершения процесса и передаётся связанным процессам как сигнал.

Частые ошибки

  • Ловить всё подряд «голым» catch без указания класса. Так легко проглотить и скрыть настоящие баги.
  • Использовать try/catch вместо супервизоров. Восстановление — задача архитектуры.
  • Путать throw и error. throw — намеренный выход, error — признак бага.

Итоги

  • Исключения делятся на три класса: error, throw, exit.
  • try ... catch Класс:Причина ... end ловит исключения локально.
  • after гарантирует очистку ресурсов при любом исходе.
  • Ловите ошибки на границах системы; внутреннюю логику доверяйте супервизорам.
Проверьте себя
1. Сколько классов исключений различает Erlang и какие они?
AОдин: error
BТри: error, throw, exit
CДва: try и catch
DЧетыре: error, warning, info, debug
2. Зачем нужен блок after в try?
AЧтобы поймать больше ошибок
BДля гарантированной очистки ресурсов при любом исходе
CЧтобы ускорить выполнение
DЧтобы завершить процесс
3. Какой подход к ошибкам поощряет Erlang в первую очередь?
AЛовить try/catch повсюду
BПадать и восстанавливаться супервизором, а try/catch применять умеренно на границах
CИгнорировать ошибки
DЛогировать и продолжать с битым состоянием