065: Church Numerals
Difficulty: 3 Level: Advanced Numbers built entirely from functions β no integers, no bits, just closures.The Problem This Solves
You've been writing Rust for a while. Numbers are `u64`, booleans are `bool`, and that's just... the way things are. But have you ever asked: what actually IS a number? Not "what does it represent" β but what IS it, at the most fundamental level? This sounds philosophical, but it has real engineering consequences. Every programming language runtime needs to answer this question. The Church numeral answers it in the most minimal way possible: a number is a behaviour. Specifically, the number N is the behaviour of "apply some function N times." This is the foundation of lambda calculus β the mathematical model that ALL functional programming languages (and Rust's type system) are built on. Understanding it makes you a better Rust developer by revealing what closures really are and what the ownership system is actually protecting. This example exists to show you the deepest roots of what functions can express β and to reveal why Rust's ownership model creates interesting friction at those roots.The Intuition
Think of a number as an action, not a value.- Zero: "Do nothing." Apply `f` to `x` zero times β return `x` unchanged.
- One: "Do it once." Apply `f` to `x` once β return `f(x)`.
- Two: "Do it twice." Apply `f` to `x` twice β return `f(f(x))`.
- Three: "Do it three times." β `f(f(f(x)))`.
// "Three" in Church encoding β it IS a function
let three = |f: fn(i64) -> i64, x: i64| f(f(f(x)));
// To extract the integer: apply "add 1" starting from 0
let result = three(|x| x + 1, 0);
// f applied 3 times: 0 β 1 β 2 β 3
assert_eq!(result, 3);
Addition then falls out naturally: `m + n` means "apply f m times, then n more times."
How It Works in Rust
The core challenge: in lambda calculus, functions are perfectly flexible β you can pass any function anywhere. In Rust, every closure has a unique unnameable type, so we need `Box<dyn Fn>` to erase those types.// A Church numeral: takes a function, returns a function
// (apply-me-n-times-to-whatever-you-give-me)
type Church = Box<dyn Fn(Box<dyn Fn(i64) -> i64>) -> Box<dyn Fn(i64) -> i64>>;
// Zero: apply f zero times = just return x
fn zero() -> Church {
Box::new(|_f| Box::new(|x| x))
}
// One: apply f exactly once
fn one() -> Church {
Box::new(|f| Box::new(move |x| f(x)))
}
// Successor: take n, return n+1 (apply f one extra time)
fn succ(n: Church) -> Church {
Box::new(move |f: Box<dyn Fn(i64) -> i64>| {
use std::rc::Rc;
// Problem: we need to give f to BOTH n and the extra application
// Rust won't let us move f twice β so we use Rc for shared ownership
let f = Rc::new(f);
let f2 = f.clone();
let inner = n(Box::new(move |x| f2(x))); // n's applications
let f3 = f.clone();
Box::new(move |x| f3(inner(x))) // one more application
})
}
// To read the number: apply "add 1" to 0
fn to_int(n: Church) -> i64 {
n(Box::new(|x| x + 1))(0)
}
Why `Rc` appears: In OCaml, closures automatically share the captured value. In Rust, the borrow checker requires explicit shared ownership. This `Rc` cost is exactly why idiomatic Rust uses a struct instead:
// Practical version: same semantics, Rust-friendly
struct ChurchNum(usize);
impl ChurchNum {
fn apply<T>(&self, f: impl Fn(T) -> T, x: T) -> T {
(0..self.0).fold(x, |acc, _| f(acc))
}
fn to_int(&self) -> usize { self.apply(|x: usize| x + 1, 0) }
fn add(self, other: Self) -> Self { ChurchNum(self.0 + other.0) }
fn mul(self, other: Self) -> Self { ChurchNum(self.0 * other.0) }
}
What This Unlocks
- Lambda calculus literacy: You can now read papers and languages that talk about Church encoding β this is used in Haskell, Coq, proof assistants, and compiler theory.
- Understanding trait objects: The `Box<dyn Fn>` gymnastics here reveal exactly WHY Rust needs trait objects and what heap allocation costs at the closure level.
- Deriving data from functions: Church encoding proves you can build lists, booleans, pairs β all data β from just functions. Used in minimalist language interpreters and formal verification.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Church numeral type | `('a -> 'a) -> 'a -> 'a` (polymorphic) | `Box<dyn Fn(Box<dyn Fn(i64)->i64>)->Box<dyn Fn(i64)->i64>>` |
| Sharing a closure | Automatic (GC) | Requires `Rc` or explicit cloning |
| Successor function | One line, elegant | Needs `Rc` for shared `f` |
| Practical use | Possible as-is | Wrap in a struct instead |
| Each closure allocation | Stack (usually) | Heap (`Box`) |
/// # Church Numerals β Functions as Numbers
///
/// Lambda calculus encoding of natural numbers using higher-order functions.
/// A Church numeral N is a function that applies `f` N times to `x`.
///
/// In Rust, we use `Box<dyn Fn>` for heap-allocated closures since
/// Rust closures have unique unnameable types (unlike OCaml's uniform representation).
/// Type alias for Church numerals: takes a function and a value, applies f n times.
type Church = Box<dyn Fn(Box<dyn Fn(i64) -> i64>) -> Box<dyn Fn(i64) -> i64>>;
/// Zero: apply f zero times (return x unchanged)
pub fn zero() -> Church {
Box::new(|_f| Box::new(|x| x))
}
/// One: apply f once
pub fn one() -> Church {
Box::new(|f| Box::new(move |x| f(x)))
}
/// Successor: given n, apply f one more time
pub fn succ(n: Church) -> Church {
Box::new(move |f: Box<dyn Fn(i64) -> i64>| {
// We need to share f between n and the extra application
// This is tricky in Rust due to ownership β use Rc
use std::rc::Rc;
let f = Rc::new(f);
let f2 = f.clone();
let inner = n(Box::new(move |x| f2(x)));
let f3 = f.clone();
Box::new(move |x| f3(inner(x)))
})
}
/// Add: m + n = apply f m times, then n times
pub fn add(m: Church, n: Church) -> Church {
Box::new(move |f: Box<dyn Fn(i64) -> i64>| {
use std::rc::Rc;
let f = Rc::new(f);
let f2 = f.clone();
let inner_m = m(Box::new(move |x| f(x)));
let inner_n = n(Box::new(move |x| f2(x)));
Box::new(move |x| inner_m(inner_n(x)))
})
}
/// Convert Church numeral to integer
pub fn to_int(n: Church) -> i64 {
let f: Box<dyn Fn(i64) -> i64> = Box::new(|x| x + 1);
n(f)(0)
}
/// A simpler approach using a concrete recursive type instead of closures
#[derive(Clone, Debug)]
pub enum ChurchNum {
Zero,
Succ(Box<ChurchNum>),
}
impl ChurchNum {
pub fn to_int(&self) -> i64 {
match self {
ChurchNum::Zero => 0,
ChurchNum::Succ(n) => 1 + n.to_int(),
}
}
pub fn from_int(n: i64) -> Self {
if n <= 0 {
ChurchNum::Zero
} else {
ChurchNum::Succ(Box::new(ChurchNum::from_int(n - 1)))
}
}
pub fn add(self, other: Self) -> Self {
match self {
ChurchNum::Zero => other,
ChurchNum::Succ(n) => ChurchNum::Succ(Box::new(n.add(other))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zero() {
assert_eq!(to_int(zero()), 0);
}
#[test]
fn test_one() {
assert_eq!(to_int(one()), 1);
}
#[test]
fn test_succ() {
assert_eq!(to_int(succ(zero())), 1);
assert_eq!(to_int(succ(one())), 2);
}
#[test]
fn test_add() {
let two = succ(one());
let three = add(one(), two);
assert_eq!(to_int(three), 3);
}
#[test]
fn test_church_num_adt() {
let three = ChurchNum::from_int(3);
let four = ChurchNum::from_int(4);
assert_eq!(three.add(four).to_int(), 7);
}
#[test]
fn test_church_num_zero() {
assert_eq!(ChurchNum::Zero.to_int(), 0);
}
}
fn main() {
println!("{:?}", to_int(zero()), 0);
println!("{:?}", to_int(one()), 1);
println!("{:?}", to_int(succ(zero())), 1);
}
(* Church Numerals β Functions as Numbers *)
(* Lambda calculus: a numeral N applies f N times to x *)
let zero _f x = x
let one f x = f x
let succ n f x = f (n f x)
let add m n f x = m f (n f x)
let mul m n f = m (n f)
let to_int n = n (fun x -> x + 1) 0
let two = succ one
let three = add one two
let six = mul two three
let nine = mul three three
let () =
Printf.printf "0=%d 1=%d 2=%d 3=%d 6=%d 9=%d\n"
(to_int zero) (to_int one) (to_int two)
(to_int three) (to_int six) (to_int nine);
assert (to_int zero = 0);
assert (to_int three = 3);
assert (to_int six = 6);
assert (to_int nine = 9);
Printf.printf "All Church numeral tests passed!\n"
π Detailed Comparison
Church Numerals β OCaml vs Rust Comparison
Core Insight
Church numerals are the purest test of first-class function support. OCaml's uniform value representation makes them trivially expressible β `let zero _f x = x` is all you need. Rust's ownership model creates significant friction: closures capture by move/borrow, have unique types, and can't be easily composed without `Box<dyn Fn>` and `Rc`.
OCaml Approach
Elegantly minimal. Functions are first-class values with uniform representation β all the same size on the heap. Composition (`mul m n f = m (n f)`) is just partial application. Type inference handles everything automatically. This is where ML languages truly shine.
Rust Approach
Two alternatives: (1) `Box<dyn Fn>` closures with `Rc` for shared ownership β works but verbose, allocates heavily, and fights the borrow checker. (2) An ADT-based `ChurchNum` enum (Zero | Succ) β more idiomatic Rust, explicit, and easier to reason about ownership. The ADT approach is essentially Peano numbers.
Comparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Memory | GC'd closures (uniform) | `Box<dyn Fn>` + `Rc` (heap alloc each) |
| Null safety | N/A | N/A |
| Errors | N/A | Ownership errors at compile time |
| Iteration | Partial application | Must clone/Rc-share closures |
| Ergonomics | 1-line definitions | 10+ lines with type annotations |
Things Rust Learners Should Notice
1. Closure types are unique β you can't name them, must use `dyn Fn` trait objects 2. `Rc` for shared ownership β when multiple closures need the same captured value 3. ADT alternative is more Rusty β Peano `Zero | Succ(Box<N>)` avoids closure complexity 4. This is where GC shines β OCaml's garbage collector makes higher-order functions effortless 5. Trade-off clarity β Rust makes the cost of abstraction visible; OCaml hides it
Further Reading
- [Closures in Rust](https://doc.rust-lang.org/book/ch13-01-closures.html)
- [Box<dyn Fn>](https://doc.rust-lang.org/std/boxed/struct.Box.html)
- [Church encoding (Wikipedia)](https://en.wikipedia.org/wiki/Church_encoding)