Fixed Points and Recursion in STLC
Learning Objectives
- You know how recursion works using small step reduction
- You know how to add a fixed point combinator to STLC
As weβve learned on this course, recursion is one of the core elements of the functional programming paradigm. This chapter introduces some problems with recursion in STLC and then tries to fix them.
Recursion in untyped lambda calculus
In untyped lambda calculus, recursion can be implemented with the Y-combinator,
Problems with typing the Y-combinator
In simply typed lambda calculus, the Y-combinator is untypable. The proof tree below shows that in order to decide upon its type, weβd have to unify with , which is impossible in the simple type system that STLC has.
This means that doing recursion in STLC requires a different approach.
General recursion with Fixed points
You may recall the idea of fixed points from the Gleam material where you first implemented the reduce
function for a language of parenthesis.
After that you implemented the reduce_all
function that applied reduce
to the input until it found a fixed point, a point where applying reduce
no longer changes the input.
We can take a similar approach to help us achieve turing completeness1 in STLC.
First, letβs understand what a fixed point is.
There are various algorithms for computing the fixed point of a function2. Due to the nature of -reduction, the following is a natural choice for functions in the simply typed lambda calculus.
We want to find the fixed point of the STLC term . The fixed point is a value , such that 3.
Note that we have to consider terms which are not abstractions, but have type , i.e. they can be reduced to an abstraction. Small-step semantics is key in the following algorithm for finding the fixed point of :
- If is an abstraction
- Return i.e. βcallβ with the βmagicβ term .
- If not
- Reduce by one step.
The βmagicβ term has to be a new term which has the above dynamics.
Letβs run an example with the fibonacci function.
First, we need to define a generating function for it. The generating function, , takes in an argument and returns the following function
The whole generating function can be written as
Now, letβs use the above algorithm to find βs fixed point .
We notice that is an abstraction, so we replace every occurrence of with
If we wanted to continue the process, we could replace inside the abstraction with the above term, but that is not necessary until applying the abstraction.
Think about what would happen if was defined such that an abstraction is a value only if its body is a value. If we kept replacing inside the abstraction with the above term, we would have to do it for infinitely long and would end up with an infinitely long term which would theoretically have the same behavior as the fibonacci function.
Letβs see what happens if we apply to the number .
Composition of Inference Rules
In the previous evaluation example, the reduction steps with in them have some kind of an βinnerβ reduction that happens. For example, the step, which reduced the condition to should be interpreted as pre-composing4 with .
Here are the inference rules in question:
The composition is done by first refining the metavariable to be , which changes the hypothesis to to be . Pre-composing the rule with the rule means that the conclusion of is used to fill the hypothesis to . In the end, we get the following partial evaluation tree:
From which we can hide the internal implementation details (the functionβs body if you wish) to compress it down to:
We used this rule in the step knowing that .
Next, letβs actually define the construction on top of simply typed lambda calculus (with integers and booleans).
Letβs define a new term Fix(t)
that computes the fixed point of t
using the algorithm described above.
We add the term constructor into the ADT:
Which is equivalent with the formation rule:
The notation is used to informally refer to .
Dynamics for Fix
Just like with other terms that have inner subterms, we usually want to reduce the inner term inside the if itβs not already a value β call this rule . The fix-point algorithm is then encoded into the rule and enables general recursion in STLC.
Letβs see how a factorial function in STLC is defined using .
As an example, letβs evaluate .
First, note that
Therefore reduces the left-hand-side of the application as above, and then substitutes in .
Then, evaluation continues by reducing the subject of the if-then-else, followed by
For reference, here are the rules used in the above reduction:
Statics for Fix
From Definition 6.8.1, itβs easy to derive the typing rule for .
If is a term with type , then .
Designing Recursive Functions in
We can use Fix
to implement recursion by passing it a function that βtakes itselfβ as the first argument.
This general pattern can be applied to previous knowledge of recursive functions.
The following βdeclarationβ of the recursive function :
can be turned into a term in like this:
We will define a simple declaration and module system in the next chapter.
Problems with our approach β Bottom type and infinite recursion
The main problem with infinite recursion is that it breaks a crucial property of lambda calculuss, the progress theorem. This problem/tradeoff is present with most functional programming languages, as recursion is such a useful feature.
In our case if we were to, for example, pass the function a negative integer, the reduction of that statement would never result in a value. This would mean that the progress theorem, i.e. reducing a term will eventually produce a value, doesnβt hold.
In addition, infinite recursion can be used to produce a term of the bottom type (), which is sometimes called the Void type5.
The bottom type as an ADT looks like the following
βΉ is used to indicate the void, nothingness, or a hole puncture in the space of terms.
In Rust, as there is no way to make a tuple of zero terms, one writes
enum Bot {}
for the bottom type ADT. In Rust, the bottom type is called never.
Statics for Bottom
The bottom type has no introduction rules (ways to create an element) as it is a product (or sum) type of types. The only rule is the elimination rule, which describes how one can destructure (do case matching) elements of it.
We simply add a new term (sometimes called abort, too)
This rule says that a term of type can always be converted to any type using .
Think about why the bottom type might be useful to have in a programming language. Feel free to add the bottom type into your STLC implementation.
This problem makes the language completely useless at being used for mathematics or logic, because every statement and its negation can be proved using the above. Stronger programming languages, like proof assistants, must ensure that the bottom type is empty, i.e. has no terms. This means that in such languages general recursion is forbidden, and termination of recursion has to be proven by the programmer.
Footnotes
Footnotes
-
STLC with fixed points is turing complete. Read more on StackExchange. β©
-
This equality loosely speaking refers to the equality defined by -reduction. E.g. if evaluates to in some number of steps, then they are equal. β©
-
This probably sounds a lot like function composition, which is absolutely the right intuition β inference rules are just functions between logical propositions. β©
-
Some languages use the type
void
to mean βno return valueβ, which is in fact the unit type, also known as the top type. This is quite confusing as bottom and top types are polar opposites. β©