Today we are going to talk about a tiny programming language that introduces a notion of function into our language.
[TPS:] From a programmer’s perspective, what is a function? What purpose does it serve?
A possible answer is that functions allow us to abstract over data. Instead of writing some repetitive code like
(define l (list 1 2 3 4))
(define l' (list (+ 1 (list-ref l 0))
(+ 1 (list-ref l 1))
(+ 1 (list-ref l 2))
(+ 1 (list-ref l 3))))
We can write a function like
(define (increment-list l)
(type-case (Listof Number) l
(empty empty)
((cons x rest) (cons (+ x 1) (increment-list rest)))
)
)
This increment-list function binds the variable l. Another way to say this is that it abstracts over l, so that it works for all lists, not just a specific one. Note that this is yet another use of the idea of a bound variable.
When we call a function, this is sometimes also called function application, as in applying the function increment-list to the argument (list 1 2 3 4).
Plait also lets us write something (as do many other languages) that is sometimes called an “anonymous function”, but what we will call a “lambda abstraction” – a function without a name. It uses the keyword lambda for this.
(lambda (l)
(type-case (Listof Number) l
(empty empty)
((cons x rest) (cons (+ x 1) (increment-list rest)))
))
Sometimes this is convenient if we only want to use a function once, such as in a call to map:
> (map (\lambda (x) (+ x 1)) (list 1 2 3 4))
- (Listof Number)
'(2 3 4 5)
We can also directly apply a lambda abstraction:
> ((\lambda (x) (+ x 1)) 4)
- Number
5
However, we actually already have a different syntax for this!
(let [(x 4)] (+ x 1))
Directly writing a lambda abstraction and then applying it to a specific argument is just another way of writing a let expression. In this sense, lambda is strictly more general than let, so once we add it to our language, we won’t really need let anymore.
Discussion: This philosophy of only including constructs in our language if they aren’t expressible in terms of existing constructs is called language minimalism. Why might it be a value for studying programming languages? How does this idea interact with the notion that, in practice, we generally want to give users nice syntax for specific uses of the underlying constructs, such as let? (In other words, why do language in practice give us a let construct even though we also have lambda?)
We’re now going to step back from the practical concerns of programming to a very abstract perspective (pun intended): a tiny language called the $\lambda$ calculus. This language is, however, going to turn out to be much more expressive, and have much more complex semantics, than the first tiny language we saw (CalcLang).
The syntax is as follows: \(\Large e ::= x \mid e\;e \mid \lambda{x}.e\)
There are just three things: lambda, application, and variables. We can think of this as a super-concise syntax where things like Plait’s (lambda (x) x) are written $\lambda{x}.\,x$.
Note that we don’t have any base types like numbers, booleans, or strings – yet, shockingly, this language will be enough to give us the most general form of computation.
A few historical notes:
We have a syntax, so what about semantics? It’s also very simple to state in terms of concepts we already understand:
Although this is simple to state, it’s a little bit tricky to see how it works in practice. Let’s talk through some examples.
(First, draw parse trees. Then, develop intuition for the meaning of the term, and what we expect it to evaluate to. Finally, step through evaluation.)
Note that lambda abstractions only “accept one argument” at a time. How can we simulate functions that accept multiple arguments?
Terms to define:
(interp : (Lambda -> Value))
(define (interp e)
(type-case Lambda e
[(varE x) (error 'runtime "unbound variable")]
[(lamE x b) (lamV x b)]
[(appE e1 e2)
(type-case Value (interp e1)
[(lamV x b) (let [(v (interp e2))] (interp (subst x v b)))]
)
]
)
)
What happens if we try to run this expression?
\[\Large (\lambda{x}.\;x\ x)\ (\lambda{x}.\;x\ x)\]Our interpreter might help us understand that this expression won’t terminate if run, but it doesn’t tell us any other useful information, so let’s write a stepper to show us one step at a time.
(step : (Lambda -> Lambda))
(define (step e)
(type-case Lambda e
[(varE X) (error 'runtime "unbound variable")]
[(lamE x b) (lamE x b)]
[(appE f arg)
(type-case Lambda f
[(lamE x b) (type-case Lambda arg
[(lamE x2 b2) (subst x (lamV x2 b2) b)]
[else (appE f (step arg))])]
[else (appE (step f) arg)])]
)
)
This lets us see that the self-application function applied to itself results in the same thing we started with:
> (step omega)
- Lambda
(appE (lamE 'x (appE (varE 'x) (varE 'x))) (lamE 'x (appE (varE 'x) (varE 'x))))
> omega
- Lambda
(appE (lamE 'x (appE (varE 'x) (varE 'x))) (lamE 'x (appE (varE 'x) (varE 'x))))
In mathematics, we sometimes write \(\Large (f\circ g)(x) = f(g(x))\) to mean composing the functions $f$ and $g$. With $\lambda-$notation, we can explicitly define the composition without explicit reference to its argument as:
\(\Large (f\circ g) = \lambda{x}.\;f(g(x))\) We can then notice that $\circ$ itself is a function, taking two functions as arguments and returning another function!
\(\Large \circ = \lambda{f}.\lambda{g}.\lambda{x}.\;f\ (g\ x)\) Example: $\circ\ f\ \mathsf{id}$
Since there are no base types, it may not be obvious how we can do any kind of “standard” programming with this language. Next time, we will show how $\lambda$-calculus lets us encode data such as Booleans, natural numbers, and pairs.