Elixir Идиоматика Elixir и обработка ошибок: теги, with, поведения, протоколы, антипаттерны
0%

Идиоматика Elixir и обработка ошибок: теги, with, поведения, протоколы, антипаттерны

Идиоматика Elixir и обработка ошибок

Знать синтаксис недостаточно — важно писать по-эликсирски. В основе идиоматики лежат: маленькие чистые функции, паттерн-матчинг вместо ветвлений, явное различие «ожидаемой ошибки» и «сбоя», а также два механизма полиморфизма — поведения (behaviours) и протоколы (protocols). Разберёмся, как это складывается в чистый код.

Две категории ошибок

Ключ к пониманию обработки ошибок в Elixir — различать:

  1. Ожидаемые исходы («файла нет», «валидация не прошла», «пользователь не найден») — это нормальная часть логики. Их возвращают как значения, обычно tagged tuples.
  2. Исключительные ситуации / баги («не должно было случиться», «инвариант нарушен») — на них падают. Их ловит супервизор, а не 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 (сбои). Подробнее о наблюдаемости — в статье Деплой и наблюдаемость.

Источники

Что дальше

Конкурентность и OTP — процессы, супервизия и то, ради чего существует BEAM.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов