Skip to content

HW 8: Lambda Calculus

In Module 08.2 and Lab 8, you encoded Booleans, pairs, lists, and numbers as functions. In this assignment you keep going: comparisons on Church numerals, a Maybe type, a second way to encode lists, binary trees, a Fibonacci function with no recursion, and finally a function that really does need recursion, built with Y from Module 08.3.

Most of these will feel familiar. The difference is that now you have nothing but functions to build them from.

You've done this before

In HW 2, you were already doing lambda calculus without knowing it. Higher-Order Arithmetic built addition and multiplication out of applyN, which is a Church numeral by another name: apply a function n times. HW 2 also had you write fib, step, and countStep, which are Parts 6, 7, and 8 of this assignment. It's worth opening your HW 2 code again and seeing how much of it carries over.

How you'll get materials and submit this assignment

Getting the starter code and turning in your work follows the same process as every homework this semester. See The Homework Workflow for the full walkthrough. In short: from the hw8 template repo, make your own private copy, clone it to the course server, and submit your test output and code on Gradescope, along with a few short written answers. The rest of this page covers what the assignment actually asks of you.

What You'll Do

  • Build new operations on Church numerals out of the ones you already have.
  • Encode data types (Maybe, lists, binary trees) as functions that choose between cases, the way Booleans do.
  • Use a Church numeral as a loop over pairs to compute something without recursion.
  • Write a function whose number of steps isn't known ahead of time, using Y.
  • Test lambda calculus definitions in lc and read its output.

Checklist

  • Get the assignment materials (see The Homework Workflow)
  • Part 1: Leq
  • Part 2: Equal
  • Part 3: Nothing and Just
  • Part 4: Nil, Cons, IsEmpty, Head, and Tail
  • Part 5: Leaf and Branch
  • Part 6: Fib
  • Part 7: Step
  • Part 8: Tnpo
  • Turn in the assignment (see Turning It In)

Collaboration

You can work with a partner on this assignment. If you do, you must cooperate on every part, follow the pair-programming rules from the syllabus at all times, and share roles equally.


The File

Everything happens in hw8.lc. The top of the file defines the Booleans, pairs, Church numeral operations (Succ, Plus, Times, Pred, Monus, IsZero), and the fixed-point combinator Y. Don't change those. The bottom of the file has a placeholder for each definition you'll write, like this:

Leq = 0;

Replace each placeholder with a real definition. You can (and should) add helper definitions of your own whenever they make your code easier to read. Put each one above the definitions that use it, or anywhere else in the file. lc doesn't care about order.

The Booleans in this file use t and f as their variable names, so lc prints True as (λt.(λf.t)) and False as (λt.(λf.f)).

How to Work: lc and Tests

lc

From the top of your repo, on the course server:

lc
:load hw8.lc

After every edit, use :reload. Some of the later problems take thousands of β-reductions, so you'll usually want :set trace off, which shows only the result and the step count. Turn the trace back on (:set trace compact or :set trace full) when you need to see where a reduction goes wrong.

When a result is exactly one of the built-in numerals, lc sometimes prints it by name, for example 3, instead of writing out the λ-expression. Both forms mean the same thing.

lc may not reduce things in the order you would by hand. That's fine. When you're debugging, focus on whether your encoding is right, not on the exact sequence of steps.

Tests

From the top of the repo:

./run-tests

The output is grouped by Part. Each line shows an expression the tests tried, what your definition reduced it to, and the points it earned. A failing test also shows what was expected. The total at the bottom is out of 100.

Until you replace a placeholder, its tests fail. That's expected. (One of Leq's tests passes from the start, by accident: the placeholder 0 is the same function as False.)

The tests run your definitions, but they can't check everything. Each Part below ends with a ✅ Check your work box. Try those examples in lc too, and try a few of your own.


Part 1: Leq (10%)

Define Leq, which takes two Church numerals so that ((Leq n) m) reduces to True if n ≤ m and to False otherwise.

If you make good use of the numeric operations already in the file, this can be one line.

✅ Check your work (Part 1)

Expression Should give
((Leq 3) 2) (λt.(λf.f)), which is False
((Leq 3) 4) (λt.(λf.t)), which is True
((Leq 5) 5) (λt.(λf.t))

Also try ((Leq 0) 0) and ((Leq 7) 0).

Part 2: Equal (10%)

Define Equal, which takes two Church numerals and returns True if they're equal and False otherwise.

Testing whether two arbitrary lambda terms are equal is undecidable. Among other reasons, you can't always tell whether β-reduction of a term will ever finish. But if both inputs are Church numerals, there's no problem. Leq gets you most of the way.

✅ Check your work (Part 2)

Expression Should give
((Equal 2) 2) (λt.(λf.t))
((Equal 2) 3) (λt.(λf.f))

Also try ((Equal 0) 0) and ((Equal 3) 2).

Part 3: Maybe (20%)

A pair holds two values. A maybe holds at most one: it's either empty or holds a single value. Every maybe is built either as Nothing (empty) or as (Just x) (holding x), just like Haskell's Maybe.

You could encode maybes the way Lab 8 encoded lists: as a pair whose first component is a Boolean tag. There's a neater way, which takes its idea from the Booleans.

The one thing you do with a Boolean is make a choice. ((b e1) e2) gives e1 if b is True and e2 if b is False. Once you can choose, you can build Not, And, and Or on top. So the Booleans are the choosers.

The one thing you do with a maybe is find out whether it's empty and, if it isn't, get at the value inside. So make each maybe a function that takes two arguments, "what to do if it's empty" and "what to do with the value if there is one," and picks the right one:

((Nothing      whenNothing) whenJust)   →*   whenNothing
(((Just value) whenNothing) whenJust)   →*   (whenJust value)

Notice that whenJust gets the value as an argument. For example, if b is a maybe that might hold a numeral, this expression means "if b is empty, give me 0; if it holds some m, give me m + 1":

((b 0) (\m. (Succ m)))

The maybe does the choosing. You hand it both options.

Define Nothing and Just so that they behave as above.

✅ Check your work (Part 3)

Expression Should give
((Nothing whenNothing) whenJust) whenNothing
(((Just value) whenNothing) whenJust) (whenJust value)

Then try the Succ example with a real maybe: (((Just 2) 0) (\m. (Succ m))) should give 3, and ((Nothing 0) (\m. (Succ m))) should give 0.

Part 4: Lists, Revisited (10%)

Lab 8 encoded a list as a tagged pair. Using what you learned from maybes, encode lists a second way. Define Nil and Cons so that:

((Nil            whenNil) whenCons)   →*   whenNil
((((Cons h) t)   whenNil) whenCons)   →*   ((whenCons h) t)

A list is either empty or has a head and a tail, so whenCons gets two arguments.

Then define IsEmpty, Head, and Tail for this encoding. Each one takes a list and applies it to two well-chosen arguments. What Head and Tail do on an empty list is up to you: return anything you like, or assume it never happens.

✅ Check your work (Part 4)

Expression Should give
((Nil whenNil) whenCons) whenNil
((((Cons h) t) whenNil) whenCons) ((whenCons h) t)
(IsEmpty Nil) (λt.(λf.t))
(IsEmpty ((Cons 3) Nil)) (λt.(λf.f))
(Head ((Cons 3) Nil)) 3
(Tail ((Cons 3) Nil)) your Nil, written out

(Tail ((Cons 3) Nil)) prints whatever your definition of Nil expands to. Check that it really is your Nil.

Part 5: Binary Trees (10%)

A binary tree is either a leaf or a branch with a value, a left subtree, and a right subtree. Define Leaf and Branch so that:

((Leaf                  whenLeaf) whenBranch)   →*   whenLeaf
(((((Branch v) l) r)    whenLeaf) whenBranch)   →*   (((whenBranch v) l) r)

✅ Check your work (Part 5)

Expression Should give
((Leaf whenLeaf) whenBranch) whenLeaf
(((((Branch v) l) r) whenLeaf) whenBranch) (((whenBranch v) l) r)

Then build a small tree, such as a branch holding 1 with two leaves, and use it to get the value at the root.

Part 6: Fibonacci Without Recursion (10%)

Every definition in the file so far is just an abbreviation. You could replace each macro name with its definition, over and over, until you had one long expression that uses only variables, λ, and application. A definition like

Fib = (\n. ... (Fib (Pred n)) ...);

breaks that rule: Fib mentions itself, so the abbreviation could never be expanded away. One fix is Y, as in Module 08.3. But often you don't need recursion at all.

Define Fib without using Y (and without Fib mentioning itself), so that (Fib n) reduces to the n-th Fibonacci number for every n ≥ 0. As a reminder, F₀ = 0, F₁ = 1, and Fₙ = Fₙ₋₁ + Fₙ₋₂ for n ≥ 2.

Use Pred from Lab 8 as your model. Pred applied a step function n times to a starting pair and then took one component. What sequence of pairs would help here?

The tests hide Y when they check Fib, so a definition that uses Y won't pass.

✅ Check your work (Part 6)

Expression Should give
(Fib 0) 0
(Fib 4) (λf.(λb.(f (f (f b))))), which is 3
(Fib 6) 8

Depending on how you wrote Fib, the output may look a little different, for example with other variable names. It should still be the right numeral.

Part 7: The 3n + 1 Step (20%)

The last two Parts are about the 3n + 1 problem, also called the Collatz conjecture. Start with a positive integer and repeatedly:

  • divide it by 2 if it's even, or
  • multiply it by 3 and add 1 if it's odd.

For example, starting at 6: 6, 3, 10, 5, 16, 8, 4, 2, 1. Every number anyone has ever tried eventually reaches 1, but nobody has proved that it always happens.

Define Step, which takes one step of this process: (Step n) gives n / 2 if n is even and 3n + 1 if n is odd.

Define and test some helper functions first, before you write Step itself. Think about what you'd need: a way to tell whether a number is odd or even, and a way to halve an even number. The pair-based trick from Pred and Fib works for these too, and so do other approaches.

✅ Check your work (Part 7)

Expression Should give
(Step 1) 4
(Step 6) 3
(Step 7) 22

Also check your helpers on their own, on both even and odd inputs, including 0.

Part 8: Counting Steps to 1 (10%)

Define Tnpo ("three n plus one"), which takes a positive number and returns how many times Step must be applied to it to reach 1:

Expression Should give
(Tnpo 1) 0
(Tnpo 2) 1
(Tnpo 5) 5
(Tnpo 3) 7

This one is different from Fib. You can't know ahead of time how many steps a number will take, so there's no numeral to use as the loop counter. This is where you need Y. Use the recipe from Module 08.3: write the recursive definition you want, turn the recursive call into an extra parameter, and hand the result to Y.

✅ Check your work (Part 8)

Run :set trace off first, then try the four expressions in the table above.

These are slow. With one reasonable definition, (Tnpo 5) takes about 9,000 β-reductions and (Tnpo 3) about 45,000. Yours may take more or fewer. How fast it runs depends a lot on how you wrote it, and you aren't graded on speed. Every correct, readable definition gets full credit.


Turning It In

Submit on Gradescope, under HW 08: Lambda Calculus:

  1. From the top of the repo, run the tests one last time: ./run-tests > testing.log. Paste the entire contents of testing.log into the test-output text box, even if some tests still fail.
  2. Upload hw8.lc, the only file you edit.

Gradescope also asks a few short questions about your definitions.

The Homework Workflow covers the submission step in context. Make sure your final code is committed and pushed to your repo as well.

Final Self-Check

  • hw8.lc loads in lc with no errors, and none of the placeholders (= 0;) are left.
  • You didn't change the provided definitions at the top of the file.
  • Fib doesn't use Y and doesn't mention itself.
  • Every ✅ Check your work example gives the expected answer.
  • ./run-tests shows the total you expect.

Where to Go From Here

In 1936, Alonzo Church and Alan Turing each answered the same question, which problems can be solved by a mechanical procedure, in very different ways: Church with the lambda calculus and Turing with his machines. They turned out to describe exactly the same set of computable functions. Turing noted at the time that lambda definitions were often easier to work with than the matching machines, which you may or may not agree with after this assignment. If you'd like to hear more of that story, Philip Wadler's talk and paper Propositions as Types connects it to the type systems in languages like Haskell.