Call it overselling, but we'll tell you up front: we have killer material for this article. This is only because I convinced my good friend Petru Marginean to be my coauthor. Petru has developed a library facility that is helpful with exceptions. Together, we streamlined the implementation until we obtained a lean, mean library that can make writing exception-safe code much easier.
Let's face it, writing correct code in the presence of exceptions is a not an easy task. Exceptions establish a separate control flow that has little to do with the main control flow of the application. Figuring out the exception flow requires a different way of thinking, as well as new tools.
Writing Exception-Safe Code Is Hard: An Example
Let's say you are developing one of those trendy instant messaging servers. Users can log on and off the system and can send messages to each other. You hold a server-side database of users, plus in-memory information for users who are logged on. Each user can have friends. The list of friends is also kept both in the database and in memory.
When a user adds or removes a friend, you need to do two things: update the database and update the in-memory cache that you keep for that user. It's that simple.
Assuming that you model per-user information with a class called User
and the user database with a UserDatabase
class, the code for adding a friend might look like this:
class User { ... string GetName(); void AddFriend(User& newFriend); private: typedef vector<User*> UserCont; UserCont friends_; UserDatabase* pDB_; }; void User::AddFriend(User& newFriend) { // Add the new friend to the database pDB_->AddFriend(GetName(), newFriend.GetName()); // Add the new friend to the vector of friends friends_.push_back(&newFriend); }
Surprisingly, the two-liner User::AddFriend
hides a pernicious bug. In an out-of-memory condition, vector::push_back
can fail by throwing an exception. In that case, you will end up having the friend added to the database, but not to the in-memory information.
Now we've got a problem, haven't we? In any circumstance, this inconsistent information is dangerous. It is likely that many parts of your application are based on the assumption that the database is in sync with the in-memory information.
A simple approach to the problem is to switch the two lines of code:
void User::AddFriend(User& newFriend) { // Add the new friend to the vector of friends // If this throws, the friend is not added to // the vector, nor the database friends_.push_back(&newFriend); // Add the new friend to the database pDB_->AddFriend(GetName(), newFriend.GetName()); }
This definitely causes consistency in the case of vector::push_back
failing. Unfortunately, as you consult UserDatabase::AddFriend
's documentation, you discover with annoyance that it can throw an exception, too! Now you might end up with the friend in the vector, but not in the database!
It's time to interrogate the database folks: "Why don't you guys return an error code instead of throwing an exception?" "Well," they say, "we're using a highly reliable cluster of XYZ database servers on a TZN network, so failure is extremely rare. Being this rare, we thought it's best to model failure with an exception, because exceptions appear only in exceptional conditions, right?"
It makes sense, but you still need to address failure. You don't want a database failure to drag the whole system towards chaos. This way you can fix the database without having to shut down the whole server.
In essence, you must do two operations, either of which can fail. If either fails, you must undo the whole thing. Let's see how this can be done.
Solution 1: Brute Force
A simple solution is to throw in (sic!) a try
-catch
block:
void User::AddFriend(User& newFriend) { friends_.push_back(&newFriend); try { pDB_->AddFriend(GetName(), newFriend.GetName()); } catch (...) { friends_.pop_back(); throw; } }
If vector::push_back
fails, that's okay because UserDatabase::AddFriend
is never reached. If UserDatabase::AddFriend
fails, you catch the exception (no matter what it is), you undo the push_back
operation with a call to vector::pop_back
, and you nicely re-throw the exact same exception.
The code works, but at the cost of increased size and clumsiness. The two-liner just became a ten-liner. This technique isn't appealing; imagine littering all of your code with such try-catch statements.
Moreover, this technique doesn't scale well. Imagine you have a third operation to do. In that case, things suddenly become much clumsier. You can choose between equally awkward solutions: nested try
statements or a more complicated control flow featuring additional flags. These solutions raise code bloating issues, efficiency issues, and, most important, severe understandability and maintenance issues.
Solution 2: The Politically Correct Approach
Show the above to any C++ expert, and you're likely to hear: "Nah, that's no good. You must use the initialization is resource acquisition idiom [1] and leverage destructors for automatic resource deallocation in case of failure."
Okay, let's go down that path. For each operation that you must undo, there's a corresponding class. The constructor of that class "does" the operation, and the destructor rolls that operation back. Unless you call a "commit" function, in which case the destructor does nothing.
Some code will make all this crystal clear. For the push_back
operation, let's put together a VectorInserter
class like so:
class VectorInserter { public: VectorInserter(std::vector<User*>& v, User& u) : container_(v), commit_(false) { container_.push_back(&u); } void Commit() throw() { commit_ = true; } ~VectorInserter() { if (!commit_) container_.pop_back(); } private: std::vector<User*>& container_; bool commit_; };
Maybe the most important thing in the above code is the throw()
specification next to Commit
. It documents the reality that Commit
always succeeds, because you already did the work Commit
just tells VectorInserter
: "Everything's fine, don't roll back anything."
You use the whole machinery like this:
void User::AddFriend(User& newFriend) { VectorInserter ins(friends_, &newFriend); pDB_->AddFriend(GetName(), newFriend.GetName()); // Everything went fine, commit the vector insertion ins.Commit(); }
AddFriend
now has two distinct parts: the activity phase, in which the operations occur, and the commitment phase, which doesn't throw it only stops the undo from happening.
The way AddFriend
works is simple: if any operation fails, the point of commitment is not reached and the whole operation is called off. The inserter pop_back
s the data entered, so the program remains in the state it was before calling AddFriend
.
The idiom works nicely in all cases. If, for example, the vector insertion fails, the destructor of ins
is not called, because ins
isn't constructed. (If you designed C++, would you have called the destructor for an object whose very construction failed?)
This approach works just fine, but in the real world, it turns out not to be that neat. You must write a bunch of little classes to support this idiom. Extra classes mean extra code to write, intellectual overhead, and additional entries to your class browser. Moreover, it turns out there are lots of places where you must deal with exception safety. Let's face it, adding a new class every so often just for undoing an arbitrary operation in its destructor is not the most productive.
Also, VectorInserter
has a bug. Did you notice it? VectorInserter
's copy constructor does very bad things. Defining classes is hard; that's another reason for avoiding writing lots of them.
Solution 3: The Real Approach
It's one or the other: either you have reviewed all the options above, or you didn't have time or care for them. At the end of the day, do you know what the real approach is? Of course you do. Here it is:
void User::AddFriend(User& newFriend) { friends_.push_back(&newFriend); pDB_->AddFriend(GetName(), newFriend.GetName()); }
It's a solution based upon not so scientific arguments.
"Who said memory's going to exhaust? There's half a gig in this box!"
"Even if memory does exhaust, the paging system will slow the program down to a crawl way before the program crashes."
"The database folks said AddFriend
cannot possibly fail. They're using XYZ and TZN!"
"It's not worth the trouble. We'll think of it at a later review."
Solutions that require a lot of discipline and grunt work are not very attractive. Under schedule pressure, a good but clumsy solution loses its utility. Everybody knows how things must be done by the book, but will consistently take the shortcut. The one true way is to provide reusable solutions that are correct and easy to use.
You check in the code, having an unpleasant feeling of imperfection, which gradually peters out as all tests run just fine. As time goes on and schedule pressure builds up, the spots that can "in theory" cause problems crop up.
You know you have a big problem: you have given up controlling the correctness of your application. Now when the server crashes, you don't have a clue about where to start: is it a hardware failure, a genuine bug, or an amok state due to an exception? Not only are you exposed to involuntary bugs, you deliberately introduced them!
Life is change. The number of users can grow, stressing memory to its limits. Your network administrator might disable paging for the sake of performance. Your database might not be so infallible. And you are unprepared for any of these.