lambda_calculus
lambda_calculus is a simple, zero-dependency implementation of pure lambda calculus in Safe Rust.
Features
- a parser for lambda expressions, both in classic and De Bruijn index notation
- 7 β-reduction strategies
- a set of standard terms (combinators)
- lambda-encoded boolean, pair, tuple, option and result data types
- single-pair-encoded list
- Church-, Scott- and Parigot-encoded numerals and lists
- Stump-Fu (embedded iterators)- and binary-encoded numerals
- signed numbers
Installation
Include the library by adding the following to your Cargo.toml:
[]
= "3"
Compilation features:
backslash_lambda: changes the display of lambdas fromλto\encoding: builds the data encoding modules; default feature
Example feature setup in Cargo.toml:
[]
= "3"
= false # do not build the data encoding modules
= ["backslash_lambda"] # use a backslash lambda
Examples
Comparing classic and De Bruijn index notation
code:
use ;
stdout:
SUCC := λa.λb.λc.b (a b c) = λλλ2(321)
PRED := λa.λb.λc.a (λd.λe.e (d b)) (λd.c) (λd.d) = λλλ3(λλ1(24))(λ2)(λ1)
De Bruijn notation is concatenative — 21 is Var(2) applied to Var(1), not the index 21 —
so a single index is one hexadecimal digit, 1 to F. An index needing more than one digit
is wrapped in brackets ([10] is 16), which keeps it distinguishable from an application and
makes {:?} output always parse back to the term it came from. The brackets only delimit:
the digits inside are hexadecimal too, so [A] and a bare A are the same index.
Parsing lambda expressions
code:
use *;
Showing β-reduction steps
code:
use *;
use pred;
stdout:
normal order β-reduction steps for PRED 1 are:
(λa.λb.λc.a (λd.λe.e (d b)) (λd.c) (λd.d)) (λa.λb.a b)
λa.λb.(λc.λd.c d) (λc.λd.d (c a)) (λc.b) (λc.c)
λa.λb.(λc.(λd.λe.e (d a)) c) (λc.b) (λc.c)
λa.λb.(λc.λd.d (c a)) (λc.b) (λc.c)
λa.λb.(λc.c ((λd.b) a)) (λc.c)
λa.λb.(λc.c) ((λc.b) a)
λa.λb.(λc.b) a
λa.λb.b
Comparing the number of steps for different reduction strategies
code:
use *;
use fac;
stdout:
comparing normalizing orders' reduction step count for FAC 3:
normal: 46
applicative: 39
hybrid normal: 46
hybrid applicative: 39
Comparing different numeral encodings
code:
use *;
stdout:
comparing different encodings of number 3 (De Bruijn indices):
Church encoding: λλ2(2(21))
Scott encoding: λλ1(λλ1(λλ1(λλ2)))
Parigot encoding: λλ2(λλ2(λλ2(λλ1)1)(2(λλ1)1))(2(λλ2(λλ1)1)(2(λλ1)1))
Stump-Fu encoding: λλ2(λλ2(2(21)))(λλ2(λλ2(21))(λλ2(λλ21)(λλ1)))
binary encoding: λλλ1(13)
Stack depth
Reduction, and every other operation on a Term, walks a tree of boxes recursively, so
stack use scales with how deeply nested the term is. Deep enough and the process dies on
a guard page: a SIGSEGV with no unwinding, no panic message and no failing assertion.
Two unrelated things cause that, and only one of them is cured by a bigger stack.
An unbounded reduction. An applicative-family order (APP, HAP) applied to a term
built on a recursion combinator never converges — it exhausts whatever stack it is given.
Reducing scott::add 1 2 under HAP costs ~192 bytes per step and never finishes; under
NOR it finishes in 16 steps and 9 KiB. The remedy is the strategy, not stack_size.
A genuinely deep term. Here the depth is bounded by the input, so a larger stack is
the right answer — and worth measuring rather than guessing. stackler reports what a
reduction actually touched, without instrumenting the code under measurement:
let = measure_peak;
println!;
Its default paint depth is 256 KiB, which is far short of a large reduction; raise
Stackler::paint_depth past the expected peak or the reading comes back as
Peak::AtLeast, a lower bound rather than a measurement. Measured on the reduction_huge
test, which reduces a Church-encoded factorial of 10 under HAP:
| profile | peak stack |
|---|---|
--release |
221 MiB |
| debug | 1.08 GiB |
Note the 5x between profiles: a stack_size tuned against a release build will not
survive cargo test. Per nesting level, for the individual operations:
| operation | debug | release | max depth on 2 MiB (debug) |
|---|---|---|---|
drop |
208 B | 64 B | ~10000 |
Debug |
513 B | 129 B | ~4000 |
clone, PartialEq, beta, eta |
545 B | 128 B | ~3800 |
Display |
578 B | 160 B | ~3600 |
Note that libtest gives each test a 2 MiB stack, not the 8 MiB of a main thread, so the
last column is the budget the test suite actually works against. The four operations are
close enough now that no single one dominates; Debug matters out of proportion to its
cost because it is what assert_eq! invokes when it fails, so past that depth a mismatch
aborts while being rendered instead of being reported. tests/stack_depth.rs keeps all of
these figures honest.
Naming is done on demand rather than up front, so displaying a term costs what its output
costs. Generating every free-variable name in advance — which is what this used to do —
made Var(4_000_000), a single node, allocate four million strings to print five
characters. tests/display.rs covers that and the surrounding index handling.
Both formatting impls write directly into the Formatter rather than building a String
per subterm. That is what keeps their frames small, and it also makes them linear: the
older String-returning version copied each subtree's rendering into a fresh allocation at
every level, which is quadratic for a linear chain — the shape a Church numeral has.
Rendering a 32000-level chain went from 409 ms to 2.9 ms for Display and 107 ms to
1.1 ms for Debug. The trade is that neither impl honours width or precision specifiers
({:>20?} no longer pads), which is the usual cost of streaming a recursive structure.