Most languages have a system where exceptions are classes and you can throw
them then use a try/catch
block to handle them.
What alternate runtime error handling systems are used, and what are their benefits and drawbacks?
Most languages have a system where exceptions are classes and you can throw
them then use a try/catch
block to handle them.
What alternate runtime error handling systems are used, and what are their benefits and drawbacks?
Result<T>
in Rust. You need to manually catch the error in order to get the result.You could also have no error handling, and abort the process if any runtime error is encountered.
A short note on checked exceptions: you may use generics to have higher order functions like this:
@FunctionalInterface
interface Fn<T, V, E extends Throwable> {
V apply(T t) throws E;
}
public static <T, V, E extends Throwable> V modusPonens(Fn<T, V, E> f, T t) throws E {
return f.apply(t);
}
So that the exception thrown for modusPonens
depends on the exception thrown by the given lambda function. But I don't know why this pattern is rarely seen in Java. Row-polymorphic error handling is essentially using row-polymorphism to handle the throws E
part.
RuntimeException
, but if you ignore that, then every exception in Java is statically checked.
$\endgroup$
Commented
May 16, 2023 at 17:43
This is a concept I learned relatively recently, and it’s a concept found exclusively in academic and functional languages for now. Effectively, it is a resumable exception. When you “raise” an effect, you save a continuation at the point of raising. When it is handled, you can resume that continuation, effectively acting like a resumable exception.
However, they can be also be used to implement generators and coroutines, among other things. Dependency injection using them is a breeze. This blog post is a great guide on algebraic effects.
especially the Processor / Software interrupts. It temporarily stops execution of the current thread, handles the event and (optionally) resumes the thread.
(source)
A great blog post with many different techniques is The [Midori] Error Model by Joe Duffy.
In addition to that and the other answers, I want to add Lisp conditions and restarts.
See here, here, and here for explanations, but the gist is that instead of unwinding the stack to callers, those callers register handlers that are invoked at the spot of the error. Those handlers can choose to handle the error or hand it off to the next handler.
And on top of that, if a handler handles an error, it can possibly allow restarting.
I'll try to explain with an example.
Say you have a program, and in that program is a cache that can grow without bound. Also, pretend that you need to allocate memory explicitly for some reason.
Now, say the program is running, but at some point, memory allocation fails. The program previously registered a handler that basically evicts the oldest items from the cache, marks the error handled, and says that a restart can happen.
Instead of just returning failure, your memory allocation code will run the handler, evict stuff from the cache, and try again, succeed, and return the allocation.
There was no unwinding of the stack, so the code asking for the allocation is none the wiser and continues on its merry way.
It turns out that not unwinding the stack is powerful because you can handle the error without information being lost or state being destroyed. And since handlers can be registered by callers, it's as though callers are still handling the error.
This is not theoretical, or even limited to Lisp, by the way. I implemented conditions and restarts in C. My malloc()
will handle memory allocation and attempt to handle failure and retry.
This is the system Rust uses: There's a "result" type (Result<T>
) for recoverable errors, like a malformed URL in a HTTP library, and an "abort" function/macro (panic!
) for unrecoverable errors like indexing out of an array. Recoverable errors are returned from functions and can be processed like any other type, but unrecoverable errors crash the program instantly.
catch_unwind
(doesn't catch all panics, though)
$\endgroup$
error(msg)
and pcall(...)
$\endgroup$
errno
Many functions in C such as malloc()
or I/O functions such as fwrite()
will return an error status such as NULL
or -1
on error and set the global error state errno
to a error code describing the error.
setjmp()
/longjmp()
A common way to implement exception handling in C is with setjmp()
and longjmp()
. Here is an example:
#include <setjmp.h>
jmp_buf buf;
void throws() {
// ...
if (error_occurred) {
longjmp(buf, -1);
}
}
int main() {
int err = setjmp(buf);
if (err) { // If this point was reached by the longjmp() call
handle_error(err);
return;
}
// ...
}
setjmp
there actually isn't permissible under the standard. You can only use it in a control-flow conditional (if (setjmp())
/switch (setjmp())
/while (setjmp())
), apply it directly to a comparison or boolean operator (!setjmp()
/setjmp() > 0
), or discard the result ((void)setjmp();
).
$\endgroup$
errno
is defined as a macro, and thus it's like it's in the global namespace, but its value is thread-local.
$\endgroup$
Commented
Jul 14, 2023 at 12:15
In Objective-C, idiomatic error handling uses an extra parameter:
error:(NSError * _Nullable *)error;
This allows you to suppress errors by passing NULL
for that parameter, but also requires a bit more setup for both the caller (have to declare NSError *error;
and then pass &error
) and the callee (have to check if (error != NULL)
before writing to it).
.NET, so C# and Visual Basic (and many other languages probably have similar concepts) allow applications to monitor unhandled exceptions, i.e. those which haven't been caught in a try-catch block. There's an UnhandledException
event for that. In most cases, it won't let you resume the program, though.
Some languages like Zig handle errors via union types. The return value of expressions which may error can be either the intended value, or an Error value. To use the intended return of these expressions, one must handle the error. (Either catching it, or re-throwing it).
Result<Success, Failure>
-like types. Is Zig's type substantially different from the one in Swift and Rust?
$\endgroup$
Result<Success, Failure>
type.
$\endgroup$
I would not recommend this in most cases for multiple reasons I state at the end of this answer. But I didn't see anyone mention it yet. And because it is a runtime error handling system that is being used in the real-world, I think it should not remain unmentioned.
The idea is that when an error occurs, then an error code is written to a global variable, and then the program execution continues as if nothing happened. To handle the error, you just check the value of that variable after performing the operation.
One language that works that way is the SAP-proprietary language ABAP. For example, looking up a value in a database table looks like this:
SELECT SINGLE name FROM customers INTO customer_name WHERE id = customer_id.
IF sy-subrc = 0.
WRITE: 'The customer name is', customer_name.
ELSE.
WRITE 'Customer not found'.
ENDIF.
The line beginning with SELECT
reads from the database. When the query doesn't return any rows, it set the global variable sy-subrc
to a value other than 0, and then the program continues. If the programmer would like to catch that error, they would then immediately check the value of that variable.
Advantage:
Disadvantages:
Which is why ABAP, the language I used as an example here, moves away from this paradigm in the past couple decades in favor of class-based exception handling using TRY...CATCH
. But old mistakes are difficult to correct if you don't want to break any old code.
This is the system implemented in Common Lisp, where you can have resumable exceptions.
You raise an exception by signaling a condition:
(signal condition ...args)
Some conditions are serious and enters the debugger if nobody handles them, they are raised by calling error
:
(error condition ...args)
In particular, for simple errors condition
can be a format string:
(error "no such file: ~s" file)
You can define handlers that execute code when some condition type matches:
(handler-bind ((some-condition (lambda (c) ...))
(other-condition (lambda (c) ...)))
...)
When an exception of type some-condition
is signaled, the associated handler is run directly starting from the call stack where the condition is signaled: there is no stack unwinding at this step. That makes error handling a bit more flexible.
The error handler needs to make a non-local exit, otherwise it is said that the handler declines to handle the signal, and the signal bubbles up to other potential handlers. A non-local exit is for example a return from a block lexically enclosing the handler:
(block :main (handler-bind ((c (lambda (_) (return-from :main "oh no")))) ...))
In that case, exiting from the closure unwinds the stack, just like exception usually do in other languages. There is in fact a handler-case
that automatically performs the unwinding:
(handler-case ...
(c (_) "oh no"))
Here the body and the catch clauses are reversed, but in addition to that, the code evaluated for c
is executed in the context of the code being caught, after the stack was unwound.
You can also define restarts:
(restart-case some-expr
(my-restart () :report "Return 10"
10)
(ignore () :report "Ignore error and return NIL"))
A restart expression defines a point in your code that your signal handler can invoke:
(handler-bind ((t (lambda (c) (invoke-restart 'my-restart))))
(restart-case (error "Who could have foreseen that?!")
(my-restart () 10)))
Here, (error ...)
signals an error and enter the debugger if no-one handles it. But the surrounding handler is executed, and invoke my-restart
, so the flow of execution resumes from the restart clause that matches my-restart
, which evaluates to 10.
The error was handled by executing a different action. Typically, when you are executing code and have an error, you enter the debugger which displays the various restarts. Suppose you write a small game with an update
function:
(defun update (world)
(destructuring-bind (&key (x 0) (y 0)) world
(list :x (1+ x) :y (1+ y))))
You can define the game loop as follows:
(let ((world '(:x 0 :y 0)))
(loop
(restart-case (setf world (update world))
(retry ()
:report "Try again")
(exit ()
:report "Exit game loop"
(return)))))
Then you can change the update
function while the main loop runs, and if it contains bugs, you can fix it and retry executing the code without forgetting the context.
For example, if I introduce a bug while the game loops:
(defun update (world)
(error "No"))
Then the debugger prints:
No
[Condition of type SIMPLE-ERROR]
Restarts:
0: [RETRY] Try again
1: [EXIT] Exit game loop
2: [ABORT] Abort compilation.
3: [*ABORT] Return to SLIME's top level.
4: [ABORT] abort thread (#<THREAD "worker" RUNNING {1021890003}>)
Backtrace:
0: (UPDATE #<unused argument>)
[No Locals]
1: ((SB-C::TOP-LEVEL-FORM (LET ((WORLD (QUOTE #1=#))) (LOOP (RESTART-CASE (SETF WORLD #1#) (RETRY NIL :REPORT "Try again") (EXIT NIL :REPORT "Exit game loop" #1#)))))) [toplevel]
Locals:
WORLD = (:X 329515384 :Y 329515384)
I can execute code in the debugger, change the WORLD
variable, or exit the loop by invoking the EXIT restart. If I recompile update
I can also use the new version of the function with the current WORLD
state.
If you have written any Go code you have probably encountered the built-in error type. Go code uses error values to indicate an abnormal state. For example, the os.Open
function returns a non-nil error value when it fails to open a file.
func Open(name string) (file *File, err error)
The following code uses os.Open
to open a file. If an error occurs it calls log.Fatal
to print the error message and stop.
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
...
Library code may use C or Go style error signaling, with minimal or no allocation in case of errors. Business logic code may be interested in stack traced exceptions that interrupt code flow.
So... why not have both?
On library code:
var err as error.return;
def func( arg int ) ( int | err )
{
var ret, err = funcB( arg );
return ret;
}
Desugars to:
def func( arg int ) ( int | err )
{
var ret, err = funcB( arg );
if ( err != null ) // Autogenerated
return err; // Autogenerated
return ret;
}
And on code that is not expected to progress after an error:
var err as error.throw;
def func( arg int ) int
{
var ret, err = funcB( arg );
return ret;
}
Desugars to:
def func( arg int ) ( int )
{
var ret, err = funcB( arg );
if ( err ) // Autogenerated
{ // Autogenerated
err.pushStackInfo( __FILE__ , __LINE__ ); // Autogenerated
throw err; // Autogenerated
}
return ret;
}
Bonus: A compiler option may cause pushStackInfo()
to be compiled as no-op, so to omit (allocating) call stacks in some builds.