Lens Composition — Zoom Into Nested Structs
Tutorial Video
Text description (accessibility)
This video demonstrates the "Lens Composition — Zoom Into Nested Structs" functional Rust example. Difficulty level: Advanced. Key concepts covered: Functional Patterns, Optics, Immutable Updates. Compose two `Lens` values into one so that reading or updating a deeply nested field requires a single `get` or `set` call rather than manually threading updates through every level. Key difference from OCaml: 1. **Function storage**: OCaml records hold functions as ordinary values; Rust needs heap
Tutorial
The Problem
Compose two Lens values into one so that reading or updating a deeply nested field requires a single get or set call rather than manually threading updates through every level.
🎯 Learning Outcomes
Box<dyn Fn(...)>Rc enables two owned closures to share a single function pointer without copying('s, 'a) lens to Rust's struct-of-boxed-closuresClone is required on the intermediate type A in a composed lens🦀 The Rust Way
Rust stores each lens as a struct with two Box<dyn Fn(...)> fields. Composition takes ownership of both lenses, wraps outer_get in an Rc so the new get and set closures can both call it, and returns a fresh Lens<S, B>. Type aliases GetFn<S, A> and SetFn<S, A> tame clippy's type_complexity lint on the raw Box<dyn Fn> fields.
Code Example
type GetFn<S, A> = Box<dyn Fn(&S) -> A>;
type SetFn<S, A> = Box<dyn Fn(A, &S) -> S>;
pub struct Lens<S, A> {
pub get: GetFn<S, A>,
pub set: SetFn<S, A>,
}
impl<S: 'static, A: Clone + 'static> Lens<S, A> {
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B> {
use std::rc::Rc;
let outer_get = Rc::new(self.get);
let outer_get2 = Rc::clone(&outer_get);
let outer_set = self.set;
let inner_get = inner.get;
let inner_set = inner.set;
Lens {
get: Box::new(move |s| inner_get(&outer_get(s))),
set: Box::new(move |b, s| {
let a = outer_get2(s);
outer_set(inner_set(b, &a), s)
}),
}
}
}Key Differences
Box<dyn Fn> to store closures of different concrete types in the same struct field.Rc<Box<dyn Fn>> when two closures must both own the same captured function.{ p with address = a } (OCaml) vs Person { address: a, ..p.clone() } (Rust) — both produce a new value; Rust needs Clone for struct update syntax.(A ∘ B) ∘ C == A ∘ (B ∘ C). The test suite verifies this property explicitly.OCaml Approach
OCaml represents a lens as a record { get; set } where both fields hold polymorphic functions. compose builds a new record whose get chains the two getters left-to-right and whose set reverses the update right-to-left — the classic van Laarhoven chain. An infix operator |>> makes multi-level composition read naturally.
Full Source
#![allow(clippy::all)]
/// Type alias for a boxed getter function.
type GetFn<S, A> = Box<dyn Fn(&S) -> A>;
/// Type alias for a boxed setter function.
type SetFn<S, A> = Box<dyn Fn(A, &S) -> S>;
/// A Lens<S, A> focuses on a field of type A inside a structure S.
/// `get` extracts the field; `set` returns a new S with the field replaced.
pub struct Lens<S, A> {
pub get: GetFn<S, A>,
pub set: SetFn<S, A>,
}
impl<S: 'static, A: 'static> Lens<S, A> {
pub fn new(get: impl Fn(&S) -> A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
Lens {
get: Box::new(get),
set: Box::new(set),
}
}
/// Compose two lenses: `self` focuses S→A, `inner` focuses A→B.
/// Result is a single Lens<S, B> that traverses both levels at once.
///
/// get: s -> inner.get(self.get(s))
/// set: (b, s) -> self.set(inner.set(b, self.get(s)), s)
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B>
where
A: Clone,
{
// Pull the boxed closures out so they can be moved into new closures.
let outer_get = self.get;
let outer_set = self.set;
let inner_get = inner.get;
let inner_set = inner.set;
// outer_get is needed by both the new `get` and `set` closures.
// Wrap in Rc so both closures can share the same allocation without copying.
use std::rc::Rc;
let outer_get = Rc::new(outer_get);
let outer_get2 = Rc::clone(&outer_get);
Lens {
get: Box::new(move |s| inner_get(&outer_get(s))),
set: Box::new(move |b, s| {
let a: A = outer_get2(s);
let a2 = inner_set(b, &a);
outer_set(a2, s)
}),
}
}
}
// ---------------------------------------------------------------------------
// Domain types for the running example
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub struct Street {
pub number: u32,
pub name: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Address {
pub street: Street,
pub city: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Person {
pub pname: String,
pub address: Address,
}
// ---------------------------------------------------------------------------
// Individual lenses — explicit closure types so the compiler can infer S/A
// ---------------------------------------------------------------------------
/// Lens: Person → Address
pub fn person_address_lens() -> Lens<Person, Address> {
Lens::new(
|p: &Person| p.address.clone(),
|a: Address, p: &Person| Person {
address: a,
..p.clone()
},
)
}
/// Lens: Address → Street
pub fn address_street_lens() -> Lens<Address, Street> {
Lens::new(
|a: &Address| a.street.clone(),
|s: Street, a: &Address| Address {
street: s,
..a.clone()
},
)
}
/// Lens: Street → number
pub fn street_number_lens() -> Lens<Street, u32> {
Lens::new(
|s: &Street| s.number,
|n: u32, s: &Street| Street {
number: n,
..s.clone()
},
)
}
/// Lens: Street → name
pub fn street_name_lens() -> Lens<Street, String> {
Lens::new(
|s: &Street| s.name.clone(),
|name: String, s: &Street| Street { name, ..s.clone() },
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_person() -> Person {
Person {
pname: "Alice".into(),
address: Address {
city: "Wonderland".into(),
street: Street {
number: 42,
name: "Main St".into(),
},
},
}
}
#[test]
fn test_compose_two_lenses_get() {
// person_address |> address_street gives Person → Street
let person_street = person_address_lens().compose(address_street_lens());
let alice = sample_person();
let street = (person_street.get)(&alice);
assert_eq!(street.number, 42);
assert_eq!(street.name, "Main St");
}
#[test]
fn test_compose_two_lenses_set() {
let person_street = person_address_lens().compose(address_street_lens());
let alice = sample_person();
let new_street = Street {
number: 99,
name: "Oak Ave".into(),
};
let updated = (person_street.set)(new_street, &alice);
assert_eq!(updated.address.street.number, 99);
assert_eq!(updated.address.street.name, "Oak Ave");
// Other fields unchanged
assert_eq!(updated.pname, "Alice");
assert_eq!(updated.address.city, "Wonderland");
}
#[test]
fn test_compose_three_lenses_get() {
// person → address → street → number (three levels deep)
let person_number = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
let alice = sample_person();
assert_eq!((person_number.get)(&alice), 42);
}
#[test]
fn test_compose_three_lenses_set() {
let person_number = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
let alice = sample_person();
let updated = (person_number.set)(7, &alice);
assert_eq!(updated.address.street.number, 7);
// Untouched fields survive
assert_eq!(updated.address.street.name, "Main St");
assert_eq!(updated.address.city, "Wonderland");
assert_eq!(updated.pname, "Alice");
}
#[test]
fn test_individual_lens_person_address() {
let lens = person_address_lens();
let alice = sample_person();
let addr = (lens.get)(&alice);
assert_eq!(addr.city, "Wonderland");
let new_addr = Address {
city: "Oz".into(),
street: addr.street.clone(),
};
let updated = (lens.set)(new_addr, &alice);
assert_eq!(updated.address.city, "Oz");
assert_eq!(updated.pname, "Alice");
}
#[test]
fn test_individual_lens_street_number() {
let lens = street_number_lens();
let s = Street {
number: 10,
name: "Elm".into(),
};
assert_eq!((lens.get)(&s), 10);
let s2 = (lens.set)(20, &s);
assert_eq!(s2.number, 20);
assert_eq!(s2.name, "Elm");
}
#[test]
fn test_composition_is_associative() {
// (person_address |> address_street) |> street_number
// should equal
// person_address |> (address_street |> street_number)
// We verify both give the same get/set results on the same data.
let alice = sample_person();
let left = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
let right =
person_address_lens().compose(address_street_lens().compose(street_number_lens()));
assert_eq!((left.get)(&alice), (right.get)(&alice));
let l_updated = (left.set)(100, &alice);
let r_updated = (right.set)(100, &alice);
assert_eq!(l_updated, r_updated);
}
}#[cfg(test)]
mod tests {
use super::*;
fn sample_person() -> Person {
Person {
pname: "Alice".into(),
address: Address {
city: "Wonderland".into(),
street: Street {
number: 42,
name: "Main St".into(),
},
},
}
}
#[test]
fn test_compose_two_lenses_get() {
// person_address |> address_street gives Person → Street
let person_street = person_address_lens().compose(address_street_lens());
let alice = sample_person();
let street = (person_street.get)(&alice);
assert_eq!(street.number, 42);
assert_eq!(street.name, "Main St");
}
#[test]
fn test_compose_two_lenses_set() {
let person_street = person_address_lens().compose(address_street_lens());
let alice = sample_person();
let new_street = Street {
number: 99,
name: "Oak Ave".into(),
};
let updated = (person_street.set)(new_street, &alice);
assert_eq!(updated.address.street.number, 99);
assert_eq!(updated.address.street.name, "Oak Ave");
// Other fields unchanged
assert_eq!(updated.pname, "Alice");
assert_eq!(updated.address.city, "Wonderland");
}
#[test]
fn test_compose_three_lenses_get() {
// person → address → street → number (three levels deep)
let person_number = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
let alice = sample_person();
assert_eq!((person_number.get)(&alice), 42);
}
#[test]
fn test_compose_three_lenses_set() {
let person_number = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
let alice = sample_person();
let updated = (person_number.set)(7, &alice);
assert_eq!(updated.address.street.number, 7);
// Untouched fields survive
assert_eq!(updated.address.street.name, "Main St");
assert_eq!(updated.address.city, "Wonderland");
assert_eq!(updated.pname, "Alice");
}
#[test]
fn test_individual_lens_person_address() {
let lens = person_address_lens();
let alice = sample_person();
let addr = (lens.get)(&alice);
assert_eq!(addr.city, "Wonderland");
let new_addr = Address {
city: "Oz".into(),
street: addr.street.clone(),
};
let updated = (lens.set)(new_addr, &alice);
assert_eq!(updated.address.city, "Oz");
assert_eq!(updated.pname, "Alice");
}
#[test]
fn test_individual_lens_street_number() {
let lens = street_number_lens();
let s = Street {
number: 10,
name: "Elm".into(),
};
assert_eq!((lens.get)(&s), 10);
let s2 = (lens.set)(20, &s);
assert_eq!(s2.number, 20);
assert_eq!(s2.name, "Elm");
}
#[test]
fn test_composition_is_associative() {
// (person_address |> address_street) |> street_number
// should equal
// person_address |> (address_street |> street_number)
// We verify both give the same get/set results on the same data.
let alice = sample_person();
let left = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
let right =
person_address_lens().compose(address_street_lens().compose(street_number_lens()));
assert_eq!((left.get)(&alice), (right.get)(&alice));
let l_updated = (left.set)(100, &alice);
let r_updated = (right.set)(100, &alice);
assert_eq!(l_updated, r_updated);
}
}
Deep Comparison
OCaml vs Rust: Lens Composition — Zoom Into Nested Structs
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 b s ->
let a = outer.get s in
outer.set (inner.set b a) s);
}
let ( |>> ) = compose
Rust (idiomatic — boxed closures)
type GetFn<S, A> = Box<dyn Fn(&S) -> A>;
type SetFn<S, A> = Box<dyn Fn(A, &S) -> S>;
pub struct Lens<S, A> {
pub get: GetFn<S, A>,
pub set: SetFn<S, A>,
}
impl<S: 'static, A: Clone + 'static> Lens<S, A> {
pub fn compose<B: 'static>(self, inner: Lens<A, B>) -> Lens<S, B> {
use std::rc::Rc;
let outer_get = Rc::new(self.get);
let outer_get2 = Rc::clone(&outer_get);
let outer_set = self.set;
let inner_get = inner.get;
let inner_set = inner.set;
Lens {
get: Box::new(move |s| inner_get(&outer_get(s))),
set: Box::new(move |b, s| {
let a = outer_get2(s);
outer_set(inner_set(b, &a), s)
}),
}
}
}
Rust (functional / recursive usage — three-level chain)
// Three lenses snapped together into one:
let person_street_number = person_address_lens()
.compose(address_street_lens())
.compose(street_number_lens());
// Read three levels deep in one call:
let n = (person_street_number.get)(&alice);
// Update three levels deep in one call:
let updated = (person_street_number.set)(99, &alice);
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Lens type | ('s, 'a) lens | Lens<S, A> |
| Get function | get : 's -> 'a | get: Box<dyn Fn(&S) -> A> |
| Set function | set : 'a -> 's -> 's | set: Box<dyn Fn(A, &S) -> S> |
| Compose result | ('s, 'b) lens | Lens<S, B> |
| Ownership of S | Immutable value | Immutable reference &S |
Key Insights
{ get; set } record maps directly to a Rust struct — but OCaml functions are first-class values while Rust closures require Box<dyn Fn(...)> to be stored in a struct field.get across two closures**: Both the composed get and set need to call outer_get. In OCaml this is free (closures share the environment by reference); in Rust we need Rc to give two owning closures a shared handle to the same boxed function.{ p with address = a } and Rust's Person { address: a, ..p.clone() } are both functional update — neither mutates in place. Rust requires Clone because ..p in a struct literal moves or copies each field.(A |>> B) |>> C equals A |>> (B |>> C) in both languages. This algebraic property means you can chain any number of lenses in any grouping and always get the same result — the test test_composition_is_associative verifies this explicitly.Box<dyn Fn(...)> performs type erasure via a vtable, paying a small indirection cost but allowing lenses of different concrete types to be stored uniformly.When to Use Each Style
Use idiomatic Rust (boxed closures) when: you want dynamically constructed lenses at runtime, or you need to store lenses of varying concrete types in a collection. Use recursive Rust (trait-based lenses) when: you have a performance-critical hot path — a trait-object-free approach avoids heap allocation and virtual dispatch at the cost of more complex generic bounds.
Exercises
over — a function that lifts a regular function A -> A into a lens update, and use it to increment a deeply nested numeric field.iso (isomorphism): a pair of functions to: A -> B and from: B -> A, compose it with a lens to transform the viewed type, and use it to treat a String field as Vec<char> through the lens.