Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

You Can Program In C++ (2006) [eng]

.pdf
Скачиваний:
80
Добавлен:
16.08.2013
Размер:
4.33 Mб
Скачать

278

CHAPTER 15

T R Y T H I S

Here is some code that we are going to use to explore exceptions:

void bar( ){

chessboard::chessboard b(std::cin);

}

void foo( ){ chessboard board;

std::stringstream source;

source << "white king at (1, 2)"; create_piece(source, board);

// bar( );

}

int main( ){ try{

chesspiece::init_text_to_enum( ); foo( );

}

catch(chessboard::exception const & except){ std::cerr << except.report( ) << '\n';

}

}

First, implement the constructor for a chessboard object from an input stream as:

chessboard::chessboard(std::istream &):board( ){ throw chessboard::corrupt_stream( );

}

That stub function throws an exception so that we have an exception to use for test purposes. If you already have a definition for this constructor, you can add the throw statement at the beginning of your existing code until you are ready to incorporate data validation into your code.

Experiment 1

Create a suitable project, and copy the header and implementation files for chesspiece and chessboard across, so that we can modify them without changing the originals. (It would probably be wise to give the copies distinct names, to avoid confusion with the originals.) Edit the implementation of the chessboard::chessboard(std::istream &) constructor to the above version. Now build and execute the project, and look carefully at the output to note the calls of the documented constructors and destructors.

Experiment 2

Remove the comment in the definition of foo( ), to activate the call to bar( ). Build and execute the new version. Check that all the constructors and destructors are called as before, followed by the message generated by the catch clause.

EXCEPTIONS

279

Experiment 3

Replace main( ) with:

int main( ){ try{

chesspiece::init_text_to_enum( ); chessboard * pointer = new chessboard; delete pointer;

}

catch(chessboard::exception const & except){ std::cerr << except.report( ) << '\n';

}

}

Build and execute this program, and note the results.

Experiment 4

Add a call to bar( ) immediately before the delete pointer, so that the new version is:

int main( ){ try{

chesspiece::init_text_to_enum( ); chessboard * pointer = new chessboard; bar( );

delete pointer;

}

catch(chessboard::exception const & except){ std::cerr << except.report( ) << '\n';

}

}

Build and execute this program. Note that the program no longer executes the chessboard destructor. This is an important feature of exceptions: the program does not execute code subsequent to the exception if an exception occurs. However, handling an exception includes executing all the destructors for stack-based objects created between the catch point and the throw point. We call that process stack-unwinding, and it is executed in reverse order, starting at the object most recently constructed before the exception is thrown. We have to ensure the release of all dynamically allocated resources. Destructors are best for that process, because the exception mechanism executes them as control passes to the selected exception handler.

Experiment 5

Try this last version, where we replace the raw pointer by a std::auto ptr<>:

int main( ){ try{

chesspiece::init_text_to_enum( ); std::auto_ptr<chessboard> pointer(new chessboard); bar( );

}

catch(chessboard::exception const & except){ std::cerr << except.report( ) << '\n';

}

}

280

CHAPTER 15

Conclusion

The important point to note is that catching an exception results in a cleanup of the function-call stack between the point of the throw and the point where the exception is caught. Another point we should note is that an exception will drill straight through code, cleaning up on the way, even though there maybe no local indication that this might happen. Therefore, we have to develop sensitivity to where exceptions might interfere, and ensure correct cleanup of all the objects. In programming terminology, we must respect class invariants and maintain them wherever an exception may pass through.

This necessary extra care, coupled with ensuring the release of dynamically executed resources by destructors, has a serious impact on our coding style. We call such a style exception-safe programming. The most fundamental element of exception-safe programming is that we only allocate a resource dynamically if there is a destructor that releases the resource. Experiment 4 above demonstrates code that is not exception safe. Experiment 5 demonstrates how to rewrite the code to make it exception safe.

Here is an example to show another aspect of exception-safe programming. Here is a minimalist start for a string class. It uses C-style strings, and functions that handle those. In particular it uses std::strcpy( ) and std::strlen( ). The header <cstring> provides all the necessary declarations. I am not going into a great deal of detail, because most readers do not need to know the grisly details of working with C-style strings when std::string is so much more robust.

class mystr{ public:

mystr(char const *);

mystr(mystr const &); // copy constructor ~mystr( );

mystr const & operator=(mystr const &); // copy assignment // rest of public interface

private:

char * data_ptr;

};

Here is an implementation:

mystr::mystr(char const * d_ptr):data_ptr(new char[strlen(d_ptr) + 1]){ std::strcpy(data_ptr, d_ptr); // copy the array data_ptr[strlen(d_ptr)] = 0; // add null terminator

}

This constructor assumes that it receives a pointer to a C-style string (a null-terminated array of char). It then obtains enough dynamic storage to hold a copy of the string including the null terminator, and stores the address of that storage in data ptr.

The destructor releases the memory:

mystr::~mystr( ){delete[ ] data_ptr;}

This uses the correct version of delete for arrays (I have not gone into detail about creating and destroying dynamic arrays because we usually use std::vector<> to handle any requirement for a dynamic array). At first sight, everything is fine. Look again and you might realize that the default copy semantics will copy the pointer rather than the object it points to. Each mystr object will need its own copy of the string. We need to deal with the copy semantics of this class.

First the copy constructor:

mystr::mystr(mystr const & original)

:data_ptr(new char[strlen(original.data_ptr) + 1]){

EXCEPTIONS 281

std::strcpy(data_ptr, original.data_ptr); // copy the array data_ptr[strlen(original.data_ptr)] = 0; // add null terminator

}

We call this process deep copying: we have not copied data ptr; instead we have copied the data it points to.

¨

There are no exception problems yet. However, look carefully at this naıve implementation of the assignment operator:

mystr const & mystr::operator=(mystr const & rhs){

// careful not to do anything if the lhs and rhs are the same object: if(this != &rhs){

delete [ ] data_ptr; // get rid of the current array for the lhs // get storage for the copy of the rhs:

data_ptr = new char[std::strlen(rhs.data_ptr) + 1]; std::strcpy(data_ptr, rhs.data_ptr); data_ptr[std::strlen(rhs.data_ptr)] = 0; // null-terminate the copy

}

return *this;

}

This is the classic form for a user-written copy-assignment operator, and you will find it in numerous books. In the days before exceptions, it was acceptable. However, with the introduction of exceptions, it is completely unacceptable. The problem is that we have deleted the memory attached to data ptr before we have something to replace it. You may wonder why that matters. The problem is that something may go wrong with the attempt to get a new block of memory and initialize it with the copied data. If that should happen (yes, I know it is not likely for this simple case), the object you are assigning to is in an unstable state (the result of a delete operation is to place the pointer in a state where it must be written to before any further attempts to read it happen). As long as our use of new works, we will not have a problem, but if it fails, we have left an object in a state where we cannot safely destroy it. (You must not apply delete twice to the same pointer without an intervening call of new or some other action that provides a valid deletable pointer value. One such value is the null-pointer constant. Nothing happens if you try to delete a null pointer; the C++ language guarantees that.

The Exception-Safe Copy-Assignment Idiom

Here is mystr::operator=( ), rewritten so that it is exception safe:

mystr const & mystr::operator=(mystr const & rhs){ // get the new block of memory:

char * temp_ptr = new char[strlen(rhs.data_ptr) + 1];

std::strcpy(temp_ptr, rhs.data_ptr);

// copy the rhs data

temp_ptr[strlen(rhs.data_ptr)] = 0;

// null-terminate the copy

delete [ ] data_ptr;

// get rid of the current array for the lhs

data_ptr = temp_ptr;

// transfer the

ownership of the copy

return *this;

 

 

}

We no longer have to check that the leftand right-hand operands of the assignment are different; if they are the same, we will only have wasted a little time with an unnecessary copy, but we get that back by avoiding the time taken for checking in the normal case where the copy assignment has work to do.

Note how the idiom works: first copy; then delete; and then assign the pointer to the copy. Pointer assignment cannot throw an exception. We first do all the work where an exception might occur; then we

282

CHAPTER 15

finish the task. The consequence is that if there is a failure, the left-hand operand (the object we are assigning to) retains its old value.

Attention to exception safety usually results in simpler code because we remove the error-handling from the main flow of the code. However, we do need to be conscious of exceptions, and write code that functions correctly in their presence. Although the design idea in old C++ books may still be useful, the code implementing them is probably flawed and vulnerable to exceptions.

Rethrowing

Sometimes we want to partially process an exception and then relay it on to another handler to complete the task. Here is a rewrite of my earlier foo( ) function to demonstrate how this is done:

void foo( ){ try{

chessboard board; std::stringstream source;

source << "white king at (1, 2)"; create_piece(source, board); bar( );

}

catch(chessboard::exception const & except){

std::cerr << "Exception caught in foo( ) and rethrown.\n"; // you can place any special processing here

throw;

}

}

The statement consisting of the simple use of throw results in the rethrowing of the caught exception for further processing by another handler. Note that you are always allowed to terminate a catch clause by throwing an entirely different and possibly unrelated exception. Indeed, there is nothing special about the body of a catch clause; the code in it is just C++ code. The single difference is that a bare throw can be used only within the body of a catch clause.

T R Y T H I S

Experiment 6

Edit your code appropriately so that you can try this revised version of foo( ). Note the resulting final message that identifies the type of the actual exception object caught in main( ).

Experiment 7

Edit your code for Experiment 6 by changing the catch clause in foo( ) to

catch(chessboard::exception except){

so that the exception object is caught by value. Build and execute the resulting program. Note the change in the final message. This is an example of a process called slicing. Any time that you pass, return, or catch a reference or pointer by value, you lose the dynamic type information.

EXCEPTIONS

283

Exception Specifications: An Idea That Failed

It seems reasonable to provide a mechanism for declaring the exceptions that can propagate from a function. Indeed, it is so reasonable that C++ provides such a mechanism via exception specifications. C++ is not alone in this: several other languages, including Java, provide a similar mechanism. However, experience has shown that it is a poor idea, leading to numerous problems.

The idea is that the declaration of a function includes a specification of the exceptions that may propagate from it. The default specification is that any exception can propagate from the function. So when you see

void foo( );

you know that code that calls foo( ) must be prepared for any exception. At the other extreme, we have

void bar( ) throw( );

which specifies that no exceptions can propagate from bar( ). Those two extremes are both clear and possibly useful. There is still some argument as to whether the ‘throws nothing’ specification of bar( ) is useful in optimizing, but it certainly seems to be, at worst, harmless. The problems arise from everything in between, i.e. where there is a list of types in the parentheses following the throw. Such a list is supposed to specify the types of exception that may propagate from the function. The original idea was that this should be statically checkable and so enforceable at compile time. Unfortunately, though this was recognized as impossible from very early on, those responsible for the C++ Standard decided to persevere with runtime enforcement of exception specifications. Most experts these days believe that was a mistake.

You need to know what those mysterious throw( ) clauses are in function declarations because you may come across them, but that is about as far as you need to go with them. If you want to use the empty throw( ) specification, please do so. Personally, I do so when writing my own code.

Exceptions and Destructors

There is a small body of experienced programmers who argue that it is OK for an exception to propagate from a destructor. However, the very large majority maintain that a destructor should never propagate an exception.

There are several arguments, but one of the most persuasive is that an object has ceased to exist when its destructor is entered. So what do you have if the destructor does not complete because it throws an exception? Think carefully about that. In essence, whatever you can do should be done before returning from the destructor; return from a destructor should always be a simple return statement (possibly implicit).

If you use throw specifications, always add throw( ) to any destructor that you declare. If the compiler complains about the implementation, have a look and see what the problem is.

REFERENCE SECTION

Exceptions

C++ exceptions are based on three keywords: try, throw, and catch. The try keyword is used to warn the compiler (and, more to the point, tell human readers of the code) that the following block of code is followed by one or more exception handlers.

284

CHAPTER 15

We use the catch keyword to introduce an exception handler. A single ‘parameter’ in parentheses follows the catch, and that is followed by the body of the handler as a block of code (i.e. enclosed in braces).

If an exception propagates into the try block, the program searches for an appropriate handler from those offered immediately after the close of the try block. The program executes the first handler whose parameter can accept the type of the exception object. If none of the available handlers can accept the exception object, the program will propagate the exception object to the next-most-recent try block and try again. If the program cannot find an acceptable handler, it calls std::terminate( ). The exact behavior of std::terminate( ) is implementation-defined, but all you need to know here is that it ends the program. C++ provides a mechanism for modifying the behavior of std::terminate( ).

There is a special-case catch clause designed to catch all exceptions. This is introduced by catch(...) and can only be used to carry out processing that is independent of the type of the exception object. However, it can use throw to rethrow the exception it caught.

Exceptions are raised by using a throw statement at the point where the code detects a problem that needs handling elsewhere. A throw statement can throw an object of any copiable type. When an exception is raised in a program, normal processing is suspended, and the program uses some implementation-provided mechanism to find the most recent handler for the exception object. When such a handler has been found, the program unwinds the stack back to the location of the handler. The process of stack-unwinding involves calling the destructors for all stack-based objects between the point where the exception is raised and the point where it is handled. If no handler is found, it is implementation-defined whether the stack is completely unwound or left completely alone. An implementation is not allowed to partially unwind the stack and then terminate the program.

Exception Specifications

C++ provides a mechanism for decorating function declarations with a list of the types of exception that may propagate from a call to it. The syntax is simple: add throw immediately after the parenthesis that closes the parameter list, and follow it with a list of types in parentheses. For example,

void foo( ) throw(std::exception);

specifies that only objects whose type is, or is derived from, std::exception may propagate from foo( ). Such a specification is checked dynamically (i.e. at execution time) in the event of an exception propagating from foo( ). If it does not meet the provided specification, std::unexpected( ) is called. The default action is to call std::terminate( ). C++ provides a mechanism for changing this default behavior. However, making such a change requires advanced understanding of C++.

Experience has led most experts to recommend that programmers do not use exception specifications. The special case of an empty specification (no exception can propagate from the function) is generally considered the only exception specification worth providing.

C H A P T E R 16

Overloading Operators

and Conversion

Operators

C++ inherited a large number of operators from C, and then proceeded to identify several other things as operators. For example, C++ treats ( ) as a function operator and [ ] as a subscript operator. C++ allows overloading of most of its operators; whether doing so is useful depends on the context. The overloading of some operators is restricted to class scope, but most of them can be overloaded at global or namespace scope. The single absolute requirement for overloading an operator is that at least one operand must be of a user-defined type.

We have had examples of operator overloading elsewhere, but this chapter provides more examples and more depth. Even so, this will be far from comprehensive, because overloading operators in C++ is rich with potential. For example, C++ allows us to overload operator new (the mechanism that new uses to acquire memory in which to construct a dynamic object) and operator delete (which releases memory after a dynamic object is destroyed). It also provides for overloading the operators used by the array versions of new and delete (operator new[ ] and operator delete[ ]). I am leaving such topics for another book.

Overloading Operators for an Arithmetic Type

(Please treat the whole of this section as an extended experiment; in other words, I expect you to work through this with your compiler. Feel free to experiment further until you understand what is happening. If you want to instrument the constructors, you will need to declare a destructor and a copy constructor, so that you can complete the instrumentation in their definitions.)

Suppose that we want to provide a rational-number type (for non-mathematicians, rational numbers are ones that can be written as the ratio of two whole numbers). We would want to provide all the normal arithmetic operations. We would also probably want to provide some form of conversion to a floating-point type. Providing a complete definition and implementation for such a type would be lengthy and of little general interest; however, there is much we can learn from a partial implementation. Here is a starter definition that you can copy into a suitable header file (rational.h). Do not forget to add a header guard.

class rational{ public:

rational( );

rational(long numerator, long denominator = 1);

// compiler-generated copy constructor, copy assignment and destructor OK long numerator( )const;

286 CHAPTER 16

long denominator( )const;

// other functions to be added struct exception{ };

struct divide_by_zero: exception{ }; private:

long d; long n;

};

I have not qualified the rational constructors as explicit. That is intentional: implicitly converting a long int to a rational with a denominator of 1 is reasonable, and most domain experts would expect it.

Here is a simple initial implementation that you can place in rational.cpp:

#include "rational.h"

rational::rational( ):n(0), d(1){ }

rational::rational(long numer, long denom):n(numer), d(denom){ } long rational::denominator( )const{return d;}

long rational::numerator( )const{return n;}

When I first wrote the implementation, I was plagued by an error message when I tried to compile it. If you want to see it, replace the parameter names in the second constructor with numerator and denominator. It seems that (at least for this compiler) the member-function names are hiding the parameter names when it comes to the initializers. I mention this because one day you may be baffled by a similar case.

Test Code

Here is a short test program for testrational.cpp (or whatever you choose to call it):

int main( ){ try{

rational r;

std::cout << r.numerator( ) << "/" << r.denominator( ) << '\n'; r = 2;

std::cout << r.numerator( ) << "/" << r.denominator( ) << '\n';

}

catch(rational::exception const & r){

std::cerr << "Exception from rational caught.\n";

}

}

Note that the assignment works because the compiler is doing two things under the covers. First, it generates a copy-assignment operator that copies the data from a right-hand operand of type rational to a left-hand operand of that type. Next, it looks to see whether it can convert 2 into a rational. Our second constructor does that by allowing a call of rational(2, 1), using the provided default value for denominator. Therefore, the compiler creates a temporary rational from 2, and then copies it to the left operand of the assignment. We do not need to provide member functions to write the numerator and denominator values to a rational. If we want to change the denominator of a rational without changing the numerator, we write something like:

r = rational(r.numerator( ), new_denom);

You can add member functions to modify the numerator and denominator some time later if you discover that you need the more direct method for efficiency. Changing a rational that way is unusual, so it is unlikely that such member functions will ever be critical to the overall performance of the rational type.

OVERLOADING OPERATORS AND CONVERSION OPERATORS

287

Providing a Streaming Operator for Output

The two output lines in the test code suggest a suitable format for an operator<<, but we cannot provide that as a class member because the first operand of such an operator is of the wrong type (an ostream &). So add

std::ostream & send_to(std::ostream &)const;

to the class definition, and add this to the class implementation:

std::ostream & rational::send_to(std::ostream & out)const{ out << n << "/" << d;

return out;

}

Now you can add

inline std::ostream & operator<<(std::ostream & out, rational const & r){ return r.send_to(out);

}

to rational.h and amend the test program to see that it works.

EXERCISE

1.Before reading the next part, please try to declare and implement a corresponding input operator (>>) that will read data in the same format that we have used for output. (A programmer would expect the provision of either both or neither of a pair of operators such as those for input and output. We call this ‘the principle of minimal surprise’.)

Providing a Streaming Operator for Input

Add a declaration of get from(istream &) to the definition of rational (note that you will need to include <istream> for the declaration of std::istream):

std::istream & get_from(std::istream &);

Add the following definition to the implementation of rational:

std::istream & rational::get_from(std::istream & in){ in >> n;

if(in.get( ) != '/') throw rational::bad_data( ); in >> d;

if(not in) throw rational::bad_data( ); return in;

}

If you have been following the ideas of using exceptions, you will realize that we need to add