Основы 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}
Почему это благо, а не расточительство:
- Безопасность конкурентности «бесплатно»: если данные нельзя изменить, два процесса не могут испортить их друг другу. Нет гонок, нет мьютексов в прикладном коде.
- Локальные рассуждения: получив структуру в функцию, вы знаете — никто её не поменяет за спиной.
- Дёшево благодаря структурному разделению (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.
- Отсюда следствие: держите состояние в процессах маленьким и локальным, гоняйте между процессами компактные сообщения. Это станет очевидным в главе про конкурентность.
Источники
- Учебник: elixir-lang.org/getting-started
- Справочник
Enum/Stream: hexdocs.pm/elixir/Enum.html - Elixir School — Basics
Что дальше
Идиоматика и обработка ошибок — как писать код «по-эликсирски» и обрабатывать сбои.