Результирующие типы данных в Эликсире

Переведено в Докдоге – системе перевода технических текстов.

От переводчиков: суть статьи – не в побуждении использовать библиотеку автора. Главная цель перевода именно этого материала – заставить читателей поразмышлять на тему railway-подхода к написаню кода, отсутствия в Эликсире полноценных составных (результирующих) типов данных и монад, организации цепочек функций. Прочитайте мысли автора, посмотрите код его библиотеки, попробуйте найти для себя ключ к более удобной организации кода. Своими умозаключениями и применяемыми техниками заходите делиться в наш Телеграм-чат.

Многие функциональные языки оперируют таким термином, как результирующий тип. Это структура данных, представляющая результат вызова функции, который может быть как значением, так и ошибкой. Классическим примером послужит результат обычного HTTP-запроса. Это может быть либо тело ответа, либо код ошибки. Результирующего типа как такового в Эликсире не существует, результат в обоих случаях представлен в виде кортежей {:ok, result} и {:error, reason}. Принцип использования этих двух кортежей многим хорошо знаком, и их описание можно найти в стандартной библиотеке. Например, в документации к File.read:

File.read("hello.txt") 
#=> {:ok, "World"} 
 
File.read("invalid.txt") 
#=> {:error, :enoent}

В чем проблема?

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

"World" 
|> String.upcase 
|> String.slice(1..3) 
|> String.duplicate(2) 
#=> "ORLORL"

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

def transform_world(str) do 
str 
|> String.upcase 
|> String.slice(1..3) 
|> String.duplicate(2) 
end 
 
case File.read("hello.txt") do 
{:ok, value} -> {:ok, transform_world(value)} 
error -> error 
end 
 
#=> {:ok, "ORLORL"}

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

Работать с результирующими кортежами станет ещё сложнее, если сцепить пайп-оператором несколько функций, также возвращающих результирующие кортежи:

def api_call1(payload) do 
  case payload do 
    {:ok, value} -> Http.get("/api1", value) 
    error -> error 
  end 
end 
 
def to_payload2(resp1) do 
  case resp1 do 
    {:ok, value} -> {:ok, resp1_to_payload2(value)} 
    error -> error 
  end 
end 
 
def api_call2(payload2) do 
  case payload2 do 
    {:ok, value} -> Http.get("/api2", value) 
    error -> error 
  end 
end 
 
{:ok, 3} 
|> api_call1 
|> to_payload2 
|> api_call2

Кажется, что-то не то… Каждая функция реализует один и тот же блок сопоставления с образцом. DRY отдыхает. Писать код таким образом было абсолютно нормально, но в Эликсире версии 1.2 появилась специальный оператор with. Восхищаясь его достоинствами, перепишем предыдущий код:

def api_call1(payload) do 
  Http.get("/api1", value) 
end 
​ 
def to_payload2(resp1) do 
  resp1_to_payload2(value) 
end 
​ 
def api_call2(payload2) do 
  Http.get("/api2", value) 
end 
​ 
with {:ok, resp1} <- api_call1({:ok, 3}), 
     {:ok, payload2} <- to_payload2(resp1), 
     do: api_call2(payload2)

Ну да, так намного лучше! Определения api_call1 и api_call2 стали гораздо проще, а to_payload2в данном случае можно вообще выкинуть, заменив на resp1_to_payload2(в примере она присутствует для наглядности). Но… Кажется по-прежнему что-то не так. Выражение с with не так уж и просто понять с первого взгляда, в нашем случае оно просто берёт значение из одного вызова и склеивает его с другим. Для этого и нужен пайп-оператор, но, к сожалению, не хотелось бы писать лишний код, необходимый для функций пайплайна.

Может, попробовать по-другому?

В каждом языке программирования случается, что непонятно то ли функция отсутствует, то ли она не из стандартной библиотеки. В Джаваскрипте, например, однозначно не хватает стандартной библиотеки. Вот и приходится на скорую руку лепить необходимые хелперы и вспомогательные функции из сторонних библиотек. Одна из таких библиотек, вдохновившая автора статьи на создание своей библиотеки для Эликсира, называется Folktale.js. Folktale содержит функции для создания, преобразования результирующих (и любых других типов) типов и сопоставления их с образцом.

Далее речь пойдёт от лица автора

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

Вернёмся к первому примеру и посмотрим, можно ли что-то изменить в лучшею сторону, используя хелперы из Moonsugar:

# Без `Moonsugar` 
def transform_world(str) do 
  str 
  |> String.upcase 
  |> String.slice(1..3) 
  |> String.duplicate(2) 
end 
​ 
case File.read("hello.txt") do 
  {:ok, value} -> {:ok, transform_world(value)} 
  error -> error 
end 
#=> {:ok, "ORLORL"}
# С `Moonsugar` 
alias Moonsugar.Result as: MR 
​ 
File.read("hello.txt") 
|> MR.map(&String.upcase/1) 
|> MR.map(&(String.slice(&1, 1..3))) 
|> MR.map(&(String.duplicate(&1, 2)) 
#=> {:ok, "ORLORL"}

Здесь используется функция map из Moonsugar, которая позволяет направить действие какой-либо функции на значение в кортеже {:ok, value}. Однако если функции map передать кортеж {:error, reason}, она не сработает, и ошибка пройдёт дальше:

File.read("no_file_exists_here.txt") 
|> MR.map(&String.upcase/1) 
|> MR.map(&(String.slice(&1, 1..3))) 
|> MR.map(&(String.duplicate(&1, 2)) 
#=> {:error, "Can not find file"}

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

И ещё одно решение - API-вызовы:

# Без `Moonsugar` 
def api_call1(payload) do 
Http.get("/api1", value) 
end 
​ 
def to_payload2(resp1) do 
resp1_to_payload2(value) 
end 
​ 
def api_call2(payload2) do 
Http.get("/api2", value) 
end 
​ 
with {:ok, resp1} <- api_call1(payload1), 
{:ok, payload2} <- to_payload2(resp1), 
do: api_call2(payload2)
# С `Moonsugar` 
alias Moonsugar.Result as: MR 
​ 
def api_call1(payload) do 
Http.get("/api1", value) 
end 
​ 
def to_payload2(resp1) do 
resp1_to_payload2(value) 
end 
​ 
def api_call2(payload2) do 
Http.get("/api2", value) 
end 
​ 
payload1 
|> MR.chain(&api_call1/1) 
|> MR.chain(&to_payload2/1) 
|> MR.chain(&api_call2/1)

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

Ура! Мы вернулись к пайплайну. Выигрывает ли это решение у предыдущего? Определённо да. Мне никогда не импонировал оператор with. Когда я только начинал изучать Эликсир, я всегда забывал про запятые, и мне казалось странным то, что синтаксис так сильно отличался. Думаю, используя функцию chain, можно сделать поток данных нагляднее.

Maybe

Существуют и другие типы, похожие на результирующие, которые часто используются в функциональном программировании. Один из них - тип maybe, который может быть представлен в Эликсире как {:just, value} или :nothing. Обычно они предназначены для замены пустых значений (nil). Используя вместо них тип maybe, можно также получить доступ к нескольким вспомогательным функциям Moonsugar и вместе с тем и определить, какие значения являются пустыми. Пару примеров из документации библиотеки:

Maybe.map({:just, 3}, fn(x) -> x * 2 end) 
#=> {:just, 6} 
​ 
Maybe.map(:nothing, fn(x) -> x * 2 end) 
#=> :nothing
Maybe.get_with_default({:just, 3}, 0) 
#=> 3 
​ 
Maybe.get_with_default(:nothing, 0) 
#=> 0

Валидация

Тип валидации - ещё одна структура, вокруг которой построена библиотека Moonsugar. В Эликсире он представлен кортежами {:success, value} и {:failure, reasons}. Данный тип очень схож с результирующим, но он создан для отображения значений, представляющих собой набор ошибок. Образно говоря, результирующий тип остановится после первой ошибки, в то время как тип валидации пойдёт дальше. Очень полезное свойство для валидации данных, введённых пользователем. Например, если пользователь пытается задать пароль из приемлемых символов, но неверной длины или без заглавных букв:

user_password 
|> MV.concat(&valid_length/1) 
|> MV.concat(&valid_chars/1) 
|> MV.concat(&has_one_cap/1) 
#=> {:failure, ["Not long enough", "Not enough capital letters"]}

Оправдает ли Moonsugar ожидания?

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

P. S. от Вунша

Участники нашего чата подсказывают аналогичные библиотеки, на которые тоже стоит взглянуть:

  • monad – из названия очевидно, что проблема решается через реализацию монад на Эликсире;
  • sage – здесь проблема рассматривается в виде «распределённых транзакций» и обработки ошибок с сохранением правильного состояния. У библиотеки довольно подробное описание на Гитхабе, так что обязательно переходите по ссылке и посмотрите сами.
© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.