Оператор конвейера |>
Оператор |> (pipe) — визитная карточка Elixir. Он превращает мешанину вложенных вызовов в читаемую цепочку «сделай это, потом это».
Pipe берёт результат слева и подставляет его первым аргументом в функцию справа. Код начинает читаться как рецепт.
Сравните вложенные вызовы и их pipe-версию:
# Без pipe — читается изнутри наружу, наоборот порядку действий
String.upcase(String.reverse(String.trim(" hello ")))
# С pipe — слева направо, в порядке выполнения
" hello "
|> String.trim()
|> String.reverse()
|> String.upcase()
# => "OLLEH"
Каждый шаг получает результат предыдущего как первый аргумент, остальные передаются явно:
[1, 2, 3, 4, 5]
|> Enum.filter(fn x -> rem(x, 2) == 0 end) # [2, 4]
|> Enum.map(fn x -> x * 10 end) # [20, 40]
|> Enum.sum() # 60
Как работает под капотом (BEAM)
Pipe — чисто синтаксическая трансформация на этапе компиляции. Выражение a |> f(b) компилятор разворачивает в f(a, b) ещё до генерации байткода. Никакой рантайм-магии, лишних аллокаций или замедления нет — на уровне BEAM это обычные вложенные вызовы функций. Именно поэтому стандартная библиотека спроектирована так, что «главный» аргумент (коллекция, строка) идёт первым: чтобы данные удобно текли через pipe.
data |> f() |> g() |> h() data ──> f ──> g ──> h ──> результат компилируется в: h(g(f(data))) (никаких накладных расходов в рантайме)
Та же идея на Python ▶
В Python нет оператора pipe, но композицию функций легко собрать вручную или через reduce.
from functools import reduce
def pipe(value, *funcs):
return reduce(lambda acc, f: f(acc), funcs, value)
result = pipe(" hello ",
str.strip,
lambda s: s[::-1],
str.upper)
print(result) # OLLEH
# Цепочка обработки коллекции, как Enum-конвейер
nums = [1, 2, 3, 4, 5]
evens = [x for x in nums if x % 2 == 0] # filter
scaled = [x * 10 for x in evens] # map
print(sum(scaled)) # 60
Частые ошибки
- Передавать данные не первым аргументом. Если функции нужен «главный» аргумент не первым, pipe подставит не туда — нужна анонимная обёртка.
- Pipe из выражения, а не функции.
x |> y, где y не вызов функции, чаще всего ошибка. - Слишком длинные цепочки. 15 шагов в одном pipe тяжело отлаживать; разбивайте на именованные функции.
Best practices
- Проектируйте функции так, чтобы «данные» были первым аргументом — тогда они идеально встают в pipe.
- Давайте промежуточным конвейерам имена через отдельные функции, если цепочка разрослась.
- Используйте pipe для линейных трансформаций; для ветвлений —
case/with.
Итог. Pipe делает код самодокументируемым: вы читаете преобразования в порядке их выполнения. Это бесплатно на уровне рантайма и формирует весь стиль стандартной библиотеки. Дальше — анонимные функции и захваты, которые часто стоят внутри pipe.
Когда pipe мешает, а не помогает
Pipe прекрасен для линейных трансформаций, но у него есть граница применимости, которую стоит чувствовать. Если в цепочке появляется ветвление или обработка ошибок на каждом шаге, pipe становится неуклюжим — туда нельзя естественно вставить «а если тут ошибка». Для таких случаев в Elixir есть конструкция with, которая последовательно матчит результаты и аккуратно выходит при первом несовпадении. Грубо говоря: pipe — для «всё хорошо, просто преобразуй», with — для «цепочка шагов, каждый из которых может не сложиться».
Ещё одна тонкость — отладка длинных конвейеров. Поскольку промежуточные значения не имеют имён, вставить отладочный вывод посередине нельзя напрямую. Идиоматичное решение — вставить |> IO.inspect(label: "после фильтра"): эта функция печатает значение и возвращает его без изменений, идеально вписываясь в pipe. Этот приём — первый инструмент отладки, который осваивает каждый Elixir-разработчик, и он отлично иллюстрирует, почему стандартная библиотека спроектирована «данные первым аргументом».