Не нарушайте конвейер, даже если вам нужен промежуточный результат.

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

Встречайте трубу

Оператор канала Эликсир |> - не новость в программировании. Если мы возьмем, например, старый добрый Ocaml, мы можем встретить там похожий подход:

let sum x y = x + y;;
sum 3 (sum 1 2);;
# - : int = 6
1
|> sum 2 
|> sum 3;;
# - : int = 6

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

iex(1)> String.split(String.upcase("Ruby is dead"))
["RUBY", "IS", "DEAD"]

Однако благодаря оператору pipe мы можем:

iex(2)> "Ruby is dead"
...(2)> |> String.upcase()
...(2)> |> String.split()
["RUBY", "IS", "DEAD"]

Это дает тот же результат более элегантным способом, потому что оператор канала «Эликсир» передает результат выражения в качестве первого параметра другого выражения.

Понять трубу

В Макромодуле есть интересная функция, которую я хотел бы здесь вызвать. Макросы - это конструкции времени компиляции, которые вызываются с AST Elixir в качестве входных данных и расширенным набором AST Elixir в качестве выходных. В частности, нас интересует функцияunpipe/1, которая разбивает выражение конвейера на список.

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

iex(3)> ast = quote do
...(3)> "Ruby is dead"
...(3)> |> String.upcase()
...(3)> |> String.split()
...(3)> end
{:|>, [context: Elixir, import: Kernel],
 [
   {:|>, [context: Elixir, import: Kernel],
    [
      "Ruby is dead",
      {{:., [], [{:__aliases__, [alias: false], [:String]}, :upcase]}, [], []}
    ]},
   {{:., [], [{:__aliases__, [alias: false], [:String]}, :split]}, [], []}
 ]}

Как видите, строка ”Ruby is dead” - это первый аргумент, переданный в String.upcase/2, а результат становится входом для функции String.split/1.

Теперь давайте отключим AST:

iex(4)> Macro.unpipe(ast)
[
  {"Ruby is dead", 0},
  {{{:., [], [{:__aliases__, [alias: false], [:String]}, :upcase]}, [], []}, 0},
  {{{:., [], [{:__aliases__, [alias: false], [:String]}, :split]}, [], []}, 0}
]

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

  1. AST строки ”Ruby is dead”.
  2. String.upcase/2 функция
  3. String.split/1 функция

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

Используйте трубу

Взглянем теперь на более сложный конвейер. Наш вариант использования выглядит следующим образом:

  1. У нас есть структуры, которые мы хотим сериализовать.
  2. Структура содержит одну ассоциацию.
  3. Данная ассоциация также должна быть сериализована, если она существует.
  4. Все остальные нерелевантные поля нужно отбросить, сбросить только значимые.
  5. Чистая карта должна представлять окончательную структуру, которую нужно сохранить.

Вот реализация:

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

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

Хорошо, я знаю, что это довольно нереально:

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

Однако ради этой статьи я хотел найти смехотворно простой способ показать вам простой трюк, о котором вы, возможно, не знали.

Трубить или не трубить?

Теперь вы можете думать примерно так:

Почему бы и нет? Но, пожалуйста, остановитесь на мгновение и подумайте о плюсах и минусах.

Заглянув в Руководство по стилю Credo, вы увидите это:

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

# preferred way - very readable due to the clear flow of data
username
|> String.strip()
|> String.downcase()
# also okay - but often slightly less readable
String.strip(username)
|> String.downcase()

И я полностью согласен с этим - либо используйте каналы во всем выражении, либо ничего - не смешивайте синтаксисы!

Во-вторых, на мой взгляд, использование оператора вертикальной черты только один раз для каждого выражения - это излишний замысел. Я не вижу причин использовать username |> String.strip() вместо просто String.strip(username).

Вертикальные или горизонтальные трубы?

Недавно я нашел интересное обсуждение на форуме Elixir о форматировании кода в терминах каналов. Автору было интересно, какие трубы использовать: горизонтальные или вертикальные:

"Ruby is dead"
|> String.upcase()
|> String.split()

vs.

"Ruby is dead" |> String.upcase() |> String.split()

В Кодовой базе Эликсира чаще встречается первый подход, тем не менее, встречается и второй.

Если строка короткая (обычно менее 80 символов), это вопрос личных предпочтений или рекомендаций компании / проекта. Конечно, если речь идет о длинных строках, форматтер выровняет их по вертикали.

Кстати, какой стиль вы предпочитаете? Лично мне нравится, чтобы все выражения были построчными (учитывая, что я никогда не использую только один канал).

Бонус: отказоустойчивая труба

С теми, кто уже знал все упомянутые вещи, я хотел бы поделиться еще одним, чтобы оставить вам хоть какую-то ценность.

Вы до сих пор думали, как можно было бы переопределить или присвоить псевдониму оператора канала? Я уверен, что вы этого не сделали, поскольку вы в здравом уме, но я воспользовался возможностью.

Функция (или фактически макрос) будет предельно простой:

defmacro left ~> right do
  quote do
    unquote(left) |> unquote(right)
  end
end

и вы можете использовать его таким же образом:

iex(1)> 1..10 ~> Enum.random()
6

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

defmacro left ~> right do
  quote do
    case unquote(left) do
      result ->
        result |> unquote(right)
    end
  end
end

Я не особо менял его, но я ввел возможность сопоставления с образцом результата с левой стороны перед передачей его в функцию справа. Как мы можем это использовать? Например, чтобы подтвердить предыдущий результат, мы можем сделать:

defmacro left ~> right do
  quote do
    case unquote(left) do
      {:error, error} ->
        {:error, error}
result ->
        result |> unquote(right)
    end
  end
end

В приведенном выше случае мы проверяем, произвела ли предыдущая операция {:error, error} кортеж, и не обрабатываем результат в таком случае.

Напоследок могу привести практический пример:

Обратите внимание, что функция parse/1 (и любая последующая) не должна обрабатывать {:error, error} кортеж! Он согласован в нашем Pipe макросе, и мы можем сосредоточиться только на содержательной логике без ненужной обработки ошибок.

Резюме

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

Оставьте свой адрес электронной почты, чтобы сразу же получать последние новости:
https://tinyletter.com/KamilLelonek

И если вы когда-нибудь задумывались о внесении каких-либо изменений в оператор канала, просто подумайте дважды, действительно ли это упростит ситуацию:

Во всяком случае, счастливого пипинга!