Lesson #18

Overview of Exception Handling

Overview

In this lesson I will cover the following topics

  1. "Old fashion" error handling.

  2. C++ exceptions.

  3. Exceptions and destructors.

  4. Exceptions and inheritance.

Body

Error handling the C way

Handling errors gracefully is difficult. Error conditions are often just ignored in order to make the programming easier and less confusing. Yet robust programs don't have the luxury of ignoring errors.

There are many ways an error might be handled.

  1. A message might be displayed to the user, either in the terminal or, in the case of a graphical interface, in a message box of some kind.

  2. A message might be written to a log file instead of (or in addition to) being presented to the interactive user.

  3. The operation that failed might be retried after the program, or the user first performs some sort of corrective action.

  4. The program might terminate immediately, or it might continue with reduced functionality.

There is no right way to handle an error in general. The best approach depends on the application. The problem is that most errors are noticed in library functions that are used in many applications. For example, suppose that you want to open a file. To do this in a C program, you would normally use the function fopen from the standard library. Yet fopen can't take it upon itself to decide how to best handle errors. Any one of the options above (and probably some other options as well) are all potentially reasonable responses. If the fopen function did something appropriate for one application it would very likely be totally inappropriate for another.

To deal with this, most functions that have the potential of failing simply return a special "error code" to the function that called them. Nothing is done about the error at the point where the error is detected. Instead, the function just tells its caller, in effect, "I wasn't able to do what you asked... make of it what you will." For example, the fopen function returns the NULL pointer if it fails.

#include <stdio.h>

int main( void )
{
    FILE *input;

    input = fopen( "afile.txt", "r" );
    if (input == NULL) {
        printf( "The open operation failed\n" );
    }

    // etc...
}

The fopen function detects the error, but doesn't know what to do about it. The main function in this application handles the error by printing a short message on the terminal. In a different application the error might be handled in a different way.

This method of dealing with errors works and it can be effective. But it has several problems.

  1. The error code that the function returns has to be something different from any legitimate return value so that it can be distinguished from the legitimate values. The NULL pointer is often used as an error code for functions that return pointers. The values 0 or -1 are often used as error codes for functions that return integers. If all the possible return values are potentially legitimate, the function has to use some other means for getting the error code back to the caller (global variable, pointer parameters, return a structure, etc.). These other means are often awkward.

  2. Usually the error code just informs the caller that the function failed, but it typically doesn't give much other information. If the caller wants to know the details about why the function failed, some additional mechanism must be used.

  3. Often the function that receives the error code does not know what to do about the error either. In that case, it has to return its own error code to its caller. This "passing back" of error codes is annoying to program. Most programmers don't bother checking for error codes on all the functions they use, and thus many errors go undetected. This causes the program to do odd things when an error situation occurs.

  4. If a function owns resources (like memory, open files, network connections, etc.), it needs to be sure to properly release all of those resources when an error is detected. Programming this correctly can be tricky. Many programs behave poorly when an error occurs.

Let me elaborate a bit on points #3 and #4 above. Here is some C code that opens an input file and an output file. Notice that if the output file fails to open, it is necessary to close the already opened input file.

FILE *input, *output;
int   rc = -1;   // A return code of -1 means an error occurred.

if( ( input = fopen( "afile.txt", "r" ) ) == NULL ) {
    printf( "Can't open input\n" );
}
else if( ( output = fopen( "bfile.txt", "w" ) ) == NULL ) {
    printf( "Can't open output\n" );
    fclose( input );
}
else {
    // Main part of function.

    // Clean up.
    fclose( input );
    fclose( output );

    // Success!
    rc = EXIT_SUCCESS;
}
return rc;

This code both prints an error message and returns an error code if an error is detected. Depending on where this code is used, printing error messages here might or might not be appropriate.

Many programmers accidentally leave out the fclose(input) when the output file fails to open. This leaves a resource (an open file) "dangling." Notice also that dealing with all the error conditions causes this code to be a contorted mess of if... else if... statements. Wouldn't it be nicer to just write

input  = fopen( "afile.txt", "r" );
output = fopen( "bfile.txt", "w" );

// Main part of function, etc...

Although most programmers do check to see if files open okay, there are many situations where programmers ignore error checking because it's just too annoying to worry about. For example, did you know that printf returns an error code if it fails to print everything you asked? How many times have you checked it?

The problem of error handling in C++

C++ has even bigger problems with error handling than C does.

  1. Operator overloading makes it difficult, if not impossible, to check for error codes being returned from functions. For example, suppose all the objects below are an abstract type BigInt:

    X = (A + B)/(C - D);
    

    How can you check for an error code being returned by the + and - operators? You can't. If you want nice expressions like this to work, you have to give up on the idea of returning error codes from the functions.

  2. Constructors are used in places where it's quite impossible to check for returned error codes (even if constructors had return values---which they don't). For example, suppose function f takes a string by value. Now consider

    f( "Hello, World" );
    

    The compiler creates a temporary, anonymous string from the character pointer "Hello, World." The function then uses that string. What if the constructor fails? How could one check for that? Something like this doesn't work either

    string temp = "Hello, World";
      // Make sure the construction of temp worked okay.
    
    f( temp );
    

    In this example, the parameter of f is initialized from temp using the copy constructor. What if the copy constructor fails?

  3. Templates mean that in general, you don't know how to check for an error. For example, suppose you did this:

    vector<BigInt> my_vector;
    BigInt         sum = 0;
    
    sum = accumulate( my_vector.begin( ), my_vector.end( ), sum );
    

    The accumulate algorithm adds up all the items in the given sequence, along with the given initial value, and returns the total sum. In this example, I'm trying to add up all the BigInt values in my vector. But what if one of those additions fail? The accumulate template can't really handle it because it doesn't know what type it is working with. The addition of BigInt might indicate an error one way, but the addition of some other type might indicate an error in a different way. The only thing accumulate can do generically is just ignore the whole issue.

The old method for handling errors in C doesn't work well in C++ because C++ does more automatically. The language doesn't give you the opportunity to insert checks for error conditions everywhere, even if you wanted to.

C++ exceptions

C++ deals with all the problems I mentioned above using exceptions. In C++ you can "throw" an exception when you encounter an error condition. Here is how it looks:

void f( )
{
    // etc...

    if( something_bad( ) )
        throw "Something bad happened!";

    // etc...
}

Here function f returns void. It can't return an error code. However, it can still report an error condition by throwing an exception. Inside function f, when an error is detected, it executes a throw statement. In my example above it throws a pointer to constant character (string literals have type const char * in C++). When the throw executes, the function ends. The statements after the throw don't execute. Of course, in my example above, the throw is only executed conditionally. If the error condition is not detected, the throw is skipped and the function continues normally.

You can actually throw any object you like. You can throw an integer, a double, or a pointer. You can even throw large, complex objects like structures, or other user-defined types.

Throwing an exception is one thing. How is it handled? Look at this example below. The function f is the same function that I show above.

void g( )
{
    // etc...
    f( );
    // etc...
}

void h( )
{
    try {
        // etc...
        g( );
        // etc...
    }
    catch( const char *message ) {
        cout << "Exception caught: " << message << endl;
    }
    catch( ... ) {
        cout << "Unknown exception caught!" << endl;
    }
}

The function h knows enough about the application to know how to handle error conditions. It defines a "try block" where it executes the various operations that might throw an exception. Below the try block it installs some "exception handlers". The exception handlers all begin with the keyword catch.

In my example, the first handler says that it can handle exception objects of the type pointer to constant character. The second handler uses the special "..." symbol to indicate that it can handle any kind of exception. What happens when these exceptions occur is defined in the body of each handler.

When function h executes normally, the try block runs and the handlers are just skipped. Notice how function g is called in the try block. Notice how function g in turn calls function f. Suppose function f detects an error condition. It then throws its exception object of type pointer to constant character. Since there are no handlers in function f, the program immediately pops back into g where it looks for an appropriate handler. However, there are no handlers in g either, so function g ends at once, and the program pops back into function h. This time there are handlers and, in fact, there is a handler for pointer to constant character exceptions. The exception object is used to initialize the parameter of the handler and the handler executes. In my example, it just prints out a silly message. In a real program it might do any number of things in response to the error.

The order in which the exception handlers are defined is significant. The "..." catch-all handler would also deal with pointer to constant character exceptions, but since it comes later it is not checked. The first handler that can process an exception is used. In my example, the catch-all handler would process all other exception types.

There are several nice things about this.

  1. The main logic of the functions is relatively unobscured by error handling code. In function g there is no mention of exceptions at all. Function g can act as if function f works; it does not need to put the call to f inside an if statement. Even in function h, the try block is not interrupted by the error handling. All the error handling logic is after the try block in the exception handlers. This organization makes the functions easier to read. It also means the error conditions in operator functions and constructors that appear in the try block can be handled the same way as error conditions in the ordinary functions.

  2. Since it is possible to throw any type of object you desire, there is no problem sending detailed information from the point where the error is detected to the handler. In many applications, a structure or class is defined just for this purpose. When an error is detected, an instance of that structure or class is filled with appropriate values and then thrown as a unit. The handler can look at the members of the exception object to find out more about what happened.

  3. The catch-all handler can process exceptions of unexpected types. In a large program, there may be many types of exception objects being thrown---including types the application programmer might not know about (for example, exceptions coming our of a third party library that was recently modified). The catch-all handler allows the program to deal with these errors to some extent even without knowing they exist.

Exceptions and destructors

It turns out that the real beauty of exceptions is in how they work with the rest of C++. When an exception passes (or propagates) through a function on its way to the handler, all local objects in that function are destroyed properly using the destructors of those objects. This is very important. Consider this example.

void f( )
{
    vector<string> temp_vector;

    // Fill temp_vector with lots of stuff.

    g( );
      // Call function g to do something useful.

    // Work with temp_vector some more.
}

Suppose function g fails and throws an exception. Since there are no handlers in f, the exception will propagate through f looking for a handler in whatever function called f. However, before the exception leaves f, the destructor of temp_vector will be invoked. This is totally appropriate and desirable. The exception will cause function f to end. It is necessary to properly destroy temp_vector so that the memory allocated by it will be recovered.

In general, your program will allocate, use, and release many resources during its lifetime. Memory is one of the most common and most important resources your program will use. Other examples of resources include open files, handles to graphical environments, locks of various kinds, and network connections. It is important that you properly release all resources that you acquire. If your program fails to do that, it is said to "leak" resources. Eventually, a program that leaks resources will be unable to function. It may even crash the system in some extreme cases.

In C, without exceptions, you must write relatively contorted code to release resources when errors occur. Even in some languages with exceptions, handling this issue properly for resources other than memory (where garbage collection is often used) can be difficult. In C++, however, if you acquire resources in the constructor of an object and release those resources in the corresponding destructor, your program will never leak. The destructors are automatically executed when a function returns normally and when a function ends due to an exception. Consider the follow example

void f( )
{
    string string1;
    // ...
    g();
    // ...
    string string2;
    // ...
}

In this case, if g throws an exception, only string1 is destroyed. This is because at the time when g is executing only string1 has been constructed. The second string does not yet exist and thus destroying it would be inappropriate. The compiler ensures that only the objects that have been fully constructed are destroyed when an exception occurs.

As a further example, consider the case when a constructor throws an exception. Since any base sub-objects and members have already been constructed when an object's constructor is executing, the compiler ensures that the base sub-objects and members are properly destroyed in this case. However, if an exception is thrown in a constructor, the destructor for the object being constructed is not executed. This is because that object has not yet been fully constructed (construction isn't complete until the constructor returns normally). Thus, if you do throw an exception in a constructor, you need to be sure that any resources you've acquired for the object are released first. I will return to this topic in the next lesson.

Exceptions and inheritance

Exceptions can also be used effectively with inheritance. It turns out that it is possible to catch an exception by reference. This means that a single handler can catch and process exception objects from a set of related error classes. Suppose you defined the following classes specifically to use for exceptions.

// Generic error class.
class Error {
public:
    virtual string message( ) = 0;
};

// Describe file errors.
class FileError : public Error {
public:
    virtual string message( );
private:
    // etc...
};

// Describe network errors.
class NetworkError : public Error {
public:
    virtual string message( );
private:
    // etc...
};

My FileError class supports the generic Error interface. It would also have members that are specific to the sort of errors you can get with files. To keep things simple, I don't show any of those members. Similarly, the NetworkError class would contain additional members that support network-specific errors. I did not show those members either.

Now suppose I just want to catch errors generically in a certain function. I can do:

void f( )
{
    try {
        // Do stuff.
    }
    catch( Error &bad_thing ) {
        cerr << "Exception caught: " << bad_thing.message( ) << endl;
    }
}

Suppose that inside the try block a FileError is thrown. Since FileError is derived from Error, my handler for Error will catch the FileError exception too. When I invoke the virtual method message on the reference to Error, it will actually execute the message method in the FileError exception object. This is, of course, just the usual mechanism for handling virtual functions.

The C++ standard defines a standard set of related exception types that you can use in your program. By deriving your own exception types from the standard types, you make it possible for other code to catch your exceptions without knowing anything about your exceptions. This is a very powerful facility, and we will look at it in more detail in a later lesson.

Summary

  1. The function that detects an error condition is not usually the function that knows how to best deal with the error. In a C program, functions that detect errors typically return an error code to their caller. The calling function can then either handle the error or pass an error code back to its caller. This approach is workable, but it does require careful attention to detail. Functions must be sure to properly release resources after an error is encountered, and they must rigorously check all the functions they call for error returns.

  2. In C++, a function can throw an exception when it detects an error condition. If the calling function does not handle the exception, it will be automatically terminated and the next higher calling function will be searched for a handler. The function that knows how to deal with the error can install an appropriate exception handler for it. All the functions between the point of the error and the handler can (ideally) just ignore the error condition entirely.

  3. When a function ends because it (or one of the functions it calls) throws an exception, the destructors of all the local objects are invoked. This allows any resources that those objects are holding to be properly released. This is a very important facility because it helps programs from leaking resources (particularly memory) while trying to handle an error.

  4. Using inheritance, you can define families of types just for error handling. A function can catch the base type by reference as a way of dealing generically with an entire family of related error conditions. The C++ standard provides several classes just for this purpose.

© Copyright 2023 by Peter Chapin.
Last Revised: August 7, 2023