Pipeline Operator
Tutorial Video
Text description (accessibility)
This video demonstrates the "Pipeline Operator" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Higher-Order Functions, Function Composition. The pipeline operator `|>` is a function composition tool that threads a value through a series of transformations from left-to-right. Key difference from OCaml: 1. **Syntax:** OCaml's `|>` is syntactic sugar for right
Tutorial
The Problem
The pipeline operator |> is a function composition tool that threads a value through a series of transformations from left-to-right. In OCaml, it's defined simply as let (|>) x f = f x, which applies function f to value x. This example demonstrates how to chain function calls in a readable sequence.
Without a pipeline operator, deeply nested function calls read inside-out: h(g(f(x))) requires the reader to start in the middle and work outward. This is cognitively harder than left-to-right reading. Unix pipes solve this for shell commands, and React's component composition solves it for UI transforms. OCaml's |> operator is the functional programming solution: x |> f |> g |> h reads in the order of execution.
🎯 Learning Outcomes
🦀 The Rust Way
Rust achieves similar composability through several idiomatic patterns: nested function calls for simple cases, the pipe function pattern for explicit composition, and function composition combinators. While Rust doesn't have a built-in |> operator, the same semantics are easily expressed using higher-order functions and closures.
Code Example
pub fn double(x: i32) -> i32 {
2 * x
}
pub fn add_one(x: i32) -> i32 {
x + 1
}
// Idiomatic Rust: direct function composition (nested calls)
pub fn compute_result_idiomatic() -> i32 {
add_one(double(5)) // 11
}
pub fn shout(s: &str) -> String {
s.to_uppercase()
}
pub fn add_exclaim(s: &str) -> String {
format!("{}!", s)
}
pub fn compute_greeting_idiomatic() -> String {
add_exclaim(&shout("hello")) // "HELLO!"
}Key Differences
|> is syntactic sugar for right-associative function application. Rust requires explicit function calls or a custom pipe function..map(), .filter(), etc.), which is the natural equivalent. OCaml doesn't have methods on built-in types.FnOnce for one-shot transformations, enforcing move semantics when values are consumed. OCaml handles this implicitly.|> is a built-in infix operator. Rust has no equivalent — closest is method chaining for iterators..map(), .filter(), etc.), which is the natural equivalent for collection pipelines. OCaml doesn't have methods on built-in types.FnOnce for one-shot transformations, enforcing move semantics when values are consumed. OCaml handles this implicitly via garbage collection.OCaml Approach
OCaml's |> operator provides a natural left-to-right reading order for chained transformations. It's defined as a simple infix operator that applies the right-hand function to the left-hand value. This makes complex pipelines easy to read: 5 |> double |> add_one reads as "take 5, double it, add one".
Full Source
#![allow(clippy::all)]
// Pure functions for integers
pub fn double(x: i32) -> i32 {
2 * x
}
pub fn add_one(x: i32) -> i32 {
x + 1
}
// Pure functions for strings
pub fn shout(s: &str) -> String {
s.to_uppercase()
}
pub fn add_exclaim(s: &str) -> String {
format!("{}!", s)
}
// Idiomatic Rust: direct function composition
// This is the natural way to compose functions in Rust.
// We apply functions left-to-right by nesting function calls.
pub fn compute_result_idiomatic() -> i32 {
add_one(double(5)) // 11
}
pub fn compute_greeting_idiomatic() -> String {
add_exclaim(&shout("hello")) // "HELLO!"
}
// Functional Rust: pipe function (mimics OCaml's |>)
// Takes a value and applies a function to it.
// In OCaml: let (|>) x f = f x
// This shows the explicit function application order.
pub fn pipe<T, U>(value: T, f: impl FnOnce(T) -> U) -> U {
f(value)
}
// Using pipe with nested calls to show the transformation chain
pub fn compute_result_with_pipe() -> i32 {
pipe(pipe(5, double), add_one) // 11
}
pub fn compute_greeting_with_pipe() -> String {
let shouted = pipe("hello", shout);
pipe(&shouted, |s| add_exclaim(s)) // "HELLO!"
}
// Composition function: creates a new function from two functions
// This shows function composition, another way to chain transformations
pub fn compose<A, B, C>(f: impl Fn(A) -> B, g: impl Fn(B) -> C) -> impl Fn(A) -> C {
move |x| g(f(x))
}
pub fn compute_result_with_composition() -> i32 {
let double_then_add_one = compose(double, add_one);
double_then_add_one(5) // 11
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_double() {
assert_eq!(double(5), 10);
assert_eq!(double(0), 0);
}
#[test]
fn test_add_one() {
assert_eq!(add_one(5), 6);
assert_eq!(add_one(0), 1);
}
#[test]
fn test_compute_result_idiomatic() {
assert_eq!(compute_result_idiomatic(), 11);
}
#[test]
fn test_compute_result_with_pipe() {
assert_eq!(compute_result_with_pipe(), 11);
}
#[test]
fn test_compute_result_with_composition() {
assert_eq!(compute_result_with_composition(), 11);
}
#[test]
fn test_compute_greeting_idiomatic() {
assert_eq!(compute_greeting_idiomatic(), "HELLO!");
}
#[test]
fn test_compute_greeting_with_pipe() {
assert_eq!(compute_greeting_with_pipe(), "HELLO!");
}
#[test]
fn test_pipe_with_closures() {
let result = pipe(5, |x| x * 2);
assert_eq!(result, 10);
}
#[test]
fn test_shout() {
assert_eq!(shout("hello"), "HELLO");
assert_eq!(shout("world"), "WORLD");
}
#[test]
fn test_add_exclaim() {
assert_eq!(add_exclaim("HELLO"), "HELLO!");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_double() {
assert_eq!(double(5), 10);
assert_eq!(double(0), 0);
}
#[test]
fn test_add_one() {
assert_eq!(add_one(5), 6);
assert_eq!(add_one(0), 1);
}
#[test]
fn test_compute_result_idiomatic() {
assert_eq!(compute_result_idiomatic(), 11);
}
#[test]
fn test_compute_result_with_pipe() {
assert_eq!(compute_result_with_pipe(), 11);
}
#[test]
fn test_compute_result_with_composition() {
assert_eq!(compute_result_with_composition(), 11);
}
#[test]
fn test_compute_greeting_idiomatic() {
assert_eq!(compute_greeting_idiomatic(), "HELLO!");
}
#[test]
fn test_compute_greeting_with_pipe() {
assert_eq!(compute_greeting_with_pipe(), "HELLO!");
}
#[test]
fn test_pipe_with_closures() {
let result = pipe(5, |x| x * 2);
assert_eq!(result, 10);
}
#[test]
fn test_shout() {
assert_eq!(shout("hello"), "HELLO");
assert_eq!(shout("world"), "WORLD");
}
#[test]
fn test_add_exclaim() {
assert_eq!(add_exclaim("HELLO"), "HELLO!");
}
}
Deep Comparison
OCaml vs Rust: Pipeline Operator
The pipeline operator is a simple but powerful tool for making function composition readable. In OCaml, it's a lightweight operator that enables left-to-right function application. In Rust, we achieve the same result through different idiomatic patterns.
Side-by-Side Code
OCaml
(* The pipeline operator is just a higher-order function *)
let ( |> ) x f = f x
let double x = 2 * x
let add1 x = x + 1
(* Read: start with 5, double it, add 1 *)
let result = 5 |> double |> add1 (* 11 *)
(* Chaining string operations *)
let shout s = String.uppercase_ascii s
let exclaim s = s ^ "!"
let greeting = "hello" |> shout |> exclaim (* "HELLO!" *)
Rust (idiomatic)
pub fn double(x: i32) -> i32 {
2 * x
}
pub fn add_one(x: i32) -> i32 {
x + 1
}
// Idiomatic Rust: direct function composition (nested calls)
pub fn compute_result_idiomatic() -> i32 {
add_one(double(5)) // 11
}
pub fn shout(s: &str) -> String {
s.to_uppercase()
}
pub fn add_exclaim(s: &str) -> String {
format!("{}!", s)
}
pub fn compute_greeting_idiomatic() -> String {
add_exclaim(&shout("hello")) // "HELLO!"
}
Rust (functional/pipe)
// Functional Rust: explicit pipe function (mimics OCaml's |>)
pub fn pipe<T, U>(value: T, f: impl FnOnce(T) -> U) -> U {
f(value)
}
pub fn compute_result_with_pipe() -> i32 {
pipe(pipe(5, double), add_one) // 11
}
pub fn compute_greeting_with_pipe() -> String {
let shouted = pipe("hello", shout);
pipe(&shouted, |s| add_exclaim(s)) // "HELLO!"
}
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Pipeline operator | val (|>) : 'a -> ('a -> 'b) -> 'b | fn pipe<T, U>(value: T, f: impl FnOnce(T) -> U) -> U |
| Integer transformation | int -> int | fn(i32) -> i32 |
| String transformation | string -> string | fn(&str) -> String |
| Composed function | Implicit through \|> | Explicit via compose trait or nested calls |
Key Insights
|> is syntactic sugar for simple function application. Rust achieves the same through nested function calls or an explicit pipe function. This shows that operators are just functions in different syntax.pipe function uses FnOnce to accept a function that consumes its input. This is Rust's way of enforcing that each transformation takes ownership of the value. OCaml handles this implicitly without explicit ownership semantics..map(), .filter(), etc.), which reads left-to-right like |>. OCaml doesn't have methods on built-in types, so |> is the idiomatic solution there.String to a function expecting &str, Rust requires an explicit closure (|s| add_exclaim(s)) to handle type conversion. OCaml's implicit coercion would handle this automatically, showing a difference in type system strictness.pipe function is generic over both input and output types with impl FnOnce(T) -> U, enabling type-safe composition without runtime overhead. OCaml's polymorphic 'a -> ('a -> 'b) -> 'b achieves the same with implicit polymorphism.When to Use Each Style
Use idiomatic Rust nested calls when:
f(g(h(x))))..map(), .filter(), etc.), which are left-to-right and naturally readable.Use the pipe function in Rust when:
|>.Use function composition in Rust when:
Syntactic Observations
OCaml's 5 |> double |> add_one reads perfectly left-to-right. Rust's equivalent nested form add_one(double(5)) reads right-to-left (inside-out), which is why method chaining and the pipe function are often preferred for readability. This is a key difference in expressiveness: OCaml's operator syntax makes the data flow obvious, while Rust requires more deliberate structuring to achieve the same clarity.
Exercises
pipe2 macro (or function pair) that chains two single-argument closures and apply it to a string processing pipeline: parse → validate → format.pipe_result that threads a Result<T, E> through a sequence of FnOnce(T) -> Result<U, E> steps, short-circuiting on the first error.pipe that computes a statistical summary (min, max, mean, standard deviation) over a Vec<f64> in a single readable chain.log_stage(name, f) wraps any function so that it prints the input and output to stderr before forwarding the result.result_pipe<T, U, E>(value: Result<T, E>, f: impl FnOnce(T) -> Result<U, E>) -> Result<U, E> — a pipe operator for fallible operations.