Tap Pattern for Side Effects
Tutorial Video
Text description (accessibility)
This video demonstrates the "Tap Pattern for Side Effects" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Data pipelines built with iterator chains or method chaining have a readability problem: inserting debug logging or instrumentation requires breaking the chain into temporary `let` bindings. Key difference from OCaml: 1. **Method vs function**: Rust's `Tap` trait enables `value.tap(f)` dot
Tutorial
The Problem
Data pipelines built with iterator chains or method chaining have a readability problem: inserting debug logging or instrumentation requires breaking the chain into temporary let bindings. The tap pattern solves this by injecting a side-effecting function at any point in a chain without disrupting the data flow — the function runs, but the original value passes through unchanged. This pattern appears in JavaScript's .tap() in lodash, Ruby's Object#tap, Haskell's (<$) for constant functors, and is commonly needed when debugging long iterator chains.
🎯 Learning Outcomes
tap<T, F: FnOnce(&T)>(value: T, f: F) -> T threads a value through a side effecttap as an extension trait for ergonomic chaining with dot notationtap (immutable peek), tap_mut (mutable modification), and tap_dbg (debug printing)tap_if(condition, f) enables conditional side effects without breaking the chainCode Example
pub trait Tap: Sized {
fn tap(self, f: impl FnOnce(&Self)) -> Self {
f(&self);
self
}
}
impl<T> Tap for T {}
// Usage
let result = vec![1, 2, 3]
.into_iter()
.map(|x| x * 2)
.collect::<Vec<_>>()
.tap(|v| println!("doubled: {} items", v.len()));Key Differences
Tap trait enables value.tap(f) dot-notation in method chains; OCaml uses |> with tap f as a free function — both achieve the same pipeline clarity.tap_mut can modify the value in-place before it passes through; OCaml's tap with a ref or mutable record achieves the same but requires explicit ref cells.tap_dbg requires T: Debug at compile time; OCaml's tap_debug uses Obj.repr or format functions at runtime with less type safety.impl<T> Tap for T adds methods to every type in scope; OCaml achieves this through the module system by opening a Tap module.OCaml Approach
OCaml achieves tap via a simple helper that is idiomatic and common in pipelines using |>:
let tap f x = f x; x
let tap_debug label x = Printf.eprintf "%s: %s\n" label (Obj.repr x |> ...); x
(* usage *)
value |> tap (fun x -> log x) |> transform |> tap (fun x -> assert_ok x)
Full Source
#![allow(clippy::all)]
//! Tap Pattern for Side Effects
//!
//! Inspect values in a pipeline without disrupting the data flow.
/// Tap: run a side effect, then return the value unchanged.
pub fn tap<T, F: FnOnce(&T)>(value: T, f: F) -> T {
f(&value);
value
}
/// Tap with a mutable reference.
pub fn tap_mut<T, F: FnOnce(&mut T)>(mut value: T, f: F) -> T {
f(&mut value);
value
}
/// Extension trait to enable chained .tap() calls.
pub trait Tap: Sized {
fn tap(self, f: impl FnOnce(&Self)) -> Self {
f(&self);
self
}
fn tap_mut(mut self, f: impl FnOnce(&mut Self)) -> Self {
f(&mut self);
self
}
fn tap_dbg(self, label: &str) -> Self
where
Self: std::fmt::Debug,
{
eprintln!("{}: {:?}", label, &self);
self
}
}
impl<T> Tap for T {}
/// Debug tap that prints the value.
pub fn tap_debug<T: std::fmt::Debug>(value: T) -> T {
eprintln!("DEBUG: {:?}", value);
value
}
/// Conditional tap.
pub fn tap_if<T, F: FnOnce(&T)>(value: T, condition: bool, f: F) -> T {
if condition {
f(&value);
}
value
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[test]
fn test_tap_function() {
let log = RefCell::new(Vec::new());
let result = tap(42, |x| log.borrow_mut().push(*x));
assert_eq!(result, 42);
assert_eq!(*log.borrow(), vec![42]);
}
#[test]
fn test_tap_mut_function() {
let result = tap_mut(vec![1, 2, 3], |v| v.push(4));
assert_eq!(result, vec![1, 2, 3, 4]);
}
#[test]
fn test_tap_trait() {
let log = RefCell::new(Vec::new());
let result = 42.tap(|x| log.borrow_mut().push(*x));
assert_eq!(result, 42);
assert_eq!(*log.borrow(), vec![42]);
}
#[test]
fn test_tap_mut_trait() {
let result = vec![1, 2, 3].tap_mut(|v| v.push(4));
assert_eq!(result, vec![1, 2, 3, 4]);
}
#[test]
fn test_tap_chain() {
let log = RefCell::new(Vec::new());
let result = 10
.tap(|x| log.borrow_mut().push(format!("start: {}", x)))
.tap(|x| log.borrow_mut().push(format!("value: {}", x)));
assert_eq!(result, 10);
assert_eq!(log.borrow().len(), 2);
}
#[test]
fn test_tap_in_pipeline() {
let log = RefCell::new(Vec::new());
let result: i32 = [1, 2, 3, 4, 5]
.iter()
.map(|&x| x * 2)
.map(|x| tap(x, |v| log.borrow_mut().push(*v)))
.sum();
assert_eq!(result, 30);
assert_eq!(*log.borrow(), vec![2, 4, 6, 8, 10]);
}
#[test]
fn test_tap_if() {
let mut called = false;
let _ = tap_if(42, true, |_| called = true);
assert!(called);
called = false;
let _ = tap_if(42, false, |_| called = true);
assert!(!called);
}
#[test]
fn test_tap_debug() {
// Just verify it compiles and returns the value
let result = tap_debug(vec![1, 2, 3]);
assert_eq!(result, vec![1, 2, 3]);
}
#[test]
fn test_tap_preserves_value() {
let original = String::from("hello");
let result = original.tap(|s| {
// Expensive debug operation
let _ = s.len();
});
assert_eq!(result, "hello");
}
}#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[test]
fn test_tap_function() {
let log = RefCell::new(Vec::new());
let result = tap(42, |x| log.borrow_mut().push(*x));
assert_eq!(result, 42);
assert_eq!(*log.borrow(), vec![42]);
}
#[test]
fn test_tap_mut_function() {
let result = tap_mut(vec![1, 2, 3], |v| v.push(4));
assert_eq!(result, vec![1, 2, 3, 4]);
}
#[test]
fn test_tap_trait() {
let log = RefCell::new(Vec::new());
let result = 42.tap(|x| log.borrow_mut().push(*x));
assert_eq!(result, 42);
assert_eq!(*log.borrow(), vec![42]);
}
#[test]
fn test_tap_mut_trait() {
let result = vec![1, 2, 3].tap_mut(|v| v.push(4));
assert_eq!(result, vec![1, 2, 3, 4]);
}
#[test]
fn test_tap_chain() {
let log = RefCell::new(Vec::new());
let result = 10
.tap(|x| log.borrow_mut().push(format!("start: {}", x)))
.tap(|x| log.borrow_mut().push(format!("value: {}", x)));
assert_eq!(result, 10);
assert_eq!(log.borrow().len(), 2);
}
#[test]
fn test_tap_in_pipeline() {
let log = RefCell::new(Vec::new());
let result: i32 = [1, 2, 3, 4, 5]
.iter()
.map(|&x| x * 2)
.map(|x| tap(x, |v| log.borrow_mut().push(*v)))
.sum();
assert_eq!(result, 30);
assert_eq!(*log.borrow(), vec![2, 4, 6, 8, 10]);
}
#[test]
fn test_tap_if() {
let mut called = false;
let _ = tap_if(42, true, |_| called = true);
assert!(called);
called = false;
let _ = tap_if(42, false, |_| called = true);
assert!(!called);
}
#[test]
fn test_tap_debug() {
// Just verify it compiles and returns the value
let result = tap_debug(vec![1, 2, 3]);
assert_eq!(result, vec![1, 2, 3]);
}
#[test]
fn test_tap_preserves_value() {
let original = String::from("hello");
let result = original.tap(|s| {
// Expensive debug operation
let _ = s.len();
});
assert_eq!(result, "hello");
}
}
Deep Comparison
OCaml vs Rust: Tap Pattern
OCaml
let tap f x = f x; x
(* Usage in pipeline *)
let result =
[1; 2; 3]
|> List.map (fun x -> x * 2)
|> tap (fun xs -> Printf.printf "doubled: %d items\n" (List.length xs))
|> List.filter (fun x -> x > 2)
Rust
pub trait Tap: Sized {
fn tap(self, f: impl FnOnce(&Self)) -> Self {
f(&self);
self
}
}
impl<T> Tap for T {}
// Usage
let result = vec![1, 2, 3]
.into_iter()
.map(|x| x * 2)
.collect::<Vec<_>>()
.tap(|v| println!("doubled: {} items", v.len()));
Key Differences
let tap f x = f x; xExercises
tap_count(counter: &mut usize) that increments counter on each call through the chain — useful for profiling how many items pass a filter.tap_mut to normalize strings (trim whitespace, lowercase) inside a pipeline without breaking the chain, then verify the final collected values are normalized.