CSE301

Lab 4 (lambda calculus and propositions-as-types)

General instructions

Introduction

This lab is about both untyped and simply-typed lambda calculus. In addition to reviewing the slides from Lecture 3, you may find it helpful to read parts of Peter Selinger’s lecture notes on the lambda calculus for background.

Simply-typed lambda calculus and principal type inference

--- STLC and principal type inference

The aim of the first part of this lab is to get a better understanding of how type inference works in Haskell (and in similar languages like OCaml and SML), by considering a few examples of simple type inference for lambda terms. For reference, let’s recall the formal typing rules of simply-typed lambda calculus (STLC):

\[ \frac{x:A \in \Gamma}{\Gamma \vdash x:A}var \qquad \frac{\Gamma,x:A \vdash e : B}{\Gamma \vdash \lambda x.e : A \to B}lam \qquad \frac{\Gamma \vdash e_1 : A \to B \quad \Gamma \vdash e_2 : A}{\Gamma \vdash e_1\ e_2 : B}app \]

Remember that we write \(\Gamma \vdash e:B\) to indicate that an expression \(e\) with free variables \(x_1,\dots,x_n\) has type \(B\) in a typing context where its free variables are assigned types \(\Gamma = x_1:A_1,\dots,x_n:A_n\). For example, we can use these rules to derive that \(\vdash \lambda f.\lambda x. f\ x : (a \to a) \to (a \to a)\). Here is the explicit derivation tree:

\[ \frac{ \frac{ \frac{ \frac{\displaystyle f : a \to a \in f : a \to a,x : a}{\displaystyle f : a \to a,x : a \vdash f : a \to a}\qquad \frac{\displaystyle x:a \in f : a \to a,x : a}{\displaystyle f : a \to a,x : a \vdash x : a} } {\displaystyle f : a \to a,x : a \vdash f\ x : a}} {\displaystyle f : a \to a \vdash \lambda x. f\ x : a \to a}} {\displaystyle\lambda f.\lambda x. f\ x : (a \to a) \to (a \to a)} \]

An important property of simple typing is that if a term is typable then it has a “best” type, called its principal type. Formally, the principal type of a term is its most general type in the following sense: to check whether the term has any other type, it is necessary and sufficient to check whether that type is an instance of the principal type (under some substitution for type variables). For example, the term \(\lambda f.\lambda x. f\ x\) has principal type \((a \to b) \to (a \to b)\), and we can see that the typing above is recovered by substituting \(a\) for \(b\).

Let’s consider two more examples:

In both cases, we can get the GHC interpreter to quickly give us this information using the :t (a.k.a. :type) command:

*Main> :t \x y z -> x (y z)
\x y z -> x (y z) :: (t1 -> t2) -> (t3 -> t1) -> t3 -> t2
*Main> :t (\x -> x x) (\x -> x x)

<interactive>:1:22: error:
Occurs check: cannot construct the infinite type: t0 ~ t0 -> t
In the first argument of ‘x’, namely ‘x’
      In the expression: x x
      In the first argument of ‘\ x -> x x’, namely ‘(\ x -> x x)’
Relevant bindings include
        x :: t0 -> t (bound at <interactive>:1:15)

Let’s take a moment to understand the process that GHC goes through in order to compute these results.

Since \(\lambda xyz.x\ (y\ z)\) is a lambda abstraction, for it to be typed using the \(lam\) rule, it would need to have a type \(A\) of the form \(A = X_1 \to B\) where \(x:X_1 \vdash \lambda yz.x\ (y\ z) : B\). By the same argument repeated twice, \(B\) must be of the form \(B = X_2 \to (X_3 \to X_4)\) where \(x:X_1, y:X_2, z:X_3 \vdash x\ (y\ z) : X_4\). Now, since \(x\ (y\ z)\) is an application, and the variable \(x\) being applied is assumed to have type \(X_1\), to use the \(app\) rule we must have \(X_1 = X_5 \to X_4\) for some \(X_5\) with \(y\ z : X_5\). Repeating the argument, since the variable \(y\) being applied in \(y\ z\) is assumed to have type \(X_2\) while the argument \(z\) is assumed to have type \(X_3\), we must have \(X_2 = X_3 \to X_5\). Combining all of these constraints \[ A = X_1 \to B \qquad B = X_2 \to X_3 \to X_4 \qquad X_1 = X_5 \to X_4 \qquad X_2 = X_3 \to X_5 \] and simplifying them, we derive that the most general type for \(\lambda xyz.x\ (y\ z)\) is \[ A = (X_5 \to X_4) \to (X_3 \to X_5) \to (X_3 \to X_4) \] which indeed is equivalent up to renaming and parenthesization with \((b \to c) \to (a \to b) \to (a \to c)\) and (t1 -> t2) -> (t3 -> t1) -> t3 -> t2.

On the other hand, for \((\lambda x.x\ x)(\lambda x.x\ x)\) to be typed using the \(app\) rule, we need to first type the subexpression \(\lambda x.x\ x\). Since it is a lambda abstraction, we can try to assign it some type \(A \to B\) where \(x : A \vdash x\ x : B\). Now, the variable \(x\) is both the function and the argument of the application \(x\ x\). Since the argument is assumed to have type \(A\), for the result to have type \(B\) we need that \(A = A \to B\). But this equation is impossible to satisfy with finite types, and so the subexpression \(\lambda x.x\ x\) is not simply-typable, and hence neither is the larger expression \((\lambda x.x\ x)(\lambda x.x\ x)\).

For a more detailed description of a type inference algorithm for STLC, you can read Section 9 of Selinger’s notes, as well as the Wikipedia article on the Hindley-Milner type system.

For each of the untyped λ-expressions below, \[ e_1 = \lambda fx. x \quad e_2 = \lambda fx. f\ (f\ x) \quad e_3 = \lambda x.x\ (\lambda y.y) \] \[ e_4 = \lambda x y.x\ y\ y \quad e_5 = \lambda xy.x\ (y\ y) \quad e_6 = \lambda xy.x\ (y\ x) \] determine:

  1. Whether it has a simple type, and

  2. If so, determine its most general type.

You should try to do this first without asking the GHC interpreter, and then verify your answer using GHC. Record your answers as comments below. (You do not need to give a full derivation, but try to understand for yourself why the answer is what it is.)

-- Exercise 1a
{-
e1 :: ??
e2 :: ??
e3 :: ??
e4 :: ??
e5 :: ??
e6 :: ??
-}

Conversely, we can also consider the problem backwards, and starting from a type try to find a corresponding term with that type. Often, knowing that a function has a polymorphic type can tell you a lot about that function. For example, the only pure lambda term that can be assigned polymorphic type \(a \to a\) is the identity function \(\lambda x.x\). Although in Haskell the type \(a \to a\) also admits some other less savory inhabitants (such as the expression undefined), in general we can nevertheless learn a lot about a function just by looking at its type.

Come up with Haskell functions with the polymorphic types below. There may be more than one valid answer, but we do not accept undefined as an answer.

-- Exercise 1b
fn1 :: a -> b -> (a -> b -> c) -> c
fn1 = undefined
fn2 :: a -> b -> (a -> b -> c) -> (a,b,c)
fn2 = undefined
fn3 :: ([a] -> b) -> a -> b
fn3 = undefined
fn4 :: ((a -> a) -> b) -> b
fn4 = undefined

Is there a simply-typed λ-expression whose principal type is \((a \to a) \to a\)? Why or why not?

-- Exercise 1c (optional)
{-
mysterylam = ??
-}

What about in Haskell? Is it possible to write a function in Haskell whose most general type is \((a \to a) \to a\), without using the expression undefined or throwing an exception? Show how, or explain why it is not possible.

-- Exercise 1d (optional)
mysteryfn = undefined

Implementing a bidirectional type checker (optional)

--- Bidirectional typing

The fact that the programmer can get away with placing very few type annotations in programming languages like Haskell and OCaml is both a benefit and a drawback. Besides being an important form of documentation, type annotations are one of the main mechanisms for a programmer to get across their intention to the programming environment, and in particular to allow the type checker to return better error messages when things go wrong.

Since pure type inference amounts to a global constraint solving problem, it can sometimes be difficult to localize the source of a type checking error. For example, consider the following code sample (adapted from the article Understanding beginners’ mistakes with Haskell), which was intended to define a recursive function multiplying the elements of a list:

prod [] = 1
prod (x:xs) = x (*) prod xs

When trying to load a file containing this definition, GHC returns the following somewhat inscrutable error message:

    • Occurs check: cannot construct the infinite type:
        t
        ~
        [(Integer -> Integer -> Integer) -> t -> t0 -> Integer] -> Integer
    • Relevant bindings include prod :: t (bound at Lab4Sols.hs:135:1)
    |
135 | prod [] = 1
    | ^^^^^^^^^^^...
Failed, no modules loaded.

Notice that GHC complains about the first clause of the definition prod [] = 1, although the real source of the bug is the second clause prod (x:xs) = x (*) prod xs, where the programmer has written (*) instead of *. If we preface the definition by a type declaration:

prod :: Num a => [a] -> a

then GHC produces a somewhat better error message (at least it correctly identifies the second clause),

    • Occurs check: cannot construct the infinite type:
        a
        ~
        (Integer -> Integer -> Integer)
        -> ([Integer] -> Integer) -> [a] -> a
    • The function ‘x’ is applied to three arguments,
      but its type ‘a’ has none
      In the expression: x (*) prod xs
      In an equation for ‘prod’: prod (x : xs) = x (*) prod xs
    • Relevant bindings include
        xs :: [a] (bound at Lab4Sols.hs:137:9)
        x :: a (bound at Lab4Sols.hs:137:7)
        prod :: [a] -> a (bound at Lab4Sols.hs:136:1)
    |
137 | prod (x:xs) = x (*) prod xs
    |               ^^^^^^^^^^^^^

although it might still take the programmer some time to understand how to fix the bug.

Here is another example of a slightly buggy function:

revString :: String -> String
revString s = foldl (flip (++)) [] s

The programmer had in mind the slick definition of list reversal using fold-left (reverse = foldl (flip (:)) []), but used list concatenation (++) in place of the cons’ing operation (:). However, the type checker mislocates the error, complaining about the type of the argument s:

    • Couldn't match type ‘Char’ with ‘[Char]’
      Expected type: [[Char]]
        Actual type: String
    • In the third argument of ‘foldl’, namely ‘s’
      In the expression: foldl (flip (++)) [] s
      In an equation for ‘revString’:
          revString s = foldl (flip (++)) [] s
  |
2 | revString s = foldl (flip (++)) [] s
  |                                    ^

The reason is because the type checker is able to satisfy all of the necessary constraints to find a principal type for the subexpression foldl (flip (++)) [], up until the point where it is applied to an argument of type String.

Much research has been carried out on the design of type systems to maximize expressivity and ease-of-use while still providing robust error messages, but it remains an open problem. In this exercise, we will explore another corner of the design space known as bidirectional typing, which in general requires more type annotations on the part of the programmer, but has the advantage of better localizing type errors.

To define a bidirectional type system for STLC, we begin by introducing a pair of checking and synthesis judgments that refine the basic typing judgment \(\Gamma \vdash e : A\) from above:

For each of the different kinds of λ-expressions we either have a checking rule or a synthesis rule,

\[ \frac{x:A \in \Gamma}{\Gamma \vdash x \Rightarrow A} \qquad \frac{\Gamma \vdash e_1 \Rightarrow A \to B \quad \Gamma \vdash e_2 \Leftarrow A}{\Gamma \vdash e_1\ e_2 \Rightarrow B} \qquad \frac{\Gamma,x:A \vdash e \Leftarrow B}{\Gamma \vdash \lambda x.e \Leftarrow A \to B} \] which can be read as follows:

We also need to include a rule for passing from synthesis to checking:

\[ \frac{\Gamma \vdash e\Rightarrow A \quad A = B}{\Gamma \vdash e \Leftarrow B} \]

In words, this says that to check \(e\) against a type \(B\) (in a given context Γ), we can try to synthesize a type \(A\) for \(e\) (in the same context) and check that \(A = B\).

Finally, we add a rule that allows to synthesize a type for a term with an explicit type annotation:

\[ \frac{\Gamma \vdash e\Leftarrow A}{\Gamma \vdash (e:A) \Rightarrow A}\qquad \]

An elegant property of the bidirectional type system is that such type annotations are only ever needed at β-redices.

The goal of this (optional) exercise will be to write a bidirectional type checker for STLC in Haskell. We are not going to implement any fancy parsing or pretty-printing, and instead just work with raw data types of types and expressions.

Let’s begin by introducing a data type of simple types:

data Ty = TV Int | Fn Ty Ty
    deriving (Show,Eq)

A simple type (represented by a value of type Ty) is either a type variable (built using the constructor TV) or a function type (built using the constructor Fn). Note we make the choice to use integers to stand for the names of type variables.

Next we’ll introduce a data type of λ-expressions with embedded type annotations:

data Expr = V Int | A Expr Expr | L Int Expr | Ann Expr Ty
    deriving (Show,Eq)

That is, an expression is either a variable (V), an application (A), a lambda abstraction (L), or a type annotation. Again we make the choice to use integers to stand for the names of (expression) variables.

For example, here is the representation of the expression \(\lambda xyz.x\ (y\ z)\):

bcomp = L 0 $ L 1 $ L 2 $ A (V 0) (A (V 1) (V 2))

and here is the representation of the annotated expression \((\lambda fx.f\ x : (a \to a) \to (a \to a))\ (\lambda x.x)\):

oneid = A (Ann (L 0 $ L 1 $ A (V 0) (V 1)) (Fn (Fn (TV 0) (TV 0)) (Fn (TV 0) (TV 0)))) (L 0 $ V 0)

To define the checking and synthesis typing judgments, we’ll represent typing contexts as lists of pairs of an integer (denoting a variable name) together with a corresponding type. It is convenient to introduce a type synonym:

type TyCxt = [(Int,Ty)]

Finally, our goal is to define functions

check :: TyCxt -> Expr -> Ty -> Bool
synth :: TyCxt -> Expr -> Maybe Ty

satisfying the following specifications: check Γ e A should evaluate to True just in case \(\Gamma \vdash e \Leftarrow A\), and otherwise evaluate to False, while synth Γ e should evaluate to Just A just in case \(\Gamma \vdash e \Rightarrow A\), and otherwise evaluate to Nothing.

Based on the typing rules explained above, complete the definitions:

-- Exercise 2 (optional)
check = undefined
synth = undefined

You will probably want to begin by pattern-matching…

Here are a few examples that you can use for testing, but you should also write your own tests!

> check [] (L 0 $ V 0) (Fn (TV 0) (TV 0))
True
> check [] (L 0 $ V 0) (Fn (TV 0) (TV 1))
False
> check [] bcomp (Fn (Fn (TV 0) (TV 0)) (Fn (Fn (TV 0) (TV 0)) (Fn (TV 0) (TV 0))))
True
> check [] bcomp (Fn (Fn (TV 1) (TV 2)) (Fn (Fn (TV 0) (TV 1)) (Fn (TV 0) (TV 2))))
True
> check [] bcomp (Fn (Fn (TV 0) (TV 1)) (Fn (Fn (TV 1) (TV 2)) (Fn (TV 0) (TV 2))))
False
> check [] oneid (Fn (TV 0) (TV 0))
True

To learn more about the history of bidirectional typing and its various applications, you may be interested in this survey article by Jana Dunfield and Neel Krishnaswami.

Programming in untyped lambda calculus

--- Programming in untyped lambda calculus

The last group of exercises will ask you to define various untyped lambda expressions computing different functions, and to test these definitions by running them on a few inputs. Of course the latter can be done by performing β-reduction by hand, but you might find it more fun to use one of the lambda calculus interpreters available online, such as George Kaye’s λ-term visualiser (demo’d in class), or Alec Minchington’s Lambster.

You should record your solutions as comments in your Haskell file in the format <name> = <λ-expression>. For example, the encodings of booleans and natural numbers and operations on them that we saw in class could be entered as follows:

-- Encodings of booleans and natural numbers from class
{-
true = \x.\y.x
false = \x.\y.y
not = \b.\x.\y.b y x
and = \b.\c.b c false
zero = \f.\x.x
one = \f.\x.f x
two = \f.\x.f (f x)
three = \f.\x.f (f (f x))
succ = \n.\f.\x.f (n f x)
add = \m.\n.m succ n
mult = \m.\n.m (add n) 0
isZero = \n.n (\b.false) true
-}

You can refer to any of these definitions in the λ-expression you define below, and of course you can refer to any λ-expressions you define yourself.

Let’s start with some exercises to warm up…

Define a λ-expression isEven which given a Church-encoded natural number as input, returns true or false depending on whether or not the number is even. For example, isEven one should normalize to false (= \(\lambda xy.y\)), and isEven two should normalize to true (= \(\lambda xy.x\)).

-- Exercise 3a
{-
isEven = <your definition here>
-}

Hint: remember that under the Church-encoding of natural numbers, the number \(n\) is represented as a “function composer”, which given a function \(f\) returns a new function corresponding to the \(n\)-fold composition of \(f\), that is, \(n = \lambda f.\lambda x.f^n x\). In this case, there is a simple function \(f\) that we can iterate \(n\) times on an appropriate initial value \(x\), in order to determine whether the number \(n\) is even.

Define a λ-expression exp which given two Church-encoded natural numbers as inputs, computes the first raised to the power of the second. For example, exp two three should normalize to the Church-numeral 8 = \(\lambda f x.f(f(f(f(f(f(f(f(x))))))))\).

-- Exercise 3b
{-
exp = <your definition here>
-}

Untyped lambda calculus does not have a built-in mechanism for defining pairs (or anything other than functions!), but like the booleans and the natural numbers, they can be encoded. The idea is that a pair is represented as a higher-order function that applies its argument to its two components. That is, we encode the tuple \((e_1, e_2)\) by: \[ (e_1, e_2) \mathbin{\overset{\mathrm{def}}=} \lambda f.f\ e_1\ e_2 \] Under this representation, the first and second projections are easily defined by application to the functions \(\lambda x.\lambda y.x\) and \(\lambda x.\lambda y.y\) respectively: \[ \pi_1 e \mathbin{\overset{\mathrm{def}}=} e\ (\lambda x.\lambda y.x) \qquad \pi_2 e \mathbin{\overset{\mathrm{def}}=} e\ (\lambda x.\lambda y.y) \]

For reference, let’s write down these encodings as comments:

-- Encodings of pairing and projections
{-
pair = \x.\y.\f.f x y
fst = \p.p (\x.\y.x)
snd = \p.p (\x.\y.y)
-}

Define a λ-expression swap which swaps the components of a pair. For example, swap (pair true one) should be β-equivalent to pair one true.

-- Exercise 3c
{-
swap = <your definition here>
-}

Define a λ-expression swapIf, which swaps the components of its second (pair) argument just in case its first (boolean) argument is true. For example, swapIf true (pair true one) should be β-equivalent to pair one true, while swapIf false (pair true one) should be β-equivalent to pair true one.

-- Exercise 3d
{-
swapIf = <your definition here>
-}

For being such a simple operation, the predecessor function on natural numbers \[ n \mapsto \begin{cases} 0 & n = 0 \\ n-1 & n > 0 \end{cases} \] is surprisingly tricky to define in untyped lambda calculus, with Kleene reporting to have found the inspiration for a really clever trick at the dentist’s office. To understand the trick, it might help to first try to understand how a similar trick can be used to define a slightly more complicated function.

Without using a fixed point operator, define a λ-expression fib that given a Church-encoded natural number \(n\) as input, returns the \(n\)th Fibonacci number as output.

-- Exercise 3e (optional)
{-
fib = <your definition here>
-}

Hint: The idea is similar to the one we used to define a linear-time version of Fibonacci in Lecture 1 by first defining a function that takes an extra pair of accumulator parameters.

Now, using your experience defining the Fibonacci sequence, define the predecessor function as a λ-expression, and finally use that to define a function testing whether two natural numbers are equal.

Define a λ-expression pred which takes a Church-encoded natural number \(n\) as input, and returns \(n-1\) if \(n>0\), or else \(0\) if \(n=0\).

-- Exercise 3f (optional)
{-
pred = <your definition here>
-}

Define a λ-expression eqNat which takes two Church-encoded natural numbers \(m\) and \(n\) as input, and returns (the Church-encoded boolean) true just in case \(m = n\), or else false.

-- Exercise 3g (optional)
{-
eqNat = <your definition here>
-}

As we discussed in class, fixed point operators can be used to encode general recursive functions in untyped lambda calculus. The first fixed point combinator was discovered by Curry (the famous “Y combinator”, not to be confused with the startup accelerator), and another important one was introduced by Turing in his proof of the equivalence between λ-definability and Turing-machine computability:

-- Curry's and Turing's fixed point operators
{-
Y = \x.(\y.x(y y))(\y.x (y y))
Theta = (\x.\y.y (x x y)) (\x.\y.y (x x y))
-}

The Collatz conjecture states that starting from any positive natural number \(n \ge 1\), iteratively applying the function \[ f(n) = \begin{cases}\frac{n}{2} & \text{if }n\text{ is even} \\ 3n+1 & \text{if }n\text{ is odd} \end{cases} \] will eventually reach the number 1. That is, for all \(n \ge 1\), there exists a \(k\) such that \(f^k(n) = 1\) (conjecturally).

Using a fixed point operator, write a λ-expression collatz that given a Church-encoded natural number \(n\), returns the smallest natural number \(k\) such that \(f^k(n) = 1\). If no such number exists (because the Collatz conjecture is false), your function is allowed to not terminate.

-- Exercise 3h (optional)
{-
collatz = <your definition here>
-}