How To Use Exceptions Correctly

Over the years I’ve noticed a lot of unclear and indirect advice about how to use exceptions, particularly in C++. This short post is an attempt at clearing up the situation for people who are newer to programming and struggling to figure out how to use exceptions correctly in their own code. This advice applies specifically to C++. It may apply to other languages but your mileage may vary.

When To Use Exceptions

The main takeaway of this post is that exceptions should only be used for “system errors.” These are situations when a system cannot accomodate a valid request because of a dynamic external fault condition. Examples include “out of space,” “device not responding,” “out of memory,” etc. In this manner exceptions should only occur in “exceptional situations” to the extent that system errors are exceptional (and they should be).

Another mechanism for signaling system errors are “return codes.” Exceptions and return codes should be used in equivalent scenarios. There should be no semantic differences between the two mechanisms, only lexical and implementation differences.

Exceptions are preferred as a mechanism for signaling system errors over return codes because they allow writing code without littering checks for errors throughout your code. For example:

  void delete_user_fully(int user_id) {
     auto txn = create_transaction();

     for (auto & subhandle : txn.get_user_subhandles(user_id)) {
         txn.delete_subhandle(subhandle);
     }

     for (auto & friend_id : txn.get_user_friends(user_id)) {
         txn.delete_friend_pair(friend_id, user_id);
     }

     txn.delete_user(user_id);
  }

Contrast the preceding exception-based code with the equivalent code that uses return codes to check for errors:

  ErrorTuple<void> delete_user_fully(int user_id) {
     auto txn_or_error = create_transaction();
     if (txn_or_error.is_error()) {
        return txn_or_error;
     }

     auto & txn = txn_or_error.get_value();

     auto subhandles_or_error = txn.get_user_subhandles(user_id);
     if (subhandles_or_error.is_error() {
        return subhandles_or_error;
     }
     for (auto & subhandle : subhandles_or_error.get_value()) {
         auto delete_error = txn.delete_subhandle(subhandle);
         if (delete_error.is_error()) {
            return delete_error;
         }
     }

     auto friends_or_error = txn.get_user_friends(user_id);
     if (friends_or_error.is_error()) {
        return friends_or_error;
     }
     for (auto & friend_id : friends_or_error.get_value()) {
         auto delete_error = txn.delete_friend_pair(friend_id, user_id);
         if (delete_error.is_error()) {
            return delete_error;
         }
     }

     return txn.delete_user(user_id);
  }

There are ways to shorten the preceding code fragement using macros or other syntactic sugar but generally that is how the code would be structured. There are other implications of the exception-based code, like not having to do any extra work in the case where there isn’t a system error but that isn’t the main point of this post.

When Not To Use Exceptions

You should think really carefully about using exceptions in situations that don’t correspond to the preceding section. One particularly common inappropriate use of exceptions is for signaling “logical errors.” These are situations where the programmer has provided a function with invalid input, has otherwise violated mandatory preconditions, or has otherwise invoked undefined behavior. This is commonly expressed as “don’t use exceptions for control flow.” Here is an example of a poor use of exceptions:

float sqrt(float value) {
    // bad
    if (value < 0) {
       throw std::invalid_argument("received negative value");
    }
    /* ... */
}

The issue is that the programmer has invoked sqrt() in an invalid way. If the negative value came from the internal logic of the program itself, then the program is incorrect. If the value came from user input, then the programmer failed to sanity check the user input. Under that logic the correct way to implement sqrt() is as follows:

float sqrt(float value) {
    // good
    assert(value >= 0);
    /* ... */
}

A correct program should not do things that are undefined. If it does then the program is incorrect by definition and cannot be further trusted to execute without doing things the user does not intend, e.g. corrupting their data. In that situation, the program (process, request, or other isolate) should generally abort execution and the user should be notified.

Conclusion

Exceptions tend to be the most useful at the boundaries of programs where external components can fail, e.g. invoking system calls, communicating with network services. If you’re using exceptions to communicate information within the interior of your own program you are likely using them incorrectly.

Hopefully that helps, please send any questions or comments to @cejetvole.

Rian Hunter
2022-11-19

Edit 2022-11-20: Earlier versions of this post indicated that this advice applied to Python as well as C++. hwayne on lobste.rs wrote in response that this is not quite right as Python is designed to use exceptions for control flow, notably with StopIteration being used to detect the end of an iterable. They’re right, the Python ecosystem uses exceptions both as a mechanism for signaling errors and also as a way to direct “flow control,” (as referenced in PEP 8) even though these are logically different uses. That said, I contend that application-level Python code should still generally avoid using exceptions for non-error signaling but that breaches into opinion territory. Arguing that point is outside the scope of this post, so I’ve removed the reference to Python.