by John M. Dlugosz
In this article, we'll take a look at the basics of exception handling and some of the underlying concepts it embodies. This article isn't a treatment of error-handling techniques; instead, we'll explain what the try/throw/catch construct does on a fundamental level. After reading this article, you should have a full understanding of the semantics of using this exotic control structure and of the basic implementation syntax.
The syntax of try/catch is fairly simple. The
semantics involve two concepts new to C++: dynamic scope
and stack unwinding. We'll look at both concepts
in more detail laterbut first, let's take a look
at the syntax.
A try/catch control structure is written
try { statements... } catch (parameter) { handler... } catch (parameter) { handler... }
The first part of the structure is called a try block. You can put any statements inside the block controlled by the try keyword, and those statements will be protected. Normal scope rules apply with respect to the use of curly braces, so any variables you define within the try block are local to that block.
One or more error handlers follow the try block, using syntax that looks similar to a function definition. A parameter list with a single parameter follows the keyword catch. After the parameter list comes a block containing arbitrary code.
Meanwhile, you use a throw expression to initiate an
exception. The throw keyword takes an argument, which
is matched with the parameter of the proper handler, as we'll
explain in the next section. Listing A
contains a complete program that illustrates this syntax.
Listing A: An example of the try/catch control structure
#include <iostream.h> #include <stdlib.h> int divide (int top, int bottom) { if (bottom==0) throw "divide by zero error!"; return top/bottom; } int main (int argc, char* argv[]) { int a= atoi(argv[1]); //first command-line parameter int b= atoi(argv[2]); //second command-line parameter cout << "attempting to divide " << a << " by " << b << endl; try { int result= divide (a,b); cout << "The result is: " << result << endl; } catch (const char* message) { cout << "error detected. Message is: " << message << endl; } cout << "program done.\n"; return 1; }
Normally, C++ uses what's known in computer science circles as lexical scope. In other words, the contextual meanings of variables and other symbols are matched to their definitions based on rules involving the text of the source code. Symbols are bound at compile-time. For example, if you have several variables called x in a program, it's simple to tell which x you mean at any point. When the program uses x, the compiler first looks for definitions local to the block in which x is used. It then looks outward to enclosing blocks within the function, then at class scope, and finally for global variables. The compiler matches the usage of a symbol with that symbol's definition based on a static analysis of the source codein short, on where the symbol is written in the program's text.
This idea of lexical scope is so ingrained in C++ and other languages, such as Pascal and Ada, that any other kind of scope can seem alien to developers. Naturally, if you hear the concept of dynamic scope explained only in computer-science terminology, it can be baffling. In some languages, such as Lisp and Perl, dynamic scoping is part of the design. But you can also find the concept in C++, by analogy with other programming.
The ANSI/ISO C++ specification says, "When an exception is thrown, control is transferred to the nearest handler with an appropriate type; 'nearest' means the handler whose try block was most recently entered by the thread of control and not yet exited...." So, the very definition of C++ states that the binding of throws to catches is temporal, not lexical. That is, it depends not on where the binding is written in the program, but on the flow of execution within the program up to that point.
To understand this concept, don't look for other name-binding rules in C++. Instead, look at the runtime sequence of events. The program in Listing B, illustrates the idea of temporal binding. A function will read the value of context as it was last set. So, foo() will read context as different values at different times, based on where context is called from and how execution reached that point.
Listing B: An example of temporal binding
#include <iostream.h> class context { static const context* current; const context* const previous; const char* name; public: context (const char* name); ~context(); static void print(); }; context::context (const char* name) : name(name), previous(current) { current= this; } context::~context() { current= previous; } void context::print() { cout << "Context is: " << current->name << endl; } const context* context::current= 0; //////////////////////////////////////////// void foo() { cout << "(foo) "; context::print(); } void baz() { context x ("local to baz"); foo(); // x goes out of scope here. } void bar() { foo(); //call foo, which is context-aware context x ("local to bar"); foo(); //same function foo called; knows the new context. baz(); // x goes out of scope here, and old context is restored. } int main() { context x ("main"); foo(); bar(); baz(); return 0; } /* output is: (foo) Context is: main (foo) Context is: main (foo) Context is: local to bar (foo) Context is: local to baz (foo) Context is: local to baz */
The creation and destruction of context variables mimics
the way try blocks work. When a program enters a try
block during execution, the program remembers the block in a stack.
When the program exits the try block, the block is popped
from the stack. To search for a handler when your program throws
an exception, the program searches the items on the stack from
nearest to farthest (using the ANSI specification's definition
of "nearest").
The other important principle to understand is stack unwinding. Something very powerful happens when a program throws an exceptionit's more than C's longjump() could accomplish. The general idea hinges on one fundamental concept: A program will call destructors for local variables upon leaving a block. You've seen this happen when control returns from a function or simply flows out of a local block. But the fundamental concept applies to any manner of leaving the block, as you can see in Figure A.
Figure A - This code illustrates how a program calls destructors for local variables.
void foo () { if (test()) { SomeClass test_object; if (a==b) return; //test_object is destructed. if (a==c) goto exit: //test_object is destructed. if (a==d) throw 42; //test_object is destructed. bar(); //do some meaningless work... } // test_object is destructed as control // flows off the end of block. exit: bar(); }
No matter how control leaves the scope of test_object, the program will call the variable's destructor. When the C++ standardization committee introduced exceptions to C++, it introduced the concept of "unwinding" to do the same thing. Now an exception can exit several scopes in one fell swoop, but so can a goto (or its cousins, break and continue). Figure B shows an example.
Figure B - The goto exits three blocks and calls destructors for three objects.
{ SomeClass object1; if (whatever()) { SomeClass object2; for (int loop= 0; loop < max; loop++) { SomeClass object3; if (whoops()) goto panic; } } } panic: do_something();
The goto will exit three local blocks and cause the program to call the destructors of three objects, each in a different scope. An exception is no differentit exits one or more scopes and causes the program to call destructors. The real difference is that you don't know where the exception will end up until runtime. But even so, the exception must return to its previous locationan outer level or calling function.
We've presented only the general idea. Explaining the specifications
of stack unwinding can be quite complexwe need to deal
with the loose ends while explaining the concept in a more abstract
way, since not every scope is formed by blocks in this manner.
Figure C - Our exception program yields this output.
entering foo. Object B constructed at 0x0012ff70 Note 1 Object B constructed at 0x0012ff24 Note 2 Object B constructed at 0x0012ff28 Note 2 Object B constructed at 0x0012ff2c Note 2 in the body of C's constructor with parameters (4,5,6) I constructed an object of type C named mort. Note 3 destructing an object of type C Destructed a B object at 0x0012ff2c Note 4 Destructed a B object at 0x0012ff28 Note 4 Destructed a B object at 0x0012ff24 Note 4 Destructed a B object at 0x0012ff70 Note 5 entering foo. Note 6 Object B constructed at 0x0012ff70 Note 7 Object B constructed at 0x0012ff24 Note 8 Destructed a B object at 0x0012ff24 Note 9 Destructed a B object at 0x0012ff70 Note 10 caught an error: B's constructor fails Note 11
Often the best way to understand something is to see it in action.
Listing C provides a detailed dissection of an exception
taking place. Figure C
shows the program's output; it includes references
to several notes in Table A.
Table A: Program output notes
Note 1 | The constructor for the local variable in foo. |
Note 2 | The members of mort being constructed in bar. |
Note 3 | The function bar is about to return, so the program will call mort's destructor. |
Note 4 | The destructors for mort's members. |
Note 5 | The destructor for the local variable in foo. |
Note 6 | Normally, functions call functions, and the program calls constructors for local variables and objects nested within them. On exit, the program calls context destructors. Now we run through the process again, but this time an error occurs. |
Note 7 | The constructor for the local variable in foo. |
Note 8 | The constructor for the first member (sub_1) of mort in bar. Next, the program calls the constructor for the second member (sub_2) of mort. However, an error arises from these lines:
if (x < 0) //a pre-condition check throw simple_exception ("B's constructor fails"); cout << "Object B constructed at " << (void*)this << endl;The program doesn't print Object B constructed at... because it detects the error first. The throw statement aborts the constructor, and execution never reaches the cout line. |
Note 9 | This is stack unwinding in action. The program destroys the first member of mort (sub_1). This undoes the partial construction of mort; the program can't destroy mort, since it wasn't fully constructed. The program will destroy only parts that it successfully constructed. So, the program tears down sub_1, but not sub_2 and sub_3. |
Note 10 | The program calls the destructor for the local variable in foo, because the program declared the variable inside the try block, and the try block is now exiting. However, the stack unwinding doesn't stop at the associated catch handler. It isn't of the proper type (it wants an int), so the program passes it over. The search continues outward, back the way it came. |
Note 11 | The program finds the catch handler in main. The parameter is suitable, so the program uses this catch block. The object thrown is a derived class (simple_exception) to the base class mentioned in the catch parameter list (exception_base). But the catch parameter is a reference, so the program can control the actual thrown object polymorphically. Note that message() works as a virtual function. This alludes to a future topic: how objects are transported from the throw expression to the catch point. |
Understanding exception handling means understanding exactly what the try/throw/catch construct does. Dynamic scope can provide for flexible error handling by letting you write a function without explicit control over how the program handles its errors, thus letting the caller decide how to deal with errors. Given the basic principles of C++, once we introduce dynamic scope, understanding the concept of stack unwinding becomes essential. In a future article we'll discuss how code can fail to be "exception safe."
John M. Dlugosz is a C++ consulting expert, developer, and prolific writer. He's a member of Borland's TeamB, he leads the C++ Study Group on CompuServe, and he's the author of C++: A Guru's Handbook. You can reach John via CompuServe at 70007,4657, on the Internet at jdlugosz@dlugosz.com, or on the Web at http://www.dlugosz.com/~jdlugosz/.
Copyright (c) 1996 The Cobb Group, a division of Ziff-Davis Publishing Company. All rights reserved. Reproduction in whole or in part in any form or medium without express written permission of Ziff-Davis Publishing Company is prohibited. The Cobb Group and The Cobb Group logo are trademarks of Ziff-Davis Publishing Company.