Elixir для рубистов

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

Некоторые особенности Ruby соотносятся со схожими явлениями в Elixir:

Ruby

Elixir

irb iex
rake tasks mix (встроенный)
bundler mix (встроенный)
binding.pry IEx.pry (встроенный)
Полиморфизм Протоколы
Lazy Enumerables Потоки
Метапрограммирование Макросы (используются очень осторожно)
Rails Phoenix

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

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

Elixir — это Erlang в оболочке Ruby

Программы на языке Elixir компилируются в код для виртуальной машины Erlang (BEAM). Язык Erlang, разработанный в 1986 году компанией Ericsson, с 1998 года является продуктом с открытым исходным кодом и поддерживается подразделением OTP (Open Telecom Program) компании Ericsson.

Производительность

Используя один лишь Erlang, команде разработчиков приложения WhatsApp удалось добиться того, чтобы один сервер смог обслуживать два миллиона TCP-соединений. Откуда такая производительность?

Виртуальная машина Erlang выглядит как один процесс операционной системы и по умолчанию запускает по одному потоку на каждом ядре. Программы на языке Elixir используют все ядра ЦП.

Процессы в Erlang не имеют никакой связи с потоками и процессами операционной системы. Они являются легковесными, их можно быстро создать и уничтожить (т.е. количество изменяется динамически), объём потребляемой памяти невелик, ресурсы расходуются экономно. Erlang-приложение запускает более одного миллиона своих процессов, которые могут существовать в единственном процессе ОС. Эти процессы не имеют общего состояния, они взаимодействуют между собой посредством асинхронного обмена сообщениями. Такой подход превращает Elixir в первую общедоступную реализацию модели акторов.

Если процесс находится в ожидании сообщения (затерявшегося в блоке receive), он не помещается в очередь на исполнение до тех пор, пока сообщение не будет найдено. Именно поэтому на одном компьютере возможен ресурсоёмкий запуск миллионов по большей части бездействующих процессов.

Эффективность работы сборщика мусора в Erlang повышается за счёт следующих допущений. Каждая переменная иммутабельна. Это означает, что с момента создания переменной её значение остаётся постоянным. Значения копируются между процессами. Таким образом, процесс практически всегда имеет изолированную область памяти, а сборщик мусора у каждого отдельного процесса свой, что достаточно оперативно. Узнать подробнее о процессах и принципах работы сборщика мусора в Erlang можно в 4 разделе статьи Programming the Parallel World.

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

Отказоустойчивость

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

Пересказав услышанные на подкасте The Changelog слова Жозе Валима, можно отметить, что отказоустойчивость в Erlang — это способность поддерживать систему в рабочем состоянии; разрыв связи с одним пользователем — вполне нормально, со всеми пользователями — уже проблема.

В отличие от интернет-сервисов у телекоммуникационных компаний нет возможности позвонить каждому клиенту и оповестить его о перебоях связи между 6:00 и 6:30 утра. В такой системе всегда должна поддерживаться работоспособность.

Чистые функции

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

Единичное присваивание и неизменяемая структура данных Erlang только способствует созданию чистых функций. Анализируя различные объёмные кодовые базы Erlang, исследователи отнесли к чистым в среднем от 30 до 50% его функций. Есть и нежелательные последствия: код можно динамически загружать и выгружать, что приводит к изменению переменных среды операционной системы.

В Ruby же можно обойтись без изолированного состояния. Значения можно изменять в любом месте программы (например, метод string.gsub!). Классическим примером нечистой функции может служить геттер: каждый раз, вызывая его с одними и теми же аргументами, в зависимости от внутреннего состояния объекта можно получить различные результаты.

Другие особенности Erlang, доступные в Elixir

К ним относятся:
  • Распределенные вычисления
  • Мягкое реальное время (черта, определяющая качество телекоммуникационных услуг)
  • Высокая доступность
  • Горячая замена кода

Собственные преимущества Elixir

Пайп-оператор

Предположим, необходимо превратить заголовки всех постов блога в постоянные ссылки. Для заголовка наподобие ExMachina Hits 1.0 — More Flexible, More Useful and Support for Ecto 2.0 постоянная ссылка должна быть такой exmachina-hits-1-0-more-flexible-more-useful-and-support-for-ecto-2-0.

Код на Ruby выглядит следующим образом:

title.
  downcase.
  gsub(/\W/, " "). # convert non-word chars (like -,!) into spaces
  split.    # drop extra whitespace
  join("-") # join words with dashes

Вызов каждого из методов возвращает объект типа String или объект, реализующий Enumerable, следовательно, мы можем объединять эти методы в цепочку. Давайте дадим проделанным шагам какие-нибудь названия, чтобы не было необходимости комментировать каждую строчку. Но как сделать код похожим на такой?

text.
  downcase.
  replace_non_words_with_spaces.
  drop_extra_whitespace.
  join_with_dashes

Чтобы этого достичь, нужно «проманкипатчить» класс String и определить в нём методы replace_non_words_with_spaces и drop_extra_whitespace, а для класса Enumerable сделать то же самое с методом join_with_dashes.

Теперь посмотрим, как всё это реализуется в языке Elixir. С точки зрения функционального подхода, код может выглядеть так:

Enum.join(
  String.split(
    String.replace(
      String.downcase(title), ~r/\W/, " "
    )
  ),
  "-"
)

Его можно сделать более читабельным, используя локальные переменные:

downcased = String.downcase(title)
non_words_to_spaces = String.replace(downcased, ~r/\W/, " ")
whitespace_to_dashes = Enum.join(String.split(non_words_to_spaces), "-")

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

title
|> String.downcase
|> String.replace(~r/\W/, " ")
|> String.split
|> Enum.join("-")

Каждый шаг можно обозначить с помощью «говорящего» названия функции:

title
|> downcased
|> non_words_to_spaces
|> whitespace_to_dashes

В Ruby существуют две реализации композиций функций, схожих с пайп-оператором. Фабио Акита разработал пакет chainable_methods (RubyGem), методы которого умеют привязывать значение к объекту так же, как это делает пайп-оператор. Чтобы получить окончательный результат, необходимо вызвать unwrap в качестве последнего метода. Майк Бёрнс представил пакет method_missing (RubyGem), позволяющий связывать функции посредством оператора *.

Оператор сопоставления =

Оператор = в Elixir называется оператором сопоставления. Он позволяет назначать и сравнивать значения. Рассмотрим такой код:

iex> {a, b, 42} = {:hello, "world", 42}
{:hello, "world", 42}
iex> a
:hello
iex> b
"world"
iex> :hello = a
:hello
iex> "world" = a
** (MatchError) no match of right hand side value: :hello

Сопоставление с образцом и составные условия функций

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

Рассмотрим пример. Пусть необходимо добавить имя активного класса к элементу li только в том случае, если указанный документ совпадет с тем, в котором в данный момент осуществляется перебор. Код будет следующим (@conn.assigns напоминает многомерный хэш).

<%= for document <- documents do %>
  <li class="<%= active_class(@conn.assigns, document.id) %>">
    <%= document.title %>
  </li>
<% end %>

Типовая реализация класса active_class включает условие, проверяющее наличие аргумента в списке (в Ruby оно выглядит так: enumerable.include?(item)). На Elixir получаем следующее:

def active_class(%{document: %{id: id}}, id), do: "active"
def active_class(_, _), do: ""

В приведенном листинге проводится сопоставление хэша и ключа document; далее, если он имеется в хэше, то проводится сопоставление по id. Условие в теле функции содержит тот же id в качестве второго аргумента, и, когда он совпадет с запрашиваемым, возвращается строка «active». Второе условие выполняется для любого другого случая, возвращая пустую строку.

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

Макросы

Еще одна отличительная черта Elixir по сравнению с Erlang — это наличие средств метапрограммирования, но это уже отдельная достаточно обширная тема.

В каком случае Elixir подходит больше, чем Ruby?

Думаю, Elixir и Ruby взаимозаменяемы для написания несложных веб-приложений с низким трафиком и невысокими требованиями по части времени отклика.

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

Тем не менее для создания некоторых приложений выбор Elixir является технически наиболее выгодным.

Масштабируемость

Elixir справляется с этим лучше, чем Ruby. Фреймворк Phoenix в 10 раз производительнее того же Rails, а это означает, что задумываться о хостинге или кэшировании при разработке на Elixir, можно немного позднее.

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

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

Системы с высоким уровнем доступности

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