Exceptions redux
(Ref to muSE v234)
Although I've written about it a bit earlier, I did not do muSE's version of exception handling and recovery enough justice then. I simply described how to use it, that too incompletely.
The bigger picture here is the question -
What interface does a function exactly provide its user?In languages such as C, functions as computation specifications are used only via their arguments, or via side effects on the environment. A function's documentation will typically tell you what it will do when you pass it certain arguments, what its "return value" will be and what side effect, if any, it will have on the environment at the time it is called. No facility was provided in the language to deal with exceptional conditions. Programmers usually used the "return value" to flag error conditions, which are expected to be "handled" by the caller, usually propagating the condition up the stack until something can be done about it.
C++ introduced (let's assume for simplicity) into the mainstream programmer's consciousness the notion that a function might "throw" an "exception" rather than return a value. The "throw" construct indicated that the function's computation was being terminated and it was not going to return to its caller as expected to do so under normal conditions. Instead, it was going to bounce around into "catch" blocks higher and higher in the stack of calls until one block can "handle" the exception - with the hope that it will somehow be able to recover from it. The stack would be unwound until such a willing handler is found or the program is terminated.
Java dutifully formalized this mechanism and declared that a function's signature is not only about its arguments and the return value, but is also about the exceptions that it can throw to its caller.
To summarize, a function can either return to its caller normally, or throw an exception. In both Java and C++, once a function terminates either by returning or by throwing an exception, all information about the intermediate state of the computation that was being performed when the exceptional condition occurred is destroyed. C++'s stack unwinding mechanism calls object destructors along the way and Java, since it doesn't keep objects on the stack, provides a finally block where you can do your own cleanup before the exception is passed to the caller.
Hmmm ... now, what can you really do in a catch block in C++ or Java? It is a good exercise to scour the net for C++ and Java code examples that purport to teach the reader the exception mechanism in these languages. I mean do that right now. See if you come up with anything more sophisticated than -
try {
// Call function which throws exception ..
int hex = 355, spell = 113;
abracadabra( hex/(double)spell, "hocus pocus" );
} catch ( IncantationFailedException &ex ) {
// Handle the exception
}
or maybe
try {
...
} catch ( IncantationFailedException &ex ) {
std::cerr << "Incantation 'abracadabra' didn't work!\n";
}
Even in Herb Sutter's insight filled book Exceptional C++, the pages 25 to 68 on Exception Safety : Issues and Techniques fail to cite a single useful case for the catch clause, let alone recommend typical usage. Though it can be argued that the chapter is on exception safety and not exception handling, all I'm asking for is one good example. If you do find a good case or description of "recommended practice" anywhere, I'd appreciate links as comments to this post.
Is it then a consequence of instructional failure that decent bodies of C++ code hardly ever use the catch mechanism and only use the destructor calls that happen when the stack unwinds? I dare a conjecture here -
The reason catch blocks that do something more substantial than code like the above are hard to find is that the catch mechanism is impotent and consequently nearly useless as a design tool.
The Common Lisp folks have provided a more flexible definition of a function - that its not just about the arguments and the "return value", but can be an arbitrary protocol in which control can pass between the caller and a particular invocation multiple times before the task is done. Peter Seibel talks in detail about the Common Lisp conditions facility in Beyond Exception Handling : Conditions and Restarts and provides a concrete example where a function is useful in more than one context because it exposes such a protocol to its caller.
Having experienced deep frustration on several occasions along the lines of - "if only this function would let me customize the way it treats this condition differently, I won't have to write my own", I realized that the exception mechanism was standing in the way of a significant amount of code reusability. Library functions were deciding too much for me. It is cumbersome to keep passing protocol handlers via arguments for every function, so nobody does that either.
I use muSE as a platform for experimenting with the language features I'd like to have, so I decided to try something more flexible than C++/Java and less cumbersome to use than CL - a mechanism with which you can ignore exceptions when you call a function or go all out to customize the way a function should behave - the former case not requiring any additional work on your part, as it should be. Along the way, I figured that pattern matching dispatch works very well to achieve this goal and makes the handling of exceptions and restarts much simpler than the Common Lisp approach.
Syntactically, the muSE approach is similar to C++/Java - an expression that might not evaluate successfully is wrapped into a try expression and a set of handlers are provided to try when the expression failed to evaluate normally. In order to signal an abnormal condition, a function uses the raise function, supplying it an arbitrary set of arguments that get passed on to the handlers. Here's a simple, though contrived, example (*)-
(define H (fn (x y)
(try (/ (+ x y)
(if (= x y)
(raise 'DivideByZeroDiff x y)
(- x y)))
(fn ('ForceResult r) r))))
The above function tries to compute the value (x + y)/(x - y). It can't perform the division if x = y, so it raises a 'DivideByZeroDiff condition in that case. It also provides a code path using which you can force the result of the function to be some arbitrary value in case nothing else can be done about it. Here's a caller trying to compute something using this function (*) -
(define G (fn (x1 y1 x2 y2)
(try (/ (H x1 y1) (H x2 y2))
(fn (ex 'DivideByZeroDiff x y)
(if (= (- x1 y1) (- x2 y2))
(ex 1)
(retry 'ForceResult 2000))))))
G is trying to compute H(x1,y1)/H(x2,y2). If one of the differences (x1-y1) and (x2-y2) becomes small, H will raise the 'DivideByZeroDiff exception. Now, G knows that if the two differences are equal, the actual value of the difference doesn't matter to the end result. The H function cannot possibly know this. So in this case, G simply asks H to assume a value of 1 instead of the (raise ...) expression and continue to compute the result. If this is not the case, G decides to cap the value of H to 2000 using the 'ForceResult code path.
Note that H embodies no knowledge about the domain that G knows about. This makes it possible to use H in situations where the original author cannot possibly know how to handle the divide by zero case. The computations performed by this example are trivial, but when they are substantial, the facility to interact with the execution flow of a function definitely renders it more useful in a broader set of contexts and therefore useful as a design tool. Peter Seibel's more substantial log file parsing example can be expressed using the try-raise-retry constructs too.
We've used pattern matching throughout to catch raised conditions and to pass on control to restart points. The Common Lisp approach has equivalent functionality, but to me appears more verbose than really needs to be. In muSE, a function's interface consists of a hierarchical set of patterns -
- The argument pattern which must match in order for the function's computation to even start,
- a tree of exception patterns, handlers and restart patterns,
- the result value and
- side effects if any.
All of them are available as design tools for a function creator so that she can create code that can be used in a variety of contexts not all of which can be known at design time.
An analogy
It is interesting to compare the hierarchy of function dependencies to a company's employee hierarchy. It is often the case that at every designation, an employee has access to some information that employees at lower designations don't. This information lets them make decisions in situations where those working under them don't know enough to make the decision themselves.
Imagine a company P that works like this - a task is handed down to the top-level employee who further describes it and hands subtasks down to those working under her. The company philosophy is that whenever an employee reaches a point at which she can't take a decision, she promptly destroys all the work she's done starting from the time her task was handed to her. Then she informs her boss that such a situation has happened. Her boss is very happy with her capacity to cleanup her work without leaving a trace, destroys all the work she has done since her task was handed down, files a report in the company activity database and talks to her boss about what happened, in her own language.
I'm sure you don't work in a company like that! Now bump up the scale and imagine if Boeing worked like that!
What usually happens is that the lower level employee suspends her work and immediately talks to her boss, presenting her with a few options. Her boss, based on what she understands, instructs her to proceed in a particular direction. The result is that the task gets done. All the way up the hierarchy.
We'll not want to work in the pathological company P, but we all choose to live with and even praise programming languages that work exactly like that.
Appendix
(*) In muSE, it is slightly more efficient to write the G and H functions as -
(define H (fn (x y)
(try (/ (+ x y)
(if (= x y)
(raise 'DivideByZeroDiff x y)
(- x y)))
{fn: ('ForceResult r) r})))
(define G (fn (x1 y1 x2 y2)
(try (/ (H x1 y1) (H x2 y2))
{fn: (ex 'DivideByZeroDiff x y)
(if (= (- x1 y1) (- x2 y2))
(ex 1)
(retry 'ForceResult 2000))})))
3 comments:
So what does retry do? It sets the return value of raise to ForceResult 2000?
What if there are several instances of DivideByZeroDiff in the called function? They will all be treated uniformly isn't it.
I suppose the documentation of H must include both the exceptions raised (DivideByZeroDiff) and the available handlers (ForceResult).
1. The retry sets the return value of raise to just 2000.
2. I like to think an exception identifier declares a category of exceptions and the top level caller can decide the policies for handling categories of exceptions.
3. Yes the documentation of H should include exception description as well.
Btw I found this writeup on the register very telling about the state of the art about exception handling -
http://www.theregister.co.uk/2006/01/11/exception_handling/
Post a Comment