Неизменяемость и привязка имён
В Elixir = — это не присваивание, а оператор сопоставления. Понять это — значит понять половину языка.
«Переменная» в Elixir — лишь имя, временно указывающее на неизменяемое значение. Само значение изменить нельзя никогда.
В императивных языках x = x + 1 меняет ячейку памяти. В Elixir значения неизменяемы: вы не меняете данные, вы создаёте новые и привязываете к ним имена.
x = 1 # привязали имя x к значению 1
x = x + 1 # x теперь указывает на НОВОЕ значение 2,
# старая единица никуда не делась
list = [1, 2, 3]
new_list = [0 | list] # [0, 1, 2, 3]
# list по-прежнему [1, 2, 3] — он не изменился
Оператор = сопоставляет правую часть с шаблоном слева. Если слева переменная — она привязывается. Чтобы сравнить со значением переменной, а не перепривязать её, ставят пин ^:
x = 1
^x = 1 # ok: сопоставление, 1 == 1
^x = 2 # ** MatchError: 2 не равно 1
Как работает под капотом (BEAM)
Неизменяемость — фундамент конкурентности на BEAM. Раз значение нельзя изменить, два процесса могут читать его без блокировок и гонок: данные просто не могут «поменяться под рукой». Когда процесс отправляет сообщение другому, значение копируется в кучу получателя (либо разделяется для крупных бинарей), и каждый работает со своей неизменяемой копией. Поэтому в Elixir нет мьютексов вокруг данных — нечего защищать.
Процесс A Процесс B [data] --copy--> [data'] Никакой общей мутируемой памяти. Нет гонок -> нет блокировок -> нет дедлоков.
Та же идея на Python ▶
Покажем разницу между мутацией и неизменяемым обновлением — это и есть стиль Elixir.
# "Изменяемый" подход (НЕ в духе Elixir)
mutable = [1, 2, 3]
mutable.append(4) # старый список изменён
print(mutable) # [1, 2, 3, 4]
# Неизменяемый подход (как в Elixir): создаём НОВЫЙ
original = (1, 2, 3) # tuple неизменяем
new_one = (0,) + original # новый объект
print(original) # (1, 2, 3) — цел
print(new_one) # (0, 1, 2, 3)
# = как привязка имени к новому значению
x = 1
x = x + 1
print(x) # 2 (старая 1 не "изменилась")
Частые ошибки
- Думать, что функции меняют аргументы. Никакая функция не изменит переданный список — она вернёт новый.
- Забыть присвоить результат.
List.delete(list, 2)без присваивания бесполезен — результат пропадёт. - Случайно перепривязать переменную. Внутри
case/ifлегко затереть имя; используйте^, когда хотите именно сравнить.
Best practices
- Принимайте неизменяемость как преимущество: код легче рассуждать, тестировать и распараллеливать.
- Используйте пин
^, чтобы матчить по значению переменной, а не перепривязывать её. - Стройте логику как поток «значение → новое значение», а не «изменить состояние».
Итог. Неизменяемость — не ограничение, а суперспособность: она убирает целый класс багов и делает безопасной конкурентность. А = как оператор сопоставления подводит нас к центральной теме языка — паттерн-матчингу.
Почему это упрощает рассуждения о коде
Неизменяемость даёт неочевидную, но огромную выгоду: локальность рассуждений. Если вы передали значение в функцию, вы знаете наверняка, что оно не изменится — функция физически не может его «испортить». Значит, читая код, вам не нужно держать в голове, кто и где мог поменять ваши данные за вашей спиной. В языках с мутацией это постоянный источник багов: список, переданный в библиотеку, неожиданно оказался изменён.
Эта же гарантия делает безопасным распараллеливание. Раз значение нельзя изменить, его могут одновременно читать сколько угодно процессов без единой блокировки. Именно поэтому в Elixir тема «потокобезопасности структур данных» попросту не возникает — все структуры данных потокобезопасны по построению. Цена — небольшой расход памяти на создание новых версий вместо изменения старых, но благодаря structural sharing и эффективному сборщику мусора на маленьких кучах эта цена на практике мизерна, а выгода в надёжности и конкурентности — колоссальна.