6. Inductive data types¶
An inductive data type builds finite values from its constructors. An
expression, for example, can be a number or an addition of two expressions.
Declare recursive fields with the parameter R and store values in Node.
fp.match supports structural recursion over these values. Each clause chooses
whether to receive a recursive field as stored or as the result of matching
that child. Evaluation uses an explicit stack, so matching a value a million
levels deep does not overflow the native stack.
Evaluate, render and simplify an expression¶
"""A recursive expression type: evaluate, render and simplify it with clauses.
Each clause handles one constructor. A field declared with the result type
arrives already evaluated; a field declared as `Expr` arrives as it is. `If`
continues with the branch it takes, so the other branch is never evaluated.
"""
import fp
from fp.adt import Data, Cases, Node, Value
from fp.matching import Next
@fieldwise_init
struct Num(Copyable, Equatable):
var value: Int
@fieldwise_init
struct Add[R: Value](Movable):
var left: Self.R
var right: Self.R
@fieldwise_init
struct Mul[R: Value](Movable):
var left: Self.R
var right: Self.R
@fieldwise_init
struct If[C: Value, B: Value](Movable):
var cond: Self.C
var then: Self.B
var other: Self.B
struct Expr(Data):
comptime Layer[R: Value] = Cases[Num, Add[R], Mul[R], If[R, R]]
comptime E = Node[Expr]
def evaluate(e: E) -> Int:
return fp.match(e,
lambda (n: Num) -> Int: n.value,
lambda (a: Add[Int]) -> Int: a.left + a.right,
lambda (m: Mul[Int]) -> Int: m.left * m.right,
lambda (c: If[Int, E]) -> Next[E]: Next(c.then if c.cond != 0 else c.other))
def render(e: E) -> String:
return fp.match(e,
lambda (n: Num) -> String: String(n.value),
lambda (a: Add[String]) -> String: "(" + a.left + " + " + a.right + ")",
lambda (m: Mul[String]) -> String: m.left + " * " + m.right,
lambda (c: If[String, String]) -> String: "if " + c.cond + " then " + c.then + " else " + c.other)
def simplify(e: E) -> E:
"""Remove additions of zero and multiplications by one, bottom-up."""
return fp.rewrite(e,
fp.when[lambda (a: Add[E]) -> Bool: a.left == Num(0), lambda (a: Add[E]) -> E: a.right],
fp.when[lambda (a: Add[E]) -> Bool: a.right == Num(0), lambda (a: Add[E]) -> E: a.left],
fp.when[lambda (m: Mul[E]) -> Bool: m.left == Num(1), lambda (m: Mul[E]) -> E: m.right],
fp.when[lambda (m: Mul[E]) -> Bool: m.right == Num(1), lambda (m: Mul[E]) -> E: m.left])
def main():
var zero: E = Num(0)
var one: E = Num(1)
var x: E = Add(E(Mul(one, E(Num(6)))), E(Add(E(Num(4)), zero)))
print(render(x), "=", evaluate(x))
var y = simplify(x)
print(render(y), "=", evaluate(y))
# The branch not taken is never evaluated, however large it is.
var big: E = one
for _ in range(40):
big = Mul(big, big)
print(evaluate(E(If(zero, big, E(Num(7))))))
# A value shared by both operands is evaluated once per match.
var shared: E = Num(2)
for _ in range(61):
shared = Add(shared, shared)
print(evaluate(shared))
# Depth is limited only by memory.
var deep: E = zero
for _ in range(1_000_000):
deep = Add(deep, one)
print(evaluate(deep))
Run from the repository root pixi run mojo run -I src docs/examples/expression.mojo
Output
(1 * 6 + (4 + 0)) = 10
(6 + 4) = 10
7
4611686018427387904
1000000
x is Add(Mul(1, 6), Add(4, 0)). render(x) gives (1 * 6 + (4 + 0)), and
evaluate(x) gives 10. simplify removes the multiplication by one and the
addition of zero, so the simplified value renders as (6 + 4) and still
evaluates to 10.
- Declare recursive fields with
R.Add[R]has two fields of typeR. InExprthe layer passesRon, so a storedAddholds twoNode[Expr]values. A constructor such asIf[C, B]may take more than one parameter, so that a clause can treat the condition differently from the branches; the layer passesRto each. - Evaluate a child. The clause parameter
Add[Int]asks for both fields as the result type: they arrive as the results of matching the two children with the same clauses.renderasks forAdd[String]and gets the rendered children. - Keep a child unevaluated.
If[Int, E]asks for the condition evaluated and the branches as stored. The clause returnsNext(...): the result of this value is the result of matching the chosen branch instead. The other branch is never evaluated, soIf(zero, big, 7)returns7without evaluatingbig, a product of 2^40 ones. - Rewrite bottom-up.
fp.rewritetransforms a value from the leaves up. Its clauses take the constructors that change, with recursive fields already rewritten, and return the new value. Every other value is rebuilt from its rewritten children, and a value whose children did not change is reused. A rewrite is one pass. - Share values.
Nodevalues are immutable, and copying one shares it, soAdd(shared, shared)refers to one value twice. After 61 doublings ofNum(2),evaluate(shared)returns4611686018427387904, evaluating each shared value once per match. - Match deeply nested values. The match keeps pending values on an explicit
stack and releases values in a loop.
deepnests a millionAddvalues, andevaluate(deep)returns1000000without a million native calls.
Lists and optional children¶
A recursive field may also be List[R] or Optional[R]:
"""Recursive fields in a list or an optional: a folder holds entries, a shortcut may hold a target."""
import fp
from fp.adt import Data, Cases, Node, Value
@fieldwise_init
struct File(Copyable):
var name: String
var size: Int
@fieldwise_init
struct Folder[R: Value](Movable):
var name: String
var entries: List[Self.R]
@fieldwise_init
struct Shortcut[R: Value](Movable):
var name: String
var target: Optional[Self.R]
struct Tree(Data):
comptime Layer[R: Value] = Cases[File, Folder[R], Shortcut[R]]
comptime T = Node[Tree]
def total_size(t: T) -> Int:
"""Every entry of a folder, and a shortcut's target when it has one, arrive evaluated."""
return fp.match(t,
lambda (f: File) -> Int: f.size,
lambda (d: Folder[Int]) -> Int: sum_of(d.entries),
lambda (l: Shortcut[Int]) -> Int: l.target.value() if l.target else 0)
def sum_of(sizes: List[Int]) -> Int:
var total = 0
for size in sizes:
total += size
return total
def listing(t: T) -> String:
return fp.match(t,
lambda (f: File) -> String: f.name,
lambda (d: Folder[String]) -> String: d.name + "/{" + joined(d.entries) + "}",
lambda (l: Shortcut[T]) -> String: l.name + "@")
def joined(items: List[String]) -> String:
var text = String()
for i in range(len(items)):
text += (", " if i > 0 else "") + items[i]
return text
def main():
var notes: T = File("notes.txt", 120)
var photos: T = Folder(String("photos"), [T(File("a.jpg", 2000)), T(File("b.jpg", 3000))])
var home: T = Folder(String("home"), [notes, photos, T(Shortcut(String("latest"), Optional[T](photos))),
T(Shortcut(String("broken"), Optional[T](None)))])
print(listing(home))
print(total_size(home))
Run from the repository root pixi run mojo run -I src docs/examples/file_tree.mojo
Output
home/{notes.txt, photos/{a.jpg, b.jpg}, latest@, broken@}
10120
Folder[Int] receives the sizes of all its entries, and Shortcut[Int] the
size of its target when it has one. home holds notes.txt (120), photos
(5000), a shortcut to photos (5000) and a shortcut with no target (0), so
total_size(home) returns 10120. listing takes Shortcut[T], the field as
stored, so it prints latest@ without following the shortcut.
Why every match ends¶
A Node can refer only to values that already exist, and it never changes, so
every value is a finite acyclic graph. Structural recursion evaluates children
of the current value; Next must continue with a value of smaller height.
These rules make the matching traversal terminate. A clause's own loops or
additional matches remain ordinary Mojo code. See
guarantees and their sources
for the termination argument and the runtime check on Next.
The next chapter combines these pieces in a configuration processor.