Do you find that as your codebase grows, it gets harder and harder to properly write code that will handle errors properly and that you are confident will work well under concurrency?
If so, here are some tools to make your life a bit easier.
Note that many of these apply to other languages other than C++. The ideas of managing state in the face of errors or concurrency are common to most languages that support such things!
In the face of exceptions, you will generally be well served by RAII, or at least objects that are able to clean up properly on destruction. Anything else would require a bunch of additional care that basically boils down to writing long-hand what the compiler would have produced for you.
Now, the general patterns you can use to go from handling exceptions to error codes when it comes to swapping things in and out is as follows, and depends on whether your objects were all RAII to being with or not.
The happy paths are not too different. If you want to handle errors, exceptions give you a grouping mechanism by exception type across a bunch of statemnets, and error codes give you more fine-grained and explicit control (but it get clumsier if you want to reuse various bits of logic across the error handlers, although frequently if-failed-cleanup kind of patterns are used).
// With exceptions, everything RAII
void fn() {
C c;
// whatever - C::~C will take care of things.
}
// With error codes, everything RAII.
Err fn() {
C c;
if (Err err = fn()) {
return err; // C::~C will take care of cleanup
}
// More checks along the way...
return Err::None();
}
// With exceptions, manually managed.
void fn() {
C c;
init(&c);
try {
// whatever - the catch block will take care of things,
// but there may be repetition if there are multiple
// exception types to handle
} catch (...) {
deinit(&c);
throw;
}
}
// With error codes, manually managed.
Err fn() {
C c;
init(&c);
if (Err err = fn()) {
deinit(&c);
return err;
}
// More checks along the way.
return Err::None();
}
// With error codes, manually managed (with goto)
// #define IFC (x) .... // if-failed-cleanup
Err fn() {
C c;
Err err = Err::None(); // this gets returned from fn
init(C&);
IFC(fn()); // check errors along the way
Cleanup:
// err holds success or an error code at this point
deinit(&C);
return err;
}
With this out of the way, I'll be sticking to exceptions and RAII, as it's the shortest/cleanest code for today's exercise.
A lot of what I'd like to say here has really been explained much better by Jon Kalb in Exception-Safe Coding in C++. If you can go and watch his YouTube videos or just go through his slides, then you'll be a better programmer for it.
Here is a quick summary of the guidelines, but really you should read the whole thing. It's very much worth your time.
Now, a related notion to Jon's "Critical Line" - the point in a function at which success is guaranteed and state can be made visibile - is also a vary useful notion for thread safety.
The basic insight is that for most common object-oriented code, state is protected via some concurrency mechanism, frequently mutexes of some sort. The object should only expose state that is consistent, and state changes should thus go from one consistent state to another.
The same pattern of state acquisition, work on stack/on the side and swap/publish after a critical line can be applied here. If you want to maximize concurrency, you don't need to do the work under a lock.
// Naive - do not use!
void C::fn() {
// Acquire state, assuming state and count should be consistent
int count;
string state;
{
auto l = this->lock_state();
count = this->count_;
state = this->state_;
}
// Do work.
count++;
state.append("something new");
// Publish new state
{
auto l = this->lock_state();
swap(this->count_, count);
swap(this->state_, state);
}
}
Now, the example above is somewhat naive, as it might lose updates if fn is called concurrently or if there are other functions that update state. We can use the concepts of optimistic and pessimitic locking, well studied in databases, to look into a couple of ways in which we can address that.
Pessimistic state assumes the common case is that conflicts happen, and so it holds locks for longer (and reduces concurrency). Optimistic assumes no conflict is more common (but still possible), and thus enables higher concurrency, but it still needss to detect and handle cases where conflicts do end up occuring.
// Pessimistic.
void C::fn() {
// Acquire state, assuming state and count should be consistent
{
auto l = this->lock_state();
int count = this->count_;
string state = this->state_;
// Do work.
count++;
state.append("something new");
swap(this->count_, count);
swap(this->state_, state);
}
}
At this point, the locals will often be omitted if there is no need to roll back changes - but in keeping with exception-safe code, we won't just party on the object's fields.
The optimistic version simply adds more checks, and needs to either fail an operation if it detects a conflict, or resolve it.
// Optimistc.
void C::fn() {
// Acquire state, assuming state and count should be consistent
int count;
string state;
{
auto l = this->lock_state();
count = this->count_;
state = this->state_;
}
// Do work.
count++;
state.append("something new");
{
auto l = this->lock_state();
// Let's assume that count is monotically incremented
// and thus a good way to detect conflict.
if (count != this->count_ + 1) {
throw std::runtime_error("oh no!");
// alternatively, adjust count_ and state_ to take into
// account new data, or perhaps retry the operation
}
swap(this->count_, count);
swap(this->state_, state);
}
}
One thing that is easy to overlook is that calling a callback or some sort of event - or even making a virtual call - is a way of "publishing" state.
There are two imporant things to consider as guidelines.
Some locks allow re-entry, but in practice it's dangerous to rely on them too much, as oftentime the assumptions on consistency are not so carefully managed.
For example, this is what calling an event might look like.,
void C::fn() {
Observers observers;
State state;
{
// Acquire outside functions, in case they subscribe/unsubscribe
auto l = this->lock_state();
observers = observers_;
state = state_;
}
// Now that we don't hold the lock, invoke the event.
observers.invoke(state);
{
// If we need to use state, we may need to re-acquire it, because it might have changed
// on invocation!
auto l ->this->lock_state();
state = state_;
}
if (state == 1) {
// do work...
}
}
A very simple example would be an object that fires an event for "connecting", but should stop work if the object is canceled or disposed during the event - it should read that its state is "disconnecting" or "disposed" and not proceed any further.
Happy safe coding!