HELP EXAMPLES Robert Duncan, March 1990 Some simple examples of programming in ML. CONTENTS - (Use g to access required sections) -- An Introduction to the PML Top Level -- The Basic Types of ML -- Functions -- Overloaded Functions -- Pattern Matching -- Defining New Types -- The Exception Mechanism -- Further Reading -- An Introduction to the PML Top Level ------------------------------- PML is an interactive system. It prompts for input initially with a single dash: - but if input spreads over more than one line a secondary prompt: = is used on lines after the first. Everything typed to the prompt is a declaration. For example, the name "x" can be declared to have the integer value 3 by typing: val x = 3; Note the keyword "val" indicating that this is a value declaration (rather than a type or exception declaration - see below) and the terminating semicolon. For each such declaration, PML will respond with a list of the bindings it has made: the response from the above input would be: val x = 3 : int This confirms the nature of the declaration ("val"), and indicates the name bound, its value and its type. In the examples which follow, the output produced by PML will be shown immediately after the example input, just as it would appear in a real session. To make things a little clearer, however, each line of output will be flagged with a ">" in the left margin. Thus: val x = 3; (* <-- your input *) > val x = 3 : int (* <-- PML's reply *) You can confirm that the output shown is correct by marking and loading the example input using the "load marked range" facility provided by VED (and described in HELP * MLVED). Once a name has been bound to a value (such as "x" above) it can then be used to stand for that value in subsequent declarations. So we could declare the value "y" as being twice that of "x" by doing val y = 2 * x; > val y = 6 : int Often it is convenient not to have to give explicit names to values computed at top-level, and a shorthand form of declaration is provided for that: an expression typed in on its own, E say, with no introductory "val", is interpreted by PML as being short for the declaration val it = E; or a binding to the name "it". So the input (x + y) * (x + y); is exactly equivalent to val it = (x + y) * (x + y); producing the output > val it = 81 : int A consequence of this is that the name "it" is always bound to the result of the last expression evaluated: 4; > val it = 4 : int it * it; > val it = 16 : int it * it; > val it = 256 : int -- The Basic Types of ML ---------------------------------------------- Every expression in ML has a type. If the type of some expression E is "ty" say, then we write E : ty The colon symbol (:) is pronounced "has type" (thus: "E has type ty"). We have seen some examples of this already in the output from the declarations above, as PML displays the type of every value it computes. The type "int" (short for "integer" of course) is one of the basic (or primitive) types of ML and is built in to the language. The complete set of basic types is as follows: int Integer numbers. Examples are: 1 ~1 256 ~300000 The prefix "~" indicates a negative number. Integers in PML can be of any magnitude. real Real numbers. Examples are: 1.0 ~1.0 0.3333 1E5 2.35E~4 Real numbers are always distinct from integers as they must include either a decimal point or the exponent symbol "E" (or both). The two types can't be mixed directly in expressions either; the built-in functions "real" and "floor" must be used to convert between them. E.g. floor(22.0 / 7.0); > val it = 3 : int real it; > val it = 3.0 : real string Character strings. There is no simple character type in ML; single character strings are used instead. Examples: "" "a" "More matter, with less art" "2 + 2 = 4" Operators on strings include "size", which returns the length of a string, and "^", which joins two strings: val message = "Hello world\n"; > val message = "Hello world\n" : string size(message ^ message ^ message); > val it = 36 : int bool Booleans (truth values). There are only two values in this type: true false Built-in predicates such as "=" (equality) and "<" (less-than ordering) return values of this type, and there is a conditional expression form which can choose between alternative values based on a boolean test. For example: if x = y then "yes" else "no"; > val it = "no" : string unit The unit type. This type contains only a single value (hence its name) written as "()": (); > val it = () : unit This type is analogous to the "void" types which exist in other languages; its principal use is to provide a value in those cases where no value is really required. For example, the built-in function "output", which prints on an output stream, is called purely for its side-effect and so returns "()" as a result: output(std_out, message); Hello world > val it = () : unit These are all constant (or "nullary") types, but ML also provides a set of built-in "type constructors" for building complex types from more basic ones. The first example of these is the "list" type constructor. A list, as a value, is some arbitrary-length sequence of values of a given type. The "list" type constructor thus takes a single type as an argument (the type "alpha", say) and constructs a new type which is the type of lists of values of type "alpha". For every type "alpha" in ML, there is a type of lists of alphas. There are an infinite number of such types, but here are some particular examples: string list (lists of strings) unit list (lists of units) int list list (lists of lists of integers) List values of known length can be written with a special syntax using brackets ("[" and "]") and commas: ["More", "matter", "with", "less", "art"] : string list [(), (), (), ()] : unit list [[1, 2], [3, 4], [5, 6, 7]] : int list list Lists can also be constructed dynamically using the operator "::" (pronounced "cons", short for "construct") which adds an item to the front of a list. For example: val numlist = [3, 4, 5]; > val numlist = [3, 4, 5] : int list 1 :: 2 :: numlist; > val it = [1, 2, 3, 4, 5] : int list In fact, if we were to start always with an empty list, written as "[]" (or sometimes called "nil"), then we could construct every list using "::", as in: "More" :: "matter" :: "with" :: "less" :: "art " :: []; > val it = ["More", "matter", "with", "less", "art "] : string list 1 :: 2 :: 3 :: 4 :: 5 :: nil; > val it = [1, 2, 3, 4, 5] : int list Just as the type constructor "list" constructs new types from more basic ones, the so-called "value constructors" "nil" and "::" construct complex lists from more basic values. This relationship between type constructors and value constructors becomes more apparent with user-defined types discussed below. The type of the empty list is of interest: []; > val it = [] : 'a list The identifier "'a" (pronounced "alpha") is a type variable which can stand for any ML type. Because there are an infinite number of types constructed with "list" but only one empty list, all the "list" types must share the same empty list. The type of the empty list is thus a general type which can be instantiated to any particular list type. For non-empty lists, every item in the list must be of the same type. There is no legal type which could be attached to an expression such as [1, "elephant"] : ??? Any expression which mixes types in the same list will cause a type error: val bad_list = [1, "elephant"]; > In file examples, line 254: Error: in expression [1, "elephant"] Not all the list members have the same type: 1 : int "elephant" : string The error message makes it clear that you can't include an integer and a string in the same list. Trying to do so is no less of an error than -- for example -- trying to add them together: val bad_sum = 1 + "elephant"; > In file examples, line 267: Error: in expression 1 + "elephant" Cannot apply function op + : int * int -> int to argument (1, "elephant") : int * string To construct aggregates whose components are of different types, ML provides the "tuple" type constructor, written as an infix "*". This constructor takes two or more types as arguments. For example: int * real (a 2-tuple) int * real * string (a 3-tuple) int * real * string * bool (a 4-tuple) The syntax for writing tuple-typed values uses parentheses ("(" and ")") and commas: (1, 1.0) : int * real (1, 1.0, "a") : int * real * string (1, 1.0, "a", true) : int * real * string * bool There is no mechanism by which tuples can be constructed dynamically (i.e. there are no value constructors for tuples): lists can be dynamic only because they contain objects of a single type; tuples may contain different types but must be of fixed length. Other type constructors built in to ML are: ref short for "reference". This type adds the concept of updatable structures to the language, but won't be considered further in this file; labelled record an extension of the tuple type which allows fields in a structure to be labelled with identifiers. This type is described in the section on user-defined types below; function this will be discussed in detail in the next section. More information about basic types is given in the file HELP * STDTYPES. The type of a value is normally deduced by the compiler (as in all the above examples) without the need for explicit declarations. An exception to this arises in the case of functions which are "overloaded" (this concept is explained later). Where a type declaration is needed, it can be attached to any expression or binding in the manner we have seen already. For example: val pair : bool * int = (true, 0) and n : int = 1; > val pair = (true, 0) : bool * int > val n = 1 : int Such type declarations (or "constraints" as they are usually called) can be useful too for documentation purposes, particularly where expressions involve complex, user-defined types. Well-placed type constraints can add useful information for the reader of a program. -- Functions ---------------------------------------------------------- Functions in ML are simply values, with the same status as values of other types. The most general type of a function is 'a -> 'b (pronounced "alpha arrow beta"): this represents a function which takes an argument of some type alpha and returns a result of some (possibly different) type beta. Particular functions will have the type variables "'a" and "'b" instantiated in different ways. Thus: real; > val it = fn : int -> real floor; > val it = fn : real -> int Because functional values have no meaningful printed representation, they are always displayed by PML just as the word "fn". There are no value constructors for function types, so a special syntax must be used to create new functional values. Here is an example of a function which doubles its argument: the function has a single formal parameter "x" and computes a result which is 2 times the value of "x". fn x => 2 * x; > val it = fn : int -> int it 4; > val it = 8 : int We could give this a name using a standard value declaration: val double = fn x => 2 * x; > val double = fn : int -> int double 4; > val it = 8 : int Named functions can be made recursive by use of the keyword "rec": val rec fac = fn n => if n = 0 then 1 else n * fac(n-1); > val fac = fn : int -> int fac 6; > val it = 720 : int We can see from this last example (and from some earlier ones) how function application can be represented simply by the juxtaposition of function and argument. Parentheses aren't necessary except to change the order of evaluation: function application binds more tightly than any other expression construct in ML and so will always be evaluated first unless parentheses are used to change this. Compare: double 4 + 4; > val it = 12 : int double (4 + 4); > val it = 16 : int An important point to note about functions is that every function takes only a single argument: this is apparent from the function type, which specifies only one argument and one result. Functions which are to operate on more than one value may be written in either of two ways. Firstly, the two (or more) values needed may be provided to the function as a tuple. Here, for example, is the maximum function on integer pairs: val max : int * int -> int = fn (n, m) => if n >= m then n else m; > val max = fn : int * int -> int max(0, 1); > val it = 1 : int This concept can cause confusion to those used to programming in other languages, as "max" here looks very much like a two-argument function would if declared in, say, POP-11 or Pascal. However, the following example emphasises that "max" does indeed take a single argument, a tuple, by constructing the tuple separately. This example also demonstrates the use of a "let" expression which localises a declaration to an expression; the binding of "arg" is not visible at top-level. let val arg = (0, 1) in max arg end; > val it = 1 : int Many of the built-in functions we have seen already (such as "+", "*", "^") are actually defined in this way. Their applications are normally written differently however, as they are all declared to be infix operators which means that they can be written between the two components of their arguments. Such operators can only be written in the usual way if the keyword "op" is used to "turn off" their infix status. For example: op ^; > val it = fn : string * string -> string op *(4, 2); > val it = 8 : int We could declare "max" as infix too if we wished: infix max; > infix 0 max 0 max 1; > val it = 1 : int An alternative solution for functions which require multiple arguments is to use functions which return new functions as results. Here's a function which adds an item to the end of a list ("rev" is a built-in function which reverses the order of items in a list): val add_to_end = fn item => fn list => rev (item :: (rev list)); > val add_to_end = fn : 'a -> 'a list -> 'a list Note the type: the argument to "add_to_end" is an alpha-value and the result is a function from alpha-lists to alpha-lists (the function arrow "->" associates to the right). Using the function requires two applications: add_to_end 4 [1, 2, 3]; > val it = [1, 2, 3, 4] : int list but the advantage of this is that we can apply it just once, and then save the resulting function for use later. val add_4_to_end = add_to_end 4; > val add_4_to_end = fn : int list -> int list add_4_to_end [1, 2, 3]; > val it = [1, 2, 3, 4] : int list add_4_to_end it; > val it = [1, 2, 3, 4, 4] : int list These example declarations are somewhat unwieldy. Fortunately, ML provides a more concise syntax for function declarations, introduced by the keyword "fun". We would normally define "add_to_end" as follows: fun add_to_end item list = rev (item :: (rev list)); > val add_to_end = fn : 'a -> 'a list -> 'a list The effect is exactly the same as with the previous definition, but the declaration is shorter and clearer and the function created will be more efficient to run. Also, functions declared with "fun" are implicitly recursive, so there is no need for the "rec" keyword. As a further example of a simple function declaration, we will define the function "ints" which, when applied to an integer argument "n", constructs a list of all the integers from 1 to "n". The definition makes use of another "let" expression to define a local function "fromto" which generates the list of integers from "lo" to "hi". fun ints n = let fun fromto lo hi = if lo > hi then [] else lo :: fromto(lo+1) hi in fromto 1 n end; > val ints = fn : int -> int list ints 10; > val it = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] : int list -- Overloaded Functions ----------------------------------------------- A complexity in the ML type system arises when the same function name is used to stand for functions of different types. This concept is called "overloading". Many of the built-in maths functions have this property. For example: 2 + 3; > val it = 5 : int 2.0 + 3.0; > val it = 5.0 : real Here the name "+" is used for both the integer and real addition functions. The relational operators are like this too: "a" < "b"; > val it = true : bool 1 < 2; > val it = true : bool Such "overloaded" names must always be used at a particular type, and type constraints are sometimes necessary to indicate which type is intended. For example, an alternative definition of "double" might be: fun double x = x + x; but this will fail with the message: > In file examples, line 521: Error: cannot determine a type for overloaded identifier val + : 'A * 'A -> 'A as there is no indication of whether the parameter "x" is meant to be integer or real. A type constraint is necessary: fun double x : int = x + x; > val double = fn : int -> int This concept is quite distinct from that of "polymorphism", where the *same* function may be used on objects of different types. List concatenation (written "@") is an example: fun dup x = x @ x; > val dup = fn : 'a list -> 'a list dup [2]; > val it = [2, 2] : int list dup [2.0]; > val it = [2.0, 2.0] : real list Polymorphism is the rule and overloading the exception in ML. -- Pattern Matching --------------------------------------------------- Function declarations may be written with more than one clause, where alternative clauses are separated by the keyword "|". An equivalent declaration of the factorial function would be: fun fac 0 = 1 | fac n = n * fac(n-1); > val fac = fn : int -> int fac 6; > val it = 720 : int In any particular application of the function only one of the alternative clauses can be used; the process by which a particular alternative is chosen is called "pattern matching". The formal parameters in each clause (0 and "n" in this case) are called "patterns". Patterns mirror the structure of values, and their role is to decompose values into their constituent parts. We say that a pattern "matches" a value if the pattern has the same structure as the value; whenever a pattern matches a value, the value can be decomposed according to the pattern. For example, the pattern 0 matches the integer value 0 only; the pattern "n" however, being a variable, matches *any* value (although in the context of the definition of "fac", the value must be an integer). A pattern such as (0, n) would match any 2-tuple whose first component was 0. Variables which occur in patterns (such as "n" in "fac") are so-called "binding occurrences": the match, if it succeeds, binds the variables in the pattern to the corresponding components of the argument value, and the bindings remain visible throughout the right hand side of the chosen clause. Thus matching "n" against, say, 3, would bind "n" to the value 3, as would matching (0, n) against (0, 3). Applying "fac" to the value 0 we get: fac 0; > val it = 1 : int The argument 0 matches the pattern in the first clause of "fac", and so that clause is chosen. The right hand side of the clause, 1, is returned straight away. Conversely, the application: fac 3; > val it = 6 : int matches the second rule of "fac", which, by the binding rules described above, expands the right hand side of the clause to 3 * fac(3-1); By following through the recursive call (and a further two calls) the answer 3 * 2 * 1 * 1 = 6 is computed. It's not only constants, variables and tuples which can be used to build patterns: the value constructors of types can be used too. This means that we can pattern-match on lists (but not on functions, which have no value constructors). In a context where we have defined: a : 'a x : 'a list then we can construct the value (a :: x) : 'a list Conversely, we can use the *pattern* (a :: x) to do the opposite task: to decompose any non-empty list into two parts "a" and "x", "a" being the first item in the list (the "head" of the list) and "x" being the rest of the list once the first item has been removed (the "tail" of the list). The function "length" uses pattern matching on lists to measure the length of a list: the length of the empty list is obviously zero, while the length of any non-empty list must be 1 greater than the length of its tail. fun length [] = 0 | length (a::x) = 1 + length x; > val length = fn : 'a list -> int length []; > val it = 0 : int length numlist; > val it = 3 : int It's important that for every function definition, at least one clause should match for every possible value to which the function might be applied. If this were not the case, the function would be undefined for some values and a run-time error could arise. The ML compiler will give a warning about any declaration which is not complete in this sense. For example, this definition of "sumlists" which adds together two integer lists component-wise is incomplete: fun sumlists [] [] : int list = [] | sumlists (n :: ns) (m :: ms) = (n + m) :: sumlists ns ms; > In file examples, line 654: Warning: possible Match exception in function sumlists > val sumlists = fn : int list -> int list -> int list The definition leaves an expression such as sumlists [1] []; undefined. Additional constructs are available in patterns which make possible some sophisticated effects. The next function, "removedups", removes duplicate items from a sorted list; the restriction to sorted lists allows us to assume that duplicate items will always be adjacent to one another in the list. Two new pattern constructs are used: the wildcard pattern, written "_", matches any value, but creates no new bindings so that the matched value is effectively discarded; the layered pattern, written "var as pattern", both binds the matching value to the variable "var", but also matches it against the subsidiary "pattern" which may decompose it further. With this construct, both the whole value and its component parts can be made accessible. fun removedups (a :: (l as b :: _)) = if a = b then removedups l else a :: removedups l | removedups l = l; > val removedups = fn : ''a list -> ''a list removedups [1, 1, 2, 3, 4, 4, 5, 6, 6, 6, 7]; > val it = [1, 2, 3, 4, 5, 6, 7] : int list -- Defining New Types ------------------------------------------------- The type system of ML is extendible: the keyword "datatype" introduces declarations of new types. This single declaration form encompasses all the sorts of user defined types found in languages such as Pascal - enumerated types, records and unions. Here, for example, is an enumerated type representing the days of the week: datatype day = Sun | Mon | Tue | Wed | Thu | Fri | Sat; > datatype day > con Sun : day > con Mon : day > con Tue : day > con Wed : day > con Thu : day > con Fri : day > con Sat : day This declaration creates a new type "day" together with a set of seven "value constructors", each of which is a value of the new type; these are in fact the *only* values of the new type. The value constructors may be used in both patterns and expressions: fun weekend Sat = true | weekend Sun = true | weekend _ = false; > val weekend = fn : day -> bool weekend Wed; > val it = false : bool weekend Sun; > val it = true : bool Value constructors share the same name space as variables, so it's not always clear when looking at a pattern which identifiers are constructors to be matched and which are variables to be bound. To minimise this confusion, there is a convention among some ML programmers to always start the names of value constructors with upper-case letters, and those of variables with lower-case. This is the convention followed in this file, and it's recommended for programmers new to the language. Value constructors may be declared to take arguments, in which case they become functions from their argument types to the newly created datatype. This supports the creation of new "record" types such as complex numbers: datatype complex = Complex of (real * real); > datatype complex > con Complex : real * real -> complex The keyword "of" introduces the argument type of the constructor; the resulting function "Complex" converts 2-tuples of real numbers into complex numbers. The "Complex" constructor can be used for pattern matching also: fun realpart (Complex(r, _)) = r and imagpart (Complex(_, i)) = i; > val realpart = fn : complex -> real > val imagpart = fn : complex -> real realpart (Complex(1.0, 0.0)); > val it = 1.0 : real imagpart (Complex(~1.5, 0.5)); > val it = 0.5 : real For more sophisticated structures, we might choose to use a "labelled record" as the argument to a value constructor. This is similar to a tuple, in that it has a fixed number of fields of mixed type, but each field is labelled with an identifier; the labels are made explicit both in the type itself and in every value of the type. With large structures this can make programs much clearer, and there is a special pattern matching construct available for selecting particular labelled fields. Labelled records are written inside braces, "{" ... "}"; all the labels in a record must be distinct, but as a consequence the fields may be written in any order. This next datatype uses a labelled record to describe a book: datatype book = Book of { title : string, author : string, date : int }; > datatype book > con Book : {author : string, date : int, title : string} -> book val good_book = Book { title = "The Definition of Standard ML", author = "Milner et.al.", date = 1990 }; > val good_book = Book({author = "Milner et.al.", date = 1990, title = "The Definition of Standard ML"}) : book Note how the colon symbol ":" is used in the type specification to separate field names and their types while the equality symbol "=" is used in expressions to associate values with fields. Fields are sorted internally into a standard order, so the output doesn't always match the input exactly. Unwanted fields of the record can be ignored in pattern matches by use of the "record wildcard" pattern, written "..." (this is another keyword of the language): fun written_by name (Book {author, ...}) = author = name; > val written_by = fn : string -> book -> bool written_by "Milner et.al." good_book; > val it = true : bool written_by "Henry James" good_book; > val it = false : bool As a further demonstration of the flexibility of datatype declarations, here is a declaration of the type of simple binary trees. We can define a simple binary tree as EITHER an empty (Null) tree OR a Node consisting of two binary trees: datatype tree = Null | Node of (tree * tree); > datatype tree > con Null : tree > con Node : tree * tree -> tree An example tree might be: val tree = Node(Node(Null, Null), Null); > val tree = Node(Node(Null, Null), Null) : tree The function "height" measures the height of a tree: fun height Null = 0 | height (Node(left, right)) = let val hl = height left and hr = height right in (if hl > hr then hl else hr) + 1 end; > val height = fn : tree -> int height tree; > 2 : int It is also possible to create parameterised type constructors. For example, a more useful binary tree would include some kind of information, perhaps at each interior node. We can parameterise the definition of trees as follows: datatype 'a tree = Null | Node of ('a tree * 'a * 'a tree); > datatype 'a tree > con Null : 'a tree > con Node : 'a tree * 'a * 'a tree -> 'a tree Now the "Node" constructor takes a 3-tuple as an argument: a pair of trees plus some information of type "alpha". We can instantiate "alpha" in any way we choose: Node(Null, 0, Null); > Node(Null, 0, Null) : int tree Node(Node(Null, "a", Null), "d", Null); > Node(Node(Null, "a", Null), "d", Null) : string tree but, as ever, we cannot mix integers and strings in the same tree. Some of the built-in types of ML are no more than datatypes. Lists, for example: (* NB: don't compile this declaration *) datatype 'a list = nil | op :: of ('a * 'a list); The "datatype" declaration creates new, unique types which match only themselves; even if a declaration is repeated word for word, the two types thus created are distinct. That's why it's a bad idea to compile the "list" declaration above - it would create a completely new type, hiding the built-in list type. An alternative declaration form, the "type" declaration, creates what is called a "type synonym" or a "type abbreviation", which defines a new name for some existing type. This offers no more type security, as types with the new name will match correctly with the original type, but it can be extremely useful in allowing more descriptive names to be given to types for documentation purposes. Thus in geometry, we might declare: type point = real * real; > eqtype point = real * real type line = point * point; > eqtype line = point * point (The word "eqtype" output here indicates that these new types admit equality. This issue is not taken further here: the two identifiers can be thought of as type constructors just like any other.) There are no value constructors associated with such types: the values of type "point" are precisely those values of type "real * real". Because of this equivalence, the ML compiler can never deduce for itself that any particular pair of real numbers is meant to represent a "point". Values must be explicitly constrained with the new types to show any effect. For example, val origin : point = (0.0, 0.0); > val origin = (0.0, 0.0) : point val x_axis : line = (origin, (1.0, 0.0)) and y_axis : line = (origin, (0.0, 1.0)); > val x_axis = ((0.0, 0.0), (1.0, 0.0)) : line > val y_axis = ((0.0, 0.0), (0.0, 1.0)) : line fun gradient (((x1, y1), (x2, y2)) : line) : real = (y2-y1)/(x2-x1); > val gradient = fn : line -> real gradient x_axis; > 0.0 : real It's worth stressing again that although a function such as "gradient" has been typed to take an argument of type "line", it will in fact work on any type whose underlying structure is (real * real) * (real * real) For example, we might choose to represent a circle as two points (its centre, plus an arbitrary point on the circumference): type circle = point * point; > eqtype circle = point * point val unit_circle : circle = (origin, (1.0, 0.0)); > val unit_circle = ((0.0, 0.0), (1.0, 0.0)) : circle We can now say (without getting a type error): gradient unit_circle; the result of which is probably meaningless. This would be impossible had we defined points, lines and circles as datatypes in a manner analogous to the definition of complex numbers above. A third form of type declaration provided by ML, the "abstype" declaration, allows the creation of abstract data types, but this will not be covered here. -- The Exception Mechanism -------------------------------------------- ML run-time errors (those not caught by the type checker) manifest themselves as exceptions. It's possible to provoke one of these with a bad usage of a built in operator such as "div": 1 div 0; > ;;; Uncaught exception: Div or, more realistically, by using the "gradient" function defined above: gradient y_axis; > ;;; Uncaught exception: Overflow The name of the exception is printed to give some clue as to what has gone wrong: the "Div" exception indicates an attempt to divide by the integer zero; "Overflow" indicates a real overflow or (as in this case) division by real zero. There is no definite way of tying up exception names with the functions that caused them: "Overflow" for example may be raised by any real arithmetic operator or function which generates a result too large for the floating-point number representation. More often than not however, exceptions are caused by a bad combination of arguments given to a function, and in this case, there is a convention that an exception should be raised which has the same name as the function except for an initial upper-case letter. So the function "sqrt" raises the exception "Sqrt" if applied to a negative number: sqrt ~1.0; > ;;; Uncaught exception: Sqrt Even this rule breaks down for symbolic names. If you don't know what's gone wrong, try the HELP command on the exception name: HELP * Overflow for example, will give some useful information. The default effect of an exception is (as above) to abort the current evaluation, print a message and return to the top-level prompt. This need not be the case however, as exceptions may be caught by means of the "handle" construct: 1 div 0 handle Div => 999; > val it = 999 : int The exception raised by the bad "div" expression is trapped by the exception handler Div => 999 This is just like an anonymous function whose argument is an exception. When an exception occurs, its name is matched against the pattern in the handler. If the names match, the value of the handler is returned as the result of the whole expression, otherwise the exception passes through the handler, stopping either at the next handler or at top-level. This next example tries catching the wrong exception, without success: gradient y_axis handle Div => 999.0; > ;;; Uncaught exception: Overflow The "Overflow" exception raised by the application of "gradient" passes through the "Div" handler and up to top-level. Note how the handler has been changed to return 999.0 (a real) instead of 999 (an integer): this is because we have (gradient y_axis) : real and the result returned by an exception handler must have the same type as that of the expression being handled. New exceptions can be declared with the "exception" keyword and then "raised" to signal error conditions in user functions. Here the function "subscript" picks an indexed item from a list, but raises an exception if the list is not long enough: exception Subscript fun subscript 1 (item :: _) = item | subscript n (_ :: list) = subscript (n-1) list | subscript n [] = raise Subscript; > exception Subscript > val subscript = fn : int -> 'a list -> 'a val days = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]; > val days = [Sun, Mon, Tue, Wed, Thu, Fri, Sat] : day list subscript 5 days; > val it = Thu : day subscript 8 days; > ;;; Uncaught exception: Subscript subscript 8 days handle Subscript => Sun; > val it = Sun : day Exceptions can also communicate information between the point at which they are raised and the point at which they are handled. In this case the exception has to be declared with an argument type; when it is raised, it is raised with a value of that type, and the handler for it can elaborate the pattern match to choose a course of action depending on the value: exception ErrCond of string fun try x = if x < 10 then raise ErrCond("too low") else if x > 19 then raise ErrCond("too high") else x * x and keep_trying x = try x handle ErrCond("too low") => keep_trying (x+1) | ErrCond("too high") => keep_trying (x-1); > exception ErrCond of string > val try = fn : int -> int > val keep_trying = fn : int -> int keep_trying 15; > val it = 225 : int keep_trying 1; > val it = 100 : int keep_trying 20; > val it = 361 : int Exceptions are themselves values. There is a type "exn" to which all exception values belong; exception names are like constructors for this type, and are more properly referred to as "exception constructors". For example: Subscript; > val it = Subscript : exn ErrCond; > val it = fn : string -> exn As with value constructors, exceptions are usually declared to start with upper-case letters. A special built-in exception "Interrupt" is raised by interrupt signals generated from the keyboard. This can be handled in the same way as any other exception, allowing ML programs to keep control after interrupts. -- Further Reading ---------------------------------------------------- There are many more advanced constructs available in ML which are not discussed at all here. The file HELP * PML gives some references to publications which describe the language more fully. There are also further examples of ML programs within the PML system. HELP * LIST gives equivalent ML definitions for each of the functions defined in the built-in structure -List-. Also, try looking at some of the ML library files provided with PML: the variable -Compile.searchpath- contains the names of the directories used for libraries on your system. --- C.all/pml/help/examples --- Copyright University of Sussex 1991. All rights reserved. ----------