Results
Essentially, the intention is to model "procedures" that
can either succeed and return a value, or fail and return
an error value. An inductive type that represents either
a success or failure value is:
The result type holds a value
of type S when created via the
Success constructor, or a
value of type F when created via the
Failure constructor. Intuitively
then, a value of type result nat bool
represents the result of some computation that returns a value
of type nat on success and a value of
type bool on failure.
Procedures
Writing computer programs involves sequencing computations. Running
a program involves evaluating the sequenced computations in some
language-specific order. A computation can therefore be represented
by the following type:
The procedure type holds a function
that, when passed a value of type unit
(of which there is only one: Unit),
returns a value of type result S F. The
unit value solely exists to delay
evaluation (the body of the function will not be evaluated until
the function is called, as with any programming language with
call-by-value semantics).
Two convenience functions to define computations that trivially
succeed and trivially fail are:
The (fun _ => ...)
notation defines an anonymous function, and the _ indicates that
no name has been given for the function argument as the argument
isn't actually used in the body of the function. As an example,
an anonymous function that multiplied a given argument by 3
would be written (fun x => x * 3).
As stated above, a procedure is executed by passing the contained
function a value of type unit:
For those unfamiliar with Coq, the above defines a function called
execute that takes a procedure
p and then uses pattern matching to
extract the contained function, which is given the name
f, and then calls
f with the value
Unit. The
execute function is also
parameterized by two types S
and F, but the curly braces indicate
that the caller of the function is not expected to pass these
types explicitly: the compiler can infer them automatically.
Obviously, a program consisting of a single computation isn't
useful at all. It's possible to combine two computations
p and
q by first executing
p and then passing the result
to q. As stated before, however,
computations may fail! In all computer programs, a sequence of
computations can only execute as far as the first computation that
fails (unless the programmer explicitly catches the error and
handles it, which will be dealt with later in this document). The
concept of combining two computations to produce a larger computation
is described by the combine
function:
The combine function
is slightly intimidating but is easy to understand if examined
in small pieces. The combine function
takes a procedure p and executes it.
If p is successful, the result
is passed to the given function f,
which returns a computation which is then executed. However,
if p returns a
Failure value, the value is
simply returned and no further execution occurs. The important
thing to notice about the combine
function is that it does not actually "perform" the computation, but
instead returns a computation that performs the above when asked. This
can be seen by the fact that the body of the function is wrapped
in an anonymous function
and passed to the Procedure
constructor. The difference is subtle: The function builds computations
that are later "executed", as opposed to executing them immediately.
A concise notation for the above is:
The above simply states that the notation
p >>= f means
the same thing as combine p f.
Often, it's necessary to have computations that perform some
side effect but do not return a useful value. An example of
this would be printf in C,
or Put_Line in Ada: both are
functions that perform a side effect but do not return a value.
The order function captures this
idea:
The order function combines
the given computations p and
q by passing an anonymous function
(fun _ => q) to
combine. In other words, the
function passed in simply ignores whatever argument is passed to it,
and then returns q. Shorthand
notation is also provided.
Exceptions
At this point, it's possible to write programs like the following:
The above procedure, when executed, returns nothing
on success (the unit
type is often used to represent "nothing"), or a
system-specific error code on failure.
Depending on the reader's experience, it may or may not be
possible to see how the type of the
combine function provides
an important static guarantee. Remember that the types of
"success" and "failure" values are given in the types of
the combinations to be combined. The type signature of the
combine function is:
From the type signature, it can be seen that the function is
combining a computation of type
procedure S F and a computation
of type procedure T F. In other
words, the two computations are allowed to return "success"
values of different types, but the "failure" type of both
computations is required to be the same (F).
Any attempt to combine two computations that have different
failure types is rejected by the compiler:
Now, obviously, computations in real computer programs can fail
in a huge variety of interesting ways. Evaluating any arithmetic
expression in an Ada program can potentially raise a
Constraint_Error exception. Executing
any I/O function in Java can result in the raising of any
subtype of IOException. Requiring
the programmer to use one single error type for the entire universe
of programs would obviously not work.
Intuitively, the point that the "failure" type of a computation
"changes" can be thought of as the point where the programmer
explicitly handled or discarded the failure. Writing a function
to explicitly handle errors is surprisingly trivial:
The catch function simply
executes the computation p
and returns the "failure" result to the given
handler function if
necessary . As an example, the catch
function could be used to explicitly ignore any error raised
by the example procedural program written earlier:
Finally, it's necessary to deal with converging errors. If the
programmer wishes to combine two computations
p and
q and both computations have
completely different error types, it is trivial to define a sum type
that indicates the resulting set of errors that the combined
computation can return.
First, some modules that define computations that return
distinct errors:
Then, a type that represents the sum of both error types:
Then, a pair of functions that return a value of type
combined_error when given
values of the previous error types:
And finally, a small program written using the above
combinators: