Awesome
В данном учебном проекте я рассматриваю способы композиции функций в Elixir и Erlang.
На примере конкретной прикладной задачи я предлагаю 5 вариантов композиции, начиная с простых и понятных, двигаюсь к более сложным способам, характерным для функционального программирования. Анализирую плюсы и минусы каждого варианта, рекомендую, что лучше использовать в реальном проекте.
Задача
У нас есть книжный магазин для котов. Он принимает заказы и доставляет книги.
У магазина есть API для создания заказа.
На входе API принимает json-данные, содержащие информацию о коте-заказчике, его адрес, и книги, которые кот хочет заказать.
Например:
{
"cat": "Tihon",
"address": "Coolcat str 7/42 Minsk Belarus",
"books": [
{"title": "Domain Modeling Made Functional", "author": "Scott Wlaschin"},
{"title": "Удовольствие от Х", "author": "Стивен Строгац"},
{"title": "Distributed systems for fun and profit", "author": "Mikito Takada"}
]
}
На выходе из API мы имеем валидный бизнес-объект Order, который передается дальше в систему для обработки, или ошибку валидации.
Для проверки данных и создания валидного объекта Order нужно выполнить следующие шаги:
- проверить кота по имени
- проверить его адрес
- проверить каждую книгу в списке
- создать Order
Для этого у нас есть следующие функции:
@spec validate_incoming_data(map) :: {:ok, map} | {:error, :invalid_incoming_data}
@spec validate_cat(binary) :: {:ok, cat} | {:error, :cat_not_found}
@spec validate_address(binary) :: {:ok, address} | {:error, :invalid_address}
@spec get_book(binary, binary) :: {:ok, Book.t} | {:error, {:book_not_found, binary}}
@spec create_order(cat, address, [Book.t]) :: Order.t
То есть, у нас есть 3 функции, которые могут вернуть успешный результат, либо ошибку. Четвертая функция, которую нужно применить несколько раз к элементам списка. И, наконец, пятая функция, которая всегда возвращает успешный результат.
Нужно выполнить композицию этих функций.
Вариант 1. Решение в лоб -- вложенные case.
Здесь получилось 4 уровня вложенности. Пока что это не так страшно. Но что, если понадобится добавить еще один шаг валидации? Два шага? Десять? Или переставить некоторые шаги местами?
Такой код -- явный пример, как не надо делать. Тем не менее, тут есть пару плюсов. Во-первых, он работает. Во-вторых, он хорошо проверяется dialyzer.
Я не поленился создать пользовательские типы данных и написать spec ко всем функциям. Так что теперь dialyzer может проверить правильность композиции функций.
Вариант 2. Каждый case в отдельную функцию.
У нас получилось 5 небольших функций, вызывающих друг друга по очереди. И некий общий State, который проходит через все эти вызовы. State нужен, чтобы накапливать промежуточные результаты и передавать их дальше.
Каждая функция маленькая и понятная. Тут легко добавить два, пять, десять, сколько угодно новых шагов валидации. Легко менять их местами.
dialyzer по-прежнему контролирует правильность композиции. Но за правильностью использования State разработчику придется следить самому. Тут появляются возможности для ошибок.
Кроме того, функции похожи, они повторяют одинаковый шаблон. И это наводит на мысль, что можно что-то обобщить, сократить количество кода.
Вариант 3. Решение с использованием исключений.
Шаблонность кода вызвана тем, что результат каждого вызова нужно проверить на ошибку. Попробуем переделать модуль BookShop, чтобы его функции сообщали об ошибках через исключения, а не через возвращаемое значение.
Получилось очень просто, лаконично. Код пишем только для happy path, try..catch решает все остальные проблемы. Красота!
В таком простом примере это решение может показаться самым лучшим. Но в больших проектах исключения создают некоторые проблемы.
Во-первых, понадобится много разных типов исключений и стратегия их использования -- какой тип для чего применять. Если разработчиков на проекте больше одного, то таких стратегий может оказаться больше одной. И тогда неизвестные исключения вдруг прилетают из неожиданных мест.
Во-вторых, вызывая функцию, разработчик не может знать всех возможных вариантов ее завершения. Разве что прочитает ее код и проследит внутренние вызовы на всю глубину. В решениях 1 и 2 все возможные варианты завершения функции описаны в ее спецификации. По-хорошему, спецификация могла бы содержать информацию, какие исключения могут возникнуть (как в Java). Но в Erlang этого нет.
В использовании исключений ничего плохого нет (даже если некоторые ФП программисты будут говорить вам обратное). Но в мире Erlang исключения не очень популярны.
Вариант 4. Pipeline, bind и sequence.
Исключения дали нам возможность сосредоточиться на happy path и не мучиться с шаблонной обработкой ошибок. В функциональном программировании есть другие инструменты с таким же эффектом.
Сначала пару слов про Haskell. Дело в том, что Haskell может показать нужные нам идеи в эталонном виде. А в Elixir/Erlang мы может реализовать только что-то похожее, с некоторым приближением. Так что прежде, чем смотреть на искаженную копию, сперва посмотрим на оригинал.
Нам нужны те же функции валидации, реализованные на Haskell: BookShop.hs
Тип данных Either ErrResult SuccessResult -- это аналог нашего {:ok, success_result} | {:error, error_result}.
Теперь мы будем соединять эти функции в цепочку. Это легко, когда результат одной функции совпадает с аргументом другой. Но что делать, если не совпадает? Собственно, вокруг этого и строится все ФП :)
Если на выходе из функции нужное нам значение завернуто в Either, а на входе другой функции это значение нужно в чистом виде, то соединить эти две функции можно оператором bind.
fun1 >>= fun2
Это оператор делает именно то, что делали наши маленькие функции во 2-м варианте -- с помощью case проверяет результат первой функции, и либо вызывает следующую, либо возвращает ошибку.
Имея несколько таких функций, их можно соединить в цепочку оператором bind:
fun1 >>= fun2 >>= fun3 >>= fun4
Получится то же самое, что во 2-м варианте, но без явных case.
Решение на Haskell выглядит так: Main4.hs.
С Elixir нас ожидает трудность -- нет оператора bind. Но способ убрать явный case есть. Мы можем цепочку функций представить как список функций, и выполнить свертку над этим списком.
Pipeline.bind data, [
&fun1/1,
&fun2/1,
&fun3/1,
&fun4/1
]).
Свертка реализуется тривиально: Pipeline.ex и позволяет получить лаконичный код: Main4.ex.
Отдельно посмотрим на валидацию списка книг. Прогнав книги через BookShop.get_book/2 мы получим:
[{:ok, Book1}, {:ok, Book2}, {:ok, Book3}]
Но нам нужно другое. Нам нужно:
{:ok, [Book1, Book2, Book3]}
Первое во второе легко превратить с помощью sequence. Это стандартная функция в Haskell, и ее легко реализовать в Elixir.
Есть еще одна проблема в Elixir, которой нет в Haskell. Если предыдущие варианты хорошо контролировались dialyzer, то 4-й вариант уже нет. Dialyzer ничего не может сказать о том, подходят ли функции в списке друг к другу. Поэтому лучше всего, чтобы все функции были одинаковые по сигнатуре:
@spec fun(my_state) -> {:ok, my_state} | {:error, some_error}.
Что, собственно, и сделано в 4-м варианте (кроме последней функции).
4-й вариант лаконичнее 2-го варианта. Но выигрыш не такой большой, потому что задача для него не самая подходящая. Функции BookShop не ложатся в pipeline непосредственно, их нужно оборачивать. Другое дело, если бы BookShop специально писался под использование в pipeline, тогда выигрыш был бы больше.
На самом деле, эту задачу я составлял для 5-го варианта :)
Вариант 5. do-нотация для Elixir.
Давайте посмотрим, как это выглядит в Haskell: Main5.hs
А выглядит это очень похоже на вариант 3 с исключениями. Только здесь нет исключений :)
Мы видим два способа получить результат выполнения функции. Стрелка влево (<-) извлекает результат из Either, знак присваивания (=) извлекает обычное значение. Полученными значениями можно пользоваться ниже. Если какая-то функция вернет ошибку, то выполнение блока do прерывается, и ошибка возвращается как результат.
Для Haskell это самый простой и лаконичный вариант. А что с Elixir? У нас есть специальная форма with, которая работает похожим образом. Мы можем точно так же извлекать значения стрелкой влево, или пользоваться обычными значениями. И точно также вычисления прекращаются, если шаблон слева от стрелки не совпал.
Получается такой код: Main5.ex. Здесь почти все хорошо, только аргументы функций обязательно нужно окружать скобками. Увы, любимый мною безскобочный стиль в Elixir работает далеко не везде :(
Можно попробовать пойти дальше, и взять настоящие монады, а не их упрощенные имитации. Для Elixir есть библиотека Monad, и в ней монада Error, которая подходит для нашего случая.
Концептуально это все тот же тип данных {:ok, success_result} | {:error, error_result}, но обернутый в более сложную сущность. Для нас сейчас важно, что эта сущность поддерживает do-нотацию.
И получается такой код: Main6.ex. Разница с Main5 не большая. Синтаксис ближе к Haskell, и можно вызывать функции без скобок.
Интересно, что в разных языках эта монада называется по-разному: в Haskell -- Either, в OCaml -- Result, в Elixir -- Error. Название Result мне кажется самым подходящим.
Вариант 5. do-нотация для Erlang.
С Erlang ситуация сложнее, встроенных средств языка нет. Но можно попробовать придумать некий DSL, реализующий ту же идею: main_5.erl.
Получилось заметно сложнее, и вряд ли понятно без дополнительный пояснений. Мы опять видим список. Здесь элементы списка не просто функции, а кортежи из трех элементов, где посередине находится функция, слева атом, а справа либо атом, либо что-то другое. Можно догадаться, что справа -- аргументы функции, а слева ее результат.
Где-то в недрах реализации прячется контекст -- обыкновенная map. Атомы слева и справа -- это ключи, по которым читаются и записываются значения в контекст. Аргументом функции может быть либо конкретное значение, либо атом, и тогда значение берется из контекста. Результат функции по заданному ключу сохраняется в контексте. Последний сохраненный результат -- это результат всего блока do. Если какая-то функция вернет ошибку, то выполнение всего блока прекращается, и возвращается ошибка.
DSL на самом деле простой, и его реализация тоже простая: wannabe_haskell.erl.
Для эксперимента это неплохо. При желании можно как-то развивать такой DSL, но брать в реальные проекты вряд ли стоит.
С DSL понятно, но нет ли для Erlang библиотек с монадами? Есть, конечно. Для нашего случая подойдет библиотека erlando от создателей rabbitmq. У Erlang, конечно, не такие возможности для метапрограммирования, как у Elixir, но авторам удалось сделать неплохую штуку. Они взяли синтаксис lists comprehension и превратили его в do-нотацию. Получилось неплохо.
C монадой error_m и {parse_transform, do} получается такой вариант: main_6.erl.
В чем принципиальна разница между pipeline и do-notation?
pipeline лучше всего подходит там, где нужно просто передавать выход одной функции на вход другой. Но у нас есть промежуточные результаты, которые нужно где-то сохранить, чтобы использовать позже. Из-за этого появляется некое состояние.
Для pipeline это состояние приходиться прокидывать через все функции. Значит, функции должны знать про состояние и уметь с ним работать. Таким образом, не любую функцию можно положить в pipeline. Функции BookShop напрямую использовать нельзя, приходиться делать для них обертки, поддерживающие состояние.
В случае с do-нотацией состояние существует отдельно. Можно использовать любые функции, и мы напрямую используем BookShop, без всяких оберток.
Выводы
1-й вариант -- это очевидный пример того, как делать не надо.
Остальные варианты вполне годятся для использования в реальных проектах.
5-й вариант на Elixir получился лучше, на Erlang хуже. На Elixir лучше просто взять with, т.к. монады добавят мало чего полезного в рамках данной задачи. Для Erlang лучше взять erlando, чем кастомный DSL.
Напоследок порекомендую книгу Domain Modeling Made Functional. Scott Wlaschin. Прекрасное введение в функциональное программирование.