Suppose we want to write a function to sum a list of integers. By now you should be able to write the following code:
fun sum (l:int list):int = case l of [] => 0 | x::xs => x + (sum xs)
Now suppose we want to concatenate a list of strings. We can write:
fun concat (l:string list):string = case l of [] => "" | x::xs => x ^ (concat xs)
Notice that both functions look almost identical. With the exception of the different types and different operation (^ vs +), both functions are equivalent. In both cases, we walk down a list performing some operation with the data at each step. Since we love to reuse code in this class, is there some easier way to encode this?
It turns out we can abstract all of the details of traversing a list. The idea is simple. As we walk across a list, we store an accumulator, a value that stores some data that is important to us. For example, if we want to walk across a list of integers and sum them, we could store the current sum in the accumulator. We start with the accumulator set to 0. As we come across each new element, we add the element to the accumulator. When we reach the end, we return the value stored in the accumulator.
Let's try to rewrite the sum function to introduce the idea of an accumulator.
fun sum' (acc:int) (l:int list):int = case l of [] => acc | x::xs => sum' (acc+x) xs
Of course, to get the sum, we must call sum' with 0 for the initial acc value. Similarly, we can rewrite concat with this concept of the accumulator.
fun concat' (acc:string) (l:string list):string = case l of [] => acc | x::xs => concat' (acc^x) xs
To use this function, we pass in the empty string for the inital accumulator. Now we see even more similarity between the two functions. We are in a position now to eliminate any differences between the two by passing in the operator that acts on the head of the list and the accumulator. The result is the most powerful list function in the list library: List.foldl.
fun foldl (f: 'a*'b->'b) (acc: 'b) (l: 'a list): 'b = case l of [] => acc | x::xs => foldl f (f(x,acc)) xs
Now we can rewrite sum and concat as
fun sum (l:int list):int = foldl (fn (x,acc) => acc+x) 0 l fun concat (l:string list):string = foldl (fn (x,acc) => acc^x) "" l
Given a function f of type 'a*'b->'b
, the expression
foldl f b [x1,x2,...,xn]
evaluates to
f(xn, f(..., f(x2, f(x1,b))))
.
The 'l' in foldl comes from the fact that it walks the list from left to
right. Thus, we have to be careful sometimes in applying functions in the
right order (hence acc^x and not x^acc in concat above). There is also a
library function foldr which traverses the list from right to left. So,
foldr f b [x1,x2,...,xn]
evaluates to f (x1,f (x2,f (...,f(xn,b))))
. The code for foldr is
fun foldr (f:'a*'b->'b) (accum:'b) (l:'a list):'b = case l of [] => acc | x::xs => f(x, (foldr f acc xs))
We can use foldr to write concat in the more natural manner.
fun concat (l:string list):string = foldr (fn (x,acc) => x^acc) "" l
But we can still do better. Remember, both foldl and foldr are curried, so that we can leave out the list argument and write
val sum = foldl (fn (x,a) => x+a) 0 val concat = foldr (fn (x,a) => x^a) ""
We can do even better. ML provides a convenient notation to use
infix binary operators as functions by qualifying their name with the structure
they belong to (such as Int
, Real
, String
,
List
, etc). So Int.+
is a
function that takes in two integers and adds them. So we can write sum and concat one last time as
val sum = foldl Int.+ 0 val concat = foldr String.^ ""
Now you see the true power of functional programming. Try rewriting functions that sum a list of ints and concatenate a list of strings in 2 short lines of Java.
Folding is so powerful, that we can write other list functions in terms of foldl! For example,
fun length l = foldl (fn (_,a) => a+1) 0 l fun rev l = foldl List.:: [] l fun map f l = foldr (fn (x,a) => (f x)::a) [] l fun app f l = foldr (fn (x,_) => f x) () l fun filter f l = foldr (fn(x,a) => if f x then x::a else a) [] l
When writing code with fold, ML's type system can be tremendously
helpful. The type of fold is ('a*'b->'b)->'b->'a
list->'b
. Suppose we know we are applying foldl to an int list
and we
want to return a string
. Then 'a is of type int
and 'b is of type
string
. So right away, we know that the function we have to pass into
foldl is of type int*string->string
. Remember to use your type system
to help you in writing code.
You should try writing some functions with foldl to get a feel for it. It can be incredibly useful.
A function that returns the value of its recursive call is said to be tail recursive. The advantage is that a tail recursive function can be compiled into a for loop. We will see exactly why later in the course, but for now just notice the following difference between the sum and sum' functions above. In the first sum function, after the recursive call returned its value, we add x to it. In the tail recursive sum', after the recursive call, we immediately return the value returned by the recursion. So in the second case, the compiler can change this recursive call into a simple goto statement. The result is that tail recursive functions tend to run faster than their standard counterparts.
Notice also that foldl is tail recursive whereas foldr is not. Typically when given a choice between using the two functions, you should use foldl for performance. However, in many cases using foldr is easier, as in the concat function above. If you need to use foldr on a very lengthy list, you may instead want to reverse the list first and use foldl. So, we could have written concat as
fun concat (l:string list) = foldl String.^ "" (rev l)
which would be tail recursive.