Thinking in C++ Vol 2 - Practical Programming |
Prev |
Home |
Next |
In Chapter 7 we ll take an in-depth look at the containers
in the Standard C++ library, including the stack container. One thing
you ll notice is that the declaration of the pop( ) member function
looks like this:
You might think it strange that pop( ) doesn t
return a value. Instead, it just removes the element at the top of the stack.
To retrieve the top value, call top( ) before you call pop( ).
There is an important reason for this behavior, and it has to do with exception
safety, a crucial consideration in library design. There are different
levels of exception safety, but most importantly, and just as the name implies,
exception safety is about correct semantics in the face of exceptions.
Suppose you are implementing a stack with a dynamic array
(we ll call it data and the counter integer count), and you try
to write pop( ) so that it returns a value. The code for such a pop( )
might look something like this:
template<class T> T stack<T>::pop() {
if(count == 0)
throw logic_error("stack underflow");
else
return data[--count];
}
What happens if the copy constructor that is called for the
return value in the last line throws an exception when the value is returned?
The popped element is not returned because of the exception, and yet count
has already been decremented, so the top element you wanted is lost forever!
The problem is that this function attempts to do two things at once: (1) return
a value, and (2) change the state of the stack. It is better to separate these
two actions into two separate member functions, which is exactly what the
standard stack class does. (In other words, follow the design practice
of cohesion every function should do one thing well.) Exception-safe
code leaves objects in a consistent state and does not leak resources.
You also need to be careful writing custom assignment
operators. In Chapter 12 of Volume 1, you saw that operator= should
adhere to the following pattern:
1. Make
sure you re not assigning to self. If you are, go to step 6. (This is strictly
an optimization.)
2. Allocate
new memory required by pointer data members.
3. Copy
data from the old memory to the new.
4. Delete
the old memory.
5. Update
the object s state by assigning the new heap pointers to the pointer data
members.
6. Return
*this.
It s important to not change the state of your object until
all the new pieces have been safely allocated and initialized. A good technique
is to move steps 2 and 3 into a separate function, often called clone( ).
The following example does this for a class that has two pointer members, theString
and theInts:
//: C01:SafeAssign.cpp
// An Exception-safe operator=.
#include <iostream>
#include <new> // For std::bad_alloc
#include <cstring>
#include <cstddef>
using namespace std;
// A class that has two pointer members using the heap
class HasPointers {
// A Handle class to hold the data
struct MyData {
const char* theString;
const int* theInts;
size_t numInts;
MyData(const char* pString, const int* pInts,
size_t nInts)
: theString(pString), theInts(pInts),
numInts(nInts) {}
} *theData; // The handle
// Clone and cleanup functions:
static MyData* clone(const char* otherString,
const int* otherInts, size_t nInts) {
char* newChars = new char[strlen(otherString)+1];
int* newInts;
try {
newInts = new int[nInts];
} catch(bad_alloc&) {
delete [] newChars;
throw;
}
try {
// This example uses built-in types, so it won't
// throw, but for class types it could throw, so
we
// use a try block for illustration. (This is the
// point of the example!)
strcpy(newChars, otherString);
for(size_t i = 0; i < nInts; ++i)
newInts[i] = otherInts[i];
} catch(...) {
delete [] newInts;
delete [] newChars;
throw;
}
return new MyData(newChars, newInts, nInts);
}
static MyData* clone(const MyData* otherData) {
return clone(otherData->theString, otherData->theInts,
otherData->numInts);
}
static void cleanup(const MyData* theData) {
delete [] theData->theString;
delete [] theData->theInts;
delete theData;
}
public:
HasPointers(const char* someString, const int*
someInts,
size_t numInts) {
theData = clone(someString, someInts, numInts);
}
HasPointers(const HasPointers& source) {
theData = clone(source.theData);
}
HasPointers& operator=(const HasPointers&
rhs) {
if(this != &rhs) {
MyData* newData = clone(rhs.theData->theString,
rhs.theData->theInts,
rhs.theData->numInts);
cleanup(theData);
theData = newData;
}
return *this;
}
~HasPointers() { cleanup(theData); }
friend ostream&
operator<<(ostream& os, const HasPointers&
obj) {
os << obj.theData->theString <<
": ";
for(size_t i = 0; i < obj.theData->numInts;
++i)
os << obj.theData->theInts[i] << '
';
return os;
}
};
int main() {
int someNums[] = { 1, 2, 3, 4 };
size_t someCount = sizeof someNums / sizeof
someNums[0];
int someMoreNums[] = { 5, 6, 7 };
size_t someMoreCount =
sizeof someMoreNums / sizeof someMoreNums[0];
HasPointers h1("Hello", someNums,
someCount);
HasPointers h2("Goodbye", someMoreNums,
someMoreCount);
cout << h1 << endl; // Hello: 1 2 3 4
h1 = h2;
cout << h1 << endl; // Goodbye: 5 6 7
} ///:~
For convenience, HasPointers uses the MyData
class as a handle to the two pointers. Whenever it s time to allocate more
memory, whether during construction or assignment, the first clone
function is ultimately called to do the job. If memory fails for the first call
to the new operator, a bad_alloc exception is thrown
automatically. If it happens on the second allocation (for theInts), we must
clean up the memory for theString hence the first try block that
catches a bad_alloc exception. The second try block isn t crucial
here because we re just copying ints and pointers (so no exceptions will
occur), but whenever you copy objects, their assignment operators can possibly
cause an exception, so everything needs to be cleaned up. In both exception
handlers, notice that we rethrow the exception. That s because we re just managing resources here; the user still needs to know that something
went wrong, so we let the exception propagate up the dynamic chain. Software
libraries that don t silently swallow exceptions are called exception
neutral. Always strive to write libraries that are both exception safe and
exception neutral.
If you inspect the previous code closely, you ll notice that
none of the delete operations will throw an exception. This code depends
on that fact. Recall that when you call delete on an object, the
object s destructor is called. It turns out to be practically impossible to
design exception-safe code without assuming that destructors don t throw
exceptions. Don t let destructors throw exceptions. (We re going to remind you
about this once more before this chapter is done).
Thinking in C++ Vol 2 - Practical Programming |
Prev |
Home |
Next |