CS 334: Lab 3: ML Programming

Overview

In this lab, you will write your first ML programs, covering basic functions, pattern matching, algebraic data types, and a stack-based evaluator.

You are required to work with a partner on this lab. You are welcome to choose your own partner, or I can help you find one during the lab session.

Getting Started

Setting Up Your Repository

You will receive an email with an invitation link to the lab3 assignment on GitHub Classroom. You can follow the same instructions as on Lab 2 for accessing and cloning your repository. See the GitHub reference for instructions to add a partner. You should answer the following in the *.sml files in your repository.

Running ML

For this lab, use the ML interpreter on the Unix machines in the computer lab, or on your own computer. See instructions here.

You can run ML on the file "example.sml" as follows:

sml < example.sml

at the command line. As with Lisp, the ML compiler will process the program in the file and print the result. For example, if "example.sml" contains

(* double an integer *)
fun double (x) = x * x;

(* return the length of a list *)
fun listLength (nil) = 0
    | listLength (l::ls) = 1 + listLength ls
    ;

double (10);
listLength (1::[2,3,4]);

the command sml < example.sml will produce the following:

val double = fn : int -> int
val listLength = fn : 'a list -> int
val it = 100 : int
val it = 4 : int

You can also run sml and enter in declarations and expressions to evaluate at the prompt.

Start early on this lab so you can get in touch with the instructor or the TAs if you have problems understanding the language. There are many valuable resources available to help you learn ML:

A few additional details:

  • Emacs on the Unix machines will provide auto-formatting and syntax highlighting while editing ML files. Be sure your file names end with ".sml" so Emacs can recognize them as containing ML code.

  • Comments in ML are delineated by (* and *).

  • Put the following line at the top of your ML files to ensure that large data types and lists are fully printed:

    Control.Print.printDepth := 100;
    Control.Print.printLength := 100;
    
  • Unless otherwise specified, you should use pattern matching where possible.

  • There are several thought questions in the descriptions below. Please answer these questions with your partner in comments in the code.

Programming Problems

1. Basic Functions

Define a function sumSquares that, given a nonnegative integer n, returns the sum of the squares of the numbers from 1 to n:

- sumSquares(4)
val it = 30 : int
- sumSquares(5)
val it = 55 : int

Define a function listDup that takes an element, e, of any type, and a non-negative number, n, and returns a list with n copies of e:

- listDup("moo", 4);
val it = ["moo","moo","moo","moo"] : string list
- listDup(1, 2);
val it = [1,1] : int list
- listDup(listDup("cow", 2), 2);
val it = [["cow","cow"],["cow","cow"]] : string list list
Question

Your function will have a type like 'a * int -> 'a list. What does this type mean? Why is it the appropriate type for your function. Answer this question as a comment in the code.

2. Zipping and Unzipping

Write the function zip to compute the product of two lists of arbitrary length. You should use pattern matching to define this function:

- zip [1,3,5,7] ["a","b","c","de"];
val it = [(1,"a"),(3,"b"),(5,"c"),(7,"de")]: (int * string) list

Curry Your Function!

This is the curried version with type 'a list -> 'b list -> ('a * 'b) list. Be sure to define it to match this type.

Also, if the lists don't have the same length, you may decide how you would like the function to behave. If you don't specify any behavior at all you will get a "match not exhaustive" warning from the compiler to indicate that you have not taken care of all possible patterns--- this is fine.

Write the inverse function, unzip, which behaves as follows:

- unzip [(1,"a"),(3,"b"),(5,"c"),(7,"de")];
val it = ([1,3,5,7], ["a","b","c","de"]): int list * string list

Write zip3, to zip three lists.

- zip3 [1,3,5,7] ["a","b","c","de"] [1,2,3,4];
val it = [(1,"a",1),(3,"b",2),(5,"c",3),(7,"de",4)]: (int * string * int) list
Question

Why can't you write a function zip_any that takes a list of any number of lists and zips them into tuples? From the first part of this question it should be pretty clear that for any fixed n, one can write a function zipn. The difficulty here is to write a single function that works for all n. In other words, can we write a single function zip_any such that zip_any [list1,list2,...,listk] returns a list of k-tuples no matter what k is? Answer this question as a comment in the code.

3. Find

Write a function find with type ''a * ''a list -> int that takes a pair of an element and a list and returns the location of the first occurrence of the element in the list. For example:

- find(3, [1, 2, 3, 4, 5]);
val it = 2 : int
- find("cow", ["cow", "dog"]);
val it = 0 : int
- find("rabbit", ["cow", "dog"]);
val it = ~1 : int

First write a definition for find where the element is guaranteed to be in the list. Then, modify your definition so that it returns ~1 if the element is not in the list.

You may wish to read this if you see a warning about polyEqual.

4. Trees

Here is the datatype definition for a binary tree storing integers at the leaves:

datatype IntTree = LEAF of int | NODE of (IntTree * IntTree);

Write a function sum:IntTree -> int that adds up the values in the leaves of a tree:

- sum(LEAF 3);
val it = 3 : int
- sum(NODE(LEAF 2, LEAF 3));
val it = 5 : int
- sum(NODE(LEAF 2, NODE(LEAF 1, LEAF 1)));
val it = 4 : int

Write a function height: IntTree -> int that returns the height of a tree:

- height(LEAF 3);
val it = 1 : int
- height(NODE(LEAF 2, LEAF 3));
val it = 2 : int
- height(NODE(LEAF 2, NODE(LEAF 1, LEAF 1)));
val it = 3 : int

Write a function balanced: IntTree -> bool that returns true if a tree is balanced (ie, both subtrees are balanced and differ in height by at most one). You may use your height function in the definition of balanced.

- balanced(LEAF 3);
val it = true : bool
- balanced(NODE(LEAF 2, LEAF 3));
val it = true : bool
- balanced(NODE(LEAF 2, NODE(LEAF 3, NODE(LEAF 1, LEAF 1))));
val it = false : bool
Question

What is non-optimal about using the height function in the definition of balanced? Can you suggest a more efficient implementation? You need not write code, but describe in a sentence or two how you would do this. Answer this question as a comment in the code.

5. Stack-based Evaluator

Certain programming languages (and HP calculators) evaluate expressions using a stack. As I am sure many of you learned in cs136, PostScript is a programming language of this ilk for describing images when sending them to a printer. We are going to implement a simple evaluator for such a language. Computation is expressed as a sequence of operations, which are drawn from the following data type:

datatype OpCode =
      PUSH of real
    | ADD
    | MULT
    | SUB
    | DIV
    | SWAP
    ;

The operations have the following effect on the operand stack. (The top of the stack is shown on the left.)

OpCode Initial Stack Resulting Stack
PUSH(r) ... r ...
ADD a b ... (b + a) ...
MULT a b ... (b * a) ...
SUB a b ... (b - a) ...
DIV a b ... (b / a) ...
SWAP a b ... b a ...

The stack may be represented using a list for this example, although we could also define a stack data type for it.

type Stack = real list;

Write a recursive evaluation function with the signature

eval : OpCode list * Stack -> real

It takes a list of operations and a stack. The function should perform each operation in order and return what is left in the top of the stack when no operations are left. For example,

eval([PUSH(2.0),PUSH(1.0),SUB],[])

returns 1.0. The eval function will have the following basic form:

fun eval (nil,a::st) = (* ... *)
    | eval (PUSH(n)::ops,st) = (* ... *)
    | (* ... *)
    | eval (_,_) = 0.0
    ;

You need to fill in the blanks and add cases for the other opcodes.

The last rule handles illegal cases by matching any operation list and stack not handled by the cases you write. These illegal cases include ending with an empty stack, performing addition when fewer than two elements are on the stack, and so on. You may ignore divide-by-zero errors for now (or look at exception handling in the online resources -- we will cover that topic in a few weeks).

Submitting Your Work

Submit your code to the GradeScope assignment named, for example, "Lab 1". You can submit in one of two ways:

  • Upload files: Click "Upload" and select all of your source files, or
  • Link GitHub: Click "GitHub" and select your repository and branch.

Please do not change the names of the starter files. Also:

  • If you worked with a partner, only one of each pair needs to submit the code.
  • Indicate who your partner is when you submit. Specifically, after you upload your files, there will be an "Add Group Member" button on the right of the Gradescope webpage -- click that and add your partner.

Autograding: Gradescope will run an autograder on your code that performs some simple tests. Be sure to look at the autograder output to verify your code works as expected. We will run more extensive tests on your code after the deadline.