Идиоматика Elixir и обработка ошибок
Знать синтаксис недостаточно — важно писать по-эликсирски. В основе идиоматики лежат: маленькие чистые функции, паттерн-матчинг вместо ветвлений, явное различие «ожидаемой ошибки» и «сбоя», а также два механизма полиморфизма — поведения (behaviours) и протоколы (protocols). Разберёмся, как это складывается в чистый код.
Две категории ошибок
Ключ к пониманию обработки ошибок в Elixir — различать:
- Ожидаемые исходы («файла нет», «валидация не прошла», «пользователь не найден») — это нормальная часть логики. Их возвращают как значения, обычно tagged tuples.
- Исключительные ситуации / баги («не должно было случиться», «инвариант нарушен») — на них падают. Их ловит супервизор, а не
try/catch.
Смешение этих категорий — источник плохого Elixir-кода. Не оборачивайте всё в try/rescue; используйте возвращаемые значения там, где исход ожидаем.
Tagged tuples: {:ok, value} / {:error, reason}
Каноничный способ вернуть «успех или ошибку». Соглашение всей экосистемы:
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
# Вызывающий обязан явно разобрать оба случая:
case fetch_user(1) do
{:ok, user} -> render(user)
{:error, :not_found} -> render_404()
end
Многие библиотечные функции идут парами: File.read/1 возвращает {:ok, _}/{:error, _}, а File.read!/1 (с !) либо возвращает значение напрямую, либо падает. Соглашение: !-версия — «я уверен, что всё хорошо, иначе это баг».
content = File.read!("config.json") # падаем, если файла нет — и это ок для «обязательного» файла
with — элегантная цепочка операций, каждая из которых может сбоить
Когда несколько шагов подряд возвращают {:ok, _}/{:error, _}, вложенные case превращаются в «лесенку смерти». with разворачивает её в плоскую последовательность:
def create_order(params) do
with {:ok, user} <- fetch_user(params.user_id),
{:ok, product} <- fetch_product(params.product_id),
:ok <- check_stock(product, params.qty),
{:ok, order} <- insert_order(user, product, params.qty) do
{:ok, order}
end
# Любой шаг вернул НЕ то, что слева от <- ?
# Результат этого шага немедленно возвращается наружу (короткое замыкание).
end
Если нужно преобразовать ошибку — используйте else:
with {:ok, user} <- fetch_user(id),
{:ok, _} <- authorize(user) do
{:ok, user}
else
{:error, :not_found} -> {:error, "Пользователь не найден"}
{:error, :forbidden} -> {:error, "Нет доступа"}
end
with — визитная карточка идиоматичного Elixir. Он читается как «сделай A, потом B, потом C; на первой же осечке — выходим».
Механизм исключений — когда он всё-таки нужен
Исключения существуют, но применяются экономно:
# Определение своего исключения
defmodule MyApp.ConfigError do
defexception [:message]
end
raise MyApp.ConfigError, message: "Отсутствует обязательный ключ"
# Ловля (по возможности избегайте в бизнес-логике)
try do
risky()
rescue
e in RuntimeError -> Logger.error(Exception.message(e))
after
cleanup() # выполнится в любом случае
end
Когда raise уместен: нарушение программного контракта, невосстановимая ошибка конфигурации при старте, публичная функция, которую неправильно вызвали. Когда неуместен: ожидаемые бизнес-исходы (для них — tagged tuples).
Помимо throw/catch и raise/rescue есть exit — сигнал завершения процесса, который перехватывают ссылки и мониторы (см. главу про конкурентность). В прикладном коде вы почти всегда работаете с tagged tuples, изредка с raise, и очень редко с throw.
Поведения (behaviours) — контракты для модулей
Поведение — это интерфейс: список функций, которые модуль-реализация обязан определить. Аналог интерфейсов в ООП, но на уровне модулей. Это основной механизм инверсии зависимостей в Elixir.
# Контракт: «чем-то, что умеет отправлять уведомление»
defmodule Notifier do
@callback send(recipient :: String.t(), message :: String.t()) ::
:ok | {:error, term()}
end
# Реализация для email
defmodule EmailNotifier do
@behaviour Notifier
@impl true
def send(recipient, message) do
# ... отправка письма
:ok
end
end
# Реализация для SMS (та же сигнатура)
defmodule SmsNotifier do
@behaviour Notifier
@impl true
def send(recipient, message), do: :ok
end
@impl true заставляет компилятор проверить, что вы действительно реализуете функцию из поведения (ловит опечатки в имени). Подменяя реализацию через конфигурацию, вы получаете тестируемость (в тестах — заглушка) и гибкость. Именно так работают моки через Mox — см. главу про тестирование.
Протоколы (protocols) — полиморфизм по типу данных
Если поведения — это «модуль реализует интерфейс», то протокол — это «диспетчеризация по типу данных аргумента». Так работают Enumerable, String.Chars, Inspect.
# Объявляем протокол
defprotocol Sizeable do
@doc "Возвращает «размер» значения"
def size(data)
end
# Реализация для разных типов
defimpl Sizeable, for: List do
def size(list), do: length(list)
end
defimpl Sizeable, for: Map do
def size(map), do: map_size(map)
end
defimpl Sizeable, for: BitString do
def size(str), do: byte_size(str)
end
Sizeable.size([1, 2, 3]) # 3
Sizeable.size(%{a: 1}) # 1
Когда что: поведение — когда вы описываете сменную стратегию/адаптер (уведомления, платёжный шлюз). Протокол — когда одна операция должна работать единообразно для разных типов данных (сериализация, подсчёт размера, обход).
Модульные атрибуты и метапрограммирование (кратко)
@moduledoc,@doc— документация (её же используют doctests, см. главу про тесты).@spec,@type— типы для Dialyzer.@constant value— константы уровня модуля (вычисляются на этапе компиляции).- Макросы (
defmacro) — мощное метапрограммирование, на котором построеныuse, DSL Ecto и Phoenix. Правило новичка: не пишите макросы, пока не исчерпаны функции. Макросы усложняют отладку; 99% кода обходится без них.
Идиомы стиля
- Маленькие чистые функции. Функция должна делать одно. Логику ветвления выносите в множественные клаузы + guards, а не в
ifвнутри тела. - Данные — первым аргументом. Чтобы функции складывались в
|>-пайплайны. - Именование: модули —
CamelCase, функции/переменные —snake_case, предикаты — с?(valid?/1), «может упасть»-варианты — с!(fetch!/1), «приватные, опасные» иногда сdo_(do_parse/1). - Возвращайте согласованные формы. Если функция иногда возвращает
{:ok, _}, а иногда просто значение — вызывающему тяжело. Держите единый контракт. - Ранний выход через паттерн-матчинг, а не глубокая вложенность.
# Плохо: ветвление в теле
def discount(user) do
if user.premium do
if user.years > 5, do: 0.2, else: 0.1
else
0.0
end
end
# Идиоматично: клаузы + guards
def discount(%{premium: true, years: y}) when y > 5, do: 0.2
def discount(%{premium: true}), do: 0.1
def discount(_), do: 0.0
Антипаттерны, которых стоит избегать
Команда Elixir опубликовала официальный список — Anti-patterns в документации. Самые частые:
- «Тряпичный» boolean-параметр (
format(data, true)) — непонятно, что значитtrue. Используйте атомы-опции (format(data, :html)). - Не паттерн-матчить возвращаемые ошибки. Игнорирование
{:error, _}— источник тихих багов; сопоставляйте оба случая. - Динамические атомы из внешнего ввода (
String.to_atom(user_input)) — атомы не собираются GC, это утечка памяти и вектор DoS. ИспользуйтеString.to_existing_atom/1или оставайтесь на строках. - Комментарии вместо имён. Если нужен комментарий, объясняющий что делает блок — выделите его в функцию с говорящим именем.
- Работа с длинным пайплайном, который трудно отлаживать. Разбивайте на именованные шаги; вставляйте
|> tap(&IO.inspect/1)для отладки. - Чрезмерные макросы и
useтам, где хватило бы функции. - Изменяемое состояние в процессе, которое можно было держать в чистых данных. Не заводите GenServer ради того, что решается функцией.
Logger — правильное логирование
require Logger
Logger.info("Заказ создан", order_id: order.id, user_id: user.id)
Logger.warning("Повторная попытка", attempt: n)
Logger.error("Не удалось списать средства", reason: inspect(reason))
Логируйте структурно (метаданными), а не склеивая всё в строку — так логи удобнее фильтровать в проде. Уровни: debug (только dev), info (значимые события), warning (аномалии, но система работает), error (сбои). Подробнее о наблюдаемости — в статье Деплой и наблюдаемость.
Источники
- Официальные Anti-patterns и Library guidelines
- Поведения и протоколы: elixir-lang.org — Protocols
- Книга «Designing Elixir Systems with OTP» (James Edward Gray II, Bruce Tate) — про чистую архитектуру функций.
Что дальше
Конкурентность и OTP — процессы, супервизия и то, ради чего существует BEAM.