Elixir Основы Elixir: паттерн-матчинг, неизменяемость, структуры данных и управление потоком
0%

Основы Elixir: паттерн-матчинг, неизменяемость, структуры данных и управление потоком

Основы Elixir

Elixir — функциональный язык, и его синтаксис компактен: вы освоите его за выходные. Но два понятия переворачивают мышление привычного императивного разработчика: паттерн-матчинг (= — это не присваивание) и неизменяемость (данные никогда не меняются на месте). Понять эти два — значит понять Elixir. Разберём всё с первых принципов.

= — это оператор сопоставления, а не присваивания

Начнём с самого контринтуитивного. В Elixir = называется оператором match (сопоставления). Он пытается сделать левую и правую части равными по форме, связывая переменные.

x = 1        # связали x с 1 — выглядит как присваивание
1 = x        # ЭТО ТОЖЕ ВАЛИДНО: 1 сопоставляется с 1, всё ок
2 = x        # ** (MatchError) — 2 не равно 1, падение

Настоящая мощь — в деструктуризации:

{:ok, result} = {:ok, 42}       # result связался с 42
[first | rest] = [1, 2, 3]      # first = 1, rest = [2, 3]
%{name: n} = %{name: "Ада", age: 36}  # n = "Ада", лишние поля игнорируются

Если форма не совпала — MatchError. Это не «ошибка, которую надо ловить», а способ утверждать инварианты: «я ожидаю именно такую структуру, иначе это баг». Отсюда стиль «let it crash».

^ (pin) фиксирует значение переменной вместо связывания:

expected = 200
^expected = status_code   # сопоставить с ТЕКУЩИМ значением expected, не перезаписать

Базовые типы

# Числа
42            # integer (произвольной точности — переполнения нет)
3.14          # float (64-bit)
0x1F          # hex, 0b1010 — binary, 1_000_000 — подчёркивания для читаемости

# Атомы — именованные константы, само имя = значение
:ok
:error
:my_atom
true          # это атом :true
nil           # это атом :nil

# Строки (UTF-8 бинарные данные)
"привет"
"интерполяция: #{1 + 1}"

# Списки символов (charlist) — редко, для совместимости с Erlang
~c"hello"

# Кортежи — фиксированная структура, доступ по индексу за O(1)
{:ok, "data"}

# Списки — связные списки, O(1) добавление в голову, O(n) доступ по индексу
[1, 2, 3]

# Ассоциативные массивы
%{"key" => "value"}          # map с произвольными ключами
%{name: "Ада", age: 36}      # map с атом-ключами (частый случай)
[a: 1, b: 2]                 # keyword list — список кортежей {атом, значение}

Когда что использовать:

  • Кортеж — когда структура известна и фиксирована ({:ok, value}, {x, y}). Не для коллекций переменной длины.
  • Список — коллекция переменной длины, обрабатываемая рекурсивно/через Enum. Дёшево добавлять в голову, дорого — в хвост.
  • Map — словарь ключ-значение, доступ и обновление за ~O(log n).
  • Keyword list — опции функций (String.split(s, ",", trim: true)); допускает повторяющиеся ключи и порядок.

Неизменяемость: что это значит на практике

Данные в Elixir никогда не мутируются. Любая «модификация» возвращает новую структуру:

list = [1, 2, 3]
List.delete(list, 2)   # => [1, 3]
list                   # => [1, 2, 3] — ОРИГИНАЛ НЕ ИЗМЕНИЛСЯ

map = %{a: 1}
map2 = Map.put(map, :b, 2)   # map2 = %{a: 1, b: 2}, map = %{a: 1}

Почему это благо, а не расточительство:

  1. Безопасность конкурентности «бесплатно»: если данные нельзя изменить, два процесса не могут испортить их друг другу. Нет гонок, нет мьютексов в прикладном коде.
  2. Локальные рассуждения: получив структуру в функцию, вы знаете — никто её не поменяет за спиной.
  3. Дёшево благодаря структурному разделению (persistent data structures): новая версия map переиспользует большую часть старой в памяти, копируется лишь изменённый путь.

Ментальная модель: вы не «меняете» данные, а преобразуете их, порождая новые версии. Переменную можно пере-связать (x = f(x)), но старое значение при этом не портится — просто имя x теперь указывает на новое.

Оператор |> (pipe) — конвейер преобразований

|> берёт результат слева и подставляет первым аргументом в функцию справа. Это делает вложенные вызовы читаемыми сверху вниз:

# Без пайпа — читается наизнанку
Enum.sum(Enum.filter(Enum.map(1..10, &(&1 * 2)), &(&1 > 5)))

# С пайпом — читается как рецепт
1..10
|> Enum.map(&(&1 * 2))      # удвоить
|> Enum.filter(&(&1 > 5))   # оставить > 5
|> Enum.sum()               # сложить

Идиома: проектируйте функции так, чтобы главные данные шли первым аргументом — тогда они естественно встают в пайплайны.

Функции, модули, замыкания

defmodule Math do
  @moduledoc "Простые математические функции"

  @doc "Складывает два числа"
  def add(a, b), do: a + b        # короткая форма

  def factorial(0), do: 1          # пример множественных клауз (см. ниже)
  def factorial(n) when n > 0 do   # длинная форма с guard
    n * factorial(n - 1)
  end

  defp helper(x), do: x * 2        # defp — приватная функция
end

# Анонимные функции
double = fn x -> x * 2 end
double.(21)                        # => 42 (обратите внимание на точку!)

# Сокращённый синтаксис захвата &
double = &(&1 * 2)                 # &1 — первый аргумент
Enum.map([1, 2, 3], &to_string/1)  # захват именованной функции по арности

Множественные клаузы + guards заменяют развесистые if/else. Компилятор выбирает первую подходящую клаузу сверху вниз:

def classify(n) when n < 0, do: :negative
def classify(0), do: :zero
def classify(n) when n > 0, do: :positive

Это идиоматичнее, чем if: логика ветвления выражена в сигнатурах, а не в теле.

Управление потоком

# case — сопоставление значения с образцами
case File.read("f.txt") do
  {:ok, content} -> IO.puts(content)
  {:error, reason} -> IO.puts("Ошибка: #{reason}")
end

# cond — цепочка условий (аналог if/else if)
cond do
  score >= 90 -> "A"
  score >= 80 -> "B"
  true -> "F"           # true — ветка «иначе»
end

# if / unless — для простых бинарных случаев (всё возвращает значение!)
status = if authorized?, do: :allow, else: :deny

# with — про него подробно в статье про идиомы и ошибки

В Elixir всё является выражением и возвращает значение — у if, case, cond есть результат, который можно присвоить. Нет return, нет операторов-инструкций.

Коллекции: Enum против Stream

Enum — «рабочая лошадь» для коллекций. Функции жадные: каждый шаг проходит всю коллекцию и создаёт промежуточный список.

Enum.map([1, 2, 3], &(&1 * 2))            # [2, 4, 6]
Enum.filter(1..10, &(rem(&1, 2) == 0))    # [2, 4, 6, 8, 10]
Enum.reduce([1, 2, 3], 0, &(&1 + &2))     # 6 (свёртка)
Enum.group_by(users, & &1.role)           # %{admin: [...], user: [...]}
Enum.into(pairs, %{})                      # собрать в map

Streamленивые вычисления. Ничего не считается, пока результат не «вытянут» (Enum.to_list, Enum.take и т.п.). Промежуточные коллекции не создаются — критично для больших/бесконечных данных:

# Обработка большого файла построчно, без загрузки в память целиком
File.stream!("huge.log")
|> Stream.map(&String.trim/1)
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Stream.take(100)          # остановимся на первых 100 — файл не дочитается до конца
|> Enum.to_list()            # <- только здесь начинается реальная работа

Правило: маленькая коллекция и простой конвейер — Enum. Большие данные, потоки, бесконечные последовательности или желание избежать промежуточных списков — Stream.

Структуры (struct) — типизированные записи

struct — это map с фиксированным набором ключей и модулем-«тегом». Способ описать доменную сущность.

defmodule User do
  @enforce_keys [:email]                    # без email создать нельзя
  defstruct [:email, :name, age: 0, active: true]  # значения по умолчанию
end

u = %User{email: "a@b.com", name: "Ада"}
u.email                    # "a@b.com"
u2 = %{u | age: 36}        # обновление поля (снова: новая структура)
%User{name: n} = u         # паттерн-матчинг по структуре

Структуры дают проверку ключей на этапе компиляции (опечатка в имени поля — ошибка) и позволяют диспетчеризацию протоколов по типу. Это основа доменных моделей и Ecto-схем.

Как читать документацию по типам

В hexdocs типы записаны как String.t(), integer(), keyword(), Enum.t(). Это typespecs — те же аннотации, что использует Dialyzer. Читать их полезно даже без статики: @spec split(String.t(), String.t()) :: [String.t()] мгновенно объясняет контракт функции.

Типичные ошибки новичка

  • Путать = с присваиванием. Помните: это сопоставление; [a, b] = [1, 2, 3] упадёт (разная длина).
  • Пытаться «изменить» переменную в цикле. Циклов for-с-мутацией нет — используйте Enum.reduce, рекурсию или comprehension.
  • Строки vs charlists. "abc" (строка/binary) ≠ ~c"abc" (список кодов). Erlang-функции иногда возвращают charlists — не пугайтесь.
  • Enum на бесконечном/огромном источнике — засосёт всё в память. Там нужен Stream.
  • Забыть точку у анонимной функции: f.(x), а не f(x).
  • Обновление несуществующего ключа синтаксисом |: %{map | :missing => 1} упадёт — | только обновляет существующие ключи; для добавления Map.put/3.

Comprehensions (for) — декларативная генерация

# Декартово произведение с фильтром и преобразованием
for x <- 1..3, y <- 1..3, x <= y, do: {x, y}
# => [{1,1},{1,2},{1,3},{2,2},{2,3},{3,3}]

# Собрать сразу в map
for {k, v} <- [a: 1, b: 2], into: %{}, do: {k, v * 10}
# => %{a: 10, b: 20}

Модель значений и память в двух словах

  • Данные неизменяемы; каждый процесс BEAM имеет собственную кучу. Сообщения между процессами копируются (кроме больших бинарей >64 байт — те шарятся через refcount).
  • Сборка мусора — per-process, поэтому нет глобальных пауз «на весь мир»; короткоживущие процессы часто вообще собираются целиком без GC.
  • Отсюда следствие: держите состояние в процессах маленьким и локальным, гоняйте между процессами компактные сообщения. Это станет очевидным в главе про конкурентность.

Источники

Что дальше

Идиоматика и обработка ошибок — как писать код «по-эликсирски» и обрабатывать сбои.

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

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

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

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