Several of the Scheme procedures that we have written or studied in
preceding labs presuppose that their arguments will meet specific preconditions -- constraints on the types or values of its arguments. For
instance, we saw in the reading on recursion with lists that the greatest-of-list
procedure requires its argument to be a non-empty list of real numbers. If
some careless programmer invokes greatest-of-list and gives it, as
an argument, the empty list, or a list in which one of the elements is not
a real number, or perhaps even some Scheme value that is not a list at all,
the computation that the definition of greatest-of-list describes
cannot be completed.
A procedure definition is like a contract between the author of the
definition and someone who invokes the procedure. The postconditions of the procedure are what the author guarantees: When the
computation directed by the procedure is finished, the postconditions shall
be met. Usually the postconditions are constraints on the value of the
result returned by the procedure. For instance, the postcondition of the
square procedure,
(define square
(lambda (root)
(* root root)))
is that the result is the square of the argument root.
The preconditions are the guarantees that the invoker of a procedure makes
to the author, the constraints that the arguments shall meet. For
instance, it is a precondition of the square procedure that root is a number.
If the invoker of a procedure violates its preconditions, then the contract
is broken and the author's guarantee of the postconditions is void. (If
root is, say, a list of symbols, then the author can't very well
guarantee to return its square.) To make it less likely that an invoker
violates a precondition by mistake, it is usual to document preconditions
carefully and to include occasional checks in one's programs, ensuring that
the preconditions are met before starting a complicated computation.
Many of DrScheme's primitive procedures have such preconditions, which they enforce by aborting the computation and displaying a diagnostic message when the preconditions are not met:
> (/ 1 0) (bug) /: division by zero > (log 0) (bug) log: undefined for 0 > (length 116) (bug) length: expects argument of type <proper list>; given 116
To enable the programmer to enforce preconditions in the same way, the
DrScheme's PLT dialects provide procedure named error, which takes a
string as its argument. Calling the error procedure aborts the
entire computation of which the call is a part and causes the string to be
displayed as a diagnostic message. (However, error is not defined
in standard Scheme as defined by the Revised5 report on the algorithmic language Scheme. Using error may
make it more difficult to ``port'' your program, that is, to adapt it for
use under some other implementation of the Scheme programming language.
This is one reason why I recommend doing most of your work under DrScheme's
``Standard (R5RS)'' language option.)
For instance, if we first use the Language menu to change into, say, PLT
Textual Scheme, we could enforce greatest-of-list's preconditions
by rewriting its definition thus:
;;; greatest-of-list: find the greatest element of ;;; a given list of real numbers ;;; Given: ;;; LS, a list of real numbers. ;;; Result: ;;; GREATEST, a real number. ;;; Precondition: ;;; LS is not empty. ;;; Postconditions: ;;; (1) GREATEST is an element of LS. ;;; (2) GREATEST is greater than or equal to ;;; every element of LS. (define greatest-of-list (lambda (ls) (if (or (not (list? ls)) (null? ls) (not (all-real? ls))) (error "greatest-of-list: requires a non-empty list of reals") (if (singleton? ls) (car ls) (max (car ls) (greatest-of-list (cdr ls)))))))
where all-real? is a predicate that takes any list as its
argument and determines whether or not all of the elements of that list
are real numbers:
;;; all-real?: determine whether all of the elements of a given ;;; list are real numbers ;;; Given: ;;; LS, a list. ;;; Result: ;;; DECISION, a boolean value. ;;; Precondition: ;;; LS is a list. ;;; Postcondition: ;;; DECISION is #T if and only if all elements of LS are ;;; real numbers. (define all-real? (lambda (ls) (or (null? ls) (and (real? (car ls)) (all-real? (cdr ls))))))
Now the greatest-of-list procedure enforces its
precondition:
> (greatest-of-list 139) (bug) greatest-of-list: requires a non-empty list of reals > (greatest-of-list '()) (bug) greatest-of-list: requires a non-empty list of reals > (greatest-of-list (list 71/3 -17 23 'oops 16/15)) (bug) greatest-of-list: requires a non-empty list of reals
Including precondition testing in your procedures often makes them markedly easier to analyze and check, so I recommend the practice, especially during program development. There is a trade-off, however: It takes time to test the preconditions, and that time will be consumed on every invocation of the procedure. Since time is often a scarce resource, it makes sense to save it by skipping the test when you can prove that the precondition will be met. This often happens when you, as programmer, control the context in which the procedure is called as well as the body of the procedure itself.
For example, in the preceding definition of greatest-of-list,
although it is useful to test the precondition when the procedure is
invoked ``from outside'' by a potentially irresponsible caller, it is a
waste of time to repeat the test of the precondition for any of the
recursive calls to the procedure. At the point of the recursive call, you
already know that ls is a list of real numbers (because you tested
that precondition on the way in) and that its cdr is not empty (because the
body of the procedure explicitly tests for that condition and does
something other than a recursive call if it is met), so the cdr must also
be a non-empty list of real numbers. So it's unnecessary to confirm this
again at the beginning of the recursive call.
One solution to this problem is to replace the definition of
greatest-of-list with two separate procedures, a ``husk'' and
a ``kernel.'' The husk interacts with the outside world, performs the
precondition test, and launches the recursion. The kernel is supposed to be
invoked only when the precondition can be proven true; its job is to
perform the main work of the original procedure, as efficiently as
possible:
;;; greatest-of-list: find the greatest element of ;;; a given list of real numbers (after testing the ;;; precondition) ;;; Given: ;;; LS, a list of real numbers. ;;; Result: ;;; GREATEST, a real number. ;;; Precondition: ;;; LS is not empty. ;;; Postconditions: ;;; (1) GREATEST is an element of LS. ;;; (2) GREATEST is greater than or equal to ;;; every element of LS. (define greatest-of-list (lambda (ls) ;; Make sure that LS is a non-empty list of real numbers. (if (or (not (list? ls)) (null? ls) (not (all-real? ls))) (error "greatest-of-list: requires a non-empty list of reals") ;; Find the greatest number in the list. (greatest-of-list-kernel ls)))) ;;; greatest-of-list-kernel: find the greatest element ;;; of a non-empty list of real numbers (assuming that the ;;; precondition has already been tested) ;;; The interface specification of this procedure is the same ;;; as that of GREATEST-OF-LIST. (define greatest-of-list-kernel (lambda (ls) (if (singleton? ls) (car ls) (max (car ls) (greatest-of-list-kernel (cdr ls))))))
The kernel has the same preconditions as the husk procedure, but does not need to enforce them, because we invoke it only in situations where we already know that the preconditions are satisfied.
The one weakness in this idea is that some potentially irresponsible caller might still call the kernel procedure directly, bypassing the husk procedure that he's supposed to invoke. In later labs, we'll see that there are a couple of ways to put the kernel back inside the husk without losing the efficiency gained by dividing the labor in this way.
I am indebted to Professor Ben Gum for his contributions to the development of this reading.