Skip to content

Instantly share code, notes, and snippets.

@KevinJones
Created October 7, 2024 17:27
Show Gist options
  • Save KevinJones/0a1d2ac9316c4b39dde91fd876836bca to your computer and use it in GitHub Desktop.
Save KevinJones/0a1d2ac9316c4b39dde91fd876836bca to your computer and use it in GitHub Desktop.
C++ Error Handling

C++ Error Handling

Written by Kevin Jones, 2024-10-07. Dedicated to the public domain, CC0, no rights reserved. Find me on GitHub or BlueSky.

How should we think about errors?

  • Every program will have bugs, every program will run into unlikelysituations. Error handling makes taking repsonsibility for protecting yourself and others from this kind of problems problems. ("Defensive programming", akin to defensive driving.)
  • When does the error happen: compile time or runtime?
  • How frequently will the error conditions happen: "should never", "not commonly", or "quite often"?
  • In what context is the error occurring: your library, somebody else's library, or your application?
  • Can the error be contributed to the code or to the environment?
  • Can the error be handled locally, or does it need to be handled non-locally?

What are invariants?

  • A (class) invariant is a logical condition of an object that must be true after the constructor establishes it and remains true after executing any public method of the object. For example, a mother always has a greater age than her biological children.
  • Invariants can be stated informally or with some explicit assert.
  • Invariants are mostly a tool for you to think more clearly about whether a program is correct.
  • Not every object has invariants.

What are preconditions and postconditions?

  • A function assumes stuff about its arguments and the state of stuff it deals with.
  • Assumptions about this state before the function does it work are called preconditions. Promises the function makes to its caller about stuff after the function does its work are called postconditions.
  • What should you do about preconditions? A combination of
    • Use static_assert for conditions that can be evaluated at compile time, so that compilation fails if the conditions don't hold.
    • Use assert from the <cassert> header to test preconditions and terminate the program if they aren't met (or a custom assert function if you want to do something special)
    • Test the preconditions in an if statement and throw an exception if they aren't met.
      • This will usually be equivalent to terminating, but gives you (or a developer using your code) the option of catching it later.
    • document the conditions, but assume the caller knows what it is doing. This is appropriate if preconditions are really complex to prove, or you need the best performance possible.
  • And what about postconditions?
    • My assumption that assert is the most appropriate technique.

The ideas of preconditions, postconditions, and invariants are part of the design by contract approach. However, C++ does not have native support for DbC features, so we use them more informally.

What is the general error handling strategy?

  • First, deal with as much at compile time as possible.
    • with the type system
    • with concepts, and the appropriate use of templates
    • with static_assert
  • Second, make your own code as correct as you can.
    • use assertions, like assert from <cassert> to check for conditions that should never occur, including preconditions, postconditions, and invariants. When an assert fails, the program terminates.
    • if an error happens commonly, and can be handled locally, do so in a pattern consistent across your code base.
      • for example, you might return a bool indicating whether the operation completed successfully, and write other stuff into relevant output parameters, like one called error& err. copy_file is a model to follow.
      • or you might make all output part of a structured return value, like a optional<T> or expected<T, E>.
      • do not use exceptions for errors you wish to handle locally.
    • if an error happens uncommonly, or cannot be handled locally, choose a strategy for your project:
      • throw an exception object (by value) derived from one of the standard library exception types
      • pass the error through an output parameter or return value up through the call stack
  • Third, make errors clear to the users of your code. (Depends on your particular error handling strategy)
    • same idea as before, except you may wish to throw or pass along errors for certain failed preconditions or class invariants that you'd otherwise only use assertions for. for example, std::invalid_argument.
    • you may want to offer a throwing version that wraps around the error code version of your function.
  • Fourth, choose a strategy for what to ultimately do about exceptional errors that can't be handled locally. (In terms of exceptions, what you do in a catch.) This might vary depending on the kind of error and the desired robustness of the program you are making.
    • simply terminate
    • write to a log file, then terminate
    • display a notification to the user about what went wrong, then terminate
    • roll back to a valid state, then terminate
    • roll back to a valid state and attempt to continue (for example, by retrying the action after a delay, or with different arguments, or after user intervention)

Particulars:

  • In C++, never use exceptions for ordinary control flow.
    • For example, if you are iterating through a vector, you should check that the iterator has reached vec.end(), not that vec.at(ii) throws out_of_range. That would be an exception thrown every time even during the "happy path" of the function.
  • Some kinds of error handling may have performance impacts. Aside from semantic confusion, this is the main reason C++ exceptions should not be thrown frequently. Measure runtime performance in a release configuration and the compiled size, then test for the effect of changes.
  • If your plan is to terminate, it's simpler to check for and recover from errors in external resources at the start of your program, rather than after the exception is thrown.
  • When you are unit testing, it may be appropriate to test that a function throws an appropriate exception type or error code. Think "fail gracefully."
  • You can implement your "thing to do before terminating" either with a catch-all block in main or with a call to std::set_terminate.
  • As of C++20, the expensive "stack unwinding" process will only occur if the thrown exception meets a matching catch handler. In other words, uncaught exceptions should be about as cheap as a function return. Microsoft has a description of how the exception mechanism executes.

What are the arguments for and against using exceptions?

  • Coding styles that favor exceptions argue that they are less verbose than error codes, more flexible, and more future-proof.
  • Coding styles that do not favor exceptions may be working in a context that is ill-suited to the potential performance costs of exceptions. They may have a large existing codebase that does not use RAII consistently, believe exception usage is too hard to get right, or simply standardize on a different pattern for handling errors that need to be handled non-locally.
  • More details later in this document.

When is throwing exceptions your only option?

  • failure in an operator, notably [] and ()
  • failure in constructors (but throwing exceptions in destructors is not possible)

Example of error codes and exceptions in copy_file()

  • Three things can happen when you call std::filesystem::copy_file(from, to): It returns true, it returns false, or it reports an error by throwing an exception.
  • For our first case we'll assume copy_options::none, the default argument.
    • returns true only when a file was successfully copied to the destination, which requires that the destination file does not already exist.
    • has no cases where it returns false.
      • I suppose the reason it returns anything at all is to stay consistent with the other forms of the function.
    • throws std::filesystem_error in these cases:
      • the source file doesn't exist
      • the source file is not a regular file
        • (it might be a directory, a Linux block device, a Windows mount point, etc)
      • the source file and destination file are the same
      • the destination file already exists, because we did not specify a control option; the function doesn't know how to behave here
    • throws std::bad_alloc if a memory allocation fails
  • If instead we call copy_file(from, to, copy_options::skip_existing)
    • returns true only when a file was successfully copied to the destination.
    • returns false when no file was copied; meaning the destination already exists.
    • throws std::filesystem_error in these cases:
      • the source file doesn't exist
      • the source file is not a regular file
      • the source file and destination file are the same
    • throws std::bad_alloc if a memory allocation fails
  • copy_file offers two overloads that will indicate filesystem errors by writing into a std::error_code& argument. This is something like a try_copy_file (akin to the the [[C Sharp TryParse Pattern|C# TryParse Pattern]]) and is more appropriate if the immediate caller of copy_file wants to check the filesystem_error and handle it immediately. However, even these will still throw std::bad_alloc if a memory allocation fails.
    • It seems pretty common to assume that completely exhausting memory is a situation that can't be recovered from, and leave it to the OS to do something about it.

What are exception safety guarantees?

  • The standard library describes many of its operations in terms of what exception safety they provide. You may also try to design your code to provide one of these guarantees, but it is difficult to provide more than the basic guarantee.
  • From strongest to weakest, they are no-fail, strong, basic, or none.
  • No-fail guarantee: The function will not throw an exception nor allow one to propagate.
    • aka no-throw, or failure transparency.
    • Destructors are assumed to be no-fail.
  • Strong guarantee: The function will not leak memory or modify program state if it goes out of scope because of an exception. It'll either succeed completely or have no effect.
    • aka commit or rollback semantics
  • Basic guarantee: The function will not leak memory and the object will stay in a usable state. However, the data might have been modified.
  • No guarantee: The behavior of the function after throwing an exception is undefined.

A moment of zen: "exceptions are for exceptional cases" is a completely useless phrase that shows up everywhere.


Why does there seem to be so much disagreement about how to use exceptions in C++, if they should be used at all?

  • C++ is a big language, and an old language, used for a variety of purposes, so naturally people have developed disagreements on what ought to be the right way of doing things.
  • There are a variety of reasons why some projects might recommend not to use exceptions:
    • some real, some imagined
    • some historically true but not any more
    • some based on practice in other programming languages that do or do not use exceptions
    • some based on personal/team culture preferences
    • some based on measurable trade-offs
    • some based on misconceptions
  • This note is my attempt to make sense of these contradictions.

Arguments in favor of using exceptions in C++

  • They are a systematic way to handle non-local errors. Think "I know there's an error, but I can't handle that failure myself" or "I can't handle this failure immediately."
    • Without using exceptions, you would instead pass information about the error from one local context to another using return values or output parameters.
  • They require no changes to code between the layer that throws the exception and the layer that catches the exception.
  • They can be used in contexts where you cannot return error codes, such as constructors and overloaded operators. The use of exceptions in constructors is particularly important to programming with RAII.
  • In general, C++ exceptions tend to be less verbose than error codes. (This is not the case for Java-style checked exceptions, which do get pretty verbose.)
  • During development, you can throw exceptions without ever catching them.
    • Throwing exceptions in this way is informative because the exception object can tell you what kind of error occurred and produce a stack trace. (TO CHECK: does a failed assert also produce a stack trace?)
    • Then, you can leave these throw statements in as appropriate if you decide to handle them later. If you don't decide to catch them, they are equivalent to an immediate call to std::terminate(), and will have no relevant impact on performance.
  • When you design a framework/reusable library, there might have errors that you cannot handle, but the user of the library might be able to, and throwing the exception is a clean way of propagating it up the call stack.
    • For this reason it's not unusual to provide a throwing and non-throwing version of the same function.
  • From Google C++ style guide
    • They give higher levels of an application a way to handle "can't happen" situations originating in deeply nested functions; error codes are error-prone
    • They are used by most other modern languages; be consistent with Python, Java, C#
    • They make it easier to work with third-party libraries that use exceptions
  • If you report an error with an error code, and ignore it, there is a risk of your program continuing in an incorrect state without telling you. On the other hand, if you throw an exception and ignore it, the program will terminate rather than continue in a potentailly-incorrect state.
    • However, there are some ways of reporting errors that don't have this risk, in the style of Rust's Result<T, E>. Getting a value out of type T out of this structure when it holds an E causes a panic (terminates the program).
  • It's easier to move type information up the stack with exceptions than by error codes.
  • Actually disabling exception handling, rather than just not adding any to your code, may have different results on different compilers and standard library implementations.

Arguments against using exceptions in C++

  • Exceptions are not well-suited to projects in these situations:
    • You are working in a situation where your code must NEVER CRASH EVER, like to pass certification on a video game console.
    • You are working in a hard-real-time system that's safety-critical. (Think "people could die if this code is too slow.") There's currrently no way to prove the worst-case performance of a throw.
    • You're working in a legacy codebase with a lot of raw pointers (instead of smart pointers and RAII). It is hard to understand where throwing an exception here could cause resource leaks. (see Cpp Core Guidelines)
    • You're making code for an embedded system with very little memory, since there is some overhead to exceptions.
    • (You would use a compiler option to disable exception support in these cases, and avoid relying on catching and recovering from an exception.)
  • from Google C++ style guide
    • Exceptions require coding with consistent RAII everywhere, else unexpectedly leaving a function will cause resource leaks.
    • They make the control flow of programs difficult to evaluate by looking at code; functions may return in places you don't expect. This can be alleviated with more stuff in a style guide, but that's extra complexity towards getting everything right.
    • It's difficult to introduce use of exceptions into existing code that is not exception-tolerant, including C code.
    • The availability of exceptions may encourage developers to throw them when they are not appropriate, or attempt to recover from them when it is not safe to do so

False claims about exceptions

  • "Exception support significantly increases binary size"
    • This is something you can measure and test for your own project. There is a difference, but it might be negligible.
    • The difference is most likely smaller for today's compilers than it was a decade ago.
  • "Exception support significantly decreases runtime performance."
  • "Exception support requires more runtime memory."
    • Again, test and measure for your own project.
  • "Exception specifications suck."
    • They do! And they were removed from the language standard in C++17.
  • "Throwing an exception from a constructor invoked by new causes a memory leak."
    • This did happen once, but it was a bug in one compiler over a decade ago. (Source: Exceptions FAQ )
  • "The standard library throws exceptions; therefore, if you disable exceptions in your compiler, you cannot use the standard library."
    • You can test this, but it is generally false. However, the notion of "disabling exceptions" is not part of the language standard, so the actual result may be different from compiler to compiler.

Sources that favor use of exceptions

  • Other popular programming languages that have an exception mechanism include Java, C#, Python, Ruby, and Ada. Do note that the intended use and performance characteristics of exceptions may vary between these languages, however.
  • Core Guidelines E.2 - in general, recommends using exceptions to implement [[C++ - RAII|RAII]]. (E.6)
  • Google's C++ Style Guide - in new "from scratch" projects or when working with exception-tolerant code.
  • Microsoft
    • "In modern C++, in most scenarios, the preferred way to report and handle both logic errors and runtime errors is to use exceptions. Exceptions provide a formal, well-defined way for code that detects errors to pass the information up the call stack."
    • the Windows operating system has a mechanism called structured exception handling (SEH)
    • also favors it in C#

Sources that favor not using or disabling exceptions

  • LLVM's style guide states the project does not use exceptions or the language's RTTI (runtime type information) in an effort to reduce code and executable size.
  • Game programming in general
  • The Go programming language does not have exceptions or assertions.
    • The ability to return multiple values conveniently makes using the canonical error type easier than it would be in C++.
    • Go also has panic
  • Rust does not have exceptions; instead you have Result<T, E> (a thing that you unwrap, for recoverable errors) and a panic! macro (for unrecoverable errors).
  • Despite new editions of the C language being standardized over the past several decades, none has added the ability to throw exceptions.
  • Apple's Swift language has try, catch, and throw keywords, but throwing an error does not involve unwinding the call stack, giving them lighter-weight performance characteristics. Only functions explicitly marked with throws can propagate errors. I interpret this as making them pretty different in nature from C++ exceptions.

What does error handling look like if you don't use exceptions?

  • The simplest thing to do is to just panic/crash/terminate when you detect the error, rather than attempt to handle it. This is an ideal approach for your prototypes, test code, and demonstrative examples. Failing fast is preferable to ignoring problems.
  • The other major paradigms are "special return values" and "type system approaches." (Covered well in a student paper here)
  • Special return values
    • C's write returns the number of bytes written, and -1 if an error occurred
    • can write error code to an output parameter, like copy_file(from, to, error_code)
    • can return a useful value and a possible error as separate entries in a pair (easier in Go than C++)
  • Type system approaches
    • an option type like std::optional<T> contains either a value of type T or nothing at all. It cannot be used as a T without considering the possibility that there is no value at all.
      • similar to returning a nullptr, except you can't forget to check if result == nullptr
    • a result type like std::expected<T, E> (C++23) contains either a successful value of type T or an error of type E.
    • like what Rust does, but certainly possible to implement in C++
  • One thing you should NOT do is write to a global error code value, as in C's errno
  • It should be noted that it's possible to use zero exceptions (or at least zero exceptions in your own code), but it's incorrect to use exceptions to handle every kind of error. You will be using assertions and a strategy for handling local errors alongside exceptions.

External links

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment