CS312 Fall 2004 Section Notes #2 Written up by Harlan Crystal Distinction between functional and imperative programming --------------------------------------------------------------- When working in SML, you can imagine the computation as repeated simplification of an expression. In the case of an arithmetic expression, you can imagine the evaluation like: 5 * (3 + 2) ==> 5 * 5 ==> 25 When one is working in an imperative language such as C and Java, you normally do a sequence of statements which modify the state by modifying the value of variables. In SML's functional style of programming, variables may be bound to values but once they are set their value is not to be changed. The same variable may be overshadowed by being re-bound, but it does not change the original value of the variable. This leads to a very different style of programming in which you are not executing sequences of instructions which modify the state but instead repeatedly simplifying expressions until they return a bare value. Basic Lists ------------------------------- SML provides a built-in list type. Here is an example of its syntax: val l = [1, 2, 3, 4] The type of the expression "l" is: int list. When we spoke about tuples, we noted that they were allowed to have elements of different types, but they must have a fixed number of elements. In contrast, lists may have varying number of elements but these elements must have homogeneous types. From an implementation perspective, this makes sense: If there could be homogeneous types in a list, and then the static type checker has no way of knowing the type of an expression that accesses the nth element in the list. Here are some more examples of list expressions: val l2 = ["hi", "bye"] (* this is a string list *) val l3 = [#"a", #"z"] (* this is a char list *) val l4 = [true, false] (* this is a bool list *) An empty list is represented with: [] Lists with more complex types ---------------------------------- We have have lists of types which are more complex than simple types as well. For example, the following is an int * string * real list; val l5 = [(5, "hello", 2.3), (2, "goodbye", 5.4)] Also, one could have lists of lists: val l6 = [[1, 2, 3], [1, 4, 7], [8, 9]] (* an int list list *) Simple operations on lists ------------------------------- Three simple operations on lists are null, hd and tl. null is a useful function that can tell you if a list is empty or not. null is a function which takes in a list and returns true if that list is empty and returns false if it is not empty. For example: null([1, 2, 3]) (* evaluates to false *) null([]) (* evaluates to true *) null([[]]) (* evaluates to false *) Another useful list operation is hd. This function gives you the first element in a list. For example: hd([1,2,3]) =====> 1 hd([3,2,1]) =====> 3 hd([(1, "hi"), (2, "bye")]) =====> (1, "hi") hd([[1,2], [3,4]]) =====> [1,2] hd([1]) =====> 1 Notice that hd is returning an element of the list and not a list: hd([1,2,3]) =====> 1 (* as opposed to returning [1] *) Calling hd on an empty list results in an error: hd([]) (* throws an exception *) Another simple list function is tl. It returns the list which results from removing the first element from the list: tl([1,2,3]) =====> [2, 3] tl([3,2,1]) =====> [2, 1] tl([(1, "hi"), (2, "bye")]) =====> [(2, "bye") tl([[1,2], [3,4]]) =====> [[3,4]] tl([1]) =====> [] As you would expect, calling tl on an empty list is an error: tl([]) (* throws an exception *) null, hd and tl can be used together to do computation which needs to process each element in a list. For example, the following code finds the sum of all the elements in an int list. fun sumlist(l:int list):int = if null(l) then 0 else hd(l) + (sumlist(tl(l))) sumlist([1,2,3]) ======> 6 sumlist([]) ======> 0 Another example would be the following function which finds the length of a list: fun listlen(l:int list):int = if null(l) then 0 else 1 + listlen(tl(l)) listlen([]) ======> 0 listlen([5]) ======> 1 listlen([5, 4, 6]) ======> 3 While null, hd, and tl are useful, we will soon learn a better way of achieving the same functionality with case statements and pattern matching. Once we have learned that methods, the style guide prescribes that you should use that instead of null, hd, tl. Some other useful list operations ----------------------------------------- One way to add an element to a list is the cons operator. It is written with the :: notation. Here are some examples of it in action: 1::[2, 3] =====> [1, 2, 3] "hello"::["goodbye", "no"] =====> ["hello", "goodbye", "no"] 3::[2, 3] =====> [3, 2, 3] (2.0, "yes")::[(3.4, "no")] =====> [(2.0, "yes"), (3.4, "no")] 5::[] =====> [5] [2,3]::[[4,5], [6, 7]] =====> [[2, 3], [4, 5], [6, 7]] Notice that the expression on the left side of the operator must has the type of elements of the list. For that reason it would be invalid to say the following expressions: [1]::[5, 7] [2,3]::[5, 7] In order to attach two lists of the same type, we use the concatenation operator: [1] @ [5, 7] ======> [1, 5, 7] [2, 3] @ [5, 7] ======> [2, 3, 5, 7] The @ operator is somewhat slow (it is O(n) on the length of the first list) and should be avoided when possible. To illustrate this, lets write our own version of concatenation: fun mycat(l1:int list, l2:int list):int list = if null l1 then l2 else hd(l1) :: mycat(tl(l1), l2) mycat([1, 2], [3, 4]) ======> [1, 2, 3, 4] Notice that the speed of this function is dependent on the length of th first list. Case statements ----------------------- In the previous section we spoke about if statements of the form: if x then 1 else 2 The syntax of an equivalent case expression would be: case x of true => 1 | false => 2 This is somewhat similar to switch statements in C and Java, but is actually much more powerful. The general syntax is: case e of v1 => e1 | v2 => e2 ... | vn => en e is an expression which may take on many values. Each of the "v"s is a possible value e can be. If e matches to v3, then the corresponding e3 is evaluated. To make this concrete: case true of true => "hello" | false => "goodbye" would simplify to: "hello" Another example of converting if statements to case would be: if x = 1 then "hello" else if x = 2 then "goodbye" else "bye" case x of 1 => "hello" | 2 => "goodbye" | _ => "bye" The "_" pattern means "match to everything else that hasn't already been listed". Case statements and pattern matching can operate on more complex constants as well. Consider the following: let val x = 5 val y = 6 in if x = 4 andalso y = 2 then "hi" else if x = 4 then "hello" else if y = 1 then "bye" else "byebye" end This can be converted to: let val x = 5 val y = 6 in case (x,y) of (4, 2) => "hi" | (4, _) => "hello" | (_, 1) => "bye" | _ => "byebye" end This example illustrates how pattern matching and case statements can be more intuitive for switching on certain combinations of values than a series of ifs. Binding in patterns -------------------------- One of the ways case expressions are more powerful than switches in C and Java is that they can add bindings when they match things. Consider the following conversion. let val a = (2, 3) in case a of (4, 2) => 90 | (5, x) => x + 3 | (y, 3) => y - 2 | (z, w) => z + w end In this example, the "x", "y", "z" and "w"s are similar to the "_" that we saw earlier in that they can match anything, but they also have the extra ability that they bind the value which is matched to the said variable name. These bindings can then be used in the expression on the right side of the =>. One thing to note is that these bindings are local to the expression on the right side of the => and do not carry over to other cases. This means that the previous example would be completely equivalent to the following expression: let val a = (2, 3) in case a of (4, 2) => 90 | (5, x) => x + 3 | (x, 3) => x - 2 | (x, y) => x + y end One other great application of case statements and pattern matching is operating on lists. When one has a list, it may either be empty or have some elements in it. A case statement may used to match either of these cases. Let's take the sumlist example we covered earlier and convert it to use case statements: fun sumlist(l:int list):int = if null(l) then 0 else hd(l) + (sumlist(tl(l))) fun sumlist(l:int list):int = case l of [] => 0 | x::xs => x + sumlist(xs) If the list is empty, it matches to the first case. If the list is non empty it matches to the second case and the head of the list is bound to the variable "x" and the tail of the list is bound to the variable "xs" I wanted to show an example of using case statements on lists since that was on the homework, so we converted the earlier "sumlist" function we had written earlier to use case: Let's also rewrite the listlen function we had written: fun listlen(l:int list):int = case l of [] => 0 | _::xs => 1 + listlen(xs) Other operators such as @ and + can not occur in patterns. The reason concerns datatypes, the representation of lists and other information we have not yet covered. Boolean operators -------------------------- If you are familiar with C or Java, you know the && and || operators. These are the logical AND and OR functions. The keywords in SML for these features are "andalso" and "orelse." Like C and Java, these operators are short-circuit. For some intuition of what short-circuit means, consider the following: x andalso y Let's say we know x is false. Then it is not necessary to look at y to know that the whole expression is false, so y is not evaluated. Similarly if "x orelse y" finds that x is true, then there is no reason to evaluate y. This is useful in situations such as the following: true orelse ((0 div 3) = 3) This will not throw a divide-by-zero error because the second expression is not evaluated. false andalso (longRunningReallySlowFunction("input")) SML will not bother spending two weeks running the slow, long function because SML already knows the entire expression is false. Symbolic Datatypes -------------------------- If you are familiar with C, you might have run across enumerations. SML's datatypes can provide similar functionality. Let's say we are creating a poker game. We could define a new type named suit which represents each of the four suits. datatype suit = HEARTS | CLUBS | SPADES | DIAMONDS A value assigned one of these values will have type suit. The expression: let val x = HEARTS in x end has type "suit" Case expressions are perfect for working on datatypes: fun getvalue(x:suit):int = case x of SPADES => 4 | DIAMONDS => 3 | CLUBS => 2 | HEARTS => 1 This is a nice opportunity to talk about some of the nice features of case statements. SML will look over your case statements to make sure there are no missing cases. For example, if we wrote: case x of SPADES => 4 | DIAMONDS => 3 | CLUBS => 2 Then SML would complain and say it is an non-exhaustive match because what happens if "x" has the value HEARTS? Similarly, if there are two cases which match the same thing, SML notify us of the problem as well: case x of SPADES => 4 | DIAMONDS => 3 | CLUBS => 2 | HEARTS => 1 | DIAMONDS => 43 (* redundant case *) Data-Carrying datatypes ------------------------------ Datatypes fairly powerful and can do much more than represent an enumeration. They may hold data in them. For example: datatype vehicle = BIKE | CAR of int (* number of doors *) We can then create variables of these types: val x:vehicle = BIKE val y:vehicle = CAR(2) val z:vehicle = CAR(4) As we saw with the card-suits example, case statements can be used to see which one of the datatypes a variable actually is. Case statements can also be used to extract the value out of these datatypes: fun numpassengers(v:vehicle):int = case v of BIKE => 1 | CAR(x) => x + 1 If you are familiar with C, you can make the analogy that these data-carrying datatypes are similar to tagged-unions. Anonymous Functions ------------------------------- A simple toy function that composes two functions and applies them to an argument is: fun apply(f:int->int, g:int->int, x:int):int = f(g (x)) Notice that the function is taking other functions as arguments. The type of this function is: ((int->int * int->int *int) -> int) Now let's write two functions to use with this apply function: fun inc(x:int):int = x + 1 fun dec(x:int):int = x - 1 Now if we were to call apply(inc, dec, 5), it would evaluate in the following way (skipping some steps): apply(inc, dec, 5) ===> inc(dec(5)) ===> inc(5 - 1) ===> inc(4) ===> 4 + 1 ===> 5 If we wanted to change the function arguments, it would be much nicer to be able to define the functions in-line. This is done with the "fn" anonymous functions. An expression which would be equivalent to our previous one is: apply(fn x=>x+1, fn x=>x-1, 5) These anonymous functions makes it easy for us to do other simple applications such as: apply(fn x=>x*5, fn y=>y-3, 5) Records -------------------- Record types are similar to tuples except that their elements are named instead of ordered. Here is an example of a record: val x = {name = "harlan", age= 22} The type of "x" is {name:string, age:int}. We were shown the tuple projects (ie #1,#2,#n) to extract values from tuples. The analogous operation on records works like the following: #name x =====> "harlan" #age x =====> 22 Like tuples, records can be pattern matched: case x of {name="mary", age=_} => "Hi mary!!!" | {name=_, age=4} => "Hi four year old!!" | {name=x, age=y} => "Hello" ^ x ^ "!!"