ExamplesBy LevelBy TopicLearning Paths
122 Intermediate

Higher-Order Functions with Lifetime Constraints

Functional Programming

Tutorial

The Problem

Higher-order functions (HOFs) — functions that take or return other functions — are the backbone of functional programming. In Rust, HOFs that deal with references must explicitly declare lifetimes to tell the borrow checker how long returned references live relative to their inputs. Without this, the compiler cannot guarantee memory safety. This example shows the patterns for safe HOFs: finding elements, composing functions, and filtering collections.

🎯 Learning Outcomes

  • • Understand how lifetime annotations on HOFs constrain the relationship between input and output references
  • • Learn function composition (compose) and how it preserves types through two transformations
  • • See how find_first and filter_refs safely return references into the input collection
  • • Practice writing generic HOFs with multiple type parameters and lifetime bounds
  • Code Example

    pub fn compose<A, B, C, F, G>(f: F, g: G) -> impl Fn(A) -> C
    where
        F: Fn(B) -> C,
        G: Fn(A) -> B,
    {
        move |x| f(g(x))
    }
    
    pub fn apply_n<T, F>(f: F, n: usize, init: T) -> T
    where
        F: Fn(T) -> T,
    {
        (0..n).fold(init, |acc, _| f(acc))
    }

    Key Differences

  • Lifetime annotations: Rust HOFs operating on references require explicit 'a annotations; OCaml infers everything with no annotation burden.
  • Monomorphization: Rust HOFs with impl Fn or F: Fn bounds generate a concrete copy per call site; OCaml uses a single polymorphic function with no code duplication.
  • Composition types: Rust compose requires three type parameters plus two function bounds; OCaml's (f >> g) uses a single polymorphic ('a -> 'b) -> ('b -> 'c) -> 'a -> 'c.
  • First-class functions: Both treat functions as first-class values; Rust stores them as zero-sized types (no heap allocation for impl Fn); OCaml stores closures as heap records.
  • OCaml Approach

    OCaml's type system handles most HOF patterns automatically. List.find, List.filter, and function composition (>>) work without lifetime annotations because the GC ensures referenced values stay alive. OCaml's higher-order functions use polymorphic types like ('a -> bool) -> 'a list -> 'a list, with the type checker inferring all instantiations.

    Full Source

    #![allow(clippy::all)]
    // Example 122: Higher-Order Functions with Lifetime Constraints
    //
    // When HOFs deal with references, lifetimes must be explicit
    // to tell the compiler how long returned references live.
    
    // Approach 1: HOF returning a reference — lifetime ties output to input
    // The output &str can't outlive the slice it came from.
    pub fn find_first<'a, F>(items: &'a [&'a str], pred: F) -> Option<&'a str>
    where
        F: Fn(&str) -> bool,
    {
        items.iter().copied().find(|&s| pred(s))
    }
    
    // Approach 2: Function composition — owned types, no lifetimes needed
    pub fn compose<A, B, C, F, G>(f: F, g: G) -> impl Fn(A) -> C
    where
        F: Fn(B) -> C,
        G: Fn(A) -> B,
    {
        move |x| f(g(x))
    }
    
    // Approach 3: Apply — takes a reference, returns a derived reference
    // Lifetime 'a ensures: the returned &str lives exactly as long as the input &str.
    pub fn apply_to_str<'a, F>(s: &'a str, f: F) -> &'a str
    where
        F: Fn(&'a str) -> &'a str,
    {
        f(s)
    }
    
    // Approach 4: Filter slice by predicate — borrows input, returns subslice refs
    // Lifetime elision applies here: the output references live as long as the input slice.
    pub fn filter_refs<T, F>(items: &[T], pred: F) -> Vec<&T>
    where
        F: Fn(&T) -> bool,
    {
        items.iter().filter(|x| pred(x)).collect()
    }
    
    // Approach 5: Recursive HOF — apply f n times
    pub fn apply_n<T, F>(f: F, n: usize, init: T) -> T
    where
        F: Fn(T) -> T,
    {
        (0..n).fold(init, |acc, _| f(acc))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_find_first_match() {
            let data = vec!["apple", "banana", "cherry", "date"];
            assert_eq!(find_first(&data, |s| s.len() > 5), Some("banana"));
        }
    
        #[test]
        fn test_find_first_no_match() {
            let data = vec!["hi", "ok", "no"];
            assert_eq!(find_first(&data, |s| s.len() > 10), None);
        }
    
        #[test]
        fn test_find_first_empty() {
            let data: Vec<&str> = vec![];
            assert_eq!(find_first(&data, |_| true), None);
        }
    
        #[test]
        fn test_compose_add_double() {
            let double = |x: i32| x * 2;
            let add1 = |x: i32| x + 1;
            let double_then_add = compose(add1, double);
            assert_eq!(double_then_add(5), 11);
        }
    
        #[test]
        fn test_compose_string_transform() {
            let trim = |s: String| s.trim().to_string();
            let upper = |s: String| s.to_uppercase();
            let trim_then_upper = compose(upper, trim);
            assert_eq!(trim_then_upper("  hello  ".to_string()), "HELLO");
        }
    
        #[test]
        fn test_apply_to_str_lifetime() {
            let s = String::from("hello world");
            // The returned slice is tied to the lifetime of `s` — safe.
            let first_word = apply_to_str(&s, |t| t.split_whitespace().next().unwrap_or(""));
            assert_eq!(first_word, "hello");
        }
    
        #[test]
        fn test_filter_refs_even() {
            let nums = vec![1, 2, 3, 4, 5, 6];
            let evens: Vec<&i32> = filter_refs(&nums, |x| *x % 2 == 0);
            assert_eq!(evens, vec![&2, &4, &6]);
        }
    
        #[test]
        fn test_filter_refs_empty_result() {
            let nums = vec![1, 3, 5];
            let evens: Vec<&i32> = filter_refs(&nums, |x| *x % 2 == 0);
            assert!(evens.is_empty());
        }
    
        #[test]
        fn test_apply_n_double() {
            // Apply double 3 times: 1 -> 2 -> 4 -> 8
            assert_eq!(apply_n(|x: i32| x * 2, 3, 1), 8);
        }
    
        #[test]
        fn test_apply_n_zero_times() {
            assert_eq!(apply_n(|x: i32| x + 100, 0, 42), 42);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_find_first_match() {
            let data = vec!["apple", "banana", "cherry", "date"];
            assert_eq!(find_first(&data, |s| s.len() > 5), Some("banana"));
        }
    
        #[test]
        fn test_find_first_no_match() {
            let data = vec!["hi", "ok", "no"];
            assert_eq!(find_first(&data, |s| s.len() > 10), None);
        }
    
        #[test]
        fn test_find_first_empty() {
            let data: Vec<&str> = vec![];
            assert_eq!(find_first(&data, |_| true), None);
        }
    
        #[test]
        fn test_compose_add_double() {
            let double = |x: i32| x * 2;
            let add1 = |x: i32| x + 1;
            let double_then_add = compose(add1, double);
            assert_eq!(double_then_add(5), 11);
        }
    
        #[test]
        fn test_compose_string_transform() {
            let trim = |s: String| s.trim().to_string();
            let upper = |s: String| s.to_uppercase();
            let trim_then_upper = compose(upper, trim);
            assert_eq!(trim_then_upper("  hello  ".to_string()), "HELLO");
        }
    
        #[test]
        fn test_apply_to_str_lifetime() {
            let s = String::from("hello world");
            // The returned slice is tied to the lifetime of `s` — safe.
            let first_word = apply_to_str(&s, |t| t.split_whitespace().next().unwrap_or(""));
            assert_eq!(first_word, "hello");
        }
    
        #[test]
        fn test_filter_refs_even() {
            let nums = vec![1, 2, 3, 4, 5, 6];
            let evens: Vec<&i32> = filter_refs(&nums, |x| *x % 2 == 0);
            assert_eq!(evens, vec![&2, &4, &6]);
        }
    
        #[test]
        fn test_filter_refs_empty_result() {
            let nums = vec![1, 3, 5];
            let evens: Vec<&i32> = filter_refs(&nums, |x| *x % 2 == 0);
            assert!(evens.is_empty());
        }
    
        #[test]
        fn test_apply_n_double() {
            // Apply double 3 times: 1 -> 2 -> 4 -> 8
            assert_eq!(apply_n(|x: i32| x * 2, 3, 1), 8);
        }
    
        #[test]
        fn test_apply_n_zero_times() {
            assert_eq!(apply_n(|x: i32| x + 100, 0, 42), 42);
        }
    }

    Deep Comparison

    OCaml vs Rust: Higher-Order Functions with Lifetime Constraints

    Side-by-Side Code

    OCaml

    (* OCaml HOFs need no lifetime annotations — GC handles memory *)
    let find_first pred lst =
      try Some (List.find pred lst)
      with Not_found -> None
    
    let compose f g x = f (g x)
    
    let apply_n f n init =
      let rec loop acc k = if k = 0 then acc else loop (f acc) (k - 1)
      in loop init n
    
    let () =
      let data = ["apple"; "banana"; "cherry"; "date"] in
      assert (find_first (fun s -> String.length s > 5) data = Some "banana");
      let double_then_add = compose (( + ) 1) (( * ) 2) in
      assert (double_then_add 5 = 11);
      assert (apply_n (( * ) 2) 3 1 = 8);
      print_endline "ok"
    

    Rust (idiomatic — owned types, no lifetimes)

    pub fn compose<A, B, C, F, G>(f: F, g: G) -> impl Fn(A) -> C
    where
        F: Fn(B) -> C,
        G: Fn(A) -> B,
    {
        move |x| f(g(x))
    }
    
    pub fn apply_n<T, F>(f: F, n: usize, init: T) -> T
    where
        F: Fn(T) -> T,
    {
        (0..n).fold(init, |acc, _| f(acc))
    }
    

    Rust (with lifetime annotations — reference-returning HOFs)

    // 'a ties the output reference lifetime to the input slice lifetime
    pub fn find_first<'a, F>(items: &'a [&'a str], pred: F) -> Option<&'a str>
    where
        F: Fn(&str) -> bool,
    {
        items.iter().copied().find(|&s| pred(s))
    }
    
    pub fn apply_to_str<'a, F>(s: &'a str, f: F) -> &'a str
    where
        F: Fn(&'a str) -> &'a str,
    {
        f(s)
    }
    
    pub fn filter_refs<'a, T, F>(items: &'a [T], pred: F) -> Vec<&'a T>
    where
        F: Fn(&T) -> bool,
    {
        items.iter().filter(|x| pred(x)).collect()
    }
    

    Type Signatures

    ConceptOCamlRust
    HOF with predicate('a -> bool) -> 'a list -> 'a optionfn find_first<'a, F>(items: &'a [&'a str], pred: F) -> Option<&'a str>
    Function composition('b -> 'c) -> ('a -> 'b) -> 'a -> 'cfn compose<A,B,C,F,G>(f: F, g: G) -> impl Fn(A) -> C
    Repeated application('a -> 'a) -> int -> 'a -> 'afn apply_n<T, F>(f: F, n: usize, init: T) -> T
    Reference in outputimplicit (GC)'a lifetime annotation required

    Key Insights

  • Lifetime annotations are only required when returning references derived from inputs. When HOFs return owned values (String, Vec<T>, i32), no lifetime annotation is needed — compose and apply_n are annotation-free.
  • OCaml's GC makes lifetimes implicit. The garbage collector tracks reference liveness automatically. Rust requires the programmer to encode the same information as lifetime parameters so the borrow checker can verify safety at compile time.
  • **'a is a promise, not a restriction.** Writing fn find_first<'a>(items: &'a [&'a str]) -> Option<&'a str> doesn't impose a fixed lifetime — it tells the compiler "whatever lifetime the caller provides, the output won't outlive it." The caller determines the actual lifetime.
  • Elision hides common cases. In filter_refs, a single input reference determines the output lifetime, so Rust could elide the annotation. Explicit 'a is used here for clarity and teaching purposes.
  • Iterator combinators replace recursive OCaml patterns. OCaml's List.find is a recursive HOF. In Rust, .iter().find() is an iterator adapter — same concept, different mechanism, zero-cost abstraction with no allocation.
  • When to Use Each Style

    Use annotation-free HOFs when: your higher-order functions work on owned data or produce owned output. compose, apply_n, and closures over String/Vec need no lifetime syntax.

    Use explicit lifetime annotations when: a HOF accepts a reference and returns a reference derived from it — find_first, apply_to_str, filter_refs. The annotation is the compiler's proof that the output won't outlive its source.

    Exercises

  • Write apply_n<T, F: Fn(T) -> T>(f: F, n: usize, init: T) -> T and use it to apply a doubling function 10 times.
  • Add explicit lifetime annotations to filter_refs and explain why the returned Vec<&T> cannot outlive the input slice.
  • Implement pipe<A, B, C>(g: impl Fn(A) -> B, f: impl Fn(B) -> C) -> impl Fn(A) -> C (same as compose but argument order reversed) and demonstrate it with string processing.
  • Open Source Repos