47
$\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
    Commented May 20, 2023 at 17:28

13 Answers 13

43
$\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$
21
  • $\begingroup$ Are there examples of checked exceptions (and row-polymorphic checked exceptions) in programming languages? Or is it "just" an "academic" concept? $\endgroup$ Commented 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$ Commented May 16, 2023 at 17:43
  • 1
    $\begingroup$ @AlexNelson I'll add to my answer. I'm looking for the PDFs now. $\endgroup$
    – ice1000
    Commented May 16, 2023 at 17:46
  • 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
    Commented May 17, 2023 at 11:02
  • 1
    $\begingroup$ @AlexisKing In Go, the output of the function is in the return value, not in parameters. $\endgroup$
    – ice1000
    Commented Aug 3, 2023 at 3:49
21
$\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$
    – rydwolf
    Commented 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
    Commented 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
    Commented Jul 15, 2023 at 1:44
20
$\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$
17
$\begingroup$

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.

$\endgroup$
7
  • $\begingroup$ I didn't see your answer, sorry $\endgroup$
    – coredump
    Commented Aug 1, 2023 at 16:38
  • $\begingroup$ Could you give other scenarios where resumption is useful? $\endgroup$
    – Pablo H
    Commented 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
    Commented 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$ Commented 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$ Commented 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$
3
  • $\begingroup$ That's not true though, you can write a panic handler in rust $\endgroup$ Commented May 16, 2023 at 17:35
  • $\begingroup$ As mousetail said, there's stuff like catch_unwind (doesn't catch all panics, though) $\endgroup$
    – user
    Commented May 20, 2023 at 16:39
  • $\begingroup$ Lua also has sort of panics. error(msg) and pcall(...) $\endgroup$
    – Hydroper
    Commented 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
  • 3
    $\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
    Commented 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
    Commented 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
    Commented 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
    Commented May 26, 2023 at 0:46
6
$\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$
6
$\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 ...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.

$\endgroup$
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$
4
$\begingroup$

Configurable and expressive

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.

$\endgroup$

You must log in to answer this question.

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