ExamplesBy LevelBy TopicLearning Paths
214 Advanced

Fold Optic — Read-Only Multi-Focus Aggregation

Functional Programming

Tutorial

The Problem

A Fold is a read-only optic that focuses on multiple values and aggregates them. Unlike a Traversal (which can modify), a Fold only reads — providing sum, count, max, any, all operations derived from a single to_list primitive. Folds are useful when you need to aggregate across deeply nested or graph-structured data without modifying it. They compose like traversals but with only read capabilities.

🎯 Learning Outcomes

  • • Understand Folds as the read-only counterpart of Traversals
  • • Derive sum, count, max, min, any, all from the to_list primitive
  • • See how Folds compose via flat-map: focusing into sub-structures
  • • Understand Folds in the context of functional reactive programming (FRP) and data pipelines
  • Code Example

    pub struct Fold<S, A> {
        to_list: Box<dyn Fn(&S) -> Vec<A>>,
    }
    
    impl<S: 'static> Fold<S, i32> {
        pub fn sum_of(&self, s: &S) -> i32 {
            self.collect(s).into_iter().sum()
        }
        pub fn any_of(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.collect(s).iter().any(pred)
        }
    }
    
    // Composition: flat-map the inner fold over the outer fold's results
    pub fn compose<B: 'static>(self, other: Fold<A, B>) -> Fold<S, B> {
        Fold::new(move |s| {
            self.collect(s).iter().flat_map(|a| other.collect(a)).collect()
        })
    }
    
    let team_scores = team_members_fold().compose(member_scores_fold());

    Key Differences

  • Read-only: Folds have no over or set — they are strictly for aggregation; traversals are a superset that adds write capability.
  • Composition via flat-map: Fold composition uses flat-map (concat_map); lens composition uses function composition — different mechanisms reflecting the different structure.
  • Lazy evaluation: A lazy Fold can focus on infinite structures without materializing all elements; to_list makes them strict; both languages can implement lazy folds.
  • FRP connection: Reactive streams and observables are essentially infinite folds — focusing on events over time.
  • OCaml Approach

    OCaml's fold optic mirrors the Haskell Fold typeclass. A fold is characterized by:

    type ('s, 'a) fold = { to_list : 's -> 'a list }
    let sum_of fold s = List.fold_left (+) 0 (fold.to_list s)
    let compose f1 f2 = { to_list = fun s -> List.concat_map f2.to_list (f1.to_list s) }
    

    List.concat_map is the flat-map that composes folds — focusing through all levels.

    Full Source

    #![allow(clippy::all)]
    // Example 214: Fold Optic — Read-Only Multi-Focus Aggregation
    //
    // A Fold is a read-only optic that focuses on multiple values and aggregates them
    // without modifying the structure. Unlike a Traversal (which can also write),
    // a Fold only supports reading — which simplifies its implementation considerably.
    //
    // The core intuition: a Fold is "give me all the values you focus on as a list,
    // and I'll provide all aggregation operations (sum, count, max, any, all) for free."
    //
    // Composition works via flat-map: if a Fold<Team, Member> gives you Members, and a
    // Fold<Member, i32> gives you scores, composing them gives Fold<Team, i32> — all
    // member scores across the whole team with one composed optic.
    
    // ---------------------------------------------------------------------------
    // Approach 1: Struct-based Fold (mirrors OCaml record directly)
    // ---------------------------------------------------------------------------
    
    type ToListFn<S, A> = Box<dyn Fn(&S) -> Vec<A>>;
    
    /// A read-only optic that focuses on zero or more values of type `A` inside `S`.
    ///
    /// OCaml equivalent:
    /// ```ocaml
    /// type ('s, 'a) fold_simple = { to_list : 's -> 'a list }
    /// ```
    pub struct Fold<S, A> {
        to_list: ToListFn<S, A>,
    }
    
    impl<S: 'static, A: 'static> Fold<S, A> {
        /// Build a Fold from a function that extracts all focused values.
        pub fn new(to_list: impl Fn(&S) -> Vec<A> + 'static) -> Self {
            Fold {
                to_list: Box::new(to_list),
            }
        }
    
        /// Collect all focused values into a `Vec`.
        ///
        /// This is the primitive from which all other combinators are derived.
        pub fn collect(&self, s: &S) -> Vec<A> {
            (self.to_list)(s)
        }
    
        /// Count how many values are focused.
        pub fn length_of(&self, s: &S) -> usize {
            self.collect(s).len()
        }
    
        /// `true` iff at least one focused value satisfies `pred`.
        pub fn any_of(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.collect(s).iter().any(pred)
        }
    
        /// `true` iff every focused value satisfies `pred`.
        pub fn all_of(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.collect(s).iter().all(pred)
        }
    
        /// Find the first focused value satisfying `pred`, if any.
        pub fn find_of(&self, s: &S, pred: impl Fn(&A) -> bool) -> Option<A>
        where
            A: Clone,
        {
            self.collect(s).into_iter().find(pred)
        }
    
        /// Return the first focused value, if any.
        pub fn first_of(&self, s: &S) -> Option<A>
        where
            A: Clone,
        {
            self.collect(s).into_iter().next()
        }
    
        /// Return the last focused value, if any.
        pub fn last_of(&self, s: &S) -> Option<A>
        where
            A: Clone,
        {
            self.collect(s).into_iter().last()
        }
    
        /// Compose this Fold with another: Fold<S, A> + Fold<A, B> → Fold<S, B>.
        ///
        /// Composition is flat-map: for each `A` the outer fold finds, apply the
        /// inner fold to get `Vec<B>`, then concatenate all results.
        ///
        /// OCaml equivalent:
        /// ```ocaml
        /// let compose outer inner = { to_list = fun s ->
        ///   List.concat_map inner.to_list (outer.to_list s) }
        /// ```
        pub fn compose<B: 'static>(self, other: Fold<A, B>) -> Fold<S, B> {
            Fold::new(move |s: &S| {
                self.collect(s)
                    .iter()
                    .flat_map(|a| other.collect(a))
                    .collect()
            })
        }
    }
    
    impl<S: 'static> Fold<S, i32> {
        /// Sum all focused `i32` values.
        pub fn sum_of(&self, s: &S) -> i32 {
            self.collect(s).into_iter().sum()
        }
    
        /// Product of all focused `i32` values.
        pub fn product_of(&self, s: &S) -> i32 {
            self.collect(s).into_iter().product()
        }
    
        /// Maximum of all focused `i32` values, or `None` if no values are focused.
        pub fn max_of(&self, s: &S) -> Option<i32> {
            self.collect(s).into_iter().max()
        }
    
        /// Minimum of all focused `i32` values, or `None` if no values are focused.
        pub fn min_of(&self, s: &S) -> Option<i32> {
            self.collect(s).into_iter().min()
        }
    }
    
    impl<S: 'static> Fold<S, f64> {
        /// Sum all focused `f64` values.
        pub fn sum_of_float(&self, s: &S) -> f64 {
            self.collect(s).into_iter().sum()
        }
    
        /// Maximum of all focused `f64` values, or `None` if no values are focused.
        pub fn max_of_float(&self, s: &S) -> Option<f64> {
            self.collect(s).into_iter().reduce(f64::max)
        }
    }
    
    // ---------------------------------------------------------------------------
    // Approach 2: Trait-based Fold (zero-cost compile-time dispatch)
    // ---------------------------------------------------------------------------
    //
    // The trait exposes `fold_collect`, and the blanket impl on slices/iterators
    // provides `sum_all`, `any_all`, etc. for free.
    
    /// A read-only optic trait: any type implementing this can extract a `Vec<A>`
    /// from an `S`. Generic aggregation functions work uniformly for any impl.
    pub trait FoldOptic<S, A> {
        /// The core focusing operation: extract all values of type `A` from `s`.
        fn fold_collect(&self, s: &S) -> Vec<A>;
    
        fn fold_length(&self, s: &S) -> usize {
            self.fold_collect(s).len()
        }
    
        fn fold_any(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.fold_collect(s).iter().any(pred)
        }
    
        fn fold_all(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.fold_collect(s).iter().all(pred)
        }
    }
    
    // ---------------------------------------------------------------------------
    // Domain model: Team → Members → Scores
    // ---------------------------------------------------------------------------
    
    /// A team member with a name and performance scores.
    #[derive(Debug, Clone, PartialEq)]
    pub struct Member {
        pub name: String,
        pub scores: Vec<i32>,
    }
    
    /// A team composed of members.
    #[derive(Debug, Clone, PartialEq)]
    pub struct Team {
        pub name: String,
        pub members: Vec<Member>,
    }
    
    /// Fold<Team, Member>: focuses on all members of a team.
    pub fn team_members_fold() -> Fold<Team, Member> {
        Fold::new(|team: &Team| team.members.clone())
    }
    
    /// Fold<Member, i32>: focuses on all scores of a member.
    pub fn member_scores_fold() -> Fold<Member, i32> {
        Fold::new(|m: &Member| m.scores.clone())
    }
    
    /// Fold<Team, i32>: focuses on every score of every member in a team.
    ///
    /// Built by composing `team_members_fold` with `member_scores_fold`.
    /// This is the key power of Fold composition: one optic, all aggregations.
    pub fn team_scores_fold() -> Fold<Team, i32> {
        team_members_fold().compose(member_scores_fold())
    }
    
    // ---------------------------------------------------------------------------
    // Approach 2: Concrete trait implementations
    // ---------------------------------------------------------------------------
    
    /// Marker type for focusing on all members of a Team (zero-cost, no heap).
    pub struct TeamMembersFold;
    
    impl FoldOptic<Team, Member> for TeamMembersFold {
        fn fold_collect(&self, s: &Team) -> Vec<Member> {
            s.members.clone()
        }
    }
    
    /// Marker type for focusing on all scores of a Member.
    pub struct MemberScoresFold;
    
    impl FoldOptic<Member, i32> for MemberScoresFold {
        fn fold_collect(&self, s: &Member) -> Vec<i32> {
            s.scores.clone()
        }
    }
    
    // ---------------------------------------------------------------------------
    // Generic aggregation helpers
    // ---------------------------------------------------------------------------
    
    /// Sum all `i32` values from any FoldOptic.
    pub fn sum_via_fold<S>(fold: &impl FoldOptic<S, i32>, s: &S) -> i32 {
        fold.fold_collect(s).into_iter().sum()
    }
    
    /// Check if all focused values satisfy `pred`.
    pub fn all_via_fold<S, A>(fold: &impl FoldOptic<S, A>, s: &S, pred: impl Fn(&A) -> bool) -> bool {
        fold.fold_collect(s).iter().all(pred)
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn sample_team() -> Team {
            Team {
                name: "Alpha".to_string(),
                members: vec![
                    Member {
                        name: "Alice".to_string(),
                        scores: vec![10, 20, 30],
                    },
                    Member {
                        name: "Bob".to_string(),
                        scores: vec![5, 15],
                    },
                    Member {
                        name: "Carol".to_string(),
                        scores: vec![100],
                    },
                ],
            }
        }
    
        // ── Approach 1: struct-based Fold ──────────────────────────────────────
    
        #[test]
        fn test_fold_collect_members() {
            let fold = team_members_fold();
            let team = sample_team();
            let members = fold.collect(&team);
            assert_eq!(members.len(), 3);
            assert_eq!(members[0].name, "Alice");
            assert_eq!(members[2].name, "Carol");
        }
    
        #[test]
        fn test_fold_length_of_counts_members() {
            let fold = team_members_fold();
            let team = sample_team();
            assert_eq!(fold.length_of(&team), 3);
        }
    
        #[test]
        fn test_fold_sum_of_member_scores() {
            let fold = member_scores_fold();
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert_eq!(fold.sum_of(&alice), 60);
        }
    
        #[test]
        fn test_fold_sum_empty_is_zero() {
            let fold = member_scores_fold();
            let empty = Member {
                name: "Empty".to_string(),
                scores: vec![],
            };
            assert_eq!(fold.sum_of(&empty), 0);
        }
    
        #[test]
        fn test_fold_max_of_scores() {
            let fold = member_scores_fold();
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert_eq!(fold.max_of(&alice), Some(30));
        }
    
        #[test]
        fn test_fold_max_empty_is_none() {
            let fold = member_scores_fold();
            let empty = Member {
                name: "Empty".to_string(),
                scores: vec![],
            };
            assert_eq!(fold.max_of(&empty), None);
        }
    
        #[test]
        fn test_fold_any_of_member_names() {
            let fold = team_members_fold();
            let team = sample_team();
            assert!(fold.any_of(&team, |m| m.name == "Bob"));
            assert!(!fold.any_of(&team, |m| m.name == "Dave"));
        }
    
        #[test]
        fn test_fold_all_of_members_have_scores() {
            let fold = team_members_fold();
            let team = sample_team();
            assert!(fold.all_of(&team, |m| !m.scores.is_empty()));
        }
    
        #[test]
        fn test_fold_find_of_returns_first_match() {
            let fold = team_members_fold();
            let team = sample_team();
            let found = fold.find_of(&team, |m| m.scores.len() == 1);
            assert_eq!(found.map(|m| m.name), Some("Carol".to_string()));
        }
    
        #[test]
        fn test_fold_find_of_returns_none_when_no_match() {
            let fold = team_members_fold();
            let team = sample_team();
            let found = fold.find_of(&team, |m| m.name == "Zara");
            assert!(found.is_none());
        }
    
        #[test]
        fn test_fold_first_of_and_last_of() {
            let fold = team_members_fold();
            let team = sample_team();
            assert_eq!(
                fold.first_of(&team).map(|m| m.name),
                Some("Alice".to_string())
            );
            assert_eq!(
                fold.last_of(&team).map(|m| m.name),
                Some("Carol".to_string())
            );
        }
    
        #[test]
        fn test_fold_product_of_scores() {
            let fold = member_scores_fold();
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![2, 3, 5],
            };
            assert_eq!(fold.product_of(&alice), 30);
        }
    
        // ── Fold composition: Team → Member → i32 ─────────────────────────────
    
        #[test]
        fn test_composed_fold_sum_all_team_scores() {
            // Alice: 10+20+30=60, Bob: 5+15=20, Carol: 100 → total 180
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.sum_of(&team), 180);
        }
    
        #[test]
        fn test_composed_fold_max_across_all_team_scores() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.max_of(&team), Some(100));
        }
    
        #[test]
        fn test_composed_fold_length_counts_all_scores() {
            // Alice has 3, Bob has 2, Carol has 1 → total 6
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.length_of(&team), 6);
        }
    
        #[test]
        fn test_composed_fold_any_score_above_threshold() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert!(fold.any_of(&team, |s| *s > 50));
            assert!(!fold.any_of(&team, |s| *s > 200));
        }
    
        #[test]
        fn test_composed_fold_all_scores_positive() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert!(fold.all_of(&team, |s| *s > 0));
        }
    
        #[test]
        fn test_composed_fold_min_across_all_scores() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.min_of(&team), Some(5));
        }
    
        #[test]
        fn test_composed_fold_empty_team() {
            let fold = team_scores_fold();
            let team = Team {
                name: "Empty".to_string(),
                members: vec![],
            };
            assert_eq!(fold.sum_of(&team), 0);
            assert_eq!(fold.length_of(&team), 0);
            assert_eq!(fold.max_of(&team), None);
        }
    
        // ── Approach 2: trait-based Fold ──────────────────────────────────────
    
        #[test]
        fn test_trait_fold_collect_members() {
            let fold = TeamMembersFold;
            let team = sample_team();
            assert_eq!(fold.fold_collect(&team).len(), 3);
        }
    
        #[test]
        fn test_trait_fold_length() {
            let fold = TeamMembersFold;
            let team = sample_team();
            assert_eq!(fold.fold_length(&team), 3);
        }
    
        #[test]
        fn test_trait_fold_any_and_all() {
            let fold = TeamMembersFold;
            let team = sample_team();
            assert!(fold.fold_any(&team, |m| m.name == "Bob"));
            assert!(fold.fold_all(&team, |m| !m.scores.is_empty()));
        }
    
        #[test]
        fn test_trait_member_scores_fold() {
            let fold = MemberScoresFold;
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert_eq!(fold.fold_collect(&alice), vec![10, 20, 30]);
            assert_eq!(sum_via_fold(&fold, &alice), 60);
        }
    
        #[test]
        fn test_generic_all_via_fold() {
            let fold = MemberScoresFold;
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert!(all_via_fold(&fold, &alice, |s| *s > 0));
            assert!(!all_via_fold(&fold, &alice, |s| *s > 15));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn sample_team() -> Team {
            Team {
                name: "Alpha".to_string(),
                members: vec![
                    Member {
                        name: "Alice".to_string(),
                        scores: vec![10, 20, 30],
                    },
                    Member {
                        name: "Bob".to_string(),
                        scores: vec![5, 15],
                    },
                    Member {
                        name: "Carol".to_string(),
                        scores: vec![100],
                    },
                ],
            }
        }
    
        // ── Approach 1: struct-based Fold ──────────────────────────────────────
    
        #[test]
        fn test_fold_collect_members() {
            let fold = team_members_fold();
            let team = sample_team();
            let members = fold.collect(&team);
            assert_eq!(members.len(), 3);
            assert_eq!(members[0].name, "Alice");
            assert_eq!(members[2].name, "Carol");
        }
    
        #[test]
        fn test_fold_length_of_counts_members() {
            let fold = team_members_fold();
            let team = sample_team();
            assert_eq!(fold.length_of(&team), 3);
        }
    
        #[test]
        fn test_fold_sum_of_member_scores() {
            let fold = member_scores_fold();
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert_eq!(fold.sum_of(&alice), 60);
        }
    
        #[test]
        fn test_fold_sum_empty_is_zero() {
            let fold = member_scores_fold();
            let empty = Member {
                name: "Empty".to_string(),
                scores: vec![],
            };
            assert_eq!(fold.sum_of(&empty), 0);
        }
    
        #[test]
        fn test_fold_max_of_scores() {
            let fold = member_scores_fold();
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert_eq!(fold.max_of(&alice), Some(30));
        }
    
        #[test]
        fn test_fold_max_empty_is_none() {
            let fold = member_scores_fold();
            let empty = Member {
                name: "Empty".to_string(),
                scores: vec![],
            };
            assert_eq!(fold.max_of(&empty), None);
        }
    
        #[test]
        fn test_fold_any_of_member_names() {
            let fold = team_members_fold();
            let team = sample_team();
            assert!(fold.any_of(&team, |m| m.name == "Bob"));
            assert!(!fold.any_of(&team, |m| m.name == "Dave"));
        }
    
        #[test]
        fn test_fold_all_of_members_have_scores() {
            let fold = team_members_fold();
            let team = sample_team();
            assert!(fold.all_of(&team, |m| !m.scores.is_empty()));
        }
    
        #[test]
        fn test_fold_find_of_returns_first_match() {
            let fold = team_members_fold();
            let team = sample_team();
            let found = fold.find_of(&team, |m| m.scores.len() == 1);
            assert_eq!(found.map(|m| m.name), Some("Carol".to_string()));
        }
    
        #[test]
        fn test_fold_find_of_returns_none_when_no_match() {
            let fold = team_members_fold();
            let team = sample_team();
            let found = fold.find_of(&team, |m| m.name == "Zara");
            assert!(found.is_none());
        }
    
        #[test]
        fn test_fold_first_of_and_last_of() {
            let fold = team_members_fold();
            let team = sample_team();
            assert_eq!(
                fold.first_of(&team).map(|m| m.name),
                Some("Alice".to_string())
            );
            assert_eq!(
                fold.last_of(&team).map(|m| m.name),
                Some("Carol".to_string())
            );
        }
    
        #[test]
        fn test_fold_product_of_scores() {
            let fold = member_scores_fold();
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![2, 3, 5],
            };
            assert_eq!(fold.product_of(&alice), 30);
        }
    
        // ── Fold composition: Team → Member → i32 ─────────────────────────────
    
        #[test]
        fn test_composed_fold_sum_all_team_scores() {
            // Alice: 10+20+30=60, Bob: 5+15=20, Carol: 100 → total 180
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.sum_of(&team), 180);
        }
    
        #[test]
        fn test_composed_fold_max_across_all_team_scores() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.max_of(&team), Some(100));
        }
    
        #[test]
        fn test_composed_fold_length_counts_all_scores() {
            // Alice has 3, Bob has 2, Carol has 1 → total 6
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.length_of(&team), 6);
        }
    
        #[test]
        fn test_composed_fold_any_score_above_threshold() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert!(fold.any_of(&team, |s| *s > 50));
            assert!(!fold.any_of(&team, |s| *s > 200));
        }
    
        #[test]
        fn test_composed_fold_all_scores_positive() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert!(fold.all_of(&team, |s| *s > 0));
        }
    
        #[test]
        fn test_composed_fold_min_across_all_scores() {
            let fold = team_scores_fold();
            let team = sample_team();
            assert_eq!(fold.min_of(&team), Some(5));
        }
    
        #[test]
        fn test_composed_fold_empty_team() {
            let fold = team_scores_fold();
            let team = Team {
                name: "Empty".to_string(),
                members: vec![],
            };
            assert_eq!(fold.sum_of(&team), 0);
            assert_eq!(fold.length_of(&team), 0);
            assert_eq!(fold.max_of(&team), None);
        }
    
        // ── Approach 2: trait-based Fold ──────────────────────────────────────
    
        #[test]
        fn test_trait_fold_collect_members() {
            let fold = TeamMembersFold;
            let team = sample_team();
            assert_eq!(fold.fold_collect(&team).len(), 3);
        }
    
        #[test]
        fn test_trait_fold_length() {
            let fold = TeamMembersFold;
            let team = sample_team();
            assert_eq!(fold.fold_length(&team), 3);
        }
    
        #[test]
        fn test_trait_fold_any_and_all() {
            let fold = TeamMembersFold;
            let team = sample_team();
            assert!(fold.fold_any(&team, |m| m.name == "Bob"));
            assert!(fold.fold_all(&team, |m| !m.scores.is_empty()));
        }
    
        #[test]
        fn test_trait_member_scores_fold() {
            let fold = MemberScoresFold;
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert_eq!(fold.fold_collect(&alice), vec![10, 20, 30]);
            assert_eq!(sum_via_fold(&fold, &alice), 60);
        }
    
        #[test]
        fn test_generic_all_via_fold() {
            let fold = MemberScoresFold;
            let alice = Member {
                name: "Alice".to_string(),
                scores: vec![10, 20, 30],
            };
            assert!(all_via_fold(&fold, &alice, |s| *s > 0));
            assert!(!all_via_fold(&fold, &alice, |s| *s > 15));
        }
    }

    Deep Comparison

    OCaml vs Rust: Fold Optic — Read-Only Multi-Focus Aggregation

    Side-by-Side Code

    OCaml

    type ('s, 'a) fold_simple = { to_list : 's -> 'a list }
    
    let sum_of f s = List.fold_left ( + ) 0 (f.to_list s)
    let any_of f pred s = List.exists pred (f.to_list s)
    let all_of f pred s = List.for_all pred (f.to_list s)
    let find_of f pred s = List.find_opt pred (f.to_list s)
    
    (* Composition via List.concat_map *)
    let compose outer inner =
      { to_list = fun s -> List.concat_map inner.to_list (outer.to_list s) }
    
    let team_scores = compose team_members member_scores
    

    Rust (idiomatic — struct-based)

    pub struct Fold<S, A> {
        to_list: Box<dyn Fn(&S) -> Vec<A>>,
    }
    
    impl<S: 'static> Fold<S, i32> {
        pub fn sum_of(&self, s: &S) -> i32 {
            self.collect(s).into_iter().sum()
        }
        pub fn any_of(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.collect(s).iter().any(pred)
        }
    }
    
    // Composition: flat-map the inner fold over the outer fold's results
    pub fn compose<B: 'static>(self, other: Fold<A, B>) -> Fold<S, B> {
        Fold::new(move |s| {
            self.collect(s).iter().flat_map(|a| other.collect(a)).collect()
        })
    }
    
    let team_scores = team_members_fold().compose(member_scores_fold());
    

    Rust (functional/recursive — trait-based)

    pub trait FoldOptic<S, A> {
        fn fold_collect(&self, s: &S) -> Vec<A>;
    
        fn fold_length(&self, s: &S) -> usize {
            self.fold_collect(s).len()
        }
        fn fold_any(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.fold_collect(s).iter().any(pred)
        }
        fn fold_all(&self, s: &S, pred: impl Fn(&A) -> bool) -> bool {
            self.fold_collect(s).iter().all(pred)
        }
    }
    
    pub struct TeamMembersFold;
    
    impl FoldOptic<Team, Member> for TeamMembersFold {
        fn fold_collect(&self, s: &Team) -> Vec<Member> { s.members.clone() }
    }
    

    Type Signatures

    ConceptOCamlRust
    Fold type('s, 'a) fold_simple = { to_list : 's -> 'a list }Fold<S, A> { to_list: Box<dyn Fn(&S) -> Vec<A>> }
    Collectf.to_list s : 'a listfold.collect(s) : Vec<A>
    SumList.fold_left (+) 0 (f.to_list s).into_iter().sum()
    CompositionList.concat_map inner.to_list (outer.to_list s).flat_map(\|a\| other.collect(a)).collect()
    FindList.find_opt pred (f.to_list s).into_iter().find(pred)
    Trait versionModule functor / first-class moduletrait FoldOptic<S, A>

    Key Insights

  • Read-only simplicity: A Fold has only one primitive — to_list / collect — compared to a Traversal which also requires over. This halves the implementation complexity while retaining all read combinators.
  • Composition is flat-map: OCaml's List.concat_map and Rust's .flat_map() are the same operation — the outer fold extracts a list of A, the inner fold maps each A to a list of B, and the results are concatenated. The Fold composition is the optic equivalent of monadic bind on lists.
  • Specialised impls for numeric aggregation: Rust's coherence rules require separate impl<S> Fold<S, i32> and impl<S> Fold<S, f64> blocks for sum_of, max_of, etc. OCaml handles this via type classes / polymorphic operators without specialisation.
  • Two encodings — heap vs zero-cost: The struct-based Fold<S, A> uses Box<dyn Fn> and pays a heap allocation per fold instance. The trait-based FoldOptic<S, A> uses marker structs with compile-time dispatch — no heap allocation, equivalent to C++ template specialisation.
  • Aggregation for free: Once collect (or fold_collect) is defined, every aggregation — sum, max, min, any, all, find, first, last, count — follows from standard iterator methods. The fold focuses; the iterator aggregates. The separation of concerns is the same in both languages.
  • When to Use Each Style

    **Use struct-based Fold<S, A> when:** You need runtime-constructed folds, composable at runtime, or when the focusing logic is passed as a closure. Good for building fold libraries and DSLs where the path is not known at compile time.

    **Use trait-based FoldOptic<S, A> when:** The focusing logic is fixed at compile time and you want zero-cost abstraction. Marker structs like TeamMembersFold compile to the same code as hand-written field access — no indirection, no heap allocation.

    Exercises

  • Implement a fold over all leaves of a tree and verify sum_of produces the correct total.
  • Write filter_fold(fold, pred) -> Fold<S, A> that focuses only on elements satisfying pred.
  • Implement map_fold(fold, f) -> Fold<S, B> that transforms focused elements — making Fold a Functor.
  • Open Source Repos