912-iterator-inspect — Iterator Inspect
Tutorial
The Problem
Debugging a multi-step iterator pipeline is difficult: the lazy evaluation means no intermediate values exist until the consumer runs. Inserting println! calls requires breaking the pipeline into named variables. Rust's .inspect(f) solves this: it taps into the pipeline at any point, passing each element to a side-effect closure while passing it through unchanged. This is the "tap" operator from Haskell and RxJS, (|>) with side effects in F#, and the ; expression evaluation in many languages. It is primarily a debugging tool but also useful for logging, metrics counting, and audit trails in production pipelines.
🎯 Learning Outcomes
.inspect(f) to observe elements at any point in an iterator pipelineCode Example
let result: Vec<i32> = (1..=10)
.inspect(|x| print!("[in:{x}] "))
.filter(|x| x % 2 == 0)
.inspect(|x| print!("[even:{x}] "))
.map(|x| x * x)
.collect();Key Differences
.inspect() is a named, purpose-built debugging method; OCaml uses a manual identity-with-side-effect in .map().AtomicUsize enables inspect-based metrics in multi-threaded pipelines; OCaml uses ref for single-threaded counting..inspect() with logging is acceptable in Rust production code (lightweight); OCaml's equivalent requires careful handling to avoid mutation in otherwise-pure pipelines..inspect() preserves the pipeline's type and output — it is truly transparent; OCaml's manual tap changes the .map() return type if not careful.OCaml Approach
OCaml lacks a standard tap operator for sequences. The closest idiom: insert (fun x -> Printf.eprintf "debug: %d\n" x; x) in a List.map chain, which both observes and passes through. For Seq: Seq.map (fun x -> let () = f x in x) seq is the manual tap. OCaml's side-effect-explicit style makes inspect less common — one would typically extract the debug information into separate operations rather than embedding side effects in a pipeline.
Full Source
#![allow(clippy::all)]
//! 273. Debugging iterators with inspect()
//!
//! `inspect(f)` taps into an iterator pipeline with a side-effect closure,
//! passing each value through unchanged — the `.tap()` pattern from Haskell/RxJS.
use std::sync::atomic::{AtomicUsize, Ordering};
/// Collect squared even numbers while recording what passed through each stage.
/// Returns (all_seen, evens_seen, result).
pub fn inspect_pipeline(range: std::ops::RangeInclusive<i32>) -> (Vec<i32>, Vec<i32>, Vec<i32>) {
let mut all_seen = Vec::new();
let mut evens_seen = Vec::new();
let result: Vec<i32> = range
.inspect(|&x| all_seen.push(x))
.filter(|x| x % 2 == 0)
.inspect(|&x| evens_seen.push(x))
.map(|x| x * x)
.collect();
(all_seen, evens_seen, result)
}
/// Count elements at each pipeline stage using atomics (safe across closures).
pub fn count_stages(
range: std::ops::RangeInclusive<i32>,
predicate: fn(&i32) -> bool,
) -> (usize, usize) {
let count_in = AtomicUsize::new(0);
let count_out = AtomicUsize::new(0);
let _: Vec<i32> = range
.inspect(|_| {
count_in.fetch_add(1, Ordering::SeqCst);
})
.filter(predicate)
.inspect(|_| {
count_out.fetch_add(1, Ordering::SeqCst);
})
.collect();
(
count_in.load(Ordering::SeqCst),
count_out.load(Ordering::SeqCst),
)
}
/// Log and discard negative values; pass positives through.
/// Returns (warnings_logged, cleaned_values).
pub fn log_negatives(values: &[i32]) -> (Vec<i32>, Vec<i32>) {
let mut warnings = Vec::new();
let cleaned: Vec<i32> = values
.iter()
.copied()
.inspect(|&x| {
if x < 0 {
warnings.push(x);
}
})
.filter(|&x| x >= 0)
.collect();
(warnings, cleaned)
}
/// Demonstrate inspect for tracing through a multi-step transformation.
/// Returns the trace log and final result.
///
/// Multiple `inspect` closures that mutate shared state require `RefCell`
/// because each closure holds a `&mut` borrow for the lifetime of the chain,
/// and the borrow checker disallows two simultaneous `&mut` borrows.
pub fn trace_pipeline(items: &[&str]) -> (Vec<String>, Vec<String>) {
use std::cell::RefCell;
let trace = RefCell::new(Vec::new());
let result: Vec<String> = items
.iter()
.copied()
.inspect(|s| trace.borrow_mut().push(format!("raw:{s}")))
.filter(|s| !s.is_empty())
.inspect(|s| trace.borrow_mut().push(format!("non-empty:{s}")))
.map(|s| s.to_uppercase())
.inspect(|s| trace.borrow_mut().push(format!("upper:{s}")))
.collect();
(trace.into_inner(), result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inspect_pipeline_observes_without_altering() {
let (all_seen, evens_seen, result) = inspect_pipeline(1..=6);
assert_eq!(all_seen, vec![1, 2, 3, 4, 5, 6]);
assert_eq!(evens_seen, vec![2, 4, 6]);
assert_eq!(result, vec![4, 16, 36]);
}
#[test]
fn test_inspect_pipeline_empty_range() {
let (all_seen, evens_seen, result) = inspect_pipeline(1..=0);
assert!(all_seen.is_empty());
assert!(evens_seen.is_empty());
assert!(result.is_empty());
}
#[test]
fn test_count_stages_tracks_filter_reduction() {
let (total_in, total_out) = count_stages(1..=20, |x| x % 3 == 0);
assert_eq!(total_in, 20);
assert_eq!(total_out, 6);
}
#[test]
fn test_count_stages_all_pass_filter() {
let (total_in, total_out) = count_stages(1..=5, |_| true);
assert_eq!(total_in, 5);
assert_eq!(total_out, 5);
}
#[test]
fn test_log_negatives_separates_correctly() {
let (warnings, cleaned) = log_negatives(&[-1, 2, -3, 4, 5]);
assert_eq!(warnings, vec![-1, -3]);
assert_eq!(cleaned, vec![2, 4, 5]);
}
#[test]
fn test_log_negatives_all_positive() {
let (warnings, cleaned) = log_negatives(&[1, 2, 3]);
assert!(warnings.is_empty());
assert_eq!(cleaned, vec![1, 2, 3]);
}
#[test]
fn test_trace_pipeline_records_each_stage() {
let (trace, result) = trace_pipeline(&["hello", "", "world"]);
assert_eq!(result, vec!["HELLO", "WORLD"]);
assert_eq!(trace.iter().filter(|t| t.starts_with("raw:")).count(), 3);
assert_eq!(
trace.iter().filter(|t| t.starts_with("non-empty:")).count(),
2
);
assert_eq!(trace.iter().filter(|t| t.starts_with("upper:")).count(), 2);
}
#[test]
fn test_inspect_does_not_consume_values() {
let result: Vec<i32> = (1..=5).inspect(|_| {}).map(|x| x * 2).collect();
assert_eq!(result, vec![2, 4, 6, 8, 10]);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inspect_pipeline_observes_without_altering() {
let (all_seen, evens_seen, result) = inspect_pipeline(1..=6);
assert_eq!(all_seen, vec![1, 2, 3, 4, 5, 6]);
assert_eq!(evens_seen, vec![2, 4, 6]);
assert_eq!(result, vec![4, 16, 36]);
}
#[test]
fn test_inspect_pipeline_empty_range() {
let (all_seen, evens_seen, result) = inspect_pipeline(1..=0);
assert!(all_seen.is_empty());
assert!(evens_seen.is_empty());
assert!(result.is_empty());
}
#[test]
fn test_count_stages_tracks_filter_reduction() {
let (total_in, total_out) = count_stages(1..=20, |x| x % 3 == 0);
assert_eq!(total_in, 20);
assert_eq!(total_out, 6);
}
#[test]
fn test_count_stages_all_pass_filter() {
let (total_in, total_out) = count_stages(1..=5, |_| true);
assert_eq!(total_in, 5);
assert_eq!(total_out, 5);
}
#[test]
fn test_log_negatives_separates_correctly() {
let (warnings, cleaned) = log_negatives(&[-1, 2, -3, 4, 5]);
assert_eq!(warnings, vec![-1, -3]);
assert_eq!(cleaned, vec![2, 4, 5]);
}
#[test]
fn test_log_negatives_all_positive() {
let (warnings, cleaned) = log_negatives(&[1, 2, 3]);
assert!(warnings.is_empty());
assert_eq!(cleaned, vec![1, 2, 3]);
}
#[test]
fn test_trace_pipeline_records_each_stage() {
let (trace, result) = trace_pipeline(&["hello", "", "world"]);
assert_eq!(result, vec!["HELLO", "WORLD"]);
assert_eq!(trace.iter().filter(|t| t.starts_with("raw:")).count(), 3);
assert_eq!(
trace.iter().filter(|t| t.starts_with("non-empty:")).count(),
2
);
assert_eq!(trace.iter().filter(|t| t.starts_with("upper:")).count(), 2);
}
#[test]
fn test_inspect_does_not_consume_values() {
let result: Vec<i32> = (1..=5).inspect(|_| {}).map(|x| x * 2).collect();
assert_eq!(result, vec![2, 4, 6, 8, 10]);
}
}
Deep Comparison
OCaml vs Rust: Iterator inspect()
Side-by-Side Code
OCaml
let tap f x = f x; x
let result =
[1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
|> List.map (tap (fun x -> Printf.printf "[in:%d] " x))
|> List.filter (fun x -> x mod 2 = 0)
|> List.map (tap (fun x -> Printf.printf "[even:%d] " x))
|> List.map (fun x -> x * x)
Rust (idiomatic)
let result: Vec<i32> = (1..=10)
.inspect(|x| print!("[in:{x}] "))
.filter(|x| x % 2 == 0)
.inspect(|x| print!("[even:{x}] "))
.map(|x| x * x)
.collect();
Rust (capturing observations into a Vec)
let mut evens_seen = Vec::new();
let result: Vec<i32> = (1..=10)
.filter(|x| x % 2 == 0)
.inspect(|&x| evens_seen.push(x))
.map(|x| x * x)
.collect();
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Tap helper | val tap : ('a -> unit) -> 'a -> 'a | built-in .inspect(f) adapter |
| Closure argument | fun x -> ... (owned value) | \|x\| ... (shared reference &T) |
| Pipeline operator | \|> (pipe-forward) | method chaining on Iterator |
| Collection | 'a list | Vec<T> via .collect() |
Key Insights
tap so you define it yourself (let tap f x = f x; x). Rust ships .inspect() as a first-class iterator adapter in std..inspect(|x| ...) receives &T, not T, because the iterator adapter borrows each element before passing it on. OCaml's tap receives the value directly since OCaml is garbage-collected and immutable by default..inspect() closures only fire when the chain is driven by .collect() or a terminal. OCaml's List.map is strict, so the side effects execute immediately at each |> stage.inspect closure needs to mutate shared state (e.g., a counter) across a lazy chain, Rust requires AtomicUsize or Cell because the closure borrows the chain environment; OCaml can just close over a ref counter without ceremony..inspect() integrates naturally with tracing spans and structured logging, while OCaml typically uses a logging library called in the tap closure.When to Use Each Style
**Use idiomatic Rust .inspect()** when debugging a live iterator pipeline, adding metrics counters, or wiring up tracing events — any case where you need to observe without restructuring the chain.
Use the capturing-Vec pattern in tests and library code where you need to assert on intermediate values without printing to stdout.
Exercises
.inspect() to implement a pipeline profiler that measures the fraction of elements passing each filter stage..inspect() to log a sample (every 100th element) without logging everything.inspect_changes<T: PartialEq + Clone>(iter: impl Iterator<Item=T>) -> impl Iterator<Item=T> that logs when the value changes between consecutive elements.