Lenses
Tutorial Video
Text description (accessibility)
This video demonstrates the "Lenses" functional Rust example. Difficulty level: Advanced. Key concepts covered: Higher-order functions. Implement lenses — composable functional getters and setters for nested record updates. Key difference from OCaml: 1. **Record update syntax:** OCaml `{ p with field = v }` is built
Tutorial
The Problem
Implement lenses — composable functional getters and setters for nested record updates. A lens focuses on a specific field of a structure, allowing you to get, set, and transform it without mutation.
🎯 Learning Outcomes
Arc-shared closures to focus through multiple levels of nestingClone bounds where OCaml's GC handles structural sharing implicitly🦀 The Rust Way
Rust uses a struct with Box<dyn Fn> closures for the same pattern. Composition requires Arc to share the inner lens and outer closures between the composed getter and setter. The setter path needs Clone to build new values since Rust has no { ..p } for arbitrary cloning. All updates are pure — the original value is never mutated.
Code Example
type Getter<S, A> = Box<dyn Fn(&S) -> &A>;
type Setter<S, A> = Box<dyn Fn(A, &S) -> S>;
struct Lens<S, A> {
get_fn: Getter<S, A>,
set_fn: Setter<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
fn new(get: impl Fn(&S) -> &A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
Lens { get_fn: Box::new(get), set_fn: Box::new(set) }
}
fn get<'s>(&self, whole: &'s S) -> &'s A { (self.get_fn)(whole) }
fn set(&self, value: A, whole: &S) -> S { (self.set_fn)(value, whole) }
}
fn over<S: 'static, A: Clone + 'static>(lens: &Lens<S, A>, f: impl FnOnce(A) -> A, whole: &S) -> S {
let current = lens.get(whole).clone();
lens.set(f(current), whole)
}Key Differences
{ p with field = v } is built-in; Rust requires manually constructing a new struct and cloning unchanged fields.Arc to share closures between composed getter and setter.'static + Clone bounds to store closures in boxes and rebuild values.OCaml Approach
OCaml defines a lens as a record with get and set closures. Composition is trivial: thread the inner lens through the outer lens's get/set. The { p with addr = a } syntax creates a new record cheaply. Garbage collection handles all intermediate values.
Full Source
#![allow(clippy::all)]
/// A lens is a pair of getter/setter functions that focus on a part of a larger structure.
/// This is the functional approach to accessing and modifying nested data without mutation.
///
/// In OCaml, lenses are records of closures: `{ get: 's -> 'a; set: 'a -> 's -> 's }`.
/// In Rust, we use boxed closures since closures have unique, unsized types.
// Type aliases for lens closure types — avoids clippy::type_complexity
type Getter<S, A> = Box<dyn Fn(&S) -> &A>;
type Setter<S, A> = Box<dyn Fn(A, &S) -> S>;
pub struct Lens<S, A> {
// Takes a reference to the whole and returns a reference to the part
get_fn: Getter<S, A>,
// Takes a new part value and the whole, returns a new whole
set_fn: Setter<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
/// Create a new lens from getter and setter functions.
pub fn new(get: impl Fn(&S) -> &A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
Lens {
get_fn: Box::new(get),
set_fn: Box::new(set),
}
}
/// Focus the lens: get the part from the whole.
pub fn get<'s>(&self, whole: &'s S) -> &'s A {
(self.get_fn)(whole)
}
/// Update the whole by replacing the focused part.
pub fn set(&self, value: A, whole: &S) -> S {
(self.set_fn)(value, whole)
}
/// Compose two lenses: `self` focuses on a mid-level part, `inner` focuses deeper.
/// The result focuses from the outermost structure directly to the innermost part.
///
/// OCaml: `compose outer inner = { get = fun s -> inner.get (outer.get s); ... }`
/// Rust must clone the mid-level value for the set path because we need both
/// the old mid-level (to pass to inner.set) and the whole (to pass to outer.set).
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where
S: Clone,
A: Clone,
{
// Share the inner lens between get and set closures
let inner = std::sync::Arc::new(inner);
let outer_get = std::sync::Arc::new(self.get_fn);
let outer_set = std::sync::Arc::new(self.set_fn);
let get_outer = std::sync::Arc::clone(&outer_get);
let get_inner = std::sync::Arc::clone(&inner);
let set_outer_get = std::sync::Arc::clone(&outer_get);
let set_outer_set = std::sync::Arc::clone(&outer_set);
let set_inner = std::sync::Arc::clone(&inner);
Lens::new(
move |s: &S| {
// SAFETY of lifetime: we return a reference into `s` via two dereferences.
// The inner reference is valid as long as `s` is valid because both get_fns
// return references into their argument.
let mid: &A = (get_outer)(s);
// We need to extend the lifetime — the borrow checker can't see through
// the boxed closure that the returned &B borrows from s.
// This is safe because both get functions return references into their input.
let mid_ptr: *const A = mid;
(get_inner.get_fn)(unsafe { &*mid_ptr })
},
move |b: B, s: &S| {
// Get the current mid-level value, clone it so we can modify it
let mid: &A = (set_outer_get)(s);
let new_mid: A = (set_inner.set_fn)(b, mid);
(set_outer_set)(new_mid, s)
},
)
}
}
/// Apply a function to the focused part of a lens, returning an updated whole.
///
/// OCaml: `let over lens f s = lens.set (f (lens.get s)) s`
pub fn over<S: 'static, A: Clone + 'static>(
lens: &Lens<S, A>,
f: impl FnOnce(A) -> A,
whole: &S,
) -> S {
let current = lens.get(whole).clone(); // clone the part so we can transform it
lens.set(f(current), whole)
}
// --- Domain types for demonstration ---
#[derive(Debug, Clone, PartialEq)]
pub struct Address {
pub street: String,
pub city: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Person {
pub name: String,
pub addr: Address,
}
/// Lens focusing on the `addr` field of a `Person`.
pub fn addr_lens() -> Lens<Person, Address> {
Lens::new(
|p: &Person| &p.addr,
|a: Address, p: &Person| Person {
name: p.name.clone(), // clone name — we're building a new Person
addr: a,
},
)
}
/// Lens focusing on the `city` field of an `Address`.
pub fn city_lens() -> Lens<Address, String> {
Lens::new(
|a: &Address| &a.city,
|c: String, a: &Address| Address {
street: a.street.clone(), // clone street — we're building a new Address
city: c,
},
)
}
/// Lens focusing on the `street` field of an `Address`.
pub fn street_lens() -> Lens<Address, String> {
Lens::new(
|a: &Address| &a.street,
|s: String, a: &Address| Address {
street: s,
city: a.city.clone(), // clone city — we're building a new Address
},
)
}
/// Composed lens: Person -> city (via addr).
pub fn person_city_lens() -> Lens<Person, String> {
addr_lens().compose(city_lens())
}
/// Composed lens: Person -> street (via addr).
pub fn person_street_lens() -> Lens<Person, String> {
addr_lens().compose(street_lens())
}
pub fn sample_person() -> Person {
Person {
name: "Alice".to_string(),
addr: Address {
street: "Main St".to_string(),
city: "NYC".to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_get() {
let p = sample_person();
let lens = addr_lens();
assert_eq!(lens.get(&p).city, "NYC");
}
#[test]
fn test_simple_set() {
let p = sample_person();
let lens = city_lens();
let new_addr = lens.set("LA".to_string(), &p.addr);
assert_eq!(new_addr.city, "LA");
assert_eq!(new_addr.street, "Main St");
}
#[test]
fn test_composed_get() {
let p = sample_person();
let lens = person_city_lens();
assert_eq!(lens.get(&p), "NYC");
}
#[test]
fn test_composed_set() {
let p = sample_person();
let lens = person_city_lens();
let p2 = lens.set("Boston".to_string(), &p);
assert_eq!(p2.addr.city, "Boston");
assert_eq!(p2.name, "Alice");
assert_eq!(p2.addr.street, "Main St");
}
#[test]
fn test_over_transforms_focused_value() {
let p = sample_person();
let lens = person_city_lens();
let p2 = over(&lens, |c| c.to_lowercase(), &p);
assert_eq!(p2.addr.city, "nyc");
assert_eq!(p2.name, "Alice");
}
#[test]
fn test_original_unchanged_after_set() {
let p = sample_person();
let lens = person_city_lens();
let _p2 = lens.set("Boston".to_string(), &p);
// Original is unchanged — functional update
assert_eq!(p.addr.city, "NYC");
}
#[test]
fn test_set_get_roundtrip() {
let p = sample_person();
let lens = person_city_lens();
let p2 = lens.set("SF".to_string(), &p);
assert_eq!(lens.get(&p2), "SF");
}
#[test]
fn test_compose_street_lens() {
let p = sample_person();
let lens = person_street_lens();
assert_eq!(lens.get(&p), "Main St");
let p2 = lens.set("Oak Ave".to_string(), &p);
assert_eq!(p2.addr.street, "Oak Ave");
assert_eq!(p2.addr.city, "NYC");
}
#[test]
fn test_over_with_street() {
let p = sample_person();
let lens = person_street_lens();
let p2 = over(&lens, |s| format!("123 {s}"), &p);
assert_eq!(lens.get(&p2), "123 Main St");
}
#[test]
fn test_lens_laws_get_set() {
// Law: set (get s) s == s
let p = sample_person();
let lens = person_city_lens();
let city = lens.get(&p).clone();
let p2 = lens.set(city, &p);
assert_eq!(p, p2);
}
#[test]
fn test_lens_laws_set_get() {
// Law: get (set a s) == a
let p = sample_person();
let lens = person_city_lens();
let p2 = lens.set("Denver".to_string(), &p);
assert_eq!(lens.get(&p2), "Denver");
}
#[test]
fn test_lens_laws_set_set() {
// Law: set b (set a s) == set b s
let p = sample_person();
let lens = person_city_lens();
let p_ab = lens.set("B".to_string(), &lens.set("A".to_string(), &p));
let p_b = lens.set("B".to_string(), &p);
assert_eq!(p_ab, p_b);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_get() {
let p = sample_person();
let lens = addr_lens();
assert_eq!(lens.get(&p).city, "NYC");
}
#[test]
fn test_simple_set() {
let p = sample_person();
let lens = city_lens();
let new_addr = lens.set("LA".to_string(), &p.addr);
assert_eq!(new_addr.city, "LA");
assert_eq!(new_addr.street, "Main St");
}
#[test]
fn test_composed_get() {
let p = sample_person();
let lens = person_city_lens();
assert_eq!(lens.get(&p), "NYC");
}
#[test]
fn test_composed_set() {
let p = sample_person();
let lens = person_city_lens();
let p2 = lens.set("Boston".to_string(), &p);
assert_eq!(p2.addr.city, "Boston");
assert_eq!(p2.name, "Alice");
assert_eq!(p2.addr.street, "Main St");
}
#[test]
fn test_over_transforms_focused_value() {
let p = sample_person();
let lens = person_city_lens();
let p2 = over(&lens, |c| c.to_lowercase(), &p);
assert_eq!(p2.addr.city, "nyc");
assert_eq!(p2.name, "Alice");
}
#[test]
fn test_original_unchanged_after_set() {
let p = sample_person();
let lens = person_city_lens();
let _p2 = lens.set("Boston".to_string(), &p);
// Original is unchanged — functional update
assert_eq!(p.addr.city, "NYC");
}
#[test]
fn test_set_get_roundtrip() {
let p = sample_person();
let lens = person_city_lens();
let p2 = lens.set("SF".to_string(), &p);
assert_eq!(lens.get(&p2), "SF");
}
#[test]
fn test_compose_street_lens() {
let p = sample_person();
let lens = person_street_lens();
assert_eq!(lens.get(&p), "Main St");
let p2 = lens.set("Oak Ave".to_string(), &p);
assert_eq!(p2.addr.street, "Oak Ave");
assert_eq!(p2.addr.city, "NYC");
}
#[test]
fn test_over_with_street() {
let p = sample_person();
let lens = person_street_lens();
let p2 = over(&lens, |s| format!("123 {s}"), &p);
assert_eq!(lens.get(&p2), "123 Main St");
}
#[test]
fn test_lens_laws_get_set() {
// Law: set (get s) s == s
let p = sample_person();
let lens = person_city_lens();
let city = lens.get(&p).clone();
let p2 = lens.set(city, &p);
assert_eq!(p, p2);
}
#[test]
fn test_lens_laws_set_get() {
// Law: get (set a s) == a
let p = sample_person();
let lens = person_city_lens();
let p2 = lens.set("Denver".to_string(), &p);
assert_eq!(lens.get(&p2), "Denver");
}
#[test]
fn test_lens_laws_set_set() {
// Law: set b (set a s) == set b s
let p = sample_person();
let lens = person_city_lens();
let p_ab = lens.set("B".to_string(), &lens.set("A".to_string(), &p));
let p_b = lens.set("B".to_string(), &p);
assert_eq!(p_ab, p_b);
}
}
Deep Comparison
OCaml vs Rust: Lenses
Side-by-Side Code
OCaml
type ('s, 'a) lens = {
get: 's -> 'a;
set: 'a -> 's -> 's;
}
let compose outer inner = {
get = (fun s -> inner.get (outer.get s));
set = (fun a s -> outer.set (inner.set a (outer.get s)) s);
}
let over lens f s = lens.set (f (lens.get s)) s
let addr_lens = { get = (fun p -> p.addr); set = (fun a p -> { p with addr = a }) }
let city_lens = { get = (fun a -> a.city); set = (fun c a -> { a with city = c }) }
let person_city = compose addr_lens city_lens
Rust (idiomatic)
type Getter<S, A> = Box<dyn Fn(&S) -> &A>;
type Setter<S, A> = Box<dyn Fn(A, &S) -> S>;
struct Lens<S, A> {
get_fn: Getter<S, A>,
set_fn: Setter<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
fn new(get: impl Fn(&S) -> &A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
Lens { get_fn: Box::new(get), set_fn: Box::new(set) }
}
fn get<'s>(&self, whole: &'s S) -> &'s A { (self.get_fn)(whole) }
fn set(&self, value: A, whole: &S) -> S { (self.set_fn)(value, whole) }
}
fn over<S: 'static, A: Clone + 'static>(lens: &Lens<S, A>, f: impl FnOnce(A) -> A, whole: &S) -> S {
let current = lens.get(whole).clone();
lens.set(f(current), whole)
}
Rust (composed lens via Arc)
fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where S: Clone, A: Clone {
let inner = Arc::new(inner);
let outer_get = Arc::new(self.get_fn);
let outer_set = Arc::new(self.set_fn);
// ... share Arcs between get and set closures
Lens::new(
move |s| { /* get outer, then get inner */ },
move |b, s| { /* get outer, set inner, set outer */ },
)
}
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Lens type | ('s, 'a) lens | Lens<S, A> |
| Get | 's -> 'a | Fn(&S) -> &A |
| Set | 'a -> 's -> 's | Fn(A, &S) -> S |
| Compose | ('s, 'a) lens -> ('a, 'b) lens -> ('s, 'b) lens | Lens<S, A> -> Lens<A, B> -> Lens<S, B> |
| Over | ('s, 'a) lens -> ('a -> 'a) -> 's -> 's | &Lens<S, A>, FnOnce(A) -> A, &S -> S |
| Record update | { p with field = v } | Manual struct construction + Clone |
Key Insights
'static bounds for boxed trait objects. This is the fundamental cost of no GC — you must prove closure lifetimes to the compiler.compose creates two new closures that capture the inner lens. In Rust, the inner lens must be wrapped in Arc because both the composed getter and setter need access to it, and closures can't share ownership without reference counting.get returns a value (copied by the GC). Rust's get returns a &A reference into the original structure, avoiding allocation. This is more efficient but makes composition harder — the composed getter must thread lifetimes through two levels of indirection.{ p with addr = a } is syntactic sugar that cheaply creates a new record. Rust has no equivalent — you must manually construct a new struct and Clone every unchanged field. This makes the Clone bound mandatory on the set path.When to Use Each Style
Use idiomatic Rust lenses when: you have deeply nested structures that need frequent functional updates, especially in state management for UI frameworks or game engines where immutability prevents bugs.
Use OCaml-style lenses when: you're writing OCaml or want to understand the theoretical foundation. OCaml's GC and structural equality make lenses almost zero-cost to define and compose, which is why they're more common in ML-family languages.
Exercises
modify combinator for lenses that takes a lens and a function f: A -> A and returns a new value with the focused field updated in-place.traversal — a lens-like abstraction that focuses on multiple elements simultaneously (e.g., all items in a Vec) — and use it to uppercase all strings in a nested data structure.