Theory of Lambda Calculi

Variables and Abstraction


Learning Objectives

  • You understand the untyped lambda calculus syntax and semantics.
  • You know the difference between free and bound variables.
  • You understand substitution and its pitfalls.
  • You can implement the Term type, free_vars, and substitute functions in Rust.

From Calculator to Calculus

In the previous part, we built a calculator that could evaluate and type check terms like + 1 2 and if 1 < 2 then 1 else 2. This is great, but we want to build programming languages, not calculators!

We begin from scratch by learning about the untyped lambda calculus — no arithmetic, no conditionals, no type checking.

Untyped Lambda Calculus

In brief, untyped lambda calculus is a formal system for expressing symbolic computation based on function abstractions and applications.

The untyped lambda calculus, at its core, contains the following three kinds of terms:

  1. Variables are for giving names to things.
  2. Abstractions are the star of the show — they let us define unnamed functions, also called lambda abstractions.
  3. Applications are terms which apply a term to another term.

These are expressed by the following enum in Rust:

/// A term of the untyped lambda calculus
pub enum Term {
    Var(String),
    Abs { var: String, body: Box<Term> },
    App(Box<Term>, Box<Term>),
}

We start using the word “term” instead of “AST” as the latter is a representation of code whereas the former is something we manipulate during evaluation. This distinction will become more clear in the following chapters.

To make talking about the language easier, we define the following mathematical notation for the Terms.

NotationTerm (Untyped Lambda Calculus)When it’s clear from the context that we are referring to a term inthe untyped lambda calculus, we use the following short-handnotation:1.A variable is expressed as an ordinary variable symbol:𝑥,𝑦,𝑧,areVar("x"),Var("y"), etc.2.An abstraction is expressed using the Greek lowercase letterlambda:𝜆𝑥.𝑥is short forAbs(var: "x", body: Var("x"))3.An application of two terms is expressed by concatenating them.Application associates left and has higher precedence than anabstraction.𝑎𝑏𝑐isApp(App(a, b), c)𝜆𝑥.𝑎𝑏isAbs(var: "x", body: App(a, b))4.Parentheses can be added for visual clarity:𝑎𝑏𝑐=(𝑎𝑏)𝑐

Let’s look at some examples next.

ExampleLambda TermsThe following are lambda terms, i.e. terms in lambda calculus:𝑥𝑥𝑦𝜆𝑥.𝑥𝜆𝑥.𝑦𝜆𝑥.𝜆𝑦.𝑥(𝜆𝑥.𝜆𝑦.𝑥)(𝜆𝑥.𝑦)

Evaluation in Lambda Calculus

In part 1: Calculation and Proof we saw the following definition of function application in functional programming

DefinitionFunction ApplicationLet 𝑓, 𝑎 be terms. To evaluate the application term 𝑓𝑎, we firstevaluate the values of 𝑓 and 𝑎 and get 𝑣𝑓 and 𝑣𝑎 respectively.Because 𝑣𝑓 is a function value, it must be of the shape fun𝑥:𝑇,𝑡1for some variable name 𝑥, type 𝑇 and term 𝑡1.If 𝑡1[𝑥𝑣𝑎] is the result of substituting 𝑥 with 𝑣𝑎 in 𝑡1, then we saythat calculating 𝑓𝑎 yields the term 𝑡1[𝑥𝑣𝑎].

This is also how we define evaluation in lambda calculus. For example, applying (𝜆𝑥.𝑥) (the identity function) to 𝑦 reduces to 𝑦:

(𝜆𝑥.𝑥)𝑦𝛽[𝑥𝑦]𝑥=𝑦

We will formally define substitution ([𝑥𝑠]𝑡) in this (and the following) chapter and then study evaluation and 𝛽-reduction.

Free, Bound and Binding Variables

Before we define what exactly free and bound variables are in lambda calculus, let’s look at an example in Rust.

fn foo(y: i32) {
    x + y
}

fn main() {
    let x = 5;
    let y = foo(x);
    println!("{y}");
}

The concept of free and bound variables is closely tied to the concept of scope. Scope tracks which name are “usable” in different parts of the code. For example, variables x and y are usable in main after they have been declared, but x is not usable in foo.

Also, as function names are tracked by scope, we say that foo and main are usable everywhere in the code, although they are not variables. Looking at just the foo function, we say that the variable y is bound but x is free.

We identify occurrences of variables using the following three categories:

  1. Binding: a variable is binding, if it occurs in the parameter list of a function (like y in (y: i32)) or in an assignment pattern (like x in let x = 5).
  2. Bound: a bound variable is one that is correctly scoped. We also consider binding variables as bound.
  3. Free: a free variable is one that is not correctly scoped like an undefined variable.

Let’s look at how this concept translates to lambda calculus to give a more precise definition of free and bound variables.

In the following term

𝜆𝑥.𝑥

we use a blue underline to denote the “scope” of the 𝑥 variable — the abstraction binds 𝑥 within its scope. As the 𝑥 in the body is within the scope of the abstraction binding 𝑥, it is bound by the abstraction.

Next, let’s look at an example with two abstractions.

(𝜆𝑥.𝑦)(𝜆𝑦.𝑥)

Notice that 𝑦 occurs in the body of the first abstraction, which is outside the scope of the 𝑦 bound in the second. Likewise 𝑥 in the second abstraction is outside the scope of the first.

Therefore we say that 𝑥 and 𝑦 occur as free variables in the term.

In the following term with nested abstractions, we observe that all occurrences of 𝑥 and 𝑦 are bound, so there are no free variables.

𝜆𝑥.𝜆𝑦.𝑥𝑦

Note: we also count the variable immediately after the 𝜆 as an occurrence even though it’s not a Var-term.

DefinitionFree VariablesLet 𝑡 be a lambda term and 𝑥 a variable name.If 𝑥 has a free occurrence in 𝑡 we say that 𝑥 is free in 𝑡.The set of free variables of 𝑡 is called FV(𝑡) and is defined asFV(𝑡)={𝑥|𝑥is free in𝑡}

Next, let’s define exactly what bound variables are.

DefinitionBound VariablesLet 𝑡 be a lambda term and 𝑥 a variable name.If there exists a subterm 𝜆𝑥.𝑡1 in 𝑡, we say that 𝑥 is a boundvariable in 𝑡.

Here are more examples of lambda terms and their sets of free and bound variables.

TermFree variablesBound variables
𝑥{𝑥}
𝜆𝑥.𝑥{𝑥}
𝜆𝑥.𝑦{𝑦}{𝑥}
𝑥(𝜆𝑥.𝑥){𝑥}{𝑥}
𝜆𝑥.𝜆𝑦.𝑥{𝑥,𝑦}
𝜆𝑥.𝑥(𝜆𝑥.𝑥){𝑥}

Notice that in 𝑥(𝜆𝑥.𝑥) for instance, 𝑥 occurs as a free variable once and as a bound variable twice.

Other Variable-Binding Operators from Math

In the following examples, every variable is bound by some operator/construction.

1: 𝑓(𝑥)𝑥22: 10𝑖=12𝑖3: lim0𝑓()4: 𝑥,𝑥+1=1+𝑥

The first equation shows how a new function 𝑓 is defined as it takes an argument 𝑥 and returns the term 𝑥2. The sum () binds the variable 𝑖 and evaluates the term 2𝑖 ten times. The limit binds a variable and uses the 𝑓 defined earlier. The quantifies, i.e. binds, the variable 𝑥 to express a logical proposition which may or may not be true1.

In the following examples, the variables 𝑥 and 𝑖 are bound just like earlier, but the variables 𝑛,𝑎,𝑏 occur free.

𝑛𝑖=1𝑖=𝑛(𝑛+1)2𝑏𝑎𝑓(𝑥)d𝑥

Computing Free Variables

Next, we give an inductive definition (i.e. a recursive algorithm) for computing the set of free variables in a term. Afterwards your get to translate the algorithm to Rust.

DefinitionFVLet 𝑡,𝑡1,𝑡2 be lambda terms and 𝑥 be a variable name. The set offree variables of 𝑡 is FV(𝑡) and is defined inductively as follows.1.If 𝑡=𝑥, then FV(𝑡)={𝑥}.2.If 𝑡=𝑡1𝑡2, then FV(𝑡)=FV(𝑡1)FV(𝑡2).3.If 𝑡=𝜆𝑥.𝑡1, then FV(𝑡)=FV(𝑡1){𝑥}.
Loading Exercise...

Loading Exercise...

Formatter for Lambda Terms

Before diving in to the exercises, let’s implement a simple formatter/pretty-printer for lambda terms.

We would like to use the following ASCII-style notation to express terms of lambda calculus:

  • Variables: x, y
  • Applications: x y, f (x y)
  • Abstractions: x => x, x => y => x, (x => x) y

Let’s implement the Display trait by recursively walking over the term.

No files opened Select a file to edit

This should make it easier to debug what the lambda terms actually look like.

If you are interested in how to make a macro for creating terms with less hassle, expand the following info box.

Parser Macro for Terms

The following declarative macro makes it possible to also conveniently write terms using the syntax we just described. For example, we can now write t!(x => y) to create the lambda term 𝜆𝑥.𝑦.

No files opened Select a file to edit

Loading Exercise...

Loading Exercise...

Closed Terms

A closed term is simply one with no free variables.

DefinitionClosed TermA term 𝑡 is closed (also called complete) if FV(𝑡)=. ExampleClosed TermsThe following are closed lambda terms.𝜆𝑥.𝑥𝜆𝑥.𝜆𝑦.𝑦𝜆𝑥.𝜆𝑦.𝑥𝑦(𝜆𝑥.𝜆𝑦.𝑥)(𝜆𝑧.𝑧)None of the following are closed.𝑥(𝜆𝑥.𝑥)𝑦𝜆𝑥.𝑦(𝜆𝑥.𝜆𝑦.𝑥)(𝜆𝑥.𝑦)

As we already have an algorithmic definition of FV, we can define is_closed : Term -> bool trivially in Rust. However, there’s another way to check if a term is closed which involves a recurring concept called context.

For now, it suffices to think of contexts as sets of variable names.

DefinitionContextsA set of variable names is also called a context and usually denotedwith the Greek uppercase letter gamma Γ.

Before we can define is_closed inductively, we define a helper function which keeps track of a context. The idea is, when recursively traversing a term and encountering an abstraction, to push the name of the bound variable to the context and pass the new context down recursively.

We will use this idea in the next definition of a term 𝑡 closed under a context Γ.

DefinitionClosed Under a Context ΓLet 𝑡 be a term and Γ a context. We define the relation 𝑡 is closedunder Γ inductively as follows:1.If 𝑡=𝑥 and 𝑥Γ then 𝑡 is closed under Γ.2.If 𝑡=𝑡1𝑡2 and both 𝑡1 and 𝑡2 closed under Γ, then 𝑡 is closedunder Γ.3.If 𝑡=𝜆𝑥.𝑡1 and 𝑡1 is closed under Γ{𝑥}, then 𝑡 is closed underΓ.

We can now define that 𝑡 is closed if and only if 𝑡 is closed under the empty context .

Closure of a Term

A context can be converted to a prefix of abstractions. For example, if Γ={𝑥,𝑦}, and 𝑡 is a term, then the following is the term formed by abstracting over 𝑡 using the variables in Γ:

𝜆𝑥.𝜆𝑦.𝑡

This idea can be turned into a function: Γ(𝑡).

Now, Γ as function acting on a term is defined inductively over contexts that are finite in size:

Γ:TermTerm{}(𝑡)=𝑡(Γ{𝑥})(𝑡)=Γ(𝜆𝑥.𝑡)

Usually contexts are used to keep track of bound variables, however nothing says we can’t define a context with free variables instead. If we take Γ=FV(𝑡) we calle Γ(𝑡) the closure of 𝑡. The closure is clearly closed, as all free variables in 𝑡 are now bound in Γ(𝑡).


Loading Exercise...

Loading Exercise...

Loading Exercise...

Substitution

Next, we define the notion of substituting a variable by a term inside a term.

Substitution only substitutes free occurrences. We denote substitution with [𝑥𝑠]𝑡, where 𝑥 is a variable name and 𝑡,𝑠 are terms where 𝑡 is the term where want to perform the substitution.

To get an intuition of substitution, let’s look at some examples before giving a precise definition.

Example[𝑥𝑥]𝑥=𝑥[𝑥𝑥]𝑦=𝑦[𝑥𝑦]𝑥=𝑦[𝑥𝑦](𝑥𝑥)=(𝑦𝑦)[𝑥𝑦](𝑥𝑦)=(𝑦𝑦)[𝑦𝑧][𝑥𝑦](𝑥𝑦)=(𝑧𝑧)

Notice that in [𝑦𝑧][𝑥𝑦](𝑥𝑦)=(𝑧𝑧), substitutions are performed from right to left, so the first (i.e. the rightmost) substitution results 𝑦𝑦.

If we want to simultaneously substitute multiple variables, we would write [𝑦𝑧,𝑥𝑦](𝑥𝑦)=(𝑦𝑧). However we will only study substituting a single variable for now.

Substitution in Abstractions

Next, let’s think about how substituting into an abstraction should work. To make this concrete, let’s again look at some Rust code for perspective.

We define a function foo which returns a closure |y| { x + y }.

fn foo(x: i32) -> impl Fn(i32) -> i32 {
    move |y| { x + y }
}

fn main() {
    let add1 = foo(1);
}

Inside main, the function application foo(10) can be thought of as taking the body of foo and replacing substituting foo’s parameters with the provided arguments. This results in the following idea:

foo(1)=[𝑥1]{ |y| { x + y } }=|y| { 1 + y }

So foo(1) becomes the function |y| { 1 + y }.

In Rust, closures are very similar to anonymous functions from lambda calculus, but due to the ownership system, the borrows checker has to be quite strict with what they can do. In this case we can transparently think of |y| { 1 + y } as an “arithmetic lambda term” 𝜆𝑦.1+𝑦.

Now, let’s look at some lambda calculus examples. As substitution should only substitute free variables, it should behave as follows.

Example[𝑥𝑦](𝜆𝑥.𝑥)=𝜆𝑥.𝑥[𝑥𝑦](𝜆𝑤.𝑥)=𝜆𝑤.𝑦[𝑦𝜆𝑥.𝑥](𝜆𝑥.𝑦)=𝜆𝑥.𝜆𝑥.𝑥[𝑦𝜆𝑥.𝑥](𝜆𝑥.𝑦(𝜆𝑦.𝑦))=𝜆𝑥.(𝜆𝑥.𝑥)(𝜆𝑦.𝑦)

It’s crucial to notice that substitution goes into an abstraction only if the substitution variable differs from the abstraction’s bound variable. For example, in [𝑥𝑦](𝜆𝑥.𝑥), nothing is substituted because all occurrences of 𝑥 are bound.

Here is the formal definition of substitution.

DefinitionSubstitution ([𝑥𝑠]𝑡)Let 𝑡,𝑠 be terms and 𝑥 a variable name.The term where we substitute 𝑠 for 𝑥 in 𝑡 denoted [𝑥𝑠]𝑡 is definedinductively as follows.1.[𝑥𝑠]𝑥=𝑠, i.e. substituting 𝑠 for 𝑥 in 𝑥 results in 𝑠.2.[𝑥𝑠]𝑦=𝑦 if 𝑥𝑦.3.[𝑥𝑠](𝑡1𝑡2)=([𝑥𝑠]𝑡1)([𝑥𝑠]𝑡2).4.[𝑥𝑠](𝜆𝑥.𝑡1)=𝜆𝑥.𝑡1.5.[𝑥𝑠](𝜆𝑦.𝑡1)=𝜆𝑥.[𝑥𝑠]𝑡1 if 𝑥𝑦.
Loading Exercise...

Loading Exercise...

Loading Exercise...

Loading Exercise...
Loading Exercise...

Substitution, as we have just defined, is a brittle process. If the “value” that is substituted for the variable is not a closed term, we may end up in trouble. For example, consider the following substitution

[𝑥𝑦](𝜆𝑦.𝑥)=𝜆𝑦.𝑦

Notice how the free variable 𝑦 got bound by an abstraction. This is called variable capturing, and must not happen during substitution. The key rule of substitution is: it must not cause any free variables in the substitute to become bound.

To avoid this, we define the notion of when a substitution [𝑥𝑠]𝑡 is legal. The process conceptually takes two steps:

  1. Find the free variables in 𝑠, i.e. compute FV(𝑠).
  2. Next, find all free occurrences of 𝑥 in 𝑡 while keeping track of the context Γ. When a free occurrence is found, check if Γ and FV(𝑠) contain shared variables. If they have a shared variable at any free occurrence of 𝑥, then the substitution is not legal.
𝑡=𝜆𝑦.𝜆𝑤.𝑥Is[𝑥𝑦]𝑡legal?

Take a moment to walk through the process described above. Here it is in detail.

Step 1, we first notice that FV(𝑦)={𝑦}. Step 2. we then look for free occurrences of 𝑥, while keeping track of the context.

Position (|)Context (Γ) | 𝜆𝑦. | 𝜆𝑤. | 𝑥 | 𝜆𝑦. | 𝜆𝑤. | 𝑥{𝑦} | 𝜆𝑦. | 𝜆𝑤. | 𝑥{𝑦,𝑤}

The context Γ starts initially as the empty set. When we traverse into the body of 𝜆𝑦 we insert 𝑦 into the context (Γ={𝑦}). When we reach the 𝑥 inside 𝜆𝑤, we notice that Γ={𝑦,𝑤}. Because the intersection FV(𝑦)Γ={𝑦} is nonempty, the substitution is not legal.

Loading Exercise...

Loading Exercise...

Loading Exercise...

Loading Exercise...

Footnotes

Footnotes

  1. If the variable 𝑥 is an ordinal, then the proposition doesn’t hold.