Конвейер |> и композиция функций

Учимся писать читаемые цепочки преобразований данных с помощью оператора конвейера |>.

Оператор |> (pipe) берёт значение слева и подставляет его последним аргументом функции справа: x |> f равно f x.

Когда над данными выполняется несколько преобразований подряд, вложенные вызовы становятся нечитаемыми: List.length (List.filter pred (List.map f xs)) приходится читать справа налево. Оператор |> переворачивает запись так, чтобы она читалась в естественном порядке — как конвейер.

Как работает |>

Определение оператора предельно простое:

let ( |> ) x f = f x

То есть x |> f — это просто f x. Благодаря левой ассоциативности и каррированию цепочка читается сверху вниз:

let result =
  [1; 2; 3; 4; 5; 6]
  |> List.filter (fun x -> x mod 2 = 0)   (* [2;4;6] *)
  |> List.map (fun x -> x * x)            (* [4;16;36] *)
  |> List.fold_left (+) 0                 (* 56 *)

Почему работает с каррированием

Заметьте, что List.filter pred — частично применённая функция, ждущая список. Конвейер list |> List.filter pred подаёт ей этот список. Именно каррирование делает |> удобным: каждый шаг — функция, у которой не хватает последнего аргумента.

Обратный конвейер @@

Есть зеркальный оператор @@ (apply): f @@ x равно f x, но он правоассоциативен и низкоприоритетен, помогая убрать скобки в правой части:

print_int @@ 2 + 3      (* вместо print_int (2 + 3) *)

Композиция функций

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

let ( << ) f g = fun x -> f (g x)   (* (f << g) x = f (g x) *)

let inc x = x + 1
let double x = x * 2
let f = inc << double      (* сначала double, потом inc *)
(* f 5 = inc (double 5) = inc 10 = 11 *)

Разница тонкая, но важная: |> сразу применяет функции к конкретному значению, а композиция << создаёт новую функцию для применения позже.

Как работает под капотом

Поскольку |> — обычная функция, компилятор её инлайнит: после оптимизации x |> f |> g превращается в те же инструкции, что и g (f x). Накладных расходов нет — чистый выигрыш в читаемости. Приоритет операторов в OCaml определяется их первым символом, поэтому |> левоассоциативен и связывает слабее, чем применение функции, — благодаря чему вся конструкция парсится как цепочка.

Частые ошибки

  • Ждать, что значение подставится первым аргументом. |> подставляет его последним; функции вроде List.map специально устроены так, что список — последний аргумент.
  • Путать |> и @@. Они дают одно, но направление и ассоциативность разные.
  • Считать композицию << встроенной. Её берут из библиотеки (Base) или определяют сами.

Итоги

  • x |> f равно f x и позволяет читать цепочки преобразований сверху вниз.
  • Конвейер опирается на каррирование: каждый шаг — функция без последнего аргумента.
  • Композиция собирает новую функцию, конвейер сразу применяет функции к значению.
Проверьте себя
1. Чему равно `x |> f`?
Af(x) с x первым аргументом
Bf x — x подставляется последним аргументом
Cновой функции f
Dошибке типов
2. Почему конвейер хорошо сочетается с List.map и List.filter?
AОни особым образом перегружены
BУ них список идёт последним аргументом, и |> его поставляет через частичное применение
CОни не работают без конвейера
DКонвейер меняет порядок аргументов
3. Чем композиция `f << g` отличается от конвейера?
AНичем
BКомпозиция создаёт новую функцию, а конвейер сразу применяет к значению
CКомпозиция работает только с int
DКонвейер создаёт функцию, композиция применяет