SML Chapter8
SML Chapter8
Imperative Programming in ML
Chapter outline
This chapter describes reference types and arrays, with examples of
their use in data structures. ML’s input and output facilities are presented.
The chapter contains the following sections:
Reference types. References stand for storage locations and can be created,
updated and inspected. Polymorphic references cannot be created, but polymor-
phic functions can use references.
319
320 8 Imperative Programming in ML
Reference types
References in ML are essentially store addresses. They correspond to the
variables of C, Pascal and similar languages, and serve as pointers in linked data
structures. For control structures, ML provides while-do loop commands; the
if-then-else and case expressions also work for imperative programming.
The section concludes by explaining the interaction between reference types and
polymorphism.
The assignment changes the contents of p to 7. Note the word ‘contents’! The
assignment does not change the value of p, which is a fixed address in the store;
it changes the contents of that address. We may use p and q like integer variables
in Pascal, except that dereferencing is explicit. We must write !p to get the
contents of p, since p by itself denotes an address.
References in data structures. Because references are ML values, they may be-
long to tuples, lists, etc.
val refs = [p,q,p];
> val refs = [ref 7, ref 2, ref 7] : int ref list
q := 1346;
> () : unit
refs;
> [ref 7, ref 1346, ref 7] : int ref list
The first and third elements of refs denote the same address as p, while the
second element is the same as q. ML compilers print the value of a reference as
ref c, where c is its contents, rather than printing the address as a number. So
assigning to q affects how refs is printed. Let us assign to the head of the list:
hd refs := 1415;
> () : unit
refs;
> [ref 1415, ref 1346, ref 1415] : int ref list
(!p,!q);
> (1415, 1346) : int * int
The assignment below updates the contents (q) of refq with the contents (1415)
322 8 Imperative Programming in ML
of the contents (p) of refp. Here refp and refq behave like Pascal pointer vari-
ables.
!refq := !(!refp);
> () : unit
(!p,!q);
> (1415, 1415) : int * int
Equality of references. The ML equality test is valid for all reference types. Two
references of the same type are equal precisely if they denote the same address.
The following tests verify that p and q are distinct references, and that the head
of refs equals p, not q:
p=q;
> false : bool
hd refs = p;
> true : bool
hd refs = q;
> false : bool
In Pascal, two pointer variables are equal if they happen to contain the same
address; an assignment makes two pointers equal. The ML notion of reference
equality may seem peculiar, for if p and q are distinct references then noth-
ing can make them equal (short of redeclaring them). In imperative languages,
where all variables can be updated, a pointer variable really involves two levels
of reference. The usual notion of pointer equality is like comparing the contents
of refp and refq, which are references to references:
!refp = !refq;
> false : bool
refq := p;
> () : unit
!refp = !refq;
> true : bool
At first, refp and refq contain different values, p and q. Assigning the value p
to refq makes refp and refq have the same contents; both ‘pointer variables’
refer to p.
When two references are equal, like p and hd refs, assigning to one affects the
contents of the other. This situation, called aliasing, can cause great confusion.
Aliasing can occur in procedural languages; in a procedure call, a global variable
and a formal parameter may denote the same address.
Cyclic data structures. Circular chains of references arise in many situations.
Suppose that we declare cp to refer to the successor function on the integers,
and dereference it in the function cFact.
8.2 Control structures 323
Each time cFact is called, it takes the current contents of cp. Initially this is the suc-
cessor function, and cFact(8) = 8 × 8 = 64:
cFact 8;
> 64 : int
Let us update cp to contain cFact. Now cFact refers to itself via cp. It becomes a
recursive function and computes factorials:
cp := cFact;
> () : unit
cFact 8;
> 40320 : int
Updating a reference to create a cycle is sometimes called ‘tying the knot.’ Many func-
tional language interpreters implement recursive functions exactly as shown above, cre-
ating a cycle in the execution environment.
Exercise 8.2 Declare the function +:= such that +:= Id E has the same
effect as Id := !Id + E , for integer E .
Exercise 8.3 With p and q declared as above, explain ML’s response when
these expressions are typed at top level:
p:=!p+1 2*!q
Note that this behaviour arises from ML’s treatment of expressions in general;
ML has only one if construct.
Similarly, the case expression can serve as a control structure:
(E1 ; E2 ; . . . ; En )
let D in E1 ; E2 ; . . . ; En end
while E1 do E2
The value returned is (), so E2 is evaluated just for its effect on the state.
fun impFact n =
let val resultp = ref 1
and ip = ref 0
in while !ip < n do (ip := !ip + 1;
resultp := !resultp * !ip);
!resultp
end;
> val impFact = fn : int -> int
The body of the while contains two assignments. At each iteration it adds one
to the contents of ip, then uses the new contents of ip to update the contents of
resultp.
Although calling impFact allocates new references, this state change is invis-
ible outside. The value of impFact(E ) is a mathematical function of the value
of E .
impFact 6;
> 720 : int
These two functions demonstrate the imperative style, but a pure recursive func-
tion is the clearest and probably the fastest way to compute factorials. More
realistic imperative programs appear later in this chapter.
Supporting library functions. The standard library declares some top level func-
tions for use in imperative programs. The function ignore ignores its argument
and returns (). Here is a typical situation:
326 8 Imperative Programming in ML
The input/output command returns a string, while the assignment returns ().
Calling ignore discards the string, preventing a clash between types string and
unit. The argument to ignore is evaluated only for its side-effects, here to skip
the next line of a file.
Sometimes we must retain an expression’s value before executing some com-
mand. For instance, if x contains 0.5 and y contains 1.2, we could exchange
their contents like this:
y := #1 (!x , x := !y);
> () : unit
(!x , !y);
> (1.2, 0.5) : real * real
The exchange works because the arguments of the pair are evaluated in order.
The function #1 returns the first component,1 which is the original contents of x .
The library infix before provides a nicer syntax for this trick. It simply returns
its first argument.
y := (!x before x := !y);
The list functional app applies a command to every element of a list. For ex-
ample, here is a function to assign the same value to each member of a list of
references:
fun initialize rs x = app (fn r => r :=x ) rs;
> val initialize = fn : ’a ref list -> ’a -> unit
initialize refs 1815;
> () : unit
refs;
> [ref 1815, ref 1815, ref 1815] : int ref list
could occur at any time, leaving the state in an abnormal condition. The follow-
ing exception handler traps any exception, tidies up the state, and re-raises the
exception. The variable e is a trivial pattern (of type exn) to match all excep-
tions:
handle e => (...(*tidy up actions*)...; raise e)
Note: Most commands return the value () of type unit. From now on, our
sessions will omit the boring response
> () : unit
Exercise 8.5 Write an imperative version of the function sqroot, which com-
putes real square roots by the Newton-Raphson method (Section 2.17).
Exercise 8.6 Write an imperative version of the function fib, which computes
Fibonacci numbers efficiently (Section 2.15).
V1 , V2 , . . . , Vn := E1 , E2 , . . . , En
first evaluates the expressions, then assigns their values to the corresponding
references. For instance x , y := !y, !x exchanges the contents of x and y. Write
an ML function to perform simultaneous assignments. It should have the poly-
morphic type (α ref )list × α list → unit.
With its polymorphic type (α → α)ref , we should be able to apply the contents
of fp to arguments of any types:
(!fp true, !fp 5);
> (true, 5) : bool * int
And its polymorphic type lets us assign a function of type bool → bool to fp:
fp := not;
!fp 5;
Substituting the declarations away affects neither the value returned nor the typ-
ing:
((fn x => x ) true, (fn x => x ) 5);
8.3 Polymorphic references 329
Now let us see how ML reacts to our imaginary session above, when packaged
as a let expression:
let val fp = ref I
in ((!fp true, !fp 5), fp := not, !fp 5) end;
> Error: Type conflict: expected int, found bool
ML rejects it, thank heavens — and with a meaningful error message too. What
happens if we substitute the declarations away?
((!(ref I ) true, !(ref I ) 5), (ref I ) := not, !(ref I ) 5);
> ((true, 5), (), 5) : (bool * int) * unit * int
The expression is evaluated without error. But the substitution has completely
altered its meaning. The original expression allocates a reference fp with ini-
tial contents I , extracts its contents twice, updates it and finally extracts the new
value. The modified expression allocates four different references, each with ini-
tial contents I . The assignment is pointless, updating a reference used nowhere
else.
The crux of the problem is that repeated calls to ref always yield different
references. We declare val fp = ref I expecting that each occurrence of fp
will denote the same reference: the same store address. Substitution does not
respect the sharing of fp. Polymorphism treats each identifier by substituting
the type of its defining expression, thereby assuming that substitution is valid.
The culprit is sharing, not side effects. We must regulate the creation of poly-
morphic references, not assignments to them.
Polymorphic value declarations. Syntactic values are expressions that are too
simple to create references. They come in several forms:
• A literal constant such as 3 is a syntactic value.
• An identifier is one also, as it refers to some other declaration that has
been dealt with already.
• A syntactic value can be built up from others using tupling, record no-
tation and constructors (excluding ref , of course).
• A function in fn notation is a syntactic value, even if its body uses ref ,
as the body is not executed until the function is called.
Calls to ref and other functions are not syntactic values.
330 8 Imperative Programming in ML
is illegal because ref I involves the type variable α, which cannot stand for bool
and int at the same time. The expression
is illegal for the same reason. Yet it is safe: if we could evaluate it, the result
would be (true, 5) with no run-time error. A monomorphic version is legal:
val fp = ref I ;
> Error: Non-value in polymorphic declaration
A monomorphic type constraint makes the top level declaration legal. The ex-
pression no longer creates polymorphic references:
fun irev l =
let val resultp = ref []
and lp = ref l
in while not (null (!lp)) do
(resultp := hd (!lp) :: !resultp;
lp := tl (!lp));
!resultp
end;
> val irev = fn : ’a list -> ’a list
The variables lp and resultp have type (α list)ref ; the type variable α is frozen
in the body of the let. ML accepts irev as a polymorphic function because it is
declared using fun.
As we can verify, irev is indeed polymorphic:
irev [25,10,1415];
> [1415, 10, 25] : int list
irev (explode("Montjoy"));
> [#"y", #"o", #"j", #"t", #"n", #"o", #"M"]
> : char list
Polymorphic exceptions. Although exceptions do not involve the store, they re-
quire a form of sharing. Consider the following nonsense:
exception Poly of 0 a; (* illegal!! *)
(raise Poly true) handle Poly x => x +1;
Exercise 8.9 Which of these declarations are legal? Which could, if evaluated,
lead to a run-time type error?
val funs = [hd ];
val l = rev [];
val l0 = tl [3];
val lp = let fun nilp x = ref [] in nilp() end;
pointers can be updated, allowing re-linking of existing data structures and the
creation of cycles.
Reference types, in conjunction with recursive datatypes, can implement such
linked data structures. This section presents two such examples: doubly-linked
circular lists and a highly efficient form of functional array. We begin with
a simpler use of references: not as link fields, but as storage for previously
computed results.
An abstract type of sequences. Structure ImpSeq implements lazy lists; see Fig-
ure 8.1 on the next page. Type α t has three constructors: Nil for the empty
sequence, Cons for non-empty sequences, and Delayed to permit delayed eval-
uation of the tail. A sequence of the form
where xf has type unit → α t, begins with x and has the sequence xf () for
its remaining elements. Note that Delayed xf is contained in a reference cell.
Applying force updates it to contain the value of xf (), removing the Delayed .
Some overhead is involved, but if the sequence element is revisited then there
will be a net gain in efficiency.
The function null tests whether a sequence is empty, while hd and tl return
the head and tail of a sequence. Because tl calls force, a sequence’s outer con-
structor cannot be Delayed . Inside structure ImpSeq, functions on sequences
may exploit pattern-matching; outside, they must use null , hd and tl because
the constructors are hidden. An opaque signature constraint ensures that the
structure yields an abstract type:
signature IMPS EQUENCE =
sig
type 0 a t
exception Empty
val empty : 0a t
val cons : 0 a * (unit -> 0 a t) -> 0 a t
334 8 Imperative Programming in ML
Cyclic sequences. The function cycle creates cyclic sequences by tying the
knot. Here is a sequence whose tail is itself:
"Never"
This behaves like the infinite sequence "Never", "Never", . . . , but occupies
a tiny amount of space in the computer. It is created by
ImpSeq.cycle(fn xf => ImpSeq.cons("Never", xf ));
> - : string ImpSeq.t
ImpSeq.take(5, it);
> ["Never", "Never", "Never", "Never", "Never"]
> : string list
When cycle is applied to some function seqfn, it creates the reference knot and
supplies it to seqfn (packaged as a function). The result of seqfn is a sequence
that, as its elements are computed, eventually refers to the contents of knot.
Updating knot to contain this very sequence creates a cycle.
Cyclic sequences can compute Fibonacci numbers in an amusing fashion. Let
add be a function that adds two sequences of integers, returning a sequence of
sums. To illustrate reference polymorphism, add is coded in terms of a function
to join two sequences into a sequence of pairs:
fun pairs(xq,yq) =
ImpSeq.cons((ImpSeq.hd xq, ImpSeq.hd yq),
fn()=>pairs(ImpSeq.tl xq, ImpSeq.tl yq));
> val pairs = fn
> : ’a ImpSeq.t * ’b ImpSeq.t -> (’a * ’b) ImpSeq.t
fun add (xq,yq) = ImpSeq.map Int.+ (pairs(xq,yq));
> val add = fn
336 8 Imperative Programming in ML
This definition is cyclic. The sequence begins 1, 1, and the remaining elements
are obtained by adding the sequence to its tail:
1 1 add
When the third element of fib is inspected by tl (tl fib), the add call computes
a 2 and force updates the sequence as follows:
1 1 2 add
1 1 2 3 add
Because the sequence is cyclic and retains computed elements, each Fibonacci
number is computed only once. This is reasonably fast. If Fibonacci numbers
were defined recursively using the sequences of Section 5.12, the cost of com-
puting the nth element would be exponential in n.
8.5 Ring buffers 337
Exercise 8.10 The Hamming problem is to enumerate all integers of the form
2i 3j 5k in increasing order. Declare a cyclic sequence consisting of these num-
bers. Hint: declare a function to merge increasing sequences, and consider the
following diagram:
×5
×3
×2
1 merge
Exercise 8.11 Implement the function iterates, which given f and x creates a
cyclic representation of the sequence [x , f (x ), f (f (x )), . . . , f k (x ), . . .].
Exercise 8.13 Code the functions omitted from structure ImpSeq but specified
in its signature, namely toList, fromList, interleave, concat and filter .
a b c
This mutable data structure, sometimes called a ring buffer, should be familiar
to most programmers. We implement it here to make a comparison between
references in Standard ML and pointer variables in procedural languages. Let us
define an abstract type with the following signature:
338 8 Imperative Programming in ML
signature RINGBUF =
sig
eqtype 0 a t
exception Empty
val empty : unit -> 0 a t
val null : 0a t -> bool
val label : 0a t -> 0 a
val moveLeft : 0 a t -> unit
val moveRight : 0 a t -> unit
val insert : 0a t 0
* a -> unit
val delete : 0a t -> 0 a
end;
A ring buffer has type α t and is a reference into a doubly-linked list. A new
ring buffer is created by calling the function empty. The function null tests
whether a ring buffer is empty, label returns the label of the current node, and
moveLeft/moveRight move the pointer to the left/right of the current node. As
shown below, insert(buf , e) inserts a node labelled e to the left of the current
node. Two links are redirected to the new node; their initial orientations are
shown by dashed arrows and their final orientations by shaded arrows:
a b
The function delete removes the current node and moves the pointer to the right.
Its value is the label of the deleted node.
The code, which appears in Figure 8.2, is much as it might be written in
Pascal. Each node of the doubly-linked list has type α buf , which contains a
label and references to the nodes on its left and right. Given a node, the functions
left and right return these references.
The constructor Nil represents an empty list and serves as a placeholder, like
Pascal’s nil pointer. If Node were the only constructor of type α buf , no value
of that type could be created. Consider the code for insert. When the first node
is created, its left and right pointers initially contain Nil . They are then updated
to contain the node itself.
Bear in mind that reference equality in ML differs from the usual notion of
pointer equality. The function delete must check whether the only node of a
buffer is about to be deleted. It cannot determine whether Node(lp, x , rp) is the
8.5 Ring buffers 339
If only insert and delete are performed, then a ring buffer behaves like a muta-
ble queue; elements can be inserted and later retrieved in the same order.
RingBuf .insert(buf , "shall");
RingBuf .delete buf ;
> "They" : string
RingBuf .insert(buf , "be");
RingBuf .insert(buf , "famed");
RingBuf .delete buf ;
> "shall" : string
RingBuf .delete buf ;
> "be" : string
RingBuf .delete buf ;
> "famed" : string
Exercise 8.14 Modify delete to return a boolean value instead of a label: true
if the modified buffer is empty and otherwise false.
Exercise 8.15 Which of the equalities below are suitable for testing whether
Node(lp, x , rp) is the only node in a ring buffer?
Exercise 8.16 Compare the following insertion function with insert; does it
have any advantages or disadvantages?
8.6 Mutable and functional arrays 341
Exercise 8.17 Code a version of insert that inserts the new node to the right
of the current point, rather than to the left.
Exercise 8.18 Show that if a value of type α RingBuf .t (with a strong type
variable) could be declared, a run-time type error could ensue.
Each array has a fixed size. An n-element array admits subscripts from 0 to n −
1. The operations raise exception Subscript if the array bound is exceeded and
raise Size upon any attempt to create an array of negative (or grossly excessive)
size.2
Here is a brief description of the main array operations:
• array(n, x ) creates an n-element array with x stored in each cell.
• fromList[x0 , x1 , . . . , xn −1 ] creates an n-element array with xk stored in
cell k , for k = 0, . . . , n − 1.
• tabulate(n, f ) creates an n-element array with f (k ) stored in cell k , for
k = 0, . . . , n − 1.
2 These exceptions are declared in the library structure General .
342 8 Imperative Programming in ML
Array are mutable objects and behave much like references. They always admit
equality: two arrays are equal if and only if they are the same object. Arrays of
arrays may be created, as in Pascal, to serve as multi-dimensional arrays.
Standard library aggregate structures. Arrays of type α array can be updated.
Immutable arrays provide random access to static data, and can make func-
tional programs more efficient. The library structure Vector declares a type α vector of
immutable arrays. It provides largely the same operations as Array, excluding update.
Functions tabulate and fromList create vectors, while Array.extract extracts a vector
from an array.
Because types α array and α vector are polymorphic, they require an additional in-
direction for every element. Monomorphic arrays and vectors can be represented more
compactly. The library signature MONO ARRAY specifies the type array of mutable
arrays over another type elem. Signature MONO VECTOR is analogous, specifying a
type vector of immutable arrays. Various standard library structures match these signa-
tures, giving arrays of characters, floating point numbers, etc.
The library regards arrays, vectors and even lists as variations on one concept: ag-
gregates. The corresponding operations agree as far as possible. Arrays, like lists, have
app and fold functionals. The function Array.fromList converts a list to an array, and
the inverse operation is easy to code:
Lists, like arrays, have a tabulate function. They both support subscripting, indexed
from zero, and both raise exception Subscript if the upper bound is exceeded.
i j
j y u v
C
i x
B
Other links into A, B and C are shown; these come from arrays created by
further updating. The arrays form a tree, called a version tree since its nodes
are ‘versions’ of the vector. Unlike ordinary trees, its links point towards the
root rather than away from it. The root of the tree is A, which is a dummy node
linked to the vector. The dummy node contains the only direct link into the
vector, in order to simplify the re-rooting operation.
Re-rooting the version tree. Although C has the correct value, with C [i ] = x ,
C [j ] = y and the other elements like in A, lookups to C are slower than they
could be. If C is the most heavily used version of the vector, then the root of
the version tree ought to be moved to C . The links from C to the vector are
reversed; the updates indicated by those nodes are executed in the vector; the
previous contents of those vector cells are recorded in the nodes.
i j
x y
C
j v
B
i u
A
This operation does not affect the values of the functional arrays, but lookups
to A become slower while lookups to C become faster. The dummy node is
now C . Nodes of the version tree that refer to A, B , or C likewise undergo a
change in lookup time, but not in value. Re-rooting does not require locating
those other nodes. If there are no other references to A or B then the ML storage
allocator will reclaim them.
344 8 Imperative Programming in ML
Exercise 8.20 Recall the function allChange of Section 3.7. With the help of
arrays, write a function that can efficiently determine the value of
length(allChange([], [5,2], 16000));
Exercise 8.22 Add a function copy to structure Varray, such that copy(va)
creates a new v -array having the same value as va.
Exercise 8.25 What are the contents of the dummy node? Could an alternative
representation of v -arrays eliminate this node?
translating between basic values and strings. The main functions are toString,
fromString, fmt and scan. Instead of overloading these functions at top level, the
library declares specialized versions in every appropriate structure. You might
declare these functions in some of your own structures.
Structure StringCvt supports more elaborate formatting. You can specify how
many decimal places to display, and pad the resulting string to a desired length.
You even have a choice of radix. For example, the DEC PDP -8 used octal nota-
tion, and padded integers to four digits:
Int.fmt StringCvt.OCT 31;
> "37" : string
StringCvt.padLeft #"0" 4 it;
> "0037" : string
Operations like String.concat can combine formatted results with other text.
Converting from strings. The function fromString converts strings to basic val-
ues. It is permissive; numeric representations go well beyond what is valid in
ML programs. For instance, the signs + and - are accepted as well as ˜:
The string is scanned from left to right and trailing characters are ignored. User
errors may go undetected:
Int.toString "1o24";
> SOME 1 : int option
Bool .fromString "falsetto";
> SOME false : bool option
Splitting strings apart. Since fromString ignores leftover characters, how are
we to translate a series of values in a string? The library structures String and
Substring provide useful functions for scanning. The function String.tokens
extracts a list of tokens from a string. Tokens are non-empty substrings sepa-
rated by one or more delimiter characters. A predicate of type char → bool
defines the delimiter characters; structure Char contains predicates for recog-
nizing letters (isAlpha), spaces (isSpace) and punctuation (isPunct). Here are
some sample invocations:
String.tokens Char .isSpace
"What is thy name? I know thy quality.";
> ["What", "is", "thy", "name?",
> "I", "know", "thy", "quality."] : string list
String.tokens Char .isPunct
"What is thy name? I know thy quality.";
> ["What is thy name", " I know thy quality"]
> : string list
We thus can split a string of inputs into its constituent parts, and pass them to
fromString. Function dateFromString (Figure 8.4) decodes dates of the form
dd -MMM -yyyy. It takes the first three hyphen-separated tokens of the input. It
parses the day and year using Int.fromString, and shortens the month to three
characters using String.substring. It returns NONE if the month is unknown,
or if exceptions are raised; Bind could arise in three places.
dateFromString "25-OCTOBRE-1415-shall-live-forever";
8.7 String processing 349
Scanning from character sources. The scan functions, found in several library
structures, give precise control over text processing. They accept any functional
character source, not just a string. If you can write a function
then type σ can be used as a character source. Calling getc either returns NONE
or else packages the next character with a further character source.
The scan functions read a basic value, consuming as many characters as pos-
sible and leaving the rest for subsequent processing. For example, let us define
lists as a character source:
fun listGetc (x ::l ) = SOME (x ,l )
| listGetc [] = NONE ;
> val listGetc = fn : ’a list -> (’a * ’a list) option
The scan functions are curried, taking the character source as their first argu-
ment. The integer scan function takes, in addition, the desired radix; DEC means
decimal. Let us scan some faulty inputs:
Bool .scan listGetc (explode "mendacious");
> NONE : (bool * char list) option
Bool .scan listGetc (explode "falsetto");
> SOME (false, [#"t", #"t", #"o"])
> : (bool * char list) option
Real .scan listGetc (explode "6.626x-34");
> SOME (6.626, [#"x", #"-", #"3", #"4"])
> : (real * char list) option
Int.scan StringCvt.DEC listGetc (explode "1o24");
> SOME (1, [#"o", #"2", #"4"])
> : (int * char list) option
The mis-typed characters x and o do not prevent numbers from being scanned,
but they remain in the input. Such errors can be detected by checking that the in-
put has either been exhausted or continues with an expected delimiter character.
In the latter case, delimiters can be skipped and further values scanned.
The fromString functions are easy to use but can let errors slip by. The scan
functions form the basis for robust input processing.
350 8 Imperative Programming in ML
Exercise 8.27 Write a function toUpper for translating all the letters in a string
to upper case, leaving other characters unchanged. (Library structures String
and Char have relevant functions.)
Exercise 8.28 Repeat the examples above using substrings instead of lists as
the source of characters. (The library structure Substring declares useful func-
tions including getc.)
Exercise 8.29 Use the scan functions to code a function for scanning dates.
It should accept an arbitrary character source. (Library structure StringCvt has
relevant functions.)
Here are brief descriptions of these items. Consult the library documentation for
more details.
• Input streams have type instream while output streams have type out-
stream. These types do not admit equality.
• Exception Io indicates that some low-level operation failed. It bundles
up the name of the affected file, a primitive function and the primitive
exception that was raised.
• stdIn and stdOut, the standard input and output streams, are connected
to the terminal in an interactive session.
• openIn(s) and openOut(s) create a stream connected to the file named s.
• closeIn(is) and closeOut(os) terminate a stream, disconnecting it from
its file. The stream may no longer transmit characters. An input stream
may be closed by its device, for example upon end of file.
• inputN (is, n) removes up to n characters from stream is and returns
them as a string. If fewer than n characters are present before the stream
closes then only those characters are returned.
• inputLine(is) reads the next line of text from stream is and returns it
as a string ending in a newline character. If stream is has closed, then
the empty string is returned.
• inputAll (is) reads the entire contents of stream is and returns them as a
string. Typically it reads in an entire file; it is not suitable for interactive
input.
• lookahead (is) returns the next character, if it exists, without removing
it from the stream is.
• endOfStream(is) is true if the stream is has no further characters be-
fore its terminator.
• output(os, s) writes the characters of string s to the stream os, provided
it has not been closed.
• flushOut(os) sends to their ultimate destination any characters waiting
in system buffers.
• print(s) writes the characters in s to the terminal, as might otherwise
be done using output and flushOut. Function print is available at top
level.
352 8 Imperative Programming in ML
The input operations above may block: wait until the required characters appear
or the stream closes.
Suppose the file Harry holds some lines by Henry V, from his message to
the French shortly before the battle of Agincourt:
Calling lookahead does not advance into the file. But now we extract ten char-
acters as a string, then read the rest of the line.
TextIO.inputN (infile,10);
> "My people " : string
TextIO.inputLine infile;
> "are with sickness much enfeebled;\n" : string
Calling inputAll gets the rest of the file as a long and unintelligible string, which
we then output to the terminal:
TextIO.inputAll infile;
> "my numbers lessened, and those few I have\nalmo#
print it;
> my numbers lessened, and those few I have
> almost no better than so many French ...
> But, God before, we say we will come on!
A final peek reveals that we are at the end of the file, so we close it:
TextIO.lookahead infile;
> NONE : char option
TextIO.inputLine infile;
> "" : string
TextIO.closeIn infile;
Closing streams when you are finished with them conserves system resources.
8.9 Text processing examples 353
Batch input/output. Our first example is a program to read a series of lines and
print the initial letters of each word. Words are tokens separated by spaces;
subscripting gets their initial characters and implode joins them to form a string:
fun firstChar s = String.sub(s,0);
> val firstChar = fn : string -> char
val initials = implode o (map firstChar ) o
(String.tokens Char .isSpace);
> val initials = fn : string -> string
initials "My ransom is this frail and worthless trunk";
> "Mritfawt" : string
The function batchInitials, given input and output streams, repeatedly reads a
line from the input and writes its initials to the output. It continues until the
input stream is exhausted.
fun batchInitials (is, os) =
while not (TextIO.endOfStream is)
do TextIO.output(os, initials (TextIO.inputLine is) ˆ "\n");
> val batchInitials = fn
> : TextIO.instream * TextIO.outstream -> unit
The output appears at the terminal because stdOut has been given as the output
stream.
Interactive input/output. We can make batchInitials read from the terminal just
by passing stdIn as its first argument. But an interactive version ought to display
a prompt before it pauses to accept input. A naı̈ve attempt calls output just
before calling inputLine:
while not (TextIO.endOfStream is)
do (TextIO.output(os, "Input line? ");
354 8 Imperative Programming in ML
But this does not print the prompt until after it has read the input! There are two
mistakes. (1) We must call flushOut to ensure that the output really appears,
instead of sitting in some buffer. (2) We must print the prompt before calling
endOfStream, which can block; therefore we must move the prompting code
between the while and do keywords. Here is a better version:
fun promptInitials (is, os) =
while (TextIO.output(os, "Input line? ");
TextIO.flushOut os;
not (TextIO.endOfStream is))
do TextIO.output(os, "Initials: " ˆ
initials(TextIO.inputLine is) ˆ "\n");
> val promptInitials = fn
> : TextIO.instream * TextIO.outstream -> unit
The final input above was Control-D, which terminates the input stream. That
does not prevent our reading further characters from stdIn in the future. Simi-
larly, after we reach the end of a file, some other process could extend the file.
Calling endOfStream can return true now and false later.
If the output stream is always the terminal, using print further simplifies the
while loop:
while (print "Input line? "; not (TextIO.endOfStream is))
do print ("Initials: " ˆ initials(TextIO.inputLine is) ˆ "\n");
Translating into HTML. Our next example performs only simple input/output,
but illustrates the use of substrings. A value of type substring is represented
by a string s and two integers i and n; it stands for the n-character segment
of s starting at position i . Substrings support certain forms of text processing
efficiently, with minimal copying and bounds checking. A substring can be
8.9 Text processing examples 355
divided into tokens or scanned from the left or right; the results are themselves
substrings.
Our task is to translate plays from plain text into HTML, the HyperText Markup
Language used for the World Wide Web. Figure 8.5 shows a typical input. Blank
lines separate paragraphs. Each speech is a paragraph; the corresponding output
must insert the <P> markup tag. The first line of a paragraph gives the char-
acter’s name, followed by a period; the output must emphasize this name by
enclosing it in the <EM> and </EM> tags. To preserve line breaks, the transla-
tion should attach the <BR> tag to subsequent lines of each paragraph.
Function firstLine deals with the first line of a paragraph, separating the name
from the rest of line. It uses three components of the library structure Substring,
namely all , splitl and string. Calling all s creates a substring representing
the whole of string s. The call to splitl scans this substring from left to right,
returning in name the substring before the first period, and in rest the remainder
of the original substring. The calls to string convert these substrings to strings
so that they can be concatenated with other strings containing the markup tags.
fun firstLine s =
let val (name,rest) =
Substring.splitl (fn c => c <> #".") (Substring.all s)
in "\n<P><EM>" ˆ Substring.string name ˆ
"</EM>" ˆ Substring.string rest
end;
356 8 Imperative Programming in ML
Function htmlCvt takes a filename and opens input and output streams. Its
main loop is the recursive function cvt, which translates one line at a time,
keeping track of whether or not it is the first line of a paragraph. An empty
string indicates the end of the input, while an empty line (containing just the
newline character) starts a new paragraph. Other lines are translated according
as whether or not they are the first. The translated line is output and the process
repeats.
fun htmlCvt fileName =
let val is = TextIO.openIn fileName
and os = TextIO.openOut (fileName ˆ ".html")
fun cvt _ "" = ()
| cvt _ "\n" = cvt true (TextIO.inputLine is)
| cvt first s =
(TextIO.output (os,
if first then firstLine s
else "<BR>" ˆ s);
cvt false (TextIO.inputLine is));
in cvt true "\n"; TextIO.closeIn is; TextIO.closeOut os
end;
> val htmlCvt = fn : string -> unit
8.10 A pretty printer 357
Finally, htmlCvt closes the streams. Closing the output stream ensures that text
held in buffers actually reaches the file. Figure 8.6 on the preceding page shows
how a Web browser displays the translated text.
Input/output and the standard library. Another useful structure is BinIO,
which supports input/output of binary data in the form of 8-bit bytes. Char-
acters and bytes are not the same thing: characters occupy more than 8 bits on some
systems, and they assign special interpretations to certain codes. Binary input/output
has no notion of line breaks, for example.
The functors ImperativeIO, StreamIO and PrimIO support input/output at lower
levels. (The library specifies them as optional, but better ML systems will provide
them.) ImperativeIO supports imperative operations, with buffering. StreamIO pro-
vides functional operations for input: items are not removed from an instream, but yield
a new instream. PrimIO is the most primitive level, without buffering and implemented
in terms of operating system calls. The functors can be applied to support specialized
input/output, say for extended character sets.
Andrew Appel designed this input/output interface with help from John Reppy and
Dave Berry.
Exercise 8.30 Write an ML program to count how many lines, words and char-
acters are contained in a file. A word is a string of characters delimited by
spaces, tabs, or newlines.
Exercise 8.31 Write a procedure to prompt for the radius of a circle, print the
corresponding area (using A = πr 2 ) and repeat. If the attempt to decode a real
number fails, it should print an error message and let the user try again.
Exercise 8.32 The four characters < > & " have special meanings in HTML.
Occurrences of them in the input should be replaced by the escape sequences
< > & " (respectively). Modify htmlCvt to do this.
((((landed | saintly) |
((˜landed) | (˜saintly))) &
(((˜rich) | saintly) |
((˜landed) |
(˜saintly)))) &
(((landed | rich) |
((˜landed) | (˜saintly))) &
(((˜rich) | rich) |
((˜landed) | (˜saintly)))))
Figure 8.7 shows the rather better display produced by a pretty printer. Two
propositions (including the one above) are formatted to margins of 30 and 60.
Finding the ideal presentation of a formula may require judgement and taste, but
a simple scheme for pretty printing gives surprisingly good results. Some ML
systems provide pretty-printing primitives similar to those described below.
The pretty printer accepts a piece of text decorated with information about
nesting and allowed break points. Let us indicate nesting by angle brackets
( ) and possible line breaks by a vertical bar ( ). An expression of the form
e1 . . . en is called a block.
For instance, the block
DD E D D E EE
a * b - ( c + d )
8.10 A pretty printer 359
represents the string a*b-(c+d). It allows line breaks after the characters *, -
and +.
When parentheses are suppressed according to operator precedences, correct
pretty printing is essential. The nesting structure of the block corresponds to the
formula
(a × b) − (c + d ) rather than a × (b − (c + d )).
If a*b-(c+d) does not fit on one line, then it should be broken after the - char-
acter; outer blocks are broken before inner blocks.
The pretty printing algorithm keeps track of how much space remains on the
current line. When it encounters a break, it determines how many characters
there are until the next break in the same block or in an enclosing block. (Thus it
ignores breaks in inner blocks.) If that many characters will not fit on the current
line, then the algorithm prints a new line, indented to match the beginning of the
current block.
The algorithm does not insist that a break should immediately follow every
block. In the previous example, the block
D E
c + d
Figure 8.8 presents the pretty printer. Observe that Block stores the total size of
a block, as computed by blo. Also, after holds the distance from the end of the
current block to the next break.
The output shown above in Figure 8.7 was produced by augmenting our tau-
tology checker as follows:
local open Pretty
in
fun prettyshow (Atom a) = str a
| prettyshow (Neg p) =
blo(1, [str "(˜", prettyshow p, str ")"])
| prettyshow (Conj (p,q)) =
blo(1, [str "(", prettyshow p, str " &",
brk 1, prettyshow q, str ")"])
| prettyshow (Disj (p,q)) =
blo(1, [str "(", prettyshow p, str " |",
brk 1, prettyshow q, str ")"]);
end;
> val prettyshow = fn : prop -> Pretty.t
Calling Pretty.pr with the result of prettyshow does the pretty printing.
Further reading. The pretty printer is inspired by Oppen (1980). Oppen’s
algorithm is complicated but requires little storage; it can process an enormous
file, storing only a few linefuls. Our pretty printer is adequate for displaying theorems
and other computed results that easily fit in store. Kennedy (1996) presents an ML
program for drawing trees.
Exercise 8.34 Implement a new kind of block, with ‘consistent breaks’: unless
the entire block fits on the current line, all of its breaks are forced. For instance,
consistent breaking of
D E
if E then E1 else E2
if E
if E then E1
would produce then E1 and never else E2
else E2
8.10 A pretty printer 361
Exercise 8.35 Write a purely functional version of the pretty printer. Instead
of writing to a stream, it should return a list of strings. Does the functional
version have any practical advantages?
describes a line of text beginning with the string ’ Input =’, followed by an
integer taking up 6 characters, followed by the string ’ Output =’, followed
by a floating point (real) number taking up 8 characters, with 2 digits to the
right of the decimal point. A file written under a Fortran format can be read
under the same format. Discuss how this kind of formatted input/output could
be implemented in ML. How would formats and data be represented?