At this point in the course, we're collecting many of the building blocks of a simple programming language, the simply typed lambda calculus or STLC. However, many of them may seem too simple to actually correspond to real programming languages. For example, our functions can only operate on single arguments, whereas functions in real programming languages operate on many arguments at a time. In this note, I'll talk about two ways we can represent multiple-argument functions in STLC, and we'll see that these ways are actually equivalent (or, if you prefer, interconvertable).
One approach to expressing functions of multiple arguments is to use product types to compact multiple "intuitive" arguments into a single actual argument. For a simple example, consider a function that adds three numbers. In a language like C or Java, this function might be written as follows.
int f(int x, int y, int z) { return x + y + z; }
We think of this as a single function f
with three arguments, x
,
y
, and z
. That is, we must apply this function to three arguments,
and when we do it will produce a result. The most direct way we can express such a function in
our language is to use products to combine multiple individual arguments into a single
compound argument. For example, we could write a function in STLC that takes a tuple of
arguments (int * int) * int -> int
as follows
\p : (int * int) * int -> let (q, z) = p in let (x, y) = q in x + y + z
We have defined a function which must be given three arguments (packaged in product types), and
then immediately computes their sum. If we call the function above f1
,
then a sample evaluation of this function would go something as follows:
f1 ((1, 2), 3) ---> let (q, z) = ((1,2), 3) in let (x, y) = q in x + y + z ---> let (x, y) = (1, 2) in x + y + 3 ---> 1 + 2 + 3 ---> 6
Hopefully this corresponds to your intuition of how a multi-argument function "ought" to work (albeit with some extra boilerplate surrounding the use of tuples).
However, you may have noticed that this doesn't seem to be the way that Haskell treats multi-argument functions. Indeed, it isn't. There's an alternative approach, called currying, which is what's actually used in Haskell (and several other functional languages). A curried function is essentially a nest of functions; each nested function collects one more argument until all the arguments have been collected. For example, we could also write our 3-argument addition function as follows.
\x : int -> (\y : int -> (\z: int -> x + y + z))
Here, the outermost function collects the value of parameter x
, the next function
collects the value of parameter y
, while the innermost function collects the value
of parameter z
and computes the result. If we call this
function f2
, then a sample evaluation of this function would go
something as follows.
((f2 1) 2) 3 ---> ((\y : int -> (\z : int -> 1 + y + z)) 2) 3 ---> (\z : int -> 1 + 2 + z) 3 ---> 1 + 2 + 3 ---> 6
The curried view may not immediately correspond to your intuition about how multi-argument functions should operate. However, hopefully this example convinces you that it's actually doing the same thing!
Haskell employs various syntactic abbreviations to make writing and using curried functions natural. For example, when you write a Haskell function that seems to have multiple arguments:
f x y z = x + y + z
Haskell actually interprets that definition as if it were defining a nest of three lambdas. You
can see this if you ask for the type, which Haskell well render as something like Int ->
Int -> Int
. When reading this type, remember that Haskell groups function arrows to the
right, so that type is equivalent to Int -> (Int -> Int)
. Similarly, when you write
a function application like map f xs
, Haskell automatically groups applications to
the left, so that is equivalent to writing (map f) xs
.
Now, we have two apparently equivalent approaches to multi-argument functions: the curried version, in which functions are actually nests of functions, and the uncurried version, in which functions take tuples of arguments. The next question that should arise is whether one of these versions is more expressive than the other. That is to say, can we write functions in a curried style that we could not write in an uncurried style, or vice-versa?
In fact, we cannot. To provide a "formal" argument that we can't, we'll write functions that convert between the curried and uncurried versions of a given function. To start, let's consider the type of the "currying" function, which takes an uncurried function and returns its curried variant.
((a * b) -> c) -> (a -> (b -> c))
I've gone a little outside of our STLC language here by writing abstract
types a,b,c
instead of concrete types like Int
or Bool
.
At this point, you should view these as "slots" in my explanation, into which you (the reader)
could plug arbitrary types. Later in the semester, we'll see how to capture this notion of
abstract types within our language.
Now, let's attempt to define a function of this type. STLC has very little syntax around, so
most of our choices are determined by the type. In particular, we're supposed to start out
creating a function that takes something of type (a * b) -> c
, so we'd better start
out with a lambda: \ f : ((a * b) -> c) -> ...
We need to produce a return value
of type a -> (b -> c)
. Again, the only syntactic form available to us is lambdas,
so we'll have a few more of them: \ f : ((a * b) -> c) -> (\x : a -> (\y -> b ->
...
Finally, we need to produce a value of type c
. For any
concrete c
, like Int
or Bool
, I would have some idea how
to produce such a value. But I'm trying to make a generic argument, where you get to pick which
types go in a, b, c
. This means that the only way I could make a value of
type c
is to call the function f
. The complete code for the currying
function is as follows:
\ f : ((a * b) -> c) -> (\x : a -> (\y -> b -> f (x, y)
Of course, we can also go the other way, and write an uncurrying function with the following type.
(a -> (b -> c)) -> ((a * b) -> c)
As for the currying function, our choices in writing this function are mostly determined by the type we'd like the result to have. I'll leave you to go through the details, but the end result should be:
\f : (a -> (b -> c)) -> (\p : (a * b) -> let (x, y) = p in f x y)
At this point, it may not be entirely obvious that we've done the right thing. Perhaps an
example will help. Let's name the currying function curry
, and let's also
introduce a function to curry. Here's a simple one.
add = \p : int * int -> let (x, y) = p in x + y
Now, if our currying function does what it's supposed to do, we should be able to use it to
curry the add
function. Let's see how the reduction works out. Note that I'll
leave the add
function un-expanded as long as possible, just to avoid symbol
overload.
((curry add) 2) 3 ---> (((\f : ((Int * Int) -> Int) -> (\x : Int -> (\y : Int -> f (x, y)))) add) 2) 3 ---> ((\x : Int -> (\y : Int -> add (x, y))) 2) 3 ---> (\y : Int -> add (2, y)) 3 ---> add (2, 3)
We could continue the evaluation of add (2, 3)
, but we don't need to. We've
already seen that currying has turned a curried function call into an uncurried one.
Optional exercise. Define a curried addition function add2
function, and then confirm
that uncurry add2 (1,2)
works as you'd expect.
Optional exercise: The uncurry
function is itself curried! Understand that
statement, and then write an uncurried version of the uncurry function. Confirm that you get
the same result if you evaluate uncurry uncurry
.
So now, in addition to having two different views of multi-argument functions, we also have functions that convert between these two views. What have we learned?
For one, we've learned that there aren't any functions we can write one way that we couldn't
write the other way. Suppose we thought that we had a curried function f
which
couldn't be written uncurried. The counterexample is immediate: uncurry f
computes
the uncurried version of function f
. More generally, we can think of these
functions as proving that the curried and uncurried functions are in some sense "the same".
That is, every uncurried function corresponds to exactly one curried function, and vice versa.
(For an analogy, think of how cartesian and polar coordinates each describe exactly the same 2-d
plane.)
This is an example of a pervasive technique in the study of programming languages. We'll frequently want to compare the expressiveness of different language features; that is, to ask whether we can express more programs using one feature than another, or to ask whether some feature actually extends the expressiveness of a language or just makes it easier to use. One way to answer these questions is to define mappings between the features, just as we've done here with multi-argument functions, or to show that such mappings can't be defined.
As the semester goes on, we'll see more and more examples of languages features and mappings between them.
You may still be left wondering about the point. What's the value of having two different ways of viewing multi-argument functions? Are we better off now than we were when we just had multi-argument functions, as in C or Java?
The first answer is that curried functions can be more flexible. Many examples of more advanced
functional programming, particularly in Haskell, rely on currying to describe computations (or
pipelines of computation). There are several examples of this in
the sample solutions to homework 0 (look in particular around uses
of the function composition operator .
).
The second answer is that currying and uncurrying is one of the simplest examples of higher-order functional programming---that is to say, programming that manipulates functions, not just values. By studying simple examples of higher-order functional programming, it will hopefully be easier to understand more complicated examples as they come along later in the semester.
The third answer is that the curried versions of functions are, in a technical sense, simpler than the uncurried versions. That is to say: uncurried functions are defined in terms of two distinct ideas, functions and products, while curried functions require only the idea of functions. Later in the semester, we'll see that with a bit more magic in our types, we'll be able to do away with the idea of products entirely.