-
Notifications
You must be signed in to change notification settings - Fork 1
Failure Propagation
There are traditionally two modes of communicating errors in programming: return codes and exceptions.
Which to use is far from a settled question as there are still new languages coming down on opposite sides. Most languages support both forms but one is favored over the other and is used more often.
Leema blends these choices together into a single, language-supported model. The goal is to improve over both return codes and exceptions by moving error handling away from the main program logic communication paths of function signatures and control flow. Leema lets these areas focus exclusively on the primary success case, with the goal of making programs more readable.
Return codes are nice because you can just look at the value to see what's wrong. But they're bad in that they're codes. If a function returns "7" what does that mean? What went wrong and how should we fix it?
However, the critical problem with return codes is that they don't propagate automatically. If the programmer doesn't check the value, it gets passed on to a subsequent function and... results are undefined.
Exceptions fix the worst of this as they propagate automatically if not caught.
Some languages fix the "code" problem by including structured data about the type of error, what caused it and what should be done. However, this can introduce a new problem.
A good example of this is Haskell, which favors the Maybe monad, the Either type and other error structures. This works great in cases when the error types are compatible, particularly when making many calls from the same library.
But it breaks down when combining functions that use different error types. Error types from one library are frequently incompatible with error types of another library used in the same function. This adds complexity to client functions that now have to convert one error type to another.
foo :: String -> IO (Maybe String)
foo _ = ...
bar :: String -> IO (Either String String)
bar _ = ...
foo_bar :: String -> String -> IO ((Maybe (Either String String))) <-- gross
foo_bar _ _ => ?
In this case, foo returns None if it fails, and bar returns Left if it fails. When we compose them into a single function, what does foo_bar return? If it returns Either and foo fails, then there's no string available to return in the Either object. If it returns Maybe String then error info from bar is lost when it fails. Combining Maybe and Either kind of helps, except that's a lot to unpack and deal with for the caller.
This problem with compatibility can be found in Java exceptions as well, as methods declare which types of exceptions they can throw. Methods that throw different exception types have to declare each exception type that they might throw.
public void foo() throws IOException { ... }
public void bar() throws ParseException { ... }
public void baz() throws TimeoutException { ... }
public void foo_bar_baz() throws IOException, ParseException, TimeoutException
{
foo();
bar();
baz();
}
It's easy to see how this gets unmanageable quickly. It is possible to convert exceptions from the standard exception types to a foo_bar_baz app-specific exception. That should be done to say something about an application error, not just because the list of standard exceptions is getting long.
One factor in this incompatibility is that error codes are left to libraries rather than providing support for them directly in the language. Inconsistency and hence, incompatibility, is a predictable result when authors are left to choose their favorite type conventions for their own libraries.
The second problem with error handling in most typed languages is that the error types are part of the regular type system. While functions generally succeed in only one way, the number of ways in which a function can fail is nearly unbounded. Attempting to represent that in the function type is unrealistic and creates needless complexity.
In Leema, function types only represent the success case; the failure type exists outside of normal type checking. To do this, the language needs to be aware of which types represent failures and which code is handling errors.
Any function can return a failure object without having to declare it as a possible value. A trade-off is that failure types are not checked in the same way as regular values. This is balanced by keeping the failure types simple and consistent.
A returned failure is then assigned to the label in the calling function as if it has succeeded. Functions can continue, but if the program attempts to use the unhandled failure, it will be propagated back down the stack.
Here's an example of how failures are propagated in Leema.
func foo(): Str ->
...
if ... {
fail #some_flag "foo failed for some reason"
}
--
func bar(s: Str): Str ->
...
if ... {
fail #another_flag "bar failed for some reason"
}
--
func baz(): Str ->
let f = foo()
let r = bar(r)
"hello $r"
--
func main() ->
baz()
--
As is clear, there are no "failure types" listed in the function signatures. This lets the code be most clear about what the program is doing when it's working correctly.
If foo() fails, a failure will be assigned to f and will be propagated to main if it is not handled before being passed to bar(). If bar() fails, a failure will be assigned to r and likewise will be propagated to main if it is not handled before the string interpolation.
Note that this program demonstrates how failures are represented and propagated in Leema. Look for a later post showing how errors are handled and how Leema stack traces differ from traditional stack traces.
In Leema, the decision is made to give up some minor type checking when handling errors in exchange for letting programmers focus on the control flow and result type in the successful case.