ExamplesBy LevelBy TopicLearning Paths
528 Intermediate

Closures Capturing References

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "Closures Capturing References" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. When a closure borrows data from its environment rather than moving it, the closure's lifetime is constrained by the validity of those borrows. Key difference from OCaml: 1. **Lifetime annotations**: Rust requires explicit `'a` to express that a closure borrows from external data; OCaml relies on the GC to keep all captured values alive, requiring no annotations.

Tutorial

The Problem

When a closure borrows data from its environment rather than moving it, the closure's lifetime is constrained by the validity of those borrows. This is the intersection of two of Rust's most distinctive features: closures and the borrow checker. The challenge is expressing in the type system that "this closure is valid as long as the data it borrows is valid." Getting this right enables zero-copy parsing, view-based APIs, and efficient filtering over borrowed slices without unnecessary cloning.

🎯 Learning Outcomes

  • • How 'a lifetime annotations constrain closures that capture references
  • • Why a closure returning impl Fn(&str) -> bool + 'a ties its lifetime to captured data
  • • How to build structs like Filter<'a, T> that hold both data and a closure over that data
  • • How make_validator captures two references with the same lifetime 'a
  • • Where reference-capturing closures appear: parsers, search indices, view layers
  • Code Example

    // Explicit lifetime ties closure to borrowed data
    pub fn make_prefix_checker<'a>(prefix: &'a str) -> impl Fn(&str) -> bool + 'a {
        move |s| s.starts_with(prefix)
    }
    
    // Closure invalid if prefix goes out of scope

    Key Differences

  • Lifetime annotations: Rust requires explicit 'a to express that a closure borrows from external data; OCaml relies on the GC to keep all captured values alive, requiring no annotations.
  • Zero-copy semantics: Rust closures over &[T] enable zero-copy filtering; OCaml's list slices are not zero-copy — sublist operations create new list nodes.
  • Lifetime propagation: Rust propagates 'a through struct fields, function signatures, and trait objects; OCaml has no equivalent concept — all captured values are safe by construction.
  • Error location: When Rust closure lifetimes are wrong, the compiler reports the conflicting borrow with a specific variable and scope; OCaml never reports these errors because the GC prevents the issue.
  • OCaml Approach

    OCaml closures capture references to the GC heap — there are no lifetime annotations. The GC ensures captured values remain alive as long as the closure exists. The equivalent of make_prefix_checker is simply:

    let make_prefix_checker prefix = fun s -> String.is_prefix s ~prefix
    

    No lifetime annotation is needed because the GC prevents the prefix from being freed.

    Full Source

    #![allow(clippy::all)]
    //! Closures Capturing References
    //!
    //! How closure lifetimes are constrained by captured borrows.
    
    /// Closure captures &str — lifetime tied to the string's scope.
    pub fn make_prefix_checker<'a>(prefix: &'a str) -> impl Fn(&str) -> bool + 'a {
        move |s| s.starts_with(prefix)
    }
    
    /// Multiple borrows in a closure.
    pub fn make_range_checker<'a>(data: &'a [i32]) -> impl Fn(i32) -> bool + 'a {
        move |target| data.contains(&target)
    }
    
    /// Struct holding a closure that borrows.
    pub struct Filter<'a, T> {
        data: &'a [T],
        predicate: Box<dyn Fn(&T) -> bool + 'a>,
    }
    
    impl<'a, T> Filter<'a, T> {
        pub fn new(data: &'a [T], predicate: impl Fn(&T) -> bool + 'a) -> Self {
            Filter {
                data,
                predicate: Box::new(predicate),
            }
        }
    
        pub fn apply(&self) -> Vec<&T> {
            self.data.iter().filter(|x| (self.predicate)(x)).collect()
        }
    }
    
    /// Closure borrowing multiple fields from a struct.
    pub fn make_validator<'a>(min: &'a i32, max: &'a i32) -> impl Fn(i32) -> bool + 'a {
        move |x| x >= *min && x <= *max
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_prefix_checker() {
            let prefix = String::from("hello");
            let checker = make_prefix_checker(&prefix);
            assert!(checker("hello world"));
            assert!(!checker("hi there"));
        }
    
        #[test]
        fn test_range_checker() {
            let data = vec![1, 2, 3, 4, 5];
            let checker = make_range_checker(&data);
            assert!(checker(3));
            assert!(!checker(10));
        }
    
        #[test]
        fn test_filter_struct() {
            let data = vec![1, 2, 3, 4, 5, 6];
            let filter = Filter::new(&data, |&x| x % 2 == 0);
            let result: Vec<i32> = filter.apply().into_iter().cloned().collect();
            assert_eq!(result, vec![2, 4, 6]);
        }
    
        #[test]
        fn test_validator() {
            let min = 10;
            let max = 20;
            let validate = make_validator(&min, &max);
            assert!(validate(15));
            assert!(!validate(5));
            assert!(!validate(25));
        }
    
        #[test]
        fn test_nested_borrow() {
            let outer = vec![1, 2, 3];
            let checker = make_range_checker(&outer);
            // checker is valid as long as outer is in scope
            assert!(checker(2));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_prefix_checker() {
            let prefix = String::from("hello");
            let checker = make_prefix_checker(&prefix);
            assert!(checker("hello world"));
            assert!(!checker("hi there"));
        }
    
        #[test]
        fn test_range_checker() {
            let data = vec![1, 2, 3, 4, 5];
            let checker = make_range_checker(&data);
            assert!(checker(3));
            assert!(!checker(10));
        }
    
        #[test]
        fn test_filter_struct() {
            let data = vec![1, 2, 3, 4, 5, 6];
            let filter = Filter::new(&data, |&x| x % 2 == 0);
            let result: Vec<i32> = filter.apply().into_iter().cloned().collect();
            assert_eq!(result, vec![2, 4, 6]);
        }
    
        #[test]
        fn test_validator() {
            let min = 10;
            let max = 20;
            let validate = make_validator(&min, &max);
            assert!(validate(15));
            assert!(!validate(5));
            assert!(!validate(25));
        }
    
        #[test]
        fn test_nested_borrow() {
            let outer = vec![1, 2, 3];
            let checker = make_range_checker(&outer);
            // checker is valid as long as outer is in scope
            assert!(checker(2));
        }
    }

    Deep Comparison

    OCaml vs Rust: Closure Lifetime Capture

    OCaml

    (* GC handles memory — no explicit lifetimes *)
    let make_prefix_checker prefix =
      fun s -> String.starts_with ~prefix s
    
    let checker = make_prefix_checker "hello"
    (* prefix kept alive by GC as long as checker exists *)
    

    Rust

    // Explicit lifetime ties closure to borrowed data
    pub fn make_prefix_checker<'a>(prefix: &'a str) -> impl Fn(&str) -> bool + 'a {
        move |s| s.starts_with(prefix)
    }
    
    // Closure invalid if prefix goes out of scope
    

    Key Differences

  • OCaml: GC keeps captured values alive automatically
  • Rust: Lifetime annotations express borrow duration
  • Rust: Compiler enforces closure doesn't outlive borrows
  • Rust: + 'a on return type bounds closure lifetime
  • Both: Closures capture environment, different memory models
  • Exercises

  • Multi-reference closure: Write make_between_checker<'a>(lo: &'a str, hi: &'a str) -> impl Fn(&str) -> bool + 'a that returns true when the input is lexicographically between lo and hi.
  • Lifetime-bounded filter struct: Implement struct Searcher<'a> { text: &'a str, pattern: &'a str } with a method matches_at(pos: usize) -> bool that checks for the pattern at a given position.
  • Two-lifetime struct: Create struct Join<'a, 'b> { left: &'a [i32], right: &'b [i32] } with a method merged_sorted(&self) -> Vec<i32> that merges without requiring both lifetimes to be equal.
  • Open Source Repos