ExamplesBy LevelBy TopicLearning Paths
204 Advanced

Lens Composition — Zoom Into Nested Structs

Functional PatternsOpticsImmutable Updates

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

  • • How to store and call closures in Rust struct fields using Box<dyn Fn(...)>
  • • How Rc enables two owned closures to share a single function pointer without copying
  • • The direct mapping from OCaml's record-of-functions ('s, 'a) lens to Rust's struct-of-boxed-closures
  • • Why Clone 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

  • Function storage: OCaml records hold functions as ordinary values; Rust needs heap-allocated Box<dyn Fn> to store closures of different concrete types in the same struct field.
  • Shared ownership: OCaml closures share captured values for free; Rust requires Rc<Box<dyn Fn>> when two closures must both own the same captured function.
  • Immutable update: { p with address = a } (OCaml) vs Person { address: a, ..p.clone() } (Rust) — both produce a new value; Rust needs Clone for struct update syntax.
  • Associativity: Composition is associative in both languages: (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);
        }
    }
    ✓ Tests Rust test suite
    #[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

    ConceptOCamlRust
    Lens type('s, 'a) lensLens<S, A>
    Get functionget : 's -> 'aget: Box<dyn Fn(&S) -> A>
    Set functionset : 'a -> 's -> 'sset: Box<dyn Fn(A, &S) -> S>
    Compose result('s, 'b) lensLens<S, B>
    Ownership of SImmutable valueImmutable reference &S

    Key Insights

  • Records vs structs with boxed closures: OCaml's { 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.
  • **Sharing the outer 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.
  • Immutable update syntax: OCaml's { 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.
  • Composition is associative: (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.
  • Type erasure cost: OCaml infers the most general polymorphic type for a lens at zero runtime cost. Rust's 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

  • Define a lens for every leaf field in a three-level nested struct and compose them to demonstrate that any deep field can be read and updated via a single composed lens.
  • Implement over — a function that lifts a regular function A -> A into a lens update, and use it to increment a deeply nested numeric field.
  • Implement an 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.
  • Open Source Repos