42
$\begingroup$

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?

$\endgroup$
1
  • 6
    $\begingroup$ For those who see this, it's best to allow the original author of an example question to post it; this was a slight misunderstanding, and I give my blessing to mousetail to post it. $\endgroup$
    – Seggan
    May 20, 2023 at 17:28

13 Answers 13

39
$\begingroup$
  • Monadic errors, like Result<T> in Rust. You need to manually catch the error in order to get the result.
  • Exceptions, like in Kotlin/Scala/Python/C#. By default, you use the return value, and you can optionally catch the error encountered.
  • Checked exceptions. Like exceptions, but statically check which exceptions are propagated and which are caught.
  • Row-polymorphic checked exceptions. Like checked exceptions, but use row polymorphism to allow higher-order checked exceptions to "flow-through" higher-order functions. Video and compiler description (see page 34)
    • Checked exceptions can be seen as first-order version of row-polymorphic exceptions
  • C-style error handling. Return value of a function is error code, and the output of the function is stored in a pointer allocated by the caller. This is a very good approach for languages that needs programmers to manage memory, because we don't have to worry about deallocation of return values as library authors.

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.

$\endgroup$
17
  • $\begingroup$ Are there examples of checked exceptions (and row-polymorphic checked exceptions) in programming languages? Or is it "just" an "academic" concept? $\endgroup$ May 16, 2023 at 17:34
  • 4
    $\begingroup$ @AlexNelson Java (in)famously implements straightforward checked exceptions. It provides a backdoor in the form of RuntimeException, but if you ignore that, then every exception in Java is statically checked. $\endgroup$ May 16, 2023 at 17:43
  • 1
    $\begingroup$ @AlexNelson I'll add to my answer. I'm looking for the PDFs now. $\endgroup$
    – ice1000
    May 16, 2023 at 17:46
  • $\begingroup$ @ice1000 Wonderful, thank you so much! $\endgroup$ May 16, 2023 at 17:47
  • 2
    $\begingroup$ I think there is another "C-style" option ─ the output of the function is the result you want, except it may be some other value (e.g. null or -1 or even a value that would potentially be a valid result) when an error occurs, and there is a separate function you are supposed to call to check if an error did occur. This approach is used in some APIs such as for OpenGL or MySQL, and it's also similar to how arithmetic works in assembly languages (i.e. you get a normal result in the place you expect, but there's also a carry flag you should check if you want to handle overflows). $\endgroup$
    – kaya3
    May 17, 2023 at 11:02
19
$\begingroup$

Interrupts

especially the Processor / Software interrupts. It temporarily stops execution of the current thread, handles the event and (optionally) resumes the thread.

enter image description here
(source)

$\endgroup$
19
$\begingroup$

Algebraic effects

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.

$\endgroup$
3
  • 2
    $\begingroup$ These are incredibly cool for way more than just errors. For example, dependency injection of things like logging functions. $\endgroup$ May 16, 2023 at 18:23
  • $\begingroup$ I would like to point out that this is actually monadic effects. Algebraic effects feature in composability, in addition to resumability of the exceptions. $\endgroup$
    – ice1000
    Jul 3, 2023 at 14:46
  • $\begingroup$ @ice1000 feel free to edit an explanation in, i don't really see what ure talking about $\endgroup$
    – Seggan
    Jul 15, 2023 at 1:44
16
$\begingroup$

A great blog post with many different techniques is the 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.

$\endgroup$
7
  • $\begingroup$ I didn't see your answer, sorry $\endgroup$
    – coredump
    Aug 1, 2023 at 16:38
  • $\begingroup$ Could you give other scenarios where resumption is useful? $\endgroup$
    – Pablo H
    Oct 23, 2023 at 16:36
  • $\begingroup$ Also, how does the code using the cache know that there's a memory allocation 7 levels deep in the call tree? $\endgroup$
    – Pablo H
    Oct 23, 2023 at 16:37
  • 1
    $\begingroup$ @PabloH another situation where resumption is useful: you're trying to connect to a CDN or another network resource, but the first try fails. The error handler could try to connect to a backup server instead. $\endgroup$ Oct 23, 2023 at 22:56
  • $\begingroup$ @PabloH, the code using the cache will not know about the memory allocation, so it registers an error handler first, then continues its work. When the memory allocation happens 7 levels deep, and fails, that error handler is called. By virtue of being called, it knows there is an allocation failure, and because it was registered by the cache code, it presumably knows about the cache too. $\endgroup$ Oct 23, 2023 at 22:59
13
$\begingroup$

Aborts/Panics

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.

$\endgroup$
5
  • $\begingroup$ How do you handle panic errors though? $\endgroup$
    – mousetail
    May 16, 2023 at 17:34
  • $\begingroup$ @mousetail Clarified. $\endgroup$
    – Ginger
    May 16, 2023 at 17:35
  • $\begingroup$ That's not true though, you can write a panic handler in rust $\endgroup$
    – mousetail
    May 16, 2023 at 17:35
  • $\begingroup$ As mousetail said, there's stuff like catch_unwind (doesn't catch all panics, though) $\endgroup$
    – user
    May 20, 2023 at 16:39
  • $\begingroup$ Lua also has sort of panics. error(msg) and pcall(...) $\endgroup$
    – Hydroper
    May 30, 2023 at 21:48
9
$\begingroup$

In C

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;
    }
    // ...
}
$\endgroup$
2
  • 2
    $\begingroup$ Your use of 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$
    – Bbrk24
    Jul 3, 2023 at 14:23
  • 2
    $\begingroup$ Technically, errno is defined as a macro, and thus it's like it's in the global namespace, but its value is thread-local. $\endgroup$
    – G. Sliepen
    Jul 14, 2023 at 12:15
9
$\begingroup$

Out-params

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).

$\endgroup$
7
$\begingroup$

.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.

$\endgroup$
7
$\begingroup$

Union Types

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).

$\endgroup$
2
  • $\begingroup$ It looks like there's already an answer that mentions Result<Success, Failure>-like types. Is Zig's type substantially different from the one in Swift and Rust? $\endgroup$
    – Bbrk24
    May 25, 2023 at 23:59
  • 1
    $\begingroup$ Zig's is using Language-level Union-types rather than a Type which implements Union Types. I think the difference is worth noting for languages that support it. Union types don't necessarily have the same restrictions as a Result<Success, Failure> type. $\endgroup$
    – ATaco
    May 26, 2023 at 0:46
5
$\begingroup$

Go-style:

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)
}

...

PS: from https://go.dev/blog/error-handling-and-go

$\endgroup$
5
$\begingroup$

Setting a global error code

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:

  • Doesn't need any new syntax constructs except two you probably have anyway: global variables and branches.
  • When programmers don't need error handling, they don't need to write any.

Disadvantages:

  • It is really easy to forget handling errors at all. Just ignoring that an operation could have failed and continuing the program execution can wreak havok.
  • The documentation of every operation has to mention if it can set error codes and if it does what they mean. Programmers need to memorize that or constantly look it up.
  • The code for handling errors must come right after the operation. The programmers can't move it out of the way like they can with, say, the try..catch pattern.
  • Moving error handling code around can result in inadvertently putting some code between the operation and the error checking that overwrites the global error code.
  • Doesn't play well with concurrency. Sharing one global error code between threads would be a recipe for disaster. So you need to find some other way to handle errors happening in concurrent operations.

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.

$\endgroup$
5
$\begingroup$

Resumable condition system

This is the system implemented in Common Lisp, where you can have resumable exceptions.

You raise an exception by signaling a condition:

(signal condition)

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.

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 "Oh no!")
    (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.

$\endgroup$
3
$\begingroup$

Configurable and expressive

Library code may use C or Go style error signaling, with minimal or no allocation, or stack traces. Business logic 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 ) ( ret :int, err )
{
    var x, err = funcB( arg );
    ...
}

Desugars to:

def nothrow func( arg :int ) ( ret :int, err :error )
{
    try {
        var x, err = funcB( arg );
        if ( err ) return 0 , err; // Autogenerated
        ...
    } catch ( e :error ) {         // Autogenerated
        return 0 , e;              // Autogenerated
    }                              // Autogenerated
}

And:

// On code that cannot progress after a error

var err as error.throw;

def func( arg :int ) ( ret :int )
{
    var x, err = funcB( arg );
    ...
}

Desugars to:

def maythrow func( arg :int ) ( ret :int )
{
    var x, err = funcB( arg );
    if ( err )                                    // Autogenerated
    {                                             // Autogenerated
        err.pushStackInfo( __FILE__ , __LINE__ ); // Autogenerated
        throw err;                                // Autogenerated
    }
    ...
}

Bonus: A compiler option may cause pushStackInfo() to be compiled as noop, so to omit (allocating) call stacks in some builds.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .