Перейти к основному содержимому

Типы данных и неизменяемость

Разработчику Архитектору

Дальше: Справочник Elixir · таблицы Операции для списка, Map, кортежа — ниже

Play ITЗагрузка интерактивного демо…


Типы данных и неизменяемость

Типы данных в Elixir делятся на две большие категории: базовые (или примитивные) и составные. Базовые типы представляют собой простые значения, которые не содержат внутри себя других структур. Составные типы строятся из базовых или других составных элементов и позволяют моделировать сложные формы информации. Все типы в Elixir реализованы как значения, передаваемые по значению, а не по ссылке, что упрощает рассуждения о поведении программы и делает её более предсказуемой.

Play ITЗагрузка интерактивного демо…


Целые числа

Целые числа в Elixir — это значения без дробной части, которые могут быть как положительными, так и отрицательными. Язык поддерживает произвольную точность целых чисел, что означает отсутствие ограничений на размер числа, кроме доступной памяти. Разработчик может свободно работать с числами, содержащими тысячи или даже миллионы цифр, без необходимости использовать специальные библиотеки. Такая гибкость особенно полезна в криптографии, математических вычислениях и других областях, где требуется высокая точность.

Целые числа можно записывать в десятичной, шестнадцатеричной, восьмеричной и двоичной системах счисления. Для этого используются префиксы: 0x для шестнадцатеричных, 0o для восьмеричных и 0b для двоичных литералов. Например, число 255 может быть записано как 0xFF, 0o377 или 0b11111111. Все эти формы эквивалентны и интерпретируются одинаково во время выполнения программы.

255 == 0xFF # true
255 == 0o377 # true
255 == 0b11111111 # true

1_000_000 + 2_500 # 1_002_500 — подчёркивания только для читаемости

Разбор:

  • 0x, 0o, 0b — префиксы систем счисления; значение одно и то же, меняется только запись.
  • Оператор == сравнивает числа семантически, даже если литералы записаны по-разному.
  • Подчёркивания в числах (1_000_000) игнорируются и нужны только для удобства чтения.

Числа с плавающей точкой

Числа с плавающей точкой в Elixir соответствуют стандарту IEEE 754 двойной точности (64-битные). Они используются для представления вещественных чисел, то есть значений, которые могут содержать дробную часть. Запись таких чисел осуществляется в десятичной форме с обязательным наличием точки, даже если дробная часть отсутствует. Например, 3.0 — корректное число с плавающей точкой, тогда как 3 будет воспринято как целое число.

Арифметические операции с числами с плавающей точкой выполняются с использованием аппаратной поддержки процессора, если она доступна. Однако, как и в большинстве языков программирования, такие операции могут быть подвержены ошибкам округления из-за особенностей двоичного представления десятичных дробей. Это требует внимания при сравнении значений или выполнении финансовых расчётов, где важна абсолютная точность.

1 === 1.0 # false — разные типы
1 == 1.0 # true — численное равенство

0.1 + 0.2 == 0.3 # false из-за округления IEEE 754

Разбор:

  • === требует совпадения и значения, и типа: целое 1 и float 1.0 не равны строго.
  • == для чисел допускает приведение: 1 и 1.0 считаются равными по величине.
  • Сумма 0.1 + 0.2 в двоичном float не даёт ровно 0.3 — для денег используют Decimal или целые копейки.

Атомы

Атомы — одна из самых характерных черт Elixir и всей экосистемы Erlang. Атом представляет собой константу, имя которой одновременно является её значением. Атомы начинаются с двоеточия, например — :ok, :error, :user_id. Они часто используются для обозначения состояний, меток, ключей в структурах данных или возвращаемых значений функций. Поскольку атомы неизменяемы и глобально уникальны, их сравнение происходит за константное время, что делает их чрезвычайно эффективными для использования в условиях высокой нагрузки.

Важно понимать, что атомы хранятся в специальной таблице атомов, которая имеет ограниченный размер. Создание слишком большого количества атомов во время выполнения программы может привести к исчерпанию этой таблицы и аварийному завершению системы. По этой причине атомы не должны создаваться динамически из ненадёжных источников, таких как пользовательский ввод. Вместо этого рекомендуется использовать строки или другие типы данных, когда требуется гибкость в формировании имён.

status = :ok
{:ok, data} = {:ok, %{id: 1}}

# Плохо для пользовательского ввода:
# String.to_atom(user_input) # риск переполнения таблицы атомов

Разбор:

  • Атом :ok часто обозначает успешное завершение без дополнительных данных.
  • В кортеже {:ok, data} атом :ok — метка ветки, data — полезная нагрузка.
  • String.to_atom/1 из ненадёжного ввода создаёт новые атомы на лету — в продакшене для таких ключей лучше строки.

Строки

Строки в Elixir — это последовательности байтов, закодированные в формате UTF-8. Это означает, что каждая строка является бинарным объектом, который может содержать любой текст, включая символы всех известных языков мира, эмодзи и специальные символы. Строки записываются в двойных кавычках: "Привет, мир!".

Поскольку строки в Elixir реализованы как бинарники, они поддерживают все операции, доступные для бинарных данных. При этом язык предоставляет богатый набор функций для работы с текстом — поиск подстрок, замена, разбиение, преобразование регистра и многое другое. Эти функции находятся в модуле String, который является частью стандартной библиотеки.

Особенностью строк в Elixir является их неизменяемость. Любая операция, изменяющая строку, создаёт новый бинарный объект. Это может показаться неэффективным на первый взгляд, но благодаря механизму копирования при записи (copy-on-write) и оптимизациям на уровне BEAM, такие операции выполняются быстро и с минимальным расходом памяти.

name = "Алиса"
greeting = "Привет, #{name}!"
String.length(greeting) # 13
String.upcase(greeting) # "ПРИВЕТ, АЛИСА!"
String.contains?(greeting, "Алиса") # true

Разбор:

  • #{name} — интерполяция: значение переменной встраивается в строку при создании новой строки.
  • String.length/1 считает кодовые точки Unicode, не байты.
  • String.upcase/1 возвращает новую строку; исходная greeting не меняется.
  • String.contains?/2 проверяет подстроку и возвращает true или false.

Символы и кодовые точки

Хотя Elixir не имеет отдельного типа "символ" в том виде, в каком он существует в некоторых других языках, он предоставляет работу с кодовыми точками Unicode. Каждый символ в строке представлен своей кодовой точкой — целым числом, соответствующим стандарту Unicode. Функции из модуля String позволяют перебирать строку по кодовым точкам, получать их числовые значения и выполнять различные преобразования.

Для удобства записи отдельных символов используется синтаксис ?a, который возвращает кодовую точку символа a (в данном случае — 97). Этот подход позволяет легко работать с отдельными символами без необходимости извлекать их из строк.

?a # 97
?ж # кодовая точка кириллической «ж»

"élixir" |> String.codepoints()
# ["é", "l", "i", "x", "i", "r"]

Разбор:

  • Префикс ? перед символом даёт его Unicode code point как целое число.
  • Для многобайтовых символов () результат — полная кодовая точка, а не один байт.
  • String.codepoints/1 разбивает строку на логические символы (графемы в простых случаях — по одной "букве").
  • Строка в Elixir хранится в UTF-8, но API работает с символами, а не с сырыми байтами.

Кортежи (Tuples)

Кортеж — это упорядоченная коллекция элементов фиксированного размера. Кортежи в Elixir записываются в фигурных скобках, например — {:ok, "файл загружен"} или {1, 2, 3}. Каждый элемент может быть любого типа — число, строка, атом, другой кортеж — ограничений нет. Размер кортежа определяется во время его создания и не может быть изменён позже.

Кортежи часто используются для возврата нескольких значений из функции, особенно для передачи результата операции вместе со статусом. Стандартная практика в экосистеме Elixir — возвращать пару вида {:ok, значение} при успехе и {:error, причина} при ошибке. Такой подход делает обработку исключений явной и предсказуемой.

Пример:

File.read("example.txt")
# Может вернуть —
# {:ok, "Содержимое файла"}
# или
# {:error, :enoent}

Разбор:

  • File.read/1 возвращает кортеж результата, а не бросает исключение в обычном потоке.
  • Вариант {:ok, ...} содержит содержимое файла, вариант {:error, ...} — причину ошибки.
  • Такой контракт удобно обрабатывать через case, with и pattern matching.

Доступ к элементам кортежа осуществляется по индексу с помощью функции elem/2:

user = {:user, "Алексей", 34}
{:user, name, age} = user
# name == "Алексей", age == 34

# Доступ по индексу — elem/2 (реже, чем сопоставление с образцом) —
name = elem(user, 1)

Разбор:

  • user — кортеж фиксированной длины из трех элементов.
  • Шаблон {:user, name, age} проверяет форму кортежа и извлекает значения по позициям.
  • elem(user, 1) читает второй элемент (индексация с нуля).
  • Pattern matching обычно читается лучше, чем доступ по индексу, особенно для доменных данных.

Изменение элемента кортежа невозможно напрямую, поскольку все данные в Elixir неизменяемы. Однако можно создать новый кортеж на основе старого с помощью синтаксиса обновления:

person = {:user, "Мария", 28}
updated_person = put_elem(person, 2, 29) # {:user, "Мария", 29}

Разбор:

  • put_elem/3 не изменяет исходный кортеж, а возвращает новый.
  • Индекс 2 указывает на третий элемент, который заменяется с 28 на 29.
  • Иммутабельность сохраняет предсказуемость: person и updated_person — разные значения.

Функция put_elem/3 принимает исходный кортеж, индекс и новое значение, возвращая новый кортеж. Это соответствует общей философии языка: вместо модификации — создание нового значения.


Интерактивное демо — примеры кода в виджете на JavaScript/Python/C#; синтаксис Elixir — в блоках ниже.

Play ITЗагрузка интерактивного демо…


Списки (Lists)

Список в Elixir — это односвязный список, реализованный как цепочка пар "голова–хвост". Голова содержит первый элемент, хвост — оставшуюся часть списка (или пустой список, если элемент последний). Списки записываются в квадратных скобках — [1, 2, 3], ["привет", "мир"].

Особенность списков — эффективное добавление элементов в начало. Операция [элемент | список] выполняется за константное время. Добавление в конец или произвольный доступ по индексу требует прохода по всему списку и имеет линейную сложность.

Примеры:

list = [1, 2, 3]
new_list = [0 | list] # [0, 1, 2, 3]

# Разбор списка через сопоставление с образцом
[head | tail] = new_list
# head == 0
# tail == [1, 2, 3]

Разбор:

  • Операция [0 | list] добавляет элемент в начало списка за O(1).
  • new_list становится новым списком, исходный list не изменяется.
  • Шаблон [head | tail] разбирает список на голову и хвост.
  • Такой паттерн — базовый инструмент для рекурсивной обработки списков.

Списки могут содержать элементы разных типов:

mixed = [:status, "готово", 200]

Разбор:

  • Elixir разрешает гетерогенные списки, где элементы имеют разные типы.
  • В примере смешаны атом, строка и число.
  • В прикладном коде чаще используют однородные списки, чтобы упрощать обработку и чтение.

Однако в реальных программах рекомендуется использовать однородные списки для повышения читаемости и поддержки типовой дисциплины.

Проверка принадлежности элемента списку:

1 in [1, 2, 3] # true
5 in [1, 2, 3] # false

Разбор:

  • Оператор in проверяет, есть ли элемент в перечислении.
  • Для списков проверка линейная: поиск идет по элементам слева направо.
  • Выражение возвращает булево значение, поэтому его удобно использовать в условиях.

Длина списка определяется функцией length/1:

length([:a, :b, :c]) # 3

Разбор:

  • length/1 считает количество элементов в списке.
  • Для linked list операция требует прохода по всем узлам, то есть O(n).
  • Функция возвращает целое число и часто используется в проверках и валидации.

Важно не путать списки с кортежами: списки предназначены для хранения последовательностей переменной длины, кортежи — для фиксированных наборов связанных значений.

Keyword list — список пар "атом → значение", удобен для опций:

opts = [timeout: 5000, retries: 3, timeout: 10_000] # дубликат ключа допустим

Keyword.get(opts, :timeout) # 5000 — первое вхождение
Keyword.get(opts, :missing, :default) # :default

Разбор:

  • Синтаксис [key: value] — сокращение для списка кортежей {:key, value}.
  • Порядок ключей сохраняется; это отличает keyword list от обычной map.
  • Keyword.get/3 безопасно читает значение и позволяет задать значение по умолчанию.
  • Дубликаты ключей редки в продакшене, но синтаксически разрешены.

Операции (список — linked list, не массив):

ДействиеСинтаксис / функция
Добавить в начало[value | list]
Прочитать голову / хвостhd(list), tl(list) или [h | t]
Длинаlength(list)
Вставка по индексуList.insert_at(list, index, value)
Удаление по индексуList.delete_at(list, index)
Замена по индексуList.replace_at(list, index, value)

Map: Map.put(map, key, val), Map.get(map, key), Map.delete(map, key).

Tuple: только чтение elem(tuple, index) — размер фиксирован при создании.


Бинарники (Binaries)

Бинарник — это последовательность байтов. Он представляет собой непрерывный блок памяти, используемый для хранения сырых данных — изображений, сетевых пакетов, сериализованных структур и, что особенно важно, строк. В Elixir строки являются UTF-8-бинарниками.

Создание бинарника:

<<1, 2, 3>> # бинарник из трёх байтов
<<65, 66, 67>> # соответствует "ABC" в ASCII
"Привет" # это тоже бинарник: <<208, 159, 209, 128, ...>>

Разбор:

  • Синтаксис <<...>> создает бинарник как последовательность байтов.
  • "Привет" тоже бинарник, потому что строки в Elixir хранятся в UTF-8.
  • Значения 65,66,67 иллюстрируют соответствие байтов и символов ASCII.

Бинарники поддерживают мощный синтаксис сопоставления с образцом, позволяющий разбирать структурированные данные на лету:

data = <<10, 20, 30>>
<<a, b, c>> = data
# a == 10, b == 20, c == 30

Разбор:

  • Переменная data содержит три байта.
  • Шаблон <<a, b, c>> разбирает бинарник по сегментам фиксированной длины.
  • В результате каждый байт связывается с отдельной переменной.

Можно указывать размер и тип каждого сегмента:

<<flags::8, length::16, payload::binary>> = <<1, 0, 5, "hello">>
# flags == 1
# length == 5
# payload == "hello"

Разбор:

  • ::8 и ::16 задают размер поля в битах для первых сегментов.
  • payload::binary забирает оставшуюся часть как бинарник.
  • Такой формат удобен для разбора протоколов с заголовком и полезной нагрузкой.

Эта возможность делает Elixir особенно удобным для работы с сетевыми протоколами, файловыми форматами и другими бинарными структурами.

Конкатенация бинарников:

greeting = "Привет"
full = greeting <> ", мир!" # "Привет, мир!"

Разбор:

  • Оператор <> выполняет конкатенацию бинарников.
  • Строки тоже бинарники, поэтому операция работает с обычным текстом.
  • Результат записывается в новую переменную full; исходная строка не меняется.

Оператор <> работает только с бинарниками и строками (которые являются их частным случаем).


Битовые строки (Bitstrings)

Битовая строка — обобщение бинарника, в котором количество битов не обязано быть кратно восьми. Бинарник — это частный случай битовой строки, где длина кратна 8.

Пример битовой строки:

<<1::3, 0::2, 1::3>> # 8 бит → эквивалентно <<0b10001001>>
<<1::1>> # 1 бит — это уже не бинарник, а битовая строка

Разбор:

  • Битовые сегменты позволяют задавать поля с длиной, не кратной байту.
  • Первая строка суммарно дает 8 бит, поэтому значение также является бинарником.
  • Вторая строка содержит 1 бит и относится только к bitstring.

Проверка:

is_binary(<<1::8>>) # true
is_binary(<<1::1>>) # false
is_bitstring(<<1::1>>) # true

Разбор:

  • is_binary/1 проверяет кратность длины восьми битам.
  • <<1::1>> не бинарник, но остается bitstring.
  • is_bitstring/1 дает обобщенную проверку для всех битовых строк.

Битовые строки редко используются в повседневном коде, но незаменимы при работе с аппаратными интерфейсами, шифрованием или компактными представлениями данных.


Диапазоны (Ranges)

Диапазон — это структура, представляющая последовательность целых чисел от начала до конца включительно. Записывается через ..:

1..5 # диапазон от 1 до 5
-3..0 # от -3 до 0

Разбор:

  • .. создает структуру Range с начальной и конечной границей.
  • Диапазон не материализует список сразу и экономит память.
  • Его можно использовать в Enum, for и проверках вхождения.

Диапазоны не генерируют все числа сразу — они хранят только границы. Это делает их экономичными по памяти.

Проверка вхождения:

3 in 1..5 # true
6 in 1..5 # false

Разбор:

  • Проверка in с Range определяет, попадает ли число в границы диапазона.
  • Результат булевый, поэтому выражение удобно в условных конструкциях.
  • Для 3 условие истинно, для 6 — ложно, так как число вне диапазона.

Диапазоны часто используются в циклах и генераторах:

for n <- 1..3, do: n * n # [1, 4, 9]

Разбор:

  • for (comprehension) проходит по диапазону и строит новую коллекцию.
  • Выражение n * n вычисляется для каждого элемента.
  • В результате получается список квадратов [1, 4, 9].

Интерактивное демо — виджет на JavaScript/Python/C#; для Elixir см. примеры с %{} ниже.

Play ITЗагрузка интерактивного демо…


Отображения (Maps)

Отображение — основная структура данных для хранения пар "ключ–значение". Ключами могут быть любые типы — атомы, строки, числа, даже другие структуры. Значения также не ограничены.

Создание:

person = %{"имя" => "Иван", "возраст" => 40}
config = %{host: "localhost", port: 8080}

Разбор:

  • %{} создает map — основную структуру "ключ-значение" в Elixir.
  • В person ключи строковые ("имя"), в config — атомы (:host, :port) в сокращенной форме.
  • Выбор типа ключей влияет на стиль доступа и на интеграцию с внешними данными.

Обратите внимание: при использовании атомов в качестве ключей допускается сокращённая запись ключ: значение.

Доступ к значению:

person["имя"] # "Иван"
config[:host] # "localhost"

Разбор:

  • Доступ через map[key] универсален и работает для любых типов ключей.
  • Для строкового ключа нужен строковый индекс, для атома — атомный.
  • Если ключ отсутствует, выражение вернет nil, что важно учитывать в логике.

Если ключ — атом, можно использовать точечную нотацию:

config.host # "localhost"

Разбор:

  • Точечная нотация работает для map со существующим атомным ключом.
  • Такой доступ более краткий и читаемый для конфигурационных структур.
  • При отсутствии ключа возможна ошибка, поэтому в динамических сценариях безопаснее Map.get/3.

Обновление значения:

updated = %{config | port: 9000} # %{host: "localhost", port: 9000}

Разбор:

  • Синтаксис %{map | key: value} обновляет только уже существующие ключи.
  • Возвращается новая map, исходная config остается прежней.
  • Для добавления новых ключей предпочтителен Map.put/3.

Этот синтаксис требует, чтобы ключ уже существовал. Для добавления нового ключа используется полная форма:

%{config | new_key: "значение"} # ошибка, если new_key не существует
%{config | "новый_ключ" => "значение"} # тоже ошибка

# Правильно — создать новое отображение —
Map.put(config, :timeout, 5000)
# или
%{config | timeout: 5000} # только если timeout уже есть

Разбор:

  • Первые два выражения демонстрируют ограничение обновляющего синтаксиса на "только существующие поля".
  • Map.put(config, :timeout, 5000) универсален: создаст ключ, если его нет.
  • %{config | timeout: 5000} безопасен только когда :timeout уже объявлен в map.

Лучше всего использовать Map.put/3 для универсального обновления:

Map.put(%{}, :key, "value") # %{key: "value"}

Разбор:

  • Map.put/3 принимает map, ключ и новое значение.
  • Если ключ отсутствует, он добавляется; если есть, значение заменяется.
  • Функция возвращает новую map без мутации исходной структуры.

В современных версиях Elixir (1.7+) map сохраняет порядок вставки ключей. Не путайте с keyword list ([key: value]) — там ключи-атомы и другой синтаксис, он чаще используется для опций функций.

Операции (Map):

ДействиеСинтаксис / функция
Добавить или заменитьMap.put(map, key, value)
ПрочитатьMap.get(map, key) или map[key]
УдалитьMap.delete(map, key)

Множества (MapSet)

Для задач "уникальные элементы без дубликатов" в Elixir есть MapSet:

set = MapSet.new([1, 2, 2, 3]) # #MapSet<[1, 2, 3]>
set = MapSet.put(set, 4)
set = MapSet.delete(set, 2)
MapSet.member?(set, 3) # true

Разбор:

  • MapSet.new/1 удаляет дубликаты и строит множество уникальных значений.
  • MapSet.put/2 добавляет элемент, MapSet.delete/2 удаляет элемент.
  • MapSet.member?/2 проверяет принадлежность и возвращает булево значение.
  • Все операции возвращают новое множество, сохраняя иммутабельность.

MapSet удобно применять для:

  • дедупликации событий;
  • проверки "видели ли уже этот идентификатор";
  • быстрых membership-проверок в пайплайнах Enum/Stream.

Операции (Set):

ДействиеСинтаксис / функция
ДобавитьMapSet.put(set, value)
УдалитьMapSet.delete(set, value)
Проверить наличиеMapSet.member?(set, value)

nil, false и "истинность"

В условиях if, unless, cond и в операторах && / || ложными считаются только false и nil. Число 0, пустая строка "", пустой список [] и пустая map %{}истинные (truthy). Это отличается от JavaScript и Python, где пустые коллекции часто считаются ложью.

if [], do: "да" # выполнится
if nil, do: "нет" # не выполнится

Разбор:

  • В Elixir только false и nil считаются ложными в условиях.
  • Пустой список [] truthy, поэтому первая строка выполняет ветку do.
  • Во второй строке условие nil, поэтому if не входит в ветку.

Переменные

В Elixir переменные — это именованные ссылки на значения. Объявление переменной происходит в момент присваивания. Синтаксис прост — имя переменной, за которым следует оператор =, и значение справа:

x = 42
name = "Елена"
status = :active

Разбор:

  • Пример показывает связывание переменных с разными типами значений — число, строка, атом.
  • = выступает как pattern matching, а не императивное присваивание.
  • Переменные дальше используются в выражениях как неизменяемые значения текущего контекста.

Имена переменных должны начинаться со строчной буквы или символа подчёркивания и могут содержать буквы, цифры, знаки подчёркивания и символы @ (в особых случаях). Примеры допустимых имён — counter, user_id, _temp, max_value.

Особенность переменных в Elixir — их связь с механизмом сопоставления с образцом (pattern matching). Оператор = не является классическим присваиванием, как в императивных языках. Он выражает утверждение: "левая часть должна соответствовать правой". Если соответствие возможно, переменные в левой части связываются со значениями из правой части. Если нет — возникает ошибка времени выполнения.

Пример:

{a, b} = {10, 20}
# a == 10, b == 20

Разбор:

  • Шаблон слева требует, чтобы справа был кортеж из двух элементов.
  • При совпадении a связывается с первым элементом, b — со вторым.
  • Такой подход позволяет сразу декомпозировать данные без дополнительных вызовов.

Здесь переменные a и b связываются с элементами кортежа. Это декларативное утверждение о структуре данных.

Переменные в Elixir могут быть связаны только один раз в рамках одного контекста. Повторное использование имени переменной приводит не к изменению значения, а к пересвязыванию (rebinding). Это означает, что старая переменная остаётся неизменной, а новое имя указывает на новое значение. Такое поведение сохраняет неизменяемость данных, но даёт удобство в написании кода.

Пример:

x = 5
x = x + 1
# x теперь связано со значением 6
# исходное значение 5 не изменилось — просто создано новое связывание

Разбор:

  • Во второй строке выполняется пересвязывание имени x с новым значением.
  • Старое значение не мутирует, так как данные в Elixir неизменяемые.
  • Такой стиль снижает побочные эффекты и делает шаги преобразования явными.

Пересвязывание работает только с неаннотированными переменными. Если переменная используется в левой части сопоставления внутри функции или блока, где она уже была связана, интерпретатор требует точного совпадения, если не указано иное.

Чтобы запретить пересвязывание и заставить переменную вести себя как константа, можно использовать модульный атрибут или явно передать значение в замыкание без повторного связывания. Однако в большинстве случаев пересвязывание считается нормальной практикой и не нарушает функциональной чистоты.

Имена, начинающиеся с заглавной буквы, зарезервированы для модулей и не могут использоваться как переменные:

User = "admin" # ошибка: User — это имя модуля

Разбор:

  • Имена с заглавной буквы зарезервированы для модулей, а не для переменных.
  • Попытка связать User как переменную приводит к ошибке.
  • Для переменных используйте user, user_name и другие имена со строчной буквы.

Переменные, начинающиеся с подчёркивания, сигнализируют о том, что значение не будет использоваться. Это соглашение помогает избежать предупреждений компилятора о неиспользуемых переменных:

{:ok, data} = File.read("config.txt")
# при ошибке сопоставление не сработает — используйте case/with

Разбор:

  • Шаблон ожидает успешный результат чтения файла.
  • Если File.read/1 вернет {:error, reason}, возникнет MatchError.
  • Для безопасной обработки обеих веток обычно применяют case или with.

Структуры (Structs)

Структура — это расширение отображения с фиксированным набором ключей, определённых во время компиляции. Структуры обеспечивают типобезопасность, читаемость и производительность при работе с именованными данными. Каждая структура привязана к модулю, и её имя совпадает с именем модуля.

Объявление структуры:

defmodule User do
defstruct name: "", age: 0, active: true
end

Создание экземпляра:

user = %User{name: "Дмитрий", age: 31}
# %User{name — "Дмитрий", age: 31, active: true}

Поля, не указанные при создании, получают значения по умолчанию, заданные в defstruct.

Доступ к полям:

user.name # "Дмитрий"
user.age # 31

Обновление:

updated = %{user | age: 32}

Попытка добавить поле, не объявленное в структуре, вызовет ошибку:

%{user | role: "admin"} # ** (KeyError) key :role not found

Это отличает структуры от обычных отображений и делает их подходящими для моделирования доменных сущностей.

Структуры наследуют все свойства отображений, но имеют собственный тип, что позволяет функциям проверять принадлежность к конкретной структуре:

is_struct(user) # true
is_map(user) # true (структура — подтип отображения)

Можно определять методы внутри модуля структуры, создавая поведение, связанное с данными:

defmodule User do
defstruct name: "", age: 0

def greet(%User{name: name}) do
"Привет, #{name}!"
end
end

user = %User{name: "Анна"}
User.greet(user) # "Привет, Анна!"

Арность и спецификации

У каждой функции есть арность — число аргументов. sum/1 и sum/2 — разные функции. В документации и @doc это отражается явно.

Опциональная аннотация @spec описывает типы аргументов и результата; вместе с Dialyzer она помогает ловить несоответствия на этапе анализа, без жёсткой компиляции типов как в Haskell.


Функции как тип данных

В Elixir функции являются полноценными значениями первого класса. Их можно присваивать переменным, передавать как аргументы, возвращать из других функций и хранить в структурах данных.

Функции создаются с помощью ключевого слова fn:

adder = fn a, b -> a + b end
result = adder.(3, 4) # 7

Обратите внимание на синтаксис вызова: после имени переменной ставится точка и скобки. Это отличает вызов анонимной функции от вызова именованной функции модуля.

Функции могут захватывать окружение, в котором были определены (замыкания):

multiplier = 10
scale = fn x -> x * multiplier end
scale.(5) # 50

Здесь multiplier — свободная переменная, захваченная из внешнего контекста.

Именованные функции модуля также могут быть представлены как значения с помощью оператора &:

double = &(&1 * 2)
# эквивалентно fn x -> x * 2 end

Enum.map([1, 2, 3], double) # [2, 4, 6]

# Или ссылка на функцию модуля —
formatter = &String.upcase/1
formatter.("hello") # "HELLO"

Анонимные и именованные функции сами по себе не хранят состояние между вызовами. Чистая функция при одинаковых аргументах даёт одинаковый результат; если внутри есть IO, обращение к процессу или БД — это уже эффект, и его стоит явно выделять в архитектуре.


Процессы и PID

Elixir построен на модели акторов: каждая единица выполнения — это изолированный процесс, который взаимодействует с другими процессами исключительно через обмен сообщениями. Эти процессы управляются виртуальной машиной BEAM и не имеют отношения к операционным потокам или процессам ОС. Они легковесны, потребляют мало памяти (около 2–3 КБ на процесс) и могут запускаться в количестве миллионов на одной машине.

Каждый процесс имеет уникальный идентификатор — PID (Process Identifier). PID — это специальный тип данных, который можно получить при создании процесса:

pid = spawn(fn ->
receive do
{:hello, sender} -> send(sender, {:world})
end
end)

PID выглядит как #PID<0.123.0> и может использоваться для отправки сообщений:

send(pid, {:hello, self()})

Функция self() возвращает PID текущего процесса.

Процессы не разделяют память. Любые данные, передаваемые между ними, копируются. Это исключает гонки данных и делает программу устойчивой к сбоям: падение одного процесса не влияет на другие.

Проверка типа:

is_pid(pid) # true

PID остаётся действительным даже после завершения процесса, но попытка отправить сообщение мёртвому процессу приведёт к его тихому игнорированию.

Процессы — основа конкурентности в Elixir. Они позволяют строить отказоустойчивые, масштабируемые и отзывчивые системы без использования блокировок, мьютексов или других примитивов синхронизации.


Как закрепить тему на практике

Мини-упражнение на 10 минут:

  1. Создайте map с настройками приложения и обновите 2 поля через Map.put/3.
  2. Преобразуйте список email-ов в MapSet и удалите дубликаты.
  3. Разберите кортеж {:ok, value} через pattern matching.
  4. Запустите процесс через spawn/1 и отправьте ему сообщение.

После этого переходите к Управляющим конструкциям и операторам Elixir, чтобы связать типы данных с ветвлениями и обработкой ошибок.


Частые ошибки в работе с типами

ОшибкаПочему случаетсяКак исправить
Путать строку ("") и charlist (~c"")похожий внешний виддля текста по умолчанию используйте строки
Динамически создавать атомы из вводариск переполнения таблицы атомовхранить пользовательские значения как строки
Выбирать tuple для изменяемых наборов данныхнеудобное обновление и чтениедля доменных данных использовать map или struct
Ожидать, что пустой список "ложный"перенос привычки из других языковпомнить: ложные только false и nil

Что дальше по маршруту

  1. Управляющие конструкции и операторы Elixir.
  2. Функции и процессы в Elixir.
  3. Простые приложения на Elixir.

Как выбирать структуру данных под задачу

Быстрый ориентир для практики:

ЗадачаЧто выбратьПочему
Последовательный обход и рекурсияlistестественная модель head/tail
Фиксированный набор связанных значенийtupleкомпактно и быстро для паттерн-матчинга
Доменная сущность с полямиstructпредсказуемая форма и безопасность полей
Словарь параметровmapгибкие ключи и удобное обновление
Набор уникальных элементовMapSetотсутствие дублей и быстрый membership-check
Опции функцииkeyword listпривычный формат key: value для API

Если сомневаетесь между tuple и map, в прикладном коде чаще выигрывает map/struct из-за читаемости.


От типов к архитектуре

На этом уровне закладывается архитектурная устойчивость:

  • правильный выбор структур упрощает API модулей;
  • ясные контракты данных облегчают обработку ошибок через case и with;
  • иммутабельность делает конкурентный код предсказуемым;
  • одинаковые формы ответов ({:ok, _} / {:error, _}) ускоряют интеграцию между компонентами.

Дальше — Управляющие конструкции и операторы, затем Функции и процессы.