Язык Elixir и функциональное программирование: что это за зверь и почему он хорош для отказоустойчивых систем
Discord, Pinterest, FT, Tesla... Их выбор для highload и real-time — Elixir. В чём секрет этого языка? Разбираемся, как его функциональный подход, мощь Erlang VM и закос под Ruby помогают создавать надёжные и масштабируемые системы.
384 открытий3К показов

Знаете про Elixir?
В первый раз слышу
Знаю
Я на нем работаю
Основные принципы функционального программирования
Elixir — функциональный язык программирования. Это значит, что здесь нет классов, объектов и прочих абстракций из ООП. Рассмотрим его особенности более детально.
Чистые функции и отсутствие побочных эффектов
Чистые функции делают код более предсказуемым и надёжным. У них есть два главных признака:
- Предсказуемый результат: для одних и тех же входных аргументов такая функция всегда возвращает одно и то же значение. Её результат зависит исключительно оттого, что ей передали, в коде нет заимствований извне.
- Отсутствие побочных эффектов: она не взаимодействует с «внешним миром» — не изменяет данные вне своей области видимости, не выводит информацию на экран, не пишет в файлы, не зависит от случайных чисел или текущего времени.
Рассмотрим на примере:
Функция sum — чистая, потому что она всегда возвращает один и тот же результат для одних и тех же входных данных (a и b). Если мы вызовем alculator.sum(10, 5), то всегда получим 15. Она не меняет никаких данных вне себя и не взаимодействует с внешним миром. Результат зависит только от её аргументов.
А это пример нечистой функции:
Функция sum_with_config выглядит похожей на sum, но она нечистая, потому что зависит от внешнего состояния и ведёт себя непредсказуемо.
Значение b берётся из файла config.txt. Если файл пуст, содержит 5 или, например, 100, результат будет разным, даже если аргумент a не изменился.
Код с чистыми функциями проще понимать, отлаживать, тестировать, рефакторить и переиспользовать. А ещё он хорошо подходит для задач, где важна безопасность при параллельном выполнении.
Иммутабельность данных
Elixir, как язык функционального программирования, поддерживает иммутабельность данных.
Вспомним, как мы обычно изменяем значения переменных в других языках на примере Python:
Мы создаём переменную x, она записывается в память, где ей присваиваем значение 10.
Далее мы присваиваем ей новое значение 11, и оно обновляется в памяти. Десятки больше нет.
Теперь рассмотрим похожий пример на Эликсире:
В память записывается значение 10, а не x. Только после этого присваиваем нашему значению «ярлык» в виде x.
Если у нас несколько переменных с одинаковыми значениями, то все они ссылаются на один участок памяти. В данном случае y ссылается на туже самую десятку.
Когда мы меняем значение переменной, мы на самом деле создаём новое в памяти, в данном случае 11, и присваиваем к нему новый «ярлык» — x.
Старое значение 10 остаётся, если у него есть другие ссылки или ярлыки. Если их нет, то его удаляет сборщик мусора. В данном случае 10 останется вместе с переменной y.
Такой подход называют иммутабельностью. Он позволяет избежать ошибок, особенно когда несколько процессов читают одни и те же данные.
Каррирование и композиция функций
Каррирование часто встречается в функциональных языках программирования. Оно означает, что мы превращаем функцию в цепочку, которая принимает только один аргумент.
- У нас есть функция sum(a, b), которая ожидает оба числа сразу.
- После каррирования мы получаем функцию curried_sum(a).
- Когда мы вызываем curried_sum(5), она не считает сумму. Вместо этого возвращает новую функцию. Назовём её условно add_five_func. Эта функция «запомнила», что a = 5, и теперь ждёт единственный аргумент — b.
- Следующим шагом мы берём эту только что полученную функцию (add_five_func) и вызываем её, передавая ей значение 3 в качестве аргумента b.
- Именно этот вызов (add_five_func(3)) запускает финальное вычисление (5 + 3) и возвращает нам результат 8.
Благодаря каррированию код получается более модульным, функции можно любым образом комбинировать и проще тестировать.
Недостаток каррирования — код становится сложным, трудно читаемым. Ещё новые функции влияют на производительность, и во многих сценариях каррирование может быть избыточным.
В некоторых языках программирования, например, в Haskell или F#, есть фишки для более удобного синтаксиса каррирования и даже автоматическое каррирование, но не в Elixir. В нём всё приходится делать с награмождением и без плюшек под капотом.
Композиция — это когда мы объединяем несколько функций в одну, где выходные данные одной функции передаются как входные данные для другой.
Представим, что нам нужно взять строку, убрать у неё лишние пробелы по краям, перевести в верхний регистр, а затем развернуть задом наперёд. Вместо того чтобы писать вложенные вызовы, мы можем использовать композицию с |>:
- Мы начинаем с initial_string.
- Оператор |> берёт значение initial_string и передаёт его как первый аргумент функции String.trim().
- Результат String.trim() ("hello elixir world") снова берётся оператором |> и передаётся как первый аргумент в String.upcase().
- Результат String.upcase() ("HELLO ELIXIR WORLD") аналогично передаётся в String.reverse().
- final_result получает итоговое значение этой цепочки — "DLROW RIXILE OLLEH".
Такие функции проще читать и комбинировать, но при отладке цепочки могут возникать проблемы.
Работа с рекурсией вместо циклов
В функциональных языках программирования часто приходится работать с рекурсией вместо циклов. Их разница в том, что цикл — это функция, которая повторяет код внутри себя. Рекурсия — функция, которая начинается, завершается и вызывает саму себя, вместо того, чтобы зацикливать содержимое.
Она работает как два зеркала, которые поставили друг напротив друга. Одно отображает другое.
Пример рекурсии в Elixir:
Этот код выводит числа меньше пяти. Он работает до тех пор, пока переменная n не будет равна нулю.
В отличие от цикла, функция print_down() в конце вызывает сама себя.
Рекурсии отлично подходят для задач с вложенностью и хорошо читаются. Но с ними надо быть начеку, так как они могут бесконечно выполняться и переполнять память.
Иногда в функциональном программировании используют хвостовую рекурсию. Она отличается от обычной рекурсии тем, что рекурсивный вызов является последним действием в функции, и после него не выполняется никаких дополнительных операций. Это позволяет виртуальной машине (в случае Elixir — BEAM) оптимизировать рекурсию, избегая создания новых кадров стека для каждого вызова.
Здесь используется аккумулятор acc, который накапливает промежуточный результат. Рекурсивный вызов factorial(n - 1, n * acc) — это последнее действие в функции, и после него ничего не происходит.
Особенности языка Elixir
Синтаксис, вдохновлённый Ruby
Хосе Валими, создатель Эликсира, хотел взять элегантность и простоту Ruby, чтобы разработчики могли писать код, который легко читается.
Читаемость и минимум шума
Как и Ruby, язык Elixir выглядит чисто — меньше скобок, точек с запятой и лишних символов. Пример с Ruby:
Пример с Elixir:
Интерполяция строк с #{} — прямое заимствование из Ruby. Это делает вывод текста интуитивным.
Блоки с do...end
В Ruby и Elixir есть блоки кода с do...end для функций и управляющих структур:
Ruby:
Elixir:
В обоих языках do...end обозначает тело функции — это привычно для рубистов и делает переход на Elixir проще.
Сахарный синтаксис
Ruby славится «синтаксическим сахаром» — удобными сокращениями. Elixir тоже этим балуется:
Ruby — пропуск скобок в вызовах:
Elixir — короткая запись для однострочных функций:
Оба языка стараются убрать лишнее, чтобы код был лаконичным.
Модули вместо классов
В Ruby классы и модули — основа ООП:
В Elixir модули — это просто контейнеры для функций, без ООП:
Имена модулей и стиль их использования явно перекликаются с Ruby, хотя и Elixir функциональный язык.
Среди языков, как Elixir, которые ориентированы на функциональное программирование и конкурентность, мало кто предлагает столь же современный и читаемый синтаксис.
Конкурентность через Actor-модель (Erlang VM)
Эликсир основан на виртуальной машине Erlang VM. Она поддерживает конкурентность и работу при помощи Actor-моделей.
Конкурентность — когда мы управляем множеством задач или потоков. Эти задачи могут выполняться параллельно, если у нас многоядерный процессор, либо быстро переключаться на одном ядре, создавая иллюзию одновременности.
Обычно конкурентные задачи работают с общей памятью. Это может приводить к малозаметным ошибкам наподобие гонки данных или взаимоблокировок. Для таких случаев в Elixir есть Actor-модели.
Это сущности, которые отвечают за выполнение задач. Акторы в Эликсире изолированы. Каждый процесс — это легковесный актор с собственной памятью:
Этот код запускает новый процесс, который выведет сообщение и умрет. А теперь представим миллион таких:
Акторы не дерутся за ресурсы — они отправляют сообщения через send и ждут ответа с receive:
Благодаря тому, что акторы изолированы друг от друга, у системы высокая отказоустойчивость. Если одни актор отвалится, то не потянет за собой остальных.
Распределённые вычисления и масштабируемость
Elixir отлично подходит для горизонтального и вертикального масштабирования. Мы можем распределять акторов по разным узлам, которые связаны между собой как одна система:
Node.connect соединяет два узла. Node.spawn запускает процесс на узле node2@host. Код выполняется там, но мы управляем им с узла node1@host.
Встроенная поддержка обработки отказов (fault tolerance)
Изоляция процессов
В Elixir задачи выполняются в отдельных процессах. Каждый процесс изолирован: у него своя память, и он не может случайно сломать другие процессы. Если один падает, система продолжает работать.
Связывание и мониторинг
Иногда нужно знать, что процесс упал, чтобы отреагировать. Для этого есть связывание (Process.link) и мониторинг (Process.monitor).
Связывание соединяет процессы. Если один процесс падает, то другой получает сигнал и тоже падает.
В этом примере spawn_link связывает процессы. Когда дочерний процесс падает (raise), главный тоже падает, потому что они связаны. Поэтому IO.puts не выполнится.
Мониторинг мягче — мы просто получаем сообщение о сбое, падение процесса не тянет за собой другие процессы.
Process.monitor(pid) включает наблюдение за процессом. Когда тот падает, мы не падаем сами, а получаем сообщение. Мы его обрабатываем через receive.
Supervisor
Supervisor — это специальный процесс в Эликсире, который следит за другими процессами и перезапускает их, если они падают. Supervisor позволяет не просто переживать сбои, а автоматически восстанавливаться.
- Demo.start запускает супервизор, который следит за одним процессом.
- start_crasher — запускает процесс, который тут же падает.
- Супервизор автоматически перезапускает его бесконечно.
Распределённость и отказоустойчивость
Отказоустойчивость Elixir работает не только на уровне одного сервера, но и на уровне кластера. Если процессы на разных узлах и один сервер падает, то нагрузка равномерно распределяется между другими серверами.
Всё происходит с одного узла (node1) — он управляет и следит. Если процесс на node2 падает (например, узел отвалился или exit(:boom)), то :DOWN ловится через monitor и запускается резерв на node3.
Сравнение Elixir с другими языками
Elixir vs Erlang: современный синтаксис против классического подхода
У Elixir и Erlang много общего. Во-первых, они оба придерживаются функционального подхода к программированию, во-вторых, у них общая виртуальная машина Erlang VM (BEAM). Именно от неё Elixir получил связанные с отказоустойчивостью фишки вроде акторов.
Общий синтаксис
Несмотря на то, что между языками много общего, эликсир был разработан с учётом требований к современным языкам программирования, в том числе к синтаксису.
У Erlang он классический, немного старомодный, с уклоном в функциональный стиль. Много точек, запятых и точек с запятой.
Elixir читаемый, минималистичный, с закосом под Ruby. Код выглядит чище и ближе к современным языкам:
Работа со строками
В Erlang строки — это списки символов, каждый «Hello» внутри — это [72,101,108,108,111].
Тут всё строго: ++ склеивает списки, а io:format с ~s выводит результат. Это работает, но медленно и неудобно, особенно для больших текстов.
А вот так выглядит код на эликсире:
Строки в эликсире — это бинарные данные, быстрые и удобные. Оператор <> соединяет их, а |> (pipe) делает код читаемым, как поток: взял «Hello», добавил «world», вывел. Никаких списков и лишних точек.
Работа со списками
Подход к работе со списками тоже отличается. В Erlang:
Тут всё вручную: [0|List] добавляет элемент в начало, а ~p выводит.
В Elixir списки тоже функциональны, но с бонусами:
У модуля Enum есть готовые функции вроде map, которые экономят время и нервы.
Работа со словарями
Erlang долго обходился без словарей, а когда они появились (maps), то остались простыми:
Всё по делу, но без изысков. А вот пример с эликсиром:
Точечная нотация (map.age), удобный синтаксис и pipe для обновлений.
Elixir vs Ruby: почему Elixir лучше подходит для высоконагруженных систем
Ruby: Использует потоки (threads) или процессы ОС (например, через Puma или Unicorn). Это тяжеловесный подход: каждый поток потребляет память, а глобальная блокировка интерпретатора (GIL в MRI Ruby) ограничивает параллелизм.
- GIL мешает реальной параллельности на одном ядре.
- Для масштабирования нужны новые сервера и внешние инструменты (Redis, Sidekiq).
У Ruby нет такой отказоустойчивости и имутабельности. В конце концов, это интерпретируемый язык с одним потоком выполнения в MRI, а значит, у него невысокая производительность.
Elixir vs Go: конкурентность через процессы BEAM против горутин
Процессы в Elixir благодаря акторам изолированы друг от друга, у них нет общей памяти и поэтому они более отказоустойчивые. Из-за изоляции один процесс весит от 300 кб.
У Go есть свои аналоги акторов, они называются горутины, но работают немного иначе. Гоурутины не изолированы между собой, у них общая память и они «дешевле». Один горутин весит от 2 кб., вместо 300 кб. как у Elixir.
Благодаря этому горутины лучше работают с памятью и более производительны в сравнении с акторами. Однако из-за отсутствия изоляции приходится заморачиваться с их синхронизацией. Такая система менее отказоустойчивая. Go лучше подходит для проектов, где важнее всего производительность, Elixir выигрывает там, где нужна стабильность.
Примеры кода на Elixir
Определение модуля и функций
Модули — это «коробки», в которые мы помещаем функции. Так проще организовать код:
- defmodule создаёт модуль, внутри него — функции с def.
- do...end — для многострочных функций, do: — для коротких.
- IO.puts — это как print, выводит результат в консоль.
Использование pipe (|>) для удобного комбинирования операций
Pipe-оператор (|>) — это фишка Elixir, которая делает код читаемым, передавая результат одной операции в следующую.
|> берёт результат и передаёт его как первый аргумент следующей функции.
Вместо вложенных вызовов (String.reverse(String.trim(String.upcase("elixir")))), мы пишем шаги сверху вниз.
Асинхронные процессы через spawn и GenServer
- spawn запускает новый процесс, который работает параллельно.
- pid — это идентификатор процесса, его можно использовать для общения между процессами.
- Процессы изолированы: один спит, другой идёт дальше.
Где применяется Elixir?
Веб-приложения с высокой нагрузкой (Phoenix Framework)
Фреймворк Phoenix, построенный на Elixir, идеален для веб-приложений, которым нужно обрабатывать тысячи или миллионы пользователей одновременно. Вот пара примеров его использования:
Bleacher Report: Этот спортивный сайт, их аудитория превышает 200 млн человек. В 2017 году его перенесли на Phoenix, чтобы улучшить производительность и справиться с пиковыми нагрузками во время крупных событий. Phoenix позволил эффективно использовать веб-сокеты для интерактивных функций, по типу обновлений в реальном времени.
Pinterest: Одна из крупнейших социальных платформ, которая использует Elixir для управления трафиком и отправки уведомлений с 2014 года. Благодаря Elixir компании удалось повысить производительность системы уведомлений до 14 тыс. в секунду. Также получилось сократить количество серверов для этой задачи с 30 до 15.
Чаты и мессенджеры
Elixir отлично подходит для real-time приложений, где важна мгновенная доставка сообщений и высокая отказоустойчивость:
Discord: Популярная платформа для геймеров и сообществ использует Elixir для обработки миллионов сообщений в реальном времени. Каждый пользователь или чат работает как отдельный процесс, что позволяет системе легко масштабироваться.
WhatsApp: В основном работает на Erlang, но Elixir здесь тоже присутствует. Он помогает обрабатывать миллиарды сообщений каждый день.
Микросервисная архитектура
Elixir используется в микросервисах — подходе, где большие системы разбиваются на независимые сервисы. Вот несколько примеров:
Financial Times: Известное издание перешло на Elixir для своего GraphQL API, чтобы повысить производительность и обеспечить стабильность при росте числа читателей.
Lonely Planet: Этот туристический ресурс использует микросервисы на Elixir для управления контентом, что позволяет быстро обновлять данные и масштабировать систему под миллионы пользователей.
PepsiCo: Компания применяет Elixir в проектах eCommerce и IoT, где микросервисы помогают эффективно обрабатывать данные и взаимодействовать с клиентами.
Изолированные процессы и встроенные инструменты для распределённых систем делают Elixir надёжным выбором для микросервисной архитектуры.
Интернет вещей (IoT) и распределённые системы
Elixir также нашёл своё место в IoT, где важны стабильность и управление множеством устройств:
Nerves: Это открытый проект, который позволяет создавать прошивки для IoT-устройств на Elixir.
TeslaMate: Приложение, написанное на Elixir, собирает данные с автомобилей Tesla в реальном времени, предоставляя владельцам подробную статистику. Оно демонстрирует, как Elixir справляется с обработкой данных от распределённых источников.
В IoT каждое устройство может работать как отдельный процесс, а супервизоры обеспечивают стабильность, даже если одно из устройств временно отключается.
Ты точно программист, если читаешь это! Больше мемов, инсайтов и боли кодеров тут.
384 открытий3К показов