Start Lecture #8
Remark: Lab2 has not yet been assigned. The first question will be to redo lab1 in ML.
This section uses material from Ernie Davis's web page in addition to the sources previously mentioned.
ML was developed by Robin Milner et al. in the late 1970's. It was originally developed for writing theorem provers. We will be most interested in its type system, especially in its ability to use type inference so that we get static typing even though the programmer need very few type declarations. Instead the ML system deduces the types from the context!
We will be using Standard ML of New Jersey
, which you invoke
by typing sml.
ML has the following characteristics
const pointers.
- 7+5 ; val it = 12 : int - val x = 7+5; val x = 12 : int
- 7 = + = 5; val x=12 : int
- 2.0+3.5; val it = 5.5 : real
- 2 + 3.5; stdIn:4.1-10.7 Error: operator and operand don't agree [literal] operator domain: int * int operand: int * real in expression: 2 + 3.5
As with Scheme, you can use ML as a desk calculator. You notice right away that the notation is conventional infix. Other simple characteristics include.
itif nothing else specified.
- [2,3,4,5]; val it = [2,3,4,5] : int list - [[2.0,3.2], [], [0.0]]; val it = [[2.0,3.2],[],[0.0]] : real list list - [2.2,5]; stdIn:8.4-9.8 Error: operator and operand don't agree [literal] operator domain: real * real list operand: real * int list in expression: 2.2 :: 5 :: nil - [1,[2,4]]; stdIn:1.1-4.2 Error: operator and operand don't agree [literal] operator domain: int * int list operand: int * int list list in expression: 1 :: (2 :: 4 :: nil) :: nil - 2::[3,5]; val it = [2,3,5] : int list - hd([2,3,5])); val it = 2 : int; - tl([2,3,4]); val it = [3,4] : int list - [2,3]@[5,7]; val it = [2,3,5,7] : int list - []; val it = [] : 'a list
We will study lists, tuples, and records, emphasizing the first two.
Lists: As in Scheme lists will prove to be important.
- val x = (2, 7.5, "Bob"); val x = (2,7.5,"Bob") : int * real * string #2 x; val it = 7.5 : real
Tuples: Tuples are of fixed length (no concatenation), and can have heterogeneous elements. They are written with parentheses and their elements are selected by index.
Tuples will also prove to be important. Formally, all procedures take only one parameter, but that parameter can be a tuple and hence it is easy to fake multiple parameters.
- val bob = { age=32, firstName="Robert", lastName="Jones", weight=183.2}; val bob = {age=32,firstName="Robert",lastName="Jones",weight=183.2} : {age:int, firstName:string, lastName:string, weight:real}
- #age bob; val it = 32 : int
Records: Records are tuples with named fields. Records are written with braces and elements are selected by giving the field name.
- if (1 < 2) then 3 else 4; val it = 3 : int
- if (1 < 2) then 3 else 4.0; stdIn:38.26-39.26 Error: types of if branches do not agree [literal] then branch: int else branch: real in expression: if 1 < 2 then 3 else 4.0
Naturally some kind of if-then-else is provided; in ML it is actually if-then-else.
Note that the value of the then arm must have the same
type as the value of the else arm.
Again this is important for type inference.
- fun add3 I = I+3; val add3 = fn : int -> int - add3 7; val it = 10 : int - add3 (7); val it = 10 : int
- fun times(X,Y) = X*Y; val times = fn : int * int -> int - times(2,3); val it = 6 : int
- val t = (2,3); val t = (2,3) : int * int - times t; val it = 6 : int
- fun sum3(x,y,z) = x+y+z; val sum3 = fn : int * int * int -> int - sum3(5,6,7); val it = 18 : int
Functions are of course crucial since ML is a functional language.
It is important to note the type of each function on the right and see that ML can determine it without user intervention.
All ML functions take only one argument. Functions of multiple arguments are actually functions of the corresponding tuple. The tuple can be written with () as in the (2,3) example or a tuple variable can be defined as we did as well.
Although not shown, a function can also return a tuple.
Note that ML responds to a fun definition with a val and a fn. As mentioned before val is akin to (define and now note that fn is akin to (lambda.
- fun sumTo(N) = if (N=0) then 0 else N+sumTo(N-1); val sumTo = fn : int -> int - sumTo 10; val it = 55 : int
fun member(X,L) = if (L=[]) then false else if hd(L)=X then true else member(X,tl(L)); stdIn:49.24 Warning: calling polyEqual stdIn:49.54 Warning: calling polyEqual val member = fn : ''a * ''a list -> bool - member(5, [1,5,3]); val it = true : bool
- fun nth(I,L) = if (I=1) then hd(L) else nth(I-1,tl(L)); val nth = fn : int * 'a list -> 'a - nth (3, ["When", "in", "the", "course"]); val it = "the" : string
fun f(X) = if X <= 2 then 1 else g(X-1) + g(X-2) and g(X) = if X <= 3 then 1 else f(X-1) + f(X-3); - f(20); val it = 2361 : int
Iteration is carried out using recursion, as in Scheme.
Again make sure you understand how ML has determined the type. For example in nth, the 1 says int and [] or hd says list
Mutually recursive functions have to be defined together
as shown in the last example.
val (x,y) = (1,2); val x = 1 : int val y = 2 : int
- val x::y = [1,2,3]; val x = 1 : int val y = [2,3] : int list
- val x::y::z = [1,2,3]; val x = 1 : int val y = 2 : int val z = [3] : int list
Patterns in ML are similar to Perl or Prolog, though much less powerful than either of those.
The idea is that it matches a constructor with variables against an object and uses the type of the object to figure out the types of the constituents of the constructor.
On the right we see examples of matching tuples and lists.
Note that for both list examples only one matching is possible.
You can give multiple possible parameters for a function with a separate body for each. When the function is invoked, ML pattern matches the argument against the possible parameters and executes the appropriate body. I find this very cute.
- fun fib 1=1 | fib 2=1 | fib N = fib(N-1) + fib(N-2); val fib = fn : int -> int - fib 10; val it = 55 : int
- fun doubleList [] = [] | doubleList L = 2*hd(L)::doubleList(tl(L)); val doubleList = fn : int list -> int list - doubleList [2,3,5]; val it = [4,6,10] : int list
- fun append ([] , ys) = ys = | append (x::xs, ys) = x::append (xs, ys); val append = fn : 'a list * 'a list -> 'a list
- fun last [h] = h | last (h::t) = last t; stdIn:18.3-18.38 Warning: match nonexhaustive h :: nil => ... h :: t => ... val last = fn : 'a list -> 'a - last [2,3,5]; val it = 5 : int - fun last [h] = h | last (h::t) = last t | last [] = 0; val last = fn : int list -> int
The first example is fairly simple: we do different things for different integer values of N.
The second example separates out the empty list. Note that in both cases the result is a (possibly empty) list of integers. ML has inferred that L is an integer since we multiply it by 2.
Write doubleList in Scheme on the board.
The append function illustrates how you divide lists into empty and those that are the cons of an element and a (smaller) list.
The function last is perhaps more interesting. It is polymorphic as it can take a list of anything (note that ML declares its argument to be of type 'a list and it return value to be of type 'a.
Note the warning that the match was nonexhaustive (i.e., not all cases were covered). The missing case is when h=[]. last([]) cannot be [] since that is of type 'a list not 'a. If you arbitrarily say last [] = 0, you lose polymorphism!
- fun twoToN(N) = if N=0 then 1 else let val p = twoToN(N-1) in p+p end; twoToN = fn : int -> int - twoToN 5; val it = 32 : int
let val J=5 val I=J+1 val L=[I,J] val Z=(J,L) in (J,I,L,Z) end; val it = (5,6,[6,5],(5,[6,5])) : int * int * int list * (int * int list)
As mentioned above ML is lexically scoped. For this to be useful, we need a way to introduce a lexical scope. Like Scheme ML uses let for this purpose.
The syntax is different, of course. The object-name-and-value pairs are written as definitions and appear between let and in. Following in comes the body and finally end.
The ML let is actually much closer to let* in
Scheme in that the bindings are done sequentially.
The second example illustrates the sequential binding.
If the first two val's were done in the reverse order, an error
results since J would not have been defined before it was used.
fun reverse(L) = let fun reverse1(L,M) = if L=[] then M else reverse1(tl(L),hd(L)::M); in reverse1(L,[]) end; val reverse = fn : ''a list -> ''a list
- reverse [3,4,5]; val it = [5,4,3] : int list
As you would expect, in addition to nested blocks (let above), ML supports nested functions. Once again let is used to indicate the nested scope.
One use of nested functions is to permit the definition of
helper
functions as in the reverse
code on the right.
Note that this implementation of reverse
is tail recursive.
Do you see why tail recursion is aided by applicative-order
reduction?
fun applyList(f,[]) = [] | applyList(f,h::t) = f(h)::applyList(f,t); val applyList = fn : ('a -> 'b) * 'a list -> 'b list
fun add3(n) = n+3; val add3 = fn : int -> int
- applyList(add3,[2,3,5]); val it = [5,6,8] : int list
fun sum(f,l,u) = if u<=l then 0 else f(u) + sum(f,l,u-1); val sum = fn : (int -> int) * int * int -> int
As with Scheme, ML functions are first class values. They can be created at run-time, passed as arguments, and returned by functions.
Passing Functions as Arguments: Compare applyList on the right to the FILTER function you wrote in Scheme for lab1.
Note that applyList is not tail-recursive; its last act is not the recursive call, but instead a ::.
It needs a helper function applyList1(f,done,rest).
It is different from reverse1 because now we are building the result
from left to right not right to left.
You can't cons an element onto the right of a list; instead you do
concatenation.
- val f = fn x => x+1; val f = fn : int -> int
- sum(fn x => x*x*x, 0,10); val it = 3025 : int
- (fn x => x*x*x) (6); val it = 216 : int
Anonymous Functions: Our use of fun is similar to using both (define and (lambda in Scheme. We could separate fun into its two parts as shown in the first example on the right, val, which is like (define, and fn...=>, which is like lambda.
Looking back at our previous uses of fun, we see that ML responds by using val and fn.
Sometimes we don't need a name for the function, i.e., it is
anonymous.
This usage is illustrated in the second and third examples, with the
anonymous function x3.
- fun adder(N) = fn X => X+N; val adder = fn : int -> int -> int
- adder 7 5 val it = 12 : int - adder (7,5); stdIn:1.1-3.2 Error: operator and operand don't agree [tycon mismatch] operator domain: int operand: int * int in expression: adder (7,5)
- val add3 = adder 3; val add3 = fn : int -> int - add3 7; val it = 10 : int
Returning Functions as Values On the right we see a simple example of one function adder returning another (anonymous) function as a result. Note the type ML assigns to adder. The → operator is right associative so the type says that adder takes an integer and returns a function from integers to integers.
Write adder on the board in Scheme and note the double lambda.
Having functions return functions gives an alternative method for
defining multi-variable functions.
This method, known as Currying
, after Haskell Curry, is
illustrated on the right.
Make sure you understand why adder 7 5 works and
why adder(7,5) gives a type error!
We can define functions like add3 above in terms of
adder.
val add3 = let val I=3 in fn X => X+I end; val add3 = fn : int -> int add3 7; val it = 10 : int;
Closures: As in Scheme it is possible to form a closure; that is, to include the local environment. In the simple case on the right, the environment is just the value of the identifier I. With the limited subset of ML that we are studying, closures do not play an important role.
If you are interested, see the end of lecture 5 where I just added a short section on closures in Scheme. Naturally it is optional for this semester.
As mentioned several time, type inference is an important feature of ML, one that distinguishes it from all the languages we have see so far.
Recall the following characteristics of the ML type system.
- fun add3 I = I+3; val add3 = fn : int -> int
- fun times(X,Y) = X*Y; val times = fn : int * int -> int
fun member(X,L) = if (L=[]) then false else if hd(L)=X then true else member(X,tl(L)); val member = fn : ''a * ''a list -> bool
- fun nth(I,L) = if (I=1) then hd(L) else nth(I-1,tl(L)); val nth = fn : int * 'a list -> 'a
fun applyList(f,[]) = [] | applyList(f,h::t) = f(h)::applyList(f,t); val applyList = fn : ('a -> 'b) * 'a list -> 'b list
fun sum(f,l,u) = if u<=l then 0 else f(u) + sum(f,l,u-1); val sum = fn : (int -> int) * int * int -> int
fun adder(N) = fn X => X+N; val adder = fn : int -> int -> int
fun reversePair(x,y) = (y,x); val reversePair = fn : 'a * 'b -> 'b * 'a
Why Does ML Do It? That it, what are the advantages of type inference.
How Does ML Do It? It is not magical, but is not trivial either! I believe the most common name for the procedure adopted by ML and other type-inferring languages is the Hindley-Milner Algorithm, but it is also sometimes referred to as Algorithm W or the Damas-Milner Algorithm.
We won't try to prove that it always works, but do note some of the facts (i.e., constraints) the algorithm uses.
The algorithm then proceeds very roughly as follows.
For a detailed discussion of type inference in ML, see Ben Goldberg's class notes (for Honors PL), Polymorphic Type Inference
Example 1: Draw my diagram on the board.
fun sum(f,l,u) = if u<=l then 0 else f(u) + sum(f,l,u-1); val sum = fn : (int → int) * int * int → int
Assign type(u)='a, type(l)='b, type(f)='c→'d, type(sum)='g →'h.
(Syntactically, f has to be a function.)
Since sum can return 0, of type int, 'h=int.
Using the expression u-1, since - is either of type int*int→int or real*real→real, and type(1)=int, infer 'a=int.
Using the expression f(u), infer 'c=int.
Using the expression u<l, since < is either of type int*int→bool or real*real→bool, and type(u)=int, infer 'b=int.
Using the expression f(u) + sum(f,l,u-1), since + is either of type int*int→int or real*real→real, and 'h=type(sum(...))=int,'d=int.
Note that 'g=type(f,l,u)=(int→int)*int*int.
Example 2: There is an alternate formulation below.
fun applyList(f,l) = if (l=[]) then [] else f(hd(l))::applyList(f,tl(l));
Assign type(l)='a, type(f)='b→'c, type(applyList)='d→'e, type([])='g list, type(hd)='h list→'h, and type(tl)='i list→i.
From the expression l=[], since [] is of type 'g list, deduce that 'a='g list.
Since hd is of type 'h list→'h, and l is of type 'g list, infer the expression hd(l) has type 'g. Hence infer that 'b='g.
Since tl is of type 'i list→'i list and l is of type 'g list, infer that tl(l) is of type 'g list.
Since :: is of type 'j*'j list→'j list, and f(hd(L)) is of type 'c, infer that f(hd(l))::tl(l)) is of type 'c list.
Since applyList may return f(hd(l))::tl(l), infer that 'e='c list.
Thus applyList is of type (fun 'g→'c) * 'g list→'c list.
An Alternate Formulation of Example2
Object | Type |
---|---|
l | 'a |
f | 'b→'c |
applyList | 'd→'e |
1st[] | 'g list |
hd | 'h list→'h |
tl | 'i list→'i |
:: | 'j * 'j list → 'j list |
2nd[] | 'k list |
Code Seg | Implication | |
---|---|---|
l=[] | => | 'a = 'g list |
hd(l) | => | 'h list = 'a = 'g list => 'h = 'g |
f(hd(l)) | => | 'b = 'h = 'g |
tl(l) | => | i' list = 'a = 'a list =>; 'i = 'g |
f(...):: | => | 'j = 'i |
can return :: | => | 'e = 'j list = 'i list = 'c list |
arg=(f,l) | => | 'd = ('b→'c) * 'a list = ('g→'c) *g list |
Hence applyList='d→'e = (('g→'c) * 'g list)→'c list |
fun applyList(f,l) = if (l=[]) then [] else f(hd(l)):: applyList(f,tl(l));
fun largeType(w,x,y,z) = x=(w,w) andalso y=(x,x) andalso z=(y,y); val largeType = fn : ''a * (''a * ''a) * ((''a * ''a) * (''a * ''a)) * (((''a * ''a) * (''a * ''a)) * ((''a * ''a) * (''a * ''a))) -> bool
Example 3: This weird example shows that it is possible for the size of a type to be exponential in the size of the function.
The operator andalso is the normal Boolean and
.
Recall that and has another meaning in ML.
Similarly orelse is the normal Boolean or
Homework: CYU 54. Explain how the type inference of ML leads naturally to polymorphism.
Homework: CYU 57 How do lists in ML differ from those of Lisp and Scheme?
Homework: Write an ML program that accepts a list of strings and responds by printing the first string in the list. If the list is empty it prints "no strings".
Homework: Write an ML program that accepts a list of integers and responds by printing the first integer in the list. If the list is empty it prints "no integers".