CS312 Lecture 2: SML Basics

SML syntax

You have a few simple examples of SML code in section yesterday.

The table below shows you a few of SML's constructs in BNF, or Backus-Naur Form. These rules summarize the various ways in which you can built-up various language constructs. Vertical bars denote alternatives (choices); the ::= symbol denotes definitions. The table is not complete, we don't define in detail what an identifier is (but the examples should give you helpful hints). Complex constructs  use syntactic (or meta-) variables as placeholders that denote components of a syntactic class; indices are used whenever several components of the syntactic class must be represented.  

As an example, an expression is defined to be any of the following constructs: an identifier, a constant, an expression preceded by a unary operator, a binary operator in-between two expressions, an if expression, a let expression, or a function evaluation. Thus expressions are defined in terms of  (simpler) expressions - such definitions are called recursive, and they are quite common (and necessary) when describing programming languages.

syntactic class syntactic variable(s) and grammar rule(s) examples
identifiers x, y a, x, y, x_y, foo1000, ...
constants c ...~2, ~1, 0, 1, 2 (integers)
 1.0, ~0.001, 3.141 (reals)
true, false (booleans)
"hello", "", "!" (strings)
#"A", #" " (characters)
unary operator u ~, not, size, ...
binary operators b +, *, -, >, <, >=, <=, ^, ...
expressions (terms) e ::= x  |  u e  |  e1 b e2  | if e then e else e  |  let d1...dn in e end  |  e (e1, ..., en) foo, ~0.001, not b, 2 + 2
declarations d ::= val x = e  |  fun y (x1:t1, ..., xn:tn): t = e val one = 1
fun square(x: int): int
types t ::= int  |  real  |  bool  |  string  |  char  |  t1*...* tn->t int, string, int->int, bool*int->bool

The table above is not complete; it does not contain a full definition of SML. In the next few lectures and recitations we will add more type constructs (for example, to define lists and polymorphic types). Also, we have completely ignored those features of SML that are not purely functional.

Let us point out a few interesting features visible in the table above:

The SML interpreter allows either terms or declarations to be typed at the prompt. For now, we can think of a program as being just an SML expression.

SML has a very clean - and strictly enforced - type-checking algorithm. As a consequence, some expressions that are evaluated transparently in other languages will not pass type-checking in SML. In particular, one can not mix integers and real numbers in arithmetic operations; expressions like 1 + 2.5 or 3 * 1.2 are illegal. More strikingly, equality is not defined for reals: expression 1.0 = 1.0 will fail type-checking. While we will not provide details here, you should know that the representation of - and computations with - real numbers are inherently imprecise in computers, so much so that not even addition is associative in floating-point arithmetic! It is possible, and common, to perform two series of computations with real numbers that should mathematically yield the same result and obtain results that are different, sometimes strikingly so. Thus real numbers that are different can represent conceptually the same value, while the equality of the bit sequences that encode two real numbers does not necessarily mean that they represent the same value (maybe lots of significant decimals have been lost). SML forces us to think carefully about the issue of real number equality but not providing an equality operator.

Program errors

There are many ways in which programs can be incorrect, but not all errors are equally hard to tackle. For our purposes, we will distinguish the following types of errors:

Lexical and syntax errors are quite easy to eliminate once one becomes familiar with the language. Type errors can be harder, but in most cases practice makes their elimination easy. Semantic errors can be very tricky to fix, and sometimes they are even very hard to detect.

Real-Eval-Print - An Introduction

Under normal circumstances, SML prints a prompt and waits for your input; at any time you can type either a declaration or an expression (term). 

Expressions are evaluated until they result in a value, which is then printed at the output. For now, you should think of values as being akin to constants - they represent themselves and can not be further simplified. We'll see later that this concept is more complicated. The resulting value is printed, and SML waits for further input.

Some expressions never evaluate to a value, case in which we sometimes say that you have an "infinite loop." This is a misnomer, since we don't have (explicit) loops in SML.

Evaluation occurs in several steps. At each step SML applies a set of rewrite (or reduction) rules to produce a simpler expression. Two of the more important rewrite rules are illustrated below:

if true then e1 else e2   -->  e1
if false then e1 else e2  -->  e2

An important concept related to rewriting expressions is substitution. An important case of substitution occurs during function calls. Consider the following definition:

fun abs(r: real):real = if r < 0.0 then ~r else r

and the function call

abs( 2.0 + 1.0).

The following steps are involved in evaluating this expression:

abs(2.0+1.0) --> abs(3.0)
             --> if 3.0 < 0.0 then ~3.0 else 3.0
             --> if false then ~3.0 else 3.0
             --> 3.0

We will later make these notions much more precise. Indeed, we will ultimately formally define how the steps involved in the evaluation occur.

A Simple Program

Consider the equation cos(x) = x. This equation has exactly one real solution in the open interval (0, pi/2). If we rewrite the equation as E(x)=cos(x)-x and we look for x such that E(x)=0, we know that E(x) will change sign exactly once between the left end of the interval, 0, and the right end, pi/2. We will perform a search in which the solution is constrained to be in open intervals whose length is halved at each step. Indeed, given an interval (left, right) that brackets the solution, we first examine the midpoint of the interval, denoted middle. If middle is the solution, we are done. If not, the sign of E at middle will coincide with the sign of E at either left or right (but not both!). Because we have a unique solution, and its multiplicity is 1, we know that the solution must be in the subinterval in which a sign change occurs: if E has the same sign at left and at middle the solution is in the interval (middle, right), otherwise it is in the interval (left, middle). This algorithm is implemented by the function below:

fun findRoot(left: real, right:real):real =
let
  fun eq(arg: real):real = Math.cos(arg) - arg
  val eps = 0.0001
  val middle = (left + right)/2
  val value = eq middle
in
  if Real.abs(value) < eps
  then middle
  else if eq(left)*value > 0
       then findRoot(middle, right)
       else findRoot(left, middle)
end

We will introduce and discuss a number of SML concepts using this example. First, by just looking at this function, can you tell whether it is correct? Well, it isn't - if you paste it into SML, you get the following errors:

stdIn:17.3-17.32 Error: operator and operand don't agree [literal]
  operator domain: real * real
  operand:         real * int
  in expression:
    (left + right) / 2
stdIn:22.8-24.35 Error: operator and operand don't agree [literal]
  operator domain: real * real
  operand:         real * int
  in expression:
    eq left * value > 0

There are two type errors here; both are due to arithmetic operations applied to arguments that are a mixture of both integers and reals. We can fix these if we rewrite the two numerical constants that appear in the code, 2 and 0, respectively, to 2.0 and 0.0. Note that the error message does gives you the line when the error occurs, and it even indicates which argument has incorrect type. Error messages can be much more cryptic - try to extract as much information as you can to faster debug your programs!

Here is the corrected program:

fun findRoot(left: real, right:real):real =
let
  fun eq(arg: real):real = Math.cos(arg) - arg
  val eps = 0.0001
  val middle = (left + right)/2.0
  val value = eq middle
in
  if Real.abs(value) < eps
  then middle
  else if eq(left)*value > 0.0
       then findRoot(middle, right)
       else findRoot(left, middle)
end

If you type this into SML, the system accepts your input and produces the following answer:

val findRoot = fn : real * real -> real.

This indicates that you have just defined a function (notice the arrow ->) with two arguments, both of type real (real * real). The function returns a real value (see the real to the right of the arrow. The name of the function is findRoot, because the function has been defined at the prompt, we say that it has been defined at top level - the function has a global scope. This means that other functions can refer to (e.g. call) findRoot; its name is "visible" (known) everywhere. This is not true for funcion eq and the identifiers defining eps, middle, and value. There definitions are made inside the let statement, and are only known from the point of definition until the end of the let statement. The definition of value can refer to eq (because eq has been defined previously, but eq could have not referred to value. The region of a program where a given identifier is accessible is called the scope of the respective identifier. SML is a statically scoped language, i.e. the scope of the identifiers is determined by analyzing the text of the program only, and it does not depend on the computations that are performed at runtime. Like with typing, it is possible to built dynamically-scoped languages.

Note that function findRoot calls itself - we say that it is recursive. Recursive functions are pervasive in SML, they are very powerful, and serve many purposes. We will often use recursive functions to implement computations that would be specified as loops in other programming languages.

As an aside, we note that when a function has a single argument only, we can dispose of the parentheses that would wrap the arguments; see the definition of value.

Qualified Identifiers and the Standard Library

If you look at the example above you will note that the existence of expressions Math.cos and Real.abs. The identifier before the dot is a structure identifier. We will later learn how to define structures, for now, you should think of structures as a means to group and make available a set of identifiers (often, functions) that are somehow related. The Standard Library contains many useful structures - study them enough so that you are aware of the functionality that they implement (you don't need to memorize these functions, though). Of special interest are structures like Int, Real, String, Math. You can save yourself a lot of effort if you use library functions instead of re-implementing them in your homeworks.