7
$\begingroup$

(This question is somewhat related to Exception handling: 'catch' without explicit 'try')

Context

I'm working on a new general-purpose programming language. Exception handling is supposed to be like in Rust, but simpler. I'm specially interested in feedback for exception handling (throw, catch).

I think, same as in Swift and Rust (and Go), exception should be handled immediately, and if not, either the program must stop, or the function must throw an exception. So, no undeclared exceptions. This is what "catch is needed, or the method needs throws" means.

Question

I think one exception type is sufficient. It has the following fields:

  • code (int)
  • message (string)
  • data (optional payload - byte array)

Are there any problems with using a single exception type an integer error code as the "discriminant" to tell apart different kinds of failure? Many languages instead use inheritance to distinguish exceptions by their types; what are the downsides of my approach compared to that?

$\endgroup$
4
  • 1
    $\begingroup$ The statement about Go is wrong in that Go has errors and panics. The latter are neither declared nor caught locally. In large scale code bases, Go is a good example why this approach is doomed to fail. The larger the project, the more important it is to treat only the relevant exceptions and propagate the others. Your approach doesn't offer this realistically the same way Go doesn't offer it. $\endgroup$
    – feldentm
    Commented Jun 27 at 17:26
  • 1
    $\begingroup$ Close vote because the question is about multiple things and it ends with four different questions in the question sections. It kind of seems to ask "is my approach a good idea" which can only be answered with a straight "no" and that wouldn't help anybody else. I suggest splitting the question into the subtopics and leaving the example away. $\endgroup$
    – feldentm
    Commented Jun 27 at 17:28
  • 1
    $\begingroup$ Hi there, I've closed this question as needing more focus, as it's asking multiple questions at once. I recommend either splitting the questions into multiple questions (ensuring they meet quality standards for the site) or editing the question to only ask a single specific question. Then, the question can be reopened. Thanks. $\endgroup$
    – lyxal
    Commented Jun 27 at 23:02
  • 2
    $\begingroup$ I'm actually happy with the answers I got, so I don't really see a point in closing the question or that the question needs "focus". Specially interesting are comments about error codes, the mention of the C++ proposal, and making it too easy to throw / re-throw. I can deal with critical comments (maybe others can't). It's fine for me to close the question, if you want, but I don't plan to work on it. I asked the question because I was not comfortable with this approach, but couldn't quite pinpoint what exactly was wrong, and now I think I understand it better. $\endgroup$ Commented Jun 28 at 7:55

5 Answers 5

19
$\begingroup$

There are two aspects of this ─ one is the idea of having homogeneous exceptions, the other is the catch statement which cannot filter what it catches.

No catch filter

You say that Rust does error-handling without inheritance, but inheritance is a red herring here; what matters is allowing custom types, which Rust does and your proposal does not. Inheritance is how languages like Java allow multiple exception types. But Rust does this through composition instead; Result<T, E> allows any type to represent a failure condition, including enums, which are easy to filter with match.

Rust's use of composition instead of inheritance for this does mean that values representing errors don't get stack traces "for free" ─ if you want a stack trace, you have to build it in your own constructor, and include it as a component of your custom error type. In languages like Java where stack traces are automatic, exceptions must inherit from Throwable, so that they invoke its constructor which builds that stack trace. So Rust loses something here from not supporting inheritance, but it's not heterogeneous error types.

In contrast to both Rust and Java, your proposed language does not allow different error conditions to be represented by different types, nor does it allow the catch statement to filter by the error condition. This encourages the "catch, check and rethrow" pattern from Javascript:

catch(e) {
    if(!(e instanceof FooError)) { throw e; }
    // ...
}

Javascript makes this bad enough, because people routinely forget to opt out from the exceptions they don't intend to handle. But in your proposal, instanceof checks are not even possible; you would have to compare the numeric error code.

Numeric error codes

These are considered harmful. Using integer constants as the preferred way of telling apart different error conditions was encouraged by C, but there are good reasons modern languages have abandoned them. (PHP used to have them, with many of the same problems.)

Some of these problems are mitigated by your exceptions also carrying strings, but this could cause problems of its own ─ particularly, it encourages comparing errors by strings instead, which will cause code to break if those strings are ever changed (e.g. to add clarifications or fix typos).

Numeric codes are harder to filter than types

Suppose there are several different error conditions which can occur when using a foo, and I want to handle all of them. Note that even without inheritance, Rust can have hierarchies of failure conditions through composition of enums, so I can handle Err(SomeErrorEnum::FooError(_)).

With type hierarchies, I can catch(FooError e) or rethrow if !(e instanceof FooError). But with integer codes I must rethrow if !is_foo_error(e) or e.code != FOO_ERROR_A && ... && e.code != FOO_ERROR_X. Either of those requires maintaining a list of all such error codes, and keeping that list in sync with the declarations of those error codes.

The latter is also fragile to changes in the list of possible error conditions. And it's particularly error-prone for novices, who often mistakenly write || instead of && when doing negative comparisons against multiple values. There is a Stack Overflow Q&A about that mistake with 275,000 views and 18 duplicates, so it's a very common pitfall, and worth steering people away from if you can.

Programmers won't bother to assign codes to their own errors

Numeric codes should be declared in one place, with a reference for what they all mean. This is extra work compared to just declaring a new class which extends Exception where you want to use it ─ with error codes you need a centralised place which declares them all.

Beyond that, you need to provide a set of helper functions so that when error 42 is thrown, the message accompanying it is the correct string. But many programmers will figure it's not worth all this effort, especially if they are already writing human-readable strings. That makes it harder to tell different error conditions apart programmatically later; all the places in the code which throw exceptions will do so in an ad hoc manner and need to be changed.

No checks to ensure correct construction of errors

If error conditions are represented by subclasses of Exception, then the constructors of those classes can check that exceptions are constructed properly with valid payloads. Probably the simplest way this is done is with access modifiers; an exception whose constructor is package-private cannot be incorrectly thrown by code outside of that package.

In contrast, if error conditions are represented by numeric codes, there's nothing to ensure that every throw-site uses these codes correctly, pairs the correct message and payload with each exception, and so on.

Numeric codes are less meaningful when debugging

If an exception makes its way to the top level of the program, then the program halts and prints the error details. An error code like 42 is less immediately useful than an exception type name like ArrayIndexOutOfBoundsException, because you have to go and look up what it means.

Numeric codes don't respect namespaces

Libraries written by different authors might use the same integer constants for different failure conditions. This wouldn't matter so much in C because each library would have its own functions for checking error conditions. For example, in C if you call mysql_errno() then you know the code returned represents a MySQL error. But in your language, all libraries will throw the same type of exception, so error codes can become ambiguous at the catch site.

No custom payload

The homogeneous exceptions you propose don't have room for custom types of payload ─ only a byte array is allowed. While a byte array does allow passing serialisable types as the payload, it's inconvenient to serialise and deserialise data for this, and many types (e.g. anything holding a callback function) cannot be serialised. Alternatively, your language might allow byte arrays to be cast to and from arbitrary other types, but this completely sacrifices memory safety.

It's very useful, particularly when debugging, to attach arbitrary user-defined types to exceptions. When an object finds itself in an invalid state where its invariant has been violated, you want to throw an exception which passes that object up the call stack to the top of the program, so its state can be easily inspected. For example, in Javascript, the browser console can be used for this.

Custom payloads are also often useful for other reasons, e.g. when deciding how to handle an error condition, or when exceptions are the most convenient way to achieve a certain flow of control (e.g. here).

$\endgroup$
1
  • $\begingroup$ Thanks a lot! I wasn't aware that Javascript doesn't have exception filtering... interesting! Error codes: I agree, it it problematic. I'm now thinking of allowing to throwing and catching reference types. But I think it is fine if a method can only throw one type, similar to Rust. (I'm aware Rust of the Rust syntax.) $\endgroup$ Commented Jun 27 at 12:00
5
$\begingroup$

Herb Sutter (the chair of the C++ standard committee) wrote a proposal named Zero-overhead deterministic exceptions: Throwing values, suggesting a new approach to exceptions in C++ based on a <success,error> union return type and syntactic sugar to make it more convenient.

He suggested a fixed error type, similar to std::error_code which already exists in the language since C++11. std::error_code contains a numeric error code and a pointer to a std::error_category object that defines the meaning of the code. There is nothing like your error-message string in Sutter's error type because the goal of the proposal is to avoid statically unpredictable overhead when an exception is thrown and propagated. The error type does contain enough information to produce a human-readable error message, but not enough for a traceback or a parameterized message.

If you want to build error codes into the language then I think it's crucial to pair them with a domain (like std::error_category) to avoid collisions. Your proposed type lacks that. Also, given that your type has a dynamic length anyway, it's not clear what benefit there is to forbidding other types. A dynamic byte array whose interpretation depends on the error seems inferior to a dynamic collection of additional fields of arbitrary types whose interpretation depends on the error.

$\endgroup$
1
  • $\begingroup$ Thanks a lot! This is very interesting. It goes a step further than my idea: I had error message and payload, while the proposal has a value type (fits in a register). The proposal uses the same type of processing (throwing by returning). Also, it references Swift, and Go, which use the same approach. $\endgroup$ Commented Jun 28 at 8:43
4
$\begingroup$

I see a few issues.

Error codes

Keeping error code as a simple integer leaves it vulnerable to collisions. What if one library decides error code 0xDEAD means "Error decoding texture" and another "SSL certificate validation failed". It would create a lot of issues if you can actually deal and recover from one but not the other.

Having it an integer also makes it hard to read, and encourages using magic numbers.

Kaya's Answer does a much better job explaining why error codes are bad

Extra Data

Adding extra data to errors is often helpful. For example, for a parser I had a special error type with some metadata that could be used to display nicely formatted parse errors. Web frameworks can use extra data to specify the HTTP status code the error should throw. There are many other uses.

Using a generic "byte string" with no agreed upon format makes these things difficult. Since you don't really know where the error originated from you don't know if the byte string is just a number representing the status code or maybe some more complex JSON with many fields.

With error types you could do something like this:

try {

} catch HTTPError e { // HTTP Error can have many sublcasses
    return e.status_code
} catch {
    // Code for error logging ommited
    return 500;
}

Without error types this becomes a lot more convoluted:

try {
    
} catch e {
     // It's impossible to add more to this list
     if e.error_code in [ERROR_CODE_HTTP, ERROR_CODE_NOT_FOUND, ERROR_CODE_NOT_AUTHORIZED] {
         try {
             data = JSON.decode(e.data)
             return data.status_code
         } catch {
             return 500
         }
         
    } else {
        return 500
    }
}

Every method will throw

Having a throws annotation is less helpfull if there is just one type of error. Every method that calls a method that throws must itself also throw so it's likely that 90% of methods will potentially throw in a large code base.

In languages with error types and declared throws, this information helps you know what kinds of things can go wrong. If a method throws FileNotFoundError you know it won't throw anything else. In your case you would not know what kinds of errors could happen and if any of them are recoverable.

It's likely lazy programmers will just add throws to every method regardless of if it actually throws (like already happens a lot with async)

A paradox of philosophy

There are two schools of thought regarding with errors:

  • Errors should normally be caught. Thse are languages like Java, Rust, etc. Languages like this provide highly detailed type safe information about errors to make it easy to deal with the errors. Letting the error leak requires extra effort. Error data is mostly meant for handling the error so it may contain information nececairy to retry the operation while ommitting things the user might want like line numbers and tracebacks.
  • Errors are not normally caught. In languages like Python or Javascript, you can handle errors if you want but it is not nececairy or the default. Error data is focused on creating a user-friendly message meant for users and does not usually have info helpful for actually recovering.

You seem to both want people to handle errors but provide a very static error format that seems more user-focused than automatic error handling focussed.

$\endgroup$
2
  • $\begingroup$ Thanks a lot! You are right, the fixed exception type is problematic... It seems better to support multiple exception types. And the simplicity of "just adding throw" can backfire. So I think it is likely better to also declare the exception type in the function. Now I need to think about if throwing multiple exceptions makes sense... $\endgroup$ Commented Jun 27 at 12:17
  • 2
    $\begingroup$ Regarding the function annotation, if you don't want to list all types of throwable exception (or you decide to have only one type), an alternative option is to use an annotation for functions which don't throw, since then the annotation doesn't need to be propagated all the way through the codebase just to make it compile. $\endgroup$
    – kaya3
    Commented Jun 27 at 13:12
1
$\begingroup$

Exception handling is a type of flow control, but only exceptional flow. There are two main (important, for me) uses cases for exceptions handling:

  • Fast exceptions (even when they occur). For this use case, it is important that exception handling is very fast; basically zero overhead. That requires that throwing exceptions does not allocate memory, and catching does not require to unwind the stack. This is not fully solved currently in C++, and this is what the proposal of Herb Sutter tries to address. C and Go solve this with special return codes, but it is not convenient (having to write repeated code). Rust and other languages solve this, but arguably it is not extremely convenient to use.

  • Slow exceptions. Those exceptions are thrown rarely, and it is fine if they are a bit slow. Heap allocations are fine, call stack unwinding is fine. Such exceptions can have nice messages etc. This is solved in languages like C++ and Java in a convenient way. Java and other languages also support undeclared exceptions - but that's yet another dimension of the problem.

There are in-between use cases. For example, most exceptions in Java (languages that use the JVM) are slow, but throwing exceptions that are pre-created (singleton exceptions) is quite fast. Arguably not extremely fast: call stack unwinding is still needed.

It would be nice if a language can address both needs (and in-between needs) in a unified way. Best would be if stack unwinding is not needed. For this case, one exception type is not sufficient, and doesn't address either use case: for the "fast exception" use case, a heap allocation is too slow, and possibly even value types with too many fields are too slow. For the "slow exception" use case, it is not flexible enough. So, it is likely better to allow different types of exceptions, with less fields (for speed) and more fields (for message, payload, handlers, etc.).

(I post an answer to my question based on the understanding I have gained so far.)

New contributor
Thomas Mueller is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.
$\endgroup$
1
  • 1
    $\begingroup$ @G.Sliepen yes, I have updated the answer: "Fast exceptions (even when they occur)". It seems if you do not even want to pay the price for internally using and checking error return values, then it might be best to use a different solution, for example registering error handlers. $\endgroup$ Commented Jun 30 at 12:05
-1
$\begingroup$

A major problem with exceptions in all major languages I've seen is that exception types do nothing to distinguish between scenarios where an attempted action failed "cleanly", and scenarios where problems exist beyond the fact that the attempted action failed. A related issue is that many languages make it unduly difficult for code which is performing resource cleanup during stack unwinding to know know whether the stack is being unwound because of a propagating exception, or because a function finished normally.

Consider, for example, a DatabaseTransaction class with operations to Commit, and Rollback. Creating a transaction and destroying it without doing Commit or Rollback would generally be a usage error, which should merit an exception, but in many languages having the cleanup for DatabaseTranaction throw an exception in scenarios where another exception created the need for cleanup would be a bad thing in languages where that could obscure the original (still pending) exception.

A couple of key features I would include in any new exception designs would thus be (1) having a field or property which indicates that the exception was likely thrown for the reason a caller would expect, but which would generally be set to false if an exception "bubbles up" through layers that don't acknowledge it as a possibility; (2) having a means by which cleanup code can know whether an exception is pending.

Also, I'd highly recommend separating out the questions of whether code should take action when an exception is thrown, versus the question of whether it should propagate upward. Situations an exception should be ignored but not propagate would be rare, but could sometimes occur with notification callbacks that fail cleanly. An example where code should act on an exception but still propagate it (as opposed to catching and rethrowing) would be the aforementioned database transaction, where cleanup-after-exception code could call a RollbackIfNotCommitted method. "Handle and don't propagate" would be appropriate for many "action that was expected to potentially fail, actually failed cleanly" scenarios, and "don't handle but do propagate" would be true for many "expected" exceptions.

$\endgroup$

You must log in to answer this question.

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