Higher-Order Functions with Lifetime Constraints
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
compose) and how it preserves types through two transformationsfind_first and filter_refs safely return references into the input collectionCode 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
'a annotations; OCaml infers everything with no annotation burden.impl Fn or F: Fn bounds generate a concrete copy per call site; OCaml uses a single polymorphic function with no code duplication.compose requires three type parameters plus two function bounds; OCaml's (f >> g) uses a single polymorphic ('a -> 'b) -> ('b -> 'c) -> 'a -> 'c.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);
}
}#[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
| Concept | OCaml | Rust |
|---|---|---|
| HOF with predicate | ('a -> bool) -> 'a list -> 'a option | fn find_first<'a, F>(items: &'a [&'a str], pred: F) -> Option<&'a str> |
| Function composition | ('b -> 'c) -> ('a -> 'b) -> 'a -> 'c | fn compose<A,B,C,F,G>(f: F, g: G) -> impl Fn(A) -> C |
| Repeated application | ('a -> 'a) -> int -> 'a -> 'a | fn apply_n<T, F>(f: F, n: usize, init: T) -> T |
| Reference in output | implicit (GC) | 'a lifetime annotation required |
Key Insights
String, Vec<T>, i32), no lifetime annotation is needed — compose and apply_n are annotation-free.'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.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.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
apply_n<T, F: Fn(T) -> T>(f: F, n: usize, init: T) -> T and use it to apply a doubling function 10 times.filter_refs and explain why the returned Vec<&T> cannot outlive the input slice.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.