ExamplesBy LevelBy TopicLearning Paths
912 Intermediate

912-iterator-inspect — Iterator Inspect

Functional Programming

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

  • • Use .inspect(f) to observe elements at any point in an iterator pipeline
  • • Collect elements seen at each stage using captured Vec references
  • • Count elements passing through each stage using atomic counters
  • • Understand inspect as a "tap" that does not affect the pipeline's output
  • • Compare with OCaml's lack of a standard tap operator
  • Code 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

  • Explicit tap: Rust .inspect() is a named, purpose-built debugging method; OCaml uses a manual identity-with-side-effect in .map().
  • Atomic counters: Rust's thread-safe AtomicUsize enables inspect-based metrics in multi-threaded pipelines; OCaml uses ref for single-threaded counting.
  • Production use: .inspect() with logging is acceptable in Rust production code (lightweight); OCaml's equivalent requires careful handling to avoid mutation in otherwise-pure pipelines.
  • No-op removal: Removing .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]);
        }
    }
    ✓ Tests Rust test suite
    #[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

    ConceptOCamlRust
    Tap helperval tap : ('a -> unit) -> 'a -> 'abuilt-in .inspect(f) adapter
    Closure argumentfun x -> ... (owned value)\|x\| ... (shared reference &T)
    Pipeline operator\|> (pipe-forward)method chaining on Iterator
    Collection'a listVec<T> via .collect()

    Key Insights

  • Built-in vs. user-defined tap: OCaml has no standard tap so you define it yourself (let tap f x = f x; x). Rust ships .inspect() as a first-class iterator adapter in std.
  • Reference semantics in closures: Rust's .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.
  • Lazy vs. eager evaluation: Rust iterators are lazy — .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.
  • Atomic counters for shared state: When an 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.
  • Production uses: Both languages use the tap pattern for logging and metrics, but Rust's .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

  • Use .inspect() to implement a pipeline profiler that measures the fraction of elements passing each filter stage.
  • Add logging to a multi-stage data transformation using .inspect() to log a sample (every 100th element) without logging everything.
  • Write inspect_changes<T: PartialEq + Clone>(iter: impl Iterator<Item=T>) -> impl Iterator<Item=T> that logs when the value changes between consecutive elements.
  • Open Source Repos