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

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

Ошибки

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

iex> :foo + 1
** (ArithmeticError) bad argument in arithmetic expression
     :erlang.+(:foo, 1)

Ошибка времени запуска может быть вызвана с помощью raise/1:

iex> raise "oops"
** (RuntimeError) oops

Другие ошибки можно вызвать, передав имя ошибки и список аргументов с ключами в raise/2:

iex> raise ArgumentError, message: "invalid argument foo"
** (ArgumentError) invalid argument foo

Вы также можете объявить собственные ошибки, создав модуль и использовав конструкцию defexception внутри него. Таким образом вы создадите ошибку с тем же именем, что и у модуля, в котором она объявлена. Наиболее распространенный случай – объявление исключений с полем message:

iex> defmodule MyError do
iex>   defexception message: "default message"
iex> end

iex> raise MyError
** (MyError) default message

iex> raise MyError, message: "custom message"
** (MyError) custom message

Ошибки могут быть обработаны с помощью конструкции try/rescue:

iex> try do
...>   raise "oops"
...> rescue
...>   e in RuntimeError -> e
...> end
%RuntimeError{message: "oops"}

В примере выше ошибка рантайма отлавливается и возвращается для вывода в сессии iex.

Если у вас нет причины использовать саму ошибку, не обязательно её предоставлять:

iex> try do
...>   raise "oops"
...> rescue
...>   RuntimeError -> "Error!"
...> end
"Error!"

На практике, однако, Эликсир-разработчики редко используют конструкцию try/rescue. Например, многие языки обязали бы вас отловить ошибку, когда файл не может быть открыт. Эликсир же предоставляет функцию File.read/1, которая возвращает кортеж с информацией о том, что файл открыт успешно:

iex> File.read "hello"
{:error, :enoent}

iex> File.write "hello", "world"
:ok

iex> File.read "hello"
{:ok, "world"}

Здесь нет try/rescue. Если вы хотите обрабатывать различные исходы попытки открыть файл, можно использовать сопоставление с образцом в конструкции case:

iex> case File.read "hello" do
...>   {:ok, body}      -> IO.puts "Success: #{body}"
...>   {:error, reason} -> IO.puts "Error: #{reason}"
...> end

В конце концов, ваше приложение должно само решать, является ли ошибка при открытии файла исключительной или нет. Поэтому Эликсир не создаёт исключений в File.read/1 и многих других функциях. Напротив, разработчик сам волен выбирать лучший способ поведения.

Для случаев, когда вы ожидаете, что файл существует (и отсутствие этого файла действительно ошибка), вы можете использовать File.read!/1:

iex> File.read! "unknown"
** (File.Error) could not read file unknown: no such file or directory
    (elixir) lib/file.ex:305: File.read!/1

Многие функции в стандартной библиотеке следуют схеме наличия двух вариантов функции, одна из которых вызывает исключения, вместо возврата кортежа. Договорённость состоит в том, чтобы была функция (foo), которая возвращает кортеж {:ok, result} или {:error, reason}, и другая функция (foo!, такое же имя с ! в конце), которая принимает те же аргументы, что и foo, но вызывает исключения в случае ошибки. foo! должна возвращать результат (не обёрнутый кортежем), если всё прошло хорошо. Модуль File – хороший пример следования этой договорённости.

В Эликсире мы избегаем использования try/rescue, потому что не используем ошибки для контроля работы приложения. Мы понимаем ошибки буквально: они оставлены для неожиданных и исключительных ситуаций. В случае, если вам действительно нужно контролировать ход работы, следует использовать выбрасывания с помощью throw. О нём мы и поговорим.

Выбрасывания

В Эликсире, некоторое значение может быть **выброшено**</abbr> и далее **отловлено**. Конструкции `throw` и `catch` зарезервированы для ситуации, когда невозможно получить значение без использования `throw` и `catch`.

Такие ситуации на практике встречаются очень нечасто, исключая случаи работы с библиотеками, которые не предоставляют нормального API. Например, представьте, что модуль Enum не предоставляет API для поиска значений и что нам нужно найти первое кратное 13 число в списке чисел:

iex> try do
...>   Enum.each -50..50, fn(x) ->
...>     if rem(x, 13) == 0, do: throw(x)
...>   end
...>   "Got nothing"
...> catch
...>   x -> "Got #{x}"
...> end
"Got -39"

Но т. к. Enum предоставляет хороший API, на практике задача решается с использованием Enum.find/2:

iex> Enum.find -50..50, &(rem(&1, 13) == 0)
-39

Выход

Весь код на Эликсире работает внутри процессов, которые общаются между собой. Когда процесс умирает по «естественным причинам» (например, необработанное исключение), он отправляет сигнал exit. Процесс также может умереть при вызове сигнала exit вручную:

iex> spawn_link fn -> exit(1) end
#PID<0.56.0>
** (EXIT from #PID<0.56.0>) 1

В примере выше, связанный процесс умирает при отправке сигнала exit со значением 1. Оболочка Эликсира автоматически обрабатывает такие сообщения и выводит их в терминал.

Сигнал exit также можно «отловить», используя try/catch:

iex> try do
...>   exit "I am exiting"
...> catch
...>   :exit, _ -> "not really"
...> end
"not really"

Использование try/catch само по себе не распространено, и использование его для отлова выходов ещё более редкое.

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

Именно эта система супервизоров делает конструкции вроде try/catch и try/rescue настолько непопулярными в Эликсире. Вместо попытки решить ошибку, мы лучше побыстрее закончим с проблемным местом и дерево супервизора будет гарантировать работу нашего приложения, откатив его к известному начальному состоянию после ошибки.

Ключевое слово after

Иногда необходимо убедиться, что ресурс очищен после некоторых действий, которые могли быть потенциальной причиной ошибки. Конструкция try/after позволяет это сделать. Например, мы можем открыть файл и использовать after для его закрытия, это будет сделано даже если что-то пойдёт не так:

iex> {:ok, file} = File.open "sample", [:utf8, :write]
iex> try do
...>   IO.write file, "olá"
...>   raise "oops, something went wrong"
...> after
...>   File.close(file)
...> end
** (RuntimeError) oops, something went wrong

Секция after будет выполнена независимо от того, выполнился блок try без ошибок или нет. Однако, обратите внимание, если связанный процесс существует, этот процесс отправит exit и секция after не будет выполнена. after предоставляет лишь неполную гарантию. К счастью, файлы в Эликсире также связаны с текущим процессом и всегда будут закрыты, если текущий процесс завершился с ошибкой, независимо от секции after. Вы можете обнаружить, что так работают и другие ресурсы: таблицы ETS, сокеты, порты и др.

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

iex> defmodule RunAfter do
...>   def without_even_trying do
...>     raise "oops"
...>   after
...>     IO.puts "cleaning up!"
...>   end
...> end

iex> RunAfter.without_even_trying
cleaning up!
** (RuntimeError) oops

Эликсир автоматически обернёт тело функции в try, если обнаружит after, rescue или catch.

Ключевое слово else

Если указан блок else, он будет выполнен после блока try в любом случае, были в процессе ошибки или нет.

iex> x = 2
2

iex> try do
...>   1 / x
...> rescue
...>   ArithmeticError ->
...>     :infinity
...> else
...>   y when y < 1 and y > -1 ->
...>     :small
...>   _ ->
...>     :large
...> end
:small

Исключения в блоке else не отлавливаются. Если ни один шаблон внутри else не подходит, будет выброшено исключение. Оно не будет отловлено текущим try/catch/rescue/after блоком.

Область видимости переменных

Важно понимать, что переменные, определенные внутри try/catch/rescue/after блока не влияют на внешний контекст. Это связано с тем, что блок try может завершиться с ошибкой и некоторые переменные могут никогда не достичь своего объявления. Другими словами, следующий код не корректен:

iex> try do
...>   raise "fail"
...>   what_happened = :did_not_raise
...> rescue
...>   _ -> what_happened = :rescued
...> end

iex> what_happened
** (RuntimeError) undefined function: what_happened/0

Вместо этого, вы можете сохранить значение выражения из try:

iex> what_happened =
...>   try do
...>     raise "fail"
...>     :did_not_raise
...>   rescue
...>     _ -> :rescued
...>   end

iex> what_happened
:rescued

На этом заканчивается наше введение в try, catch и rescue. Вы можете обнаружить, что в Эликсире они используются реже, чем в других языках, хотя они могут быть полезны в некоторых ситуациях, когда библиотека или некоторая часть кода играет «не по правилам».

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.