From your first line of code, to your first day on the job — Educative has you covered. Join 2M+ developers learning in-demand programming skills.
For anyone coming from a background of working with imperative programming languages, the experience of programming using a functional programming language can feel surreal. With no recourse to familiar looping constructs like for and while loops, with the glaring absence of assignment statements and familiar data structures, the learning curve consists of not just learning new syntax but also learning a new way of thinking. While the precise syntax and the details depend on the particular functional language being learned, the crucial underlying way of thinking is common across the board for all functional languages. This way of thinking is essentially the lambda calculus.
Lambda calculus was invented by Alonzo Church in the 1930s, who, as part of his inquiry into the foundational questions in mathematics, explored the idea of representing computation using functions. In fact, lambda calculus is equivalent to Turing machinesAn abstract model of computation invented by Alan Turing, who later went on to become Church’s PhD student. in the sense that everything that can be computed can also be expressed through lambda calculus, and everything that can be expressed through lambda calculus can be computed.
This blog discusses some of the essential elements in lambda calculus, and highlights how the symbolic expressions in lambda calculus can be used for computing. In particular, we show how such expressions can be used for implementing data types, as well as for representing arithmetic, logical, and relational operators.
The fundamental building blocks of lambda calculus are known as lambda expressions. A lambda expression can be one of the following three things:
A variable: A variable is just a name. For example, x, y, and z can serve as variables.
A function abstraction: A function abstraction is a function that has no name and takes in a single parameter.
The general form of a function abstraction is
λx.B
Here, the symbol λ means the start of an anonymous function. The name that follows λ is the parameter. The dot symbol (.) indicates that the function body is about to begin. The function body B consists of a lambda expression.
For convenience, we occasionally use the term lambda function to mean a function abstraction.
A function application: A function application consists of any two lambda expressions, E1 and E2, placed side by side. In a function application such as
(E1E2)
, the expression E2 can be thought of as an argument passed to E1.
And that’s all the machinery at our disposal. Lambda calculus essentially consists of function abstractions and applying them in meaningful ways to other lambda expressions. While that may not sound like much, it’s amazing what can be accomplished using just these definitions.
λx.x is the identity function. It returns whatever it receives.
λx.y is an abstraction that takes a lambda expression x, ignores it completely, and returns some other lambda expression y. For this reason, it can be thought of as the constant function.
λy.yy is an abstraction that takes a lambda expression and applies it to itself.
Convention on spaces: When variable names consist of a single letter, as in the context of this blog, they need not be separated by spaces.
The body or the scope of a lambda function goes as far to the right as possible. For example, the underbraces show the scope of λy and λx in the following lambda expression:
(λy.y(yy))(λx.xz)
Convention on parenthesization: Do note that the parentheses are part of the syntax to help with the grouping of the function applications. They can’t always be omitted without loss of meaning, but we do make some exceptions to avoid clutter:
We omit the outermost parentheses around a standalone lambda expression.
We omit the parenthesis surrounding two variables.
Unlike mathematical functions, each lambda function has only one associated variable bound to its scope. To express the idea of a function on more than one variable, the lambda functions can be nested. For example, the function λx.λy.xy can be seen as a function that takes a parameter x and returns a function of y that applies x to y. This is easier to see with parentheses in place:
(λx.(λy.xy))
The following visualization of a lambda expression may help internalize this idea:
A nesting of lambda expressions
Often, a lambda expression such as
λx.λy.λz.E is written in shorthand by gathering all variables against a single λ as shown:
To do computations with lambda expressions we need to be able to evaluate them, or reduce them to simpler forms. The primary way to express the evaluation of a function is through a rule called β-reduction.
A function application like (λx.E1)E2β-reduces to an expression E1′ if E1′ is obtained by replacing all occurrences of the bound variable x in E1 by E2. This is denoted
(λx.E1)E2⟹βE1′
In applying β-reductions, the leftmost λx. is removed, and the bound variable x in the body of the abstraction is substituted by the expression E2.
This represents the idea of passing E2 as an argument to an actual function call and returning the result.
Let’s look at some examples of function applications that are beta reducible:
Example 1: The identity function applied to a free variable reduces to that free variable (since the identity function returns its argument.)
⟹β(λx.x)yy
Example 2: The identity function applied to itself reduces to the identity function.
⟹β(λx.x)(λx.x)λx.x
Example 3: A function that applies its argument to itself, when applied to the identity function β-reduces to the identity function.
⟹β⟹β(λy.yy)(λx.x)(λx.x)(λx.x)λy is removed and y is substituted by λx.xλx.x by Example 1
Example 4: We can keep applying β-reductions to the following expression, but the computation does not terminate.
Example 5: We illustrate the application of a nested function abstraction through the following set of slides.
1 / 4
Application of the leftmost function abstraction to a is beta reducible
Notation: We use the notation ⟹β∗ to express zero or more beta reductions.
For example,
((λx.x)(λy.yy))(λw.w)⟹β∗λw.w
Note that when a lambda expression can’t be β-reduced any further, it’s said to be in normal form. A non-terminating function application that keeps reducing to itself has no normal form. If the normal form of a lambda expression exists, then reducing the leftmost, outermost lambda application is guaranteed to lead to a unique normal form. This reduction strategy is called a normal order reduction.
It doesn’t matter what name we give to a bound variable in a function as long as we do so consistently for all occurrences of the variable in the scope of that abstraction. So λx.x is the same abstraction as λy.y.
Now, consider the following expression. The variable y within the lambda abstraction is a bound variable, but the variable y to which the abstraction applies is a free variable. They’re not the same.
(λxy.xy)y
After a β-reduction is attempted, the variable passed in as the argument becomes a bound variable.
⟹βattempted(λxy.xy)yλy.yy
This is called capturing a free variable. Notice how this capturing of y inadvertently changed the meaning of the lambda expression to a self-application—which is other than what was intended.
To avoid capturing free variables, a bound variable can be carefully renamed across the entire scope of that abstraction. Such a renaming is called an α-conversion.
For the lambda expression from above, if we rename the bound variable y as z, we get:
⟹α⟹β(λxy.xy)y(λxz.xz)yλz.yz
This now says what was intended: Apply the free variable y to the argument of the lambda function.
Note: We used ⟹α to explicitly indicate an alpha conversion, but these are usually done implicitly, as needed before applying β-reductions.
But where’s the data?
We’re thinking about doing computations. But all we have are functions (abstracted functions, to be precise) and variables. So where’s the data? Well, when all we have is a lambda expression, the lambda expression is the data.
Before going into exactly what that means, we adopt a small notational convenience to keep ourselves from drowning in a frenzy of symbols:
Notation: We give a name to an abstraction. When naming an abstraction, we use the word define before the name given to it.
We name the following two function abstractions as
True and False.
defineTrue=λx.λy.x
defineFalse=λx.λy.y
If we look at the semantics of these abstractions, True takes two parameters and returns the first. Whereas False takes two parameters and returns the second.
Note: We could have defined these differently, but it’s easier to go with how it’s typically done.
Let’s think about how to represent a not operator.
defineNot=λa.aFalseTrue=λa.a(λxy.y)(λxy.x)
Applying Not to True returns False as shown below.
We saw above how boolean values can be encoded as function abstractions. The set of non-negative integers can also be encoded similarly. First, a function is defined and is given the name 0. A successor function applied to 0 is called a 1. The successor is applied to 1 and is called a 2, and so on.
define0=λsz.z
If z is a variable representing zero and s is a successor function, ignore s and return z.
define1=λsz.sz
Apply the successor function s on z.
define2=λsz.s(sz)
First, apply the successor function s on z to get sz, and then apply s on sz a second time.
define3=λsz.s(s(sz))
definen=λsz.ntimess(…(s(sz)))
Encodings defined this way are called Church numerals.
Terminology note: When referring to a lambda expression representation of a number n as a Church numeral, we call it the numeraln.
Suppose we want to apply a lambda abstraction f repeatedly to another function g a total of n times, where n is known in advance. How would we do this? Repetitions of this kind can be achieved through the application of the following abstraction:
defineRepeat=λnxy.nxy
We use the variable name n to help remember that the function expects the Church numeral n.
If Repeat is applied to a Church numeral n and the expressions f and g, we see that f is applied to g repeatedly:
How does one add two Church numerals? Let’s first encode something simpler: an increment of one.
defineInc=λnsz.s(nsz)
The key idea is that the expression passed in s is applied n times to z and then one more time again to get the Church numeral n+1.
Let’s apply Inc on 2 to get 3:
≡⟹β⟹β⟹β≡Inc2(λnsz.s(nsz))(λtx.t(tx))(λsz.s((λtx.t(tx))sz)(λsz.s((λx.s(sx))z)λt is reduced next as it’s the leftmost function application(λsz.s(s(sz)))λx is reduced next since it’s the leftmost lambda application3
To represent adding two numbers n and m, we apply Inc on the Church numeral m a total of n times:
defineAdd=λnm.nIncm
When applied to a Church numeral n, the numeral takes Inc as the successor function and m as the expression on which Inc is to be applied n times. So in fact, such an application would reduce to the following:
Previously, we defined Inc to get the successor of a numeral. It’s not so easy how we would get a predecessor of a numeral n. Consider a numeral such as
n=λsz.ntimess(…s(sz))
How do we strip an s to get the following?
n−1=λsz.n−1timess(…s(sz))
Intuition: The way around this is to incrementally construct pairs of numbers (k,k−1) where k increases in steps of one, and then once we have the pair (n,n−1), the second element of the pair is returned to get the predecessor of n.
definePair=λxyb.bxy
The role of the third variable b envisioned here is to return one of the two elements in the (x,y) pair. This can be seen by passing True or False as an argument to b:
Here, the pair p applied to True selects the first element in the pair p. This selected element is used twice in order to construct the next pair. For example, the abstraction NextPair applied to (0,0) gives us the encoding for (1,0):
⟹β∗NextPair(0,0)(λb.b00)λb.b10
We’d like to apply the NextPair abstraction n times starting with the pair (0,0).
Note: In starting with (0,0), we assume (for convenience) that the predecessor of 0 is 0.
We apply the abstraction NextPairn times as follows:
≡λn.nNextPair(λb.b00)(n,n−1)
Finally, to select the second element in a pair, we apply that pair to False:
defineSecond=λp.pFalse
For example, let’s apply Second on (2,1) to see how this works:
Finally, we look at how relational operators such as =, ≤, ≥, <, > (for expressing equality and inequality) are expressed in lambda calculus.
For example, to check if m≤n, we can perform the subtraction m−n and see if the outcome is a 0. Testing for 0 suffices since we defined the predecessor of 0 to be 0.
We define the abstraction TestZero as follows to check for equality of the input with 0. Later, we’ll use this abstraction to define other operators.
defineTestZero=λn.n(λw.False)True
Recall that the numeral 0 is defined as λsz.z, which is exactly the definition of False. So when TestZero is applied to 0, it returns True as shown below:
Note that the effect of applying TestZero to a numeral other than 0 means that (λw.False) is repeatedly applied to True. The repeated applications always return False.
To implement m≥n, we need to test n−m and see whether it’s 0. The subtraction n−m is performed
by using the predecessor expression Pred on the numeral n repeatedly (m times):
defineGE=λmn.TestZero(mPredn)
If m is strictly less than n, then n−m is not 0. Otherwise it is 0.
Similarly, we can construct a lambda expression, say LE, to implement the less than or equal to operator (≤).
Checking for strict inequality can be done using other operators. For example, we can check if n is strictly greater than m through the following lambda expression:
defineGT=λnm.And(GEnm)(NEnm)
We can check for strictly less than in a similar way.
defineLT=λnm.And(LEnm)(NEnm)
Let’s recap what we covered:
We defined lambda expressions to represent boolean values and non-negative integers.
We saw how to encode the idea of an if-else expression and the idea of repetition.
We figured out how to build logical operators and arithmetic operators from first principles.
We hope that this was fun for you and that it continues to be useful to you as you learn and grow!
Frequently Asked Questions
What is lambda calculus used for?
Lambda calculus, or λ-calculus for short, is like a language in mathematical logic. It’s all about explaining how computations work, using function abstraction and application, and playing around with variables and substitutions. You can think of it as a one-size-fits-all model for computation—it can mimic any Turing machine.