Closures Capturing References
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
'a lifetime annotations constrain closures that capture referencesimpl Fn(&str) -> bool + 'a ties its lifetime to captured dataFilter<'a, T> that hold both data and a closure over that datamake_validator captures two references with the same lifetime 'aCode 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 scopeKey Differences
'a to express that a closure borrows from external data; OCaml relies on the GC to keep all captured values alive, requiring no annotations.&[T] enable zero-copy filtering; OCaml's list slices are not zero-copy — sublist operations create new list nodes.'a through struct fields, function signatures, and trait objects; OCaml has no equivalent concept — all captured values are safe by construction.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));
}
}#[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
Exercises
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.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.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.