A Practical Approach to Error Handling

A Practical Approach to Error Handling #

Error handling in C++ is a complicated topic that has received different answers over the years. For an exhaustive discussion see [SUTTER2018].

The solution described below has been used by the author for over 20 years in medium to large scale projects.

Characteristics of Errors #

Errors can be divided in different categories based on their characteristics.

  1. Synchronous vs Asynchronous Errors. Synchronous errors are those that, given the same conditions will always be triggered. For instance a bad argument to a function or a wrong user input are synchronous errors. Meanwhile a communication timeout or a bad memory bit are asynchronous errors. They occur due to external factors that can be mitigated but not controlled by the program.
  2. Recoverable vs Fatal Errors. In some cases, when an error occurs, there is a meaningful way for the program to continue. In other cases the only reasonable action is to terminate the program.
  3. Expected vs Unexpected Errors. Some of the error conditions are so benign that the programmer always provides handling mechanisms for them. For instance an “end-of-file” situation is something so common that most people would not even consider it an error. However, when designing C++ streams, someone considered it on par with “fail” and “bad” (see ios_base::iostate). Other error conditions are so rare that almost no one provides coping mechanisms. Most people don’t write error handling code for memory failure. Sometimes the difference between the two is not so clear cut. For instance an “out-of-memory” condition can be unexpected when allocating a small buffer to parse command line options, but a real possibility when processing a huge data model.

Taxonomy #

What we commonly call “errors” can be further subdivided into different categories:

  1. External errors are caused by a malfunction outside the program. For instance a bad user input or a faulty hardware device. They can be in any of the three categories shown before. For instance, bad user input is a synchronous error (will always be triggered by that input) and, in most cases is, or should be, recoverable and expected (obligatory xkcd)
    .
    Meanwhile, a memory failure is asynchronous, and, in most cases, is unexpected and fatal.

  2. Logic errors - are caused by one part of the program not respecting the requirements of another part of the program. For instance calling the sqrt() function with a negative argument. Most are synchronous, unexpected (in a finished program; in the debugging phase they are expected) and fatal. There are however logic errors, like race conditions, that are asynchronous.

  3. Resource limit errors - “out of memory” or buffer overflow are probably the best known examples of such errors.

A Brief History of Error Handling #

Over time, various solutions have been proposed. Probably the first one was the error handler method (although not named as such). In FORTRAN you can write

READ (5, 30, END=40) A, B

or,

READ (5, 30, END=40, ERR=50) A, B

Which means that, when an end of file condition occurs, control is transferred to statement numbered “40” and control would be transferred to statement “50” when an I/O error occurs. As FORTRAN is almost completely devoid of structure, it doesn’t quite matter where the error handler is placed. Similarly, PL/I introduced the ON clauses with a syntax like:

ON <condition> <block>

To detect an end of file condition, one would write:

ON ENDFILE GOTO L2;

Another method are error codes. These are integer values returned by functions to indicate anomalous conditions. Well-known examples are the malloc() function that returns 0 if memory allocation fails or fread() function that returns 0 if an end of file or error condition occurs. Biggest problem with error codes is that they require immediate testing and action. If the code that tested the error condition doesn’t know what action to take it usually passes the error to the upper levels.

These two early example show one of the problems with error handling: local vs global handling. Error codes implicitly favor local error handling. The function that triggered the error returns the error code and caller can/should take an action. Meanwhile, in the global approach, errors that potentially come from different sources are routed to one handler.

Designers of C language came up with a mixed solution, the setjump()/longjump() combination. The longjump() function allows program to pass control to an arbitrary point (provided that point is still on the stack), bypassing any intermediate stack frames. When control returned to the setjump() point the error cause is provided by the second argument to longjump().

C++ introduced exceptions as an attempt to solve local/global dilemma. Similarly to error handlers, exception handlers can be installed at any stack level. The language also allows arbitrary information to be passed from the exception site to the handler. In theory this works fine but in practice it is difficult to control what exceptions can bubble up from lower levels. A first attempt in C++98 to add exception specifications to functions signature had to be dropped in subsequent versions of the language. Even mundane problems like allocating the exception object don’t have a convenient solution (how do you allocate an object after you had a “not enough memory” condition?).

Newer solutions are the std::expected<SuccessType, ErrorType> as proposed in [BOTET2017] or boost::outcome described in [DOUGLAS2014]. They both try to multiplex the function return channel to pass both the normal function result and the error conditions.

Each method has advantages and disadvantages and there is no one size fits all solution. In my opinion, the global vs local dilema is the central conundrum of error handling:

At lower level, where the error occurs, all the information about the condition is available, but you cannot decide what action to take. At the upper levels, where you can make decisions, there is not enough information about what happened (unless we accept to bubble up arbitrarily large objects that contain all the required information).

This is not unlike the situation in many human organizations where at lower levels one knows precisely issues but doesn’t have the authority to solve them, while those higer up have the authority but don’t know exactly what the problem is.

The Error Code Objects — Overview #

The original idea was taken from an article that appeared in an article in Dr Dobbs Journal [GUILLEMOT2000]. An error code object (called erc for short) behaves just like a regular integer value except that, if it goes out of scope untested, it can (and will) throw an exception. The biggest advantage compared with the regular error codes is that these objects cannot be ignored.

Look at the follwing code fragment:

int do_task_that_can_fail (void* params)
{
  if (!params)
    return FAILURE;
  do_task();
  return SUCCESS;
}
//....
int main ()
{
  int value = 3;
  do_task_that_can_fail (NULL);
  do_task_that_can_fail (&value);

  printf ("all good\n");
}

The function call that failed is ignored in this case and the program continues to run with potential disatrous effects.

Compare this to the next fragment:

erc do_task_that_can_fail (void* params)
{
  if (!params)
    return erc(FAILURE);
  do_task();
  return erc::success;
}
//....
int main ()
{
  int value = 3;
  do_task_that_can_fail (NULL);
  do_task_that_can_fail (&value);

  printf ("all good\n");
}

The main program looks the same but now the first function call will return an erc object. As a last step after the function call, the compiler invokes the destructor for the returned object. In turn, this destructor will throw an exception and the program will terminate.

Surely the function could have been modified to directly throw an exception:

void do_task_that_can_fail (void* params)
{
  if (!params)
    throw FAILURE;
  do_task();
}
//....
int main ()
{
  int value = 3;
  do_task_that_can_fail (NULL);
  do_task_that_can_fail (&value);

  printf ("all good\n");
}

However, in this case the user of this function would have to look at the source code or documentation to find out what exceptions could be thrown and eventually modify the calling program to look more like this:

void do_task_that_can_fail (void* params)
{
  if (!params)
    throw FAILURE;
  do_task();
}
//....
int main ()
{
  try{
    int value = 3;
    do_task_that_can_fail (NULL);
    do_task_that_can_fail (&value);
    printf ("all good\n");
  } catch (int& x) {
    printf ("something wrong\n");
  }
}

With erc objects the user has the choice to use this exception based approach:

erc do_task_that_can_fail (void* params)
{
  if (!params)
    return erc(FAILURE);
  do_task();
  return erc::success;
}
//....
int main ()
{
  try{
    int value = 3;
    do_task_that_can_fail (NULL);
    do_task_that_can_fail (&value);
    printf ("all good\n");
  } catch (erc& x) {
    printf ("something wrong\n");
  }
}

or, he or she can use an error code approach:

erc do_task_that_can_fail (void* params)
{
  if (!params)
    return erc(FAILURE);
  do_task();
  return erc::success;
}
//....
int main ()
{
  int value = 3;
  if (do_task_that_can_fail (NULL) != FAILURE
   && do_task_that_can_fail (&value) != FAILURE)
    printf ("all good\n");
}
else
  printf ("something wrong\n");

Once an erc object has been conveted to an integer, like in the comparisson operation above, it no longer throws an exception when destructed.

Error categories #

Not all abnormal situations are equal and to

Error Grouping #

Once a program starts growing and is composed of different modules it becomes impractical to assume that different error conditions can be identified only by an integer value. While there is no shortage of numbers, it’s not simple to make sure that different modules generate different values. To solve this problem, erc objects have associated an “error facility” (errfac for short).

Bibliography #

[BOTET2017]: Vicente J. Botet Escribá - Utility class to represent expected object
[DOUGLAS2014]: Niall Douglas - Outcome 2.2 library
[CLINE] Marshall P. Cline, Greg A. Lomow - C++ FAQs: frequently asked questions
[GUILLEMOT2000]:Marc Guillemot - Catching Untested Return Codes
[SUTTER2018]: Herb Sutter - Zero-overhead deterministic exceptions: Throwing values

Copyright © Mircea Neacsu 2013-2026. All rights reserved.