CS312 Lecture 4:  Polymorphism and Parameterized Types

Administrivia

PS#1 due 4PM. Deadline is firm.  However, people who have their submissions bounced by the submission system because of not being registered will be treated fairly. Please do not email problem sets to us unless a member of the course staff asks you to.

BNF grammars

So far we've been describing the grammar of SML a bit informally. We can make our description a bit more crisp by introducing a formal notation for writing down language syntax known as Backus-Naur Form (or BNF). Before we wrote things like this:

(base types btype int, real, string, bool, char, unit
(types type)      btype, type->type, type*...*type, { id : type, ..., id : type}

This indicates what are the legal base types and types in SML. In BNF, we associate with each syntactic category a meta-variable. For example, we might associate id with identifiers, b with base types, and t with types. The BNF definition of these two kinds of syntax is the following: (note: underline terminal symbols on the board)

    (base types)   b ::= int | real | string | bool | char
 
(types)            t ::= b | t ->t | t1*t2*...*tn | { id1 : t1, ..., idn : tn } | id

A metavariable (also called a nonterminal) always appears on the left-hand side of the ::= symbol, and a list of productions appears on the right-hand side, separated by vertical bars. We read "::=" as "is defined to be" and "|" as "or".  The productions may mention ordinary keywords and other language tokens (terminals) in addition to nonterminals. Each production defines one way that we can build legal language expressions using other legal language expressions. We sometimes write subscripts on the metavariables appearing in productions to help us keep track of them.

BNF notation looks a lot like SML datatype constructors, which is no accident. We can think of BNF notation as defining datatypes that describe the syntax of the programming language. And in fact we can write datatypes that correspond directly to this syntax, which we'll see later.

The constants, expressions, declarations, and patterns of SML can be summarized by the following BNF definitions. BNF isn't very good at specifying things like the legal integer constants or the legal strings, so we assume that those pieces of syntax are defined elsewhere.

(constants)  c ::= integer | character | boolean | real | string 
(exprs.) e ::= id | id1 . id2 | c | unop e | e1 binop e2 | fn p => e | e1(e2) | (e1, ..., en) | #integer e | {id1=e1,...,idn=en} | #id elet d1 ... dn in e end | if e then e else e | case e of p1 => e1 | ... | pn => en
(decls.) d ::= val p = e | fun id(p) : t = e | datatype id = id1 of t1 | ... | idn of tn
(patterns) p ::= _ | id : t | c | id ( p ) | (p1,...,pn) | {id1=p1,...,idn=pn}

Note again that we are just defining correct syntax; syntactically valid ML can fail to type-check.

Declarations

Declarations are either value declarations, function declarations, or datatype declarations.  Value declarations are used to bind values to identifiers.   For instance, the declaration val pi: real = 3.14159 binds the real number 3.14159 to the identifier pi.  In general, we can use a pattern in a value declaration to deconstruct a value.  For instance, the value declaration val (x:int,y:int) = (3,4) binds x to the first component of the tuple and y to the second component of the tuple.   This is the same as writing two declarations:  val x:int = #1 (3,4) followed by val y:int = #2 (3,4).  

Function declarations define new functions and bind them to an identifier.   We've seen lots of examples of function declarations and we'll see a lot more below.  Notice that in general, the argument to a function can be deconstructed with a pattern just as a value can be deconstructed in a value declaration.

Datatype declarations define two things:  a new type and a set of data constructors.  The data constructors are not types, rather you can think of them as functions which create values of the new type.  For example the declaration:

	datatype foo = A of int | B of (string * real) | C | D

defines a new type foo.  It also defines four data constructors A, B, C, and D.  The only way to create values of type foo is to use these data constructors in an expression.  Furthermore, the constructors A and B require arguments in order to create values of type foo.  So, for instance, all of the following expressions have type foo:

	C
	D
	A(3)
	B("bzzz",2.178)

Notice that since neither the C nor the D data constructors has "of t" written after it, they take no arguments.

Patterns

Patterns are used in value declarations, in function arguments, and in case-expressions to deconstruct values.  The wild-card pattern (an underscore _ ) matches any value.   A variable pattern that is not a data constructor also matches any value, but it also binds the value to the pattern.  A variable pattern that is a data constructor from some datatype definition matches only that data constructor.  A tuple pattern (p1,...,pn) matches any tuple value as long as the components of the tuple match the nested patterns.   Similarly, a record pattern {id1=p1, ..., idn=pn} matches any record as long as the components of the record with the corresponding names match the appropriate nested patterns.


Polymorphism

Type systems are nice but they can get in your way. In a lot of programming languages (e.g., Java) we find that we end up rewriting the same code over and over again so that it works for different types. SML doesn't have this problem, but we need to introduce new features to avoid it. Suppose we want to write a function that swaps the position of values in an ordered pair:

fun swap_int(x:int,y:int):(int*int) = (y,x)
fun swap_real(x:real,y:real):(real*real) = (y,x)
fun swap_string(x:string,y:string):(string*string) = (y,x)
This is tedious, because we're writing exactly the same algorithm each time. It gets worse! What if the two pair elements have different types?
fun swap_int_real(x:int,y:real):(real*int) = (y,x)
fun swap_real_int(x:real,y:int):(int*real) = (y,x)
And so on. There has to be a better way... and here it is:
- fun swap(x:'a, y:'b):('b * 'a) = (y,x)
val swap = fn : 'a * 'b -> 'b * 'a
Instead of writing explicit types for x and y, we write type variables 'a and 'b. The type of swap is 'a * 'b -> 'b * 'a. What this means is that we can use swap as if it had any type that we could get by plugging into its type any types we want for 'a and 'b. We can use the new swap in place of all the old definitions:
swap(1,2);         (* swap : (int * int) -> (int * int) *)
swap(3.14,2.17);   (* swap : (real * real) -> (real * real) *)
swap("foo","bar"); (* swap : (string * string) -> (string * string) *)
swap("foo",3.14);  (* swap : (string * real) -> (real * string) *)
This ability to use swap as though it had many different types is known as polymorphism, from the Greek for "many shapes". If we think of swap as having a "shape" that its type defines, then swap can have many shapes: it is polymorphic.

It's important to note that swap doesn't use its arguments x or y in any interesting way. It treats them as if they were black boxes. When the SML type checker is checking the definition of swap, all it knows is that x is of some arbitrary type 'a. It doesn't allow any operation to be performed on x that couldn't be performed on an arbitrary type. This means that the code is guaranteed to work for any x and y. If we want some operations to be performed on values whose types are type variables, we have to provide them. For example,

- fun appendHello(x: 'a, toString: 'a->string): string =
  toString(x) ^ ", hello!"
val appendHello = fn : 'a * ('a -> string) -> string
- appendHello(2, Int.toString)
val it = "2, hello!" : string
- appendHello("hello", fn(x:string)=>x)
val it = "hello, hello!" : string

Parameterized Types

The ability to write polymorphic code is pretty useless unless it comes with the ability to define data structures whose types depend on type variables. For example, last time we defined lists of integers as

datatype intlist = Nil | Cons of (int * intlist)
But of course we'd like to be able to make lists of anything we want, not just integers. Furthermore, last time we wrote lots of functions for manipulating lists, and many of these functions didn't depend on what kind of values were stored in the list. The length function is a good example:
fun length(lst:intlist):int = 
    case lst of
      Nil => 0
    | Cons(_,rest:intlist) => 1 + length(rest)
We can avoid defining lots of list datatypes and associated operations by declaring a parameterized datatype like the following:
datatype 'a List = Nil | Cons of ('a * ('a List));

A parameterized datatype is a recipe for creating a family of related datatypes. The type variable 'a is a type parameter for which any other type may be supplied. For example, int List is a list of integers, real List is a list of reals, and so on. However, List itself is not a type. Notice also that we cannot use List to create a list each of whose elements can be any type. All of the elements of a T List must be T's.

val il : int List = Cons(1,Cons(2,Cons(3,Nil)));  (* [1,2,3] *)

val rl : real List = Cons(3.14,Cons(2.17,Nil));  (* [3.14,2.17] *)

val sl : string List = Cons("foo",Cons("bar",Nil)); (* ["foo","bar"] *)

val srp : (string*int) List = 
    Cons(("foo",1),Cons(("bar",2),Nil)); (* [("foo",1), ("bar",2)] *)

val recp : {name:string, age:int} List = 
    Cons({name = "Greg", age = 150},
         Cons({name = "Amy", age = 3},
              Cons({name = "John", age = 1}, Nil)));
We can think of List as being a function that, when applied to a type like int, produces another type (int List). We can also define polymorphic functions that know how to manipulate lists:
(* is the list empty? *)
fun is_empty(x:'a List):bool = 
    case x of
        Nil => true
      | _ => false;
(* return the length of the list *)
fun length(lst:'a List):int = 
    case lst of
      Nil => 0
    | Cons(_,rest:'a List) => 1 + length(rest)

(* append two lists:  append([a,b,c],[d,e,f]) = [a,b,c,d,e,f] *)
fun append(x:'a List, y:'a List):'a List = 
    case x of
        Nil => y
      | Cons(hd:'a, tl:'a List) =>
          Cons(hd, append(tl, y));

val il2 = append(il,il);
val il3 = append(il2,il2);
val il4 = append(il3,il3);

val sl2 = append(sl,sl);
val sl3 = append(sl2,sl2);

(* reverse the list:  reverse([a,b,c,d]) = [d,c,b,a] *)
fun reverse(x:'a List):'a List = 
    case x of
        Nil => Nil
      | Cons(hd,tl) => append(reverse(tl), Cons(hd,Nil));

val il5 = reverse(il4);
val sl4 = reverse(sl3);

(* apply the function f to each element of x:  
 *    map(f,[a,b,c]) = [f(a),f(b),f(c)] 
 *)
fun map(f:'a->'b, x:'a List):'b List = 
    case x of
        Nil => Nil
      | Cons(hd,tl) => Cons(f(hd), map(f,tl));

val sl5 = map(Int.toString,il5);

(* insert sep between each element of x: 
 *    separate(s,[a,b,c,d]) = [a,s,b,s,c,s,d]
 *)
fun separate(sep:'a, x:'a List) = 
    case x of
        Nil => Nil
      | Cons(hd,Nil) => x
      | Cons(hd,tl) => Cons(hd,Cons(sep,separate(sep,tl)));

(* prints out a list of elements as long as we can convert the
 * elements to a string using to_string.
 *)
fun print_list(to_string:'a -> string, x:'a List):unit = 
    let val strings = separate(",",map(to_string,x))
    in 
        print("[");
        map(print,strings);
        print("]\n")
    end;

fun print_ints(x:int List):unit = 
    print_list(Int.toString,x);

fun print_reals(x:real List):unit = 
    print_list(Real.toString,x);

fun print_strings(x:string List):unit = 
    let fun f(s:string) = "\"" ^ s ^ "\""
    in 
        print_list(f,x)
    end;
Of course, we can define many more interesting and useful parameterized types, such as trees:
datatype 'a Tree = Leaf | Branch of ('a Tree) * 'a * ('a Tree)

Abstract Syntax

Earlier we commented that there was a similarity between BNF declarations and datatype declarations. In fact, we can define datatype declarations that act like the corresponding BNF declarations. The values of these datatypes then represent legal expressions that can occur in the language. For example, our earlier BNF definition of legal SML types

(base types)   b ::= int | real | string | bool | char
(types)            t ::= b | t ->t | t1*t2*...*tn | { id1 : t1, ..., idn : tn } | id

has the same structure as the following datatype declarations:

type id = string
datatype baseType = Int | Real | String | Bool | Char
datatype type_ = Base of baseType | Arrow of type_*type_ | Product of type_ List
                 | Record of (id*type_) List | DatatypeName of id

Any legal SML type expression can be represented by a value of type type_ that contains all the information in the type expression. This value is known as the abstract syntax for that expression. It is abstract, because it doesn't contain any information about the actual symbols used to represent the expression in the program. For example, the abstract syntax for the expression int*bool->{name: string} would be

Arrow( Product(Cons(Base Int, Cons(Base Bool, Nil))),
       Record(Cons(("name", Base String), Nil)))

Compilers typically use abstract syntax internally to represent the program that they are compiling.