Before diving deeper into the concepts of C++ high performance, we would like to emphasize some concepts that you should not compromise on when writing C++ code.
Class interfaces and exceptions
Strict class interfaces
A fundamental guideline when writing classes, is to relieve the user of the class from dealing with the internal state by exposing a strict interface. In C++, the copy-semantics of a class is part of the interface, and shall therefore also be as strict as necessary.
Classes should either behave as deep-copied or should fail to compile when copied. Copying a class should not have side effects where the resulting copied class can modify the original class. This may sound obvious, but there are many circumstances when, for example, a class requires a heap-allocated object accessed by a pointer member variable of some sort, for example std::shared_ptr, as follows:
class Engine { public:
auto set_oil_amount(float v) { oil_ = v; } auto get_oil_amount() const { return oil_; } private: float oil_{};
};
class YamahaEngine : public Engine { //...
};
The programmer of the Boat class has left a rather loose interface without any precautions regarding copy semantics:
class Boat {
public:
Boat(std::shared_ptr<Engine> e, float l) : engine_{e} , length_{l} {}
auto set_length(float l) { length_ = l; }
auto& get_engine() { return engine_; }
private: // Being a derivable class, engine_ has to be heap allocated std::shared_ptr<Engine> engine_; float length_{};
};
Later, another programmer uses the Boat class and expects correct copy behavior:
auto boat0 = Boat{std::make_shared<YamahaEngine>(), 6.7f};
auto boat1 = boat0;
// ... and does not realize that the oil amount applies to both boats
boat1.set_length(8.56f);
boat1.get_engine()->set_oil_amount(3.4f);
This could have been prevented if the Boat class interface were made stricter by preventing copying. Now, the second programmer will have to rethink the design of the algorithm handling boats, but she won't accidentally introduce any subtle bugs:
class Boat { private:
Boat(const Boat& b) = delete; // Noncopyable
auto operator=(const Boat& b) -> Boat& = delete; // Noncopyable
public:
Boat(std::shared_ptr<Engine> e, float l) : engine_{e}, length_{l} {}
auto set_length(float l) { length_ = l; }
auto& get_engine() { return engine_; }
private:
float length_{};
std::shared_ptr<Engine> engine_; };
// When the other programmer tries to copy a Boat object...
auto boat0 = Boat{std::make_shared<YamahaEngine>(), 6.7f};
// ...won't compile, the second programmer will have to find
// another solution compliant with the limitations of the Boat
auto boat1 = boat0;
Error handling and resource acquisition
In our experience, exceptions are being used in many different ways in different C++ code bases. (To be fair, this also applies to other languages which supports exceptions.) One reason is that distinct applications can have vastly different requirements when dealing with runtime errors. With some applications, such as a pacemaker or a power plant control system, which may have a severe impact if they crash, we may have to deal with every possible exceptional circumstance, such as running out of memory, and keep the application in a running state. Some applications even completely stay away from using the heap memory as the heap introduces an uncontrollable uncertainty as mechanics of allocating new memory is out of the applications control.
In most applications, though, these circumstances could be considered so exceptional that it's perfectly okay to save the current state and quit gracefully. By exceptional, we mean that they are thrown due to environmental circumstances, such as running out of memory or disk space. Exceptions should not be used as an escape route for buggy code or as some sort of signal system.
Preserving the valid state
Take a look at the following example. If the branches_ = ot.branches_ operation throws an exception due to being out of memory (branches_ might be a very big member variable), the tree0 method will be left in an invalid state containing a copy of leafs_ from tree1 and branches_ that it had before:
struct Leaf { /* ... */ };
struct Branch { /* ... */ };
class OakTree {
public:
auto& operator=(const OakTree& other) {
leafs_ = other.leafs_;
// If copying the branches throws, only the leafs has been
// copied and the OakTree is left in an invalid state
branches_ = other.branches_;
*this;
}
std::vector<Leaf> leafs_;
std::vector<Branch> branches_;
}; auto save_to_disk(const std::vector<OakTree>& trees) {
// Persist all trees ...
}
auto oaktree_func() {
auto tree0 = OakTree{std::vector<Leaf>{1000}, std::vector<Branch>{100}};
auto tree1 = OakTree{std::vector<Leaf>{50}, std::vector<Branch>{5}}
try {
tree0 = tree1;
}
catch(const std::exception& e) {
// tree0 might be broken
save_to_disk({tree0, tree1});
}
}
We want the operation to preserve the valid state of tree0 that it had before the assignment operation so that we can save all our oak trees (pretend we are creating an oak tree generator application) and quit.
This can be fixed by using an idiom called copy-and-swap, which means that we perform the operations that might throw exceptions before we let the application's state be modified by non-throwing swap functions:
class OakTree {
public:
auto& operator=(const OakTree& other) {
// First create local copies without modifying the OakTree objects.
// Copying may throw, but this OakTree will still be in a valid state
auto leafs = other.leafs_;
auto branches = other.branches_;
// No exceptions thrown, we can now safely modify
// the state of this object by non-throwing swap
std::swap(leads_, leafs);
std::swap(branches_, branches);
return *this;
}
std::vector<Leaf> leafs_;
std::vector<Branch> branches_;
};
Resource acquisition
Note that the destructors of all the local variables are still executed, meaning that any resources (in this case, memory) allocated by leafs and branches will be released. The destruction of C++ objects is predictable, meaning that we have full control over when, and in what order, resources that we have acquired are being released. This is further illustrated in the following example, where the mutex variable m is always unlocked when exiting the function as the lock guard releases it when we exit the scope, regardless of how and where we exit:
auto func(std::mutex& m, int val, bool b) {
auto guard = std::lock_guard<std::mutex>{m}; // The mutex is locked
if (b) {
// The guard automatically releases the mutex at early exit
return;
}
if (val == 313) {
// The guard automatically releases if an exception is thrown
throw std::exception{};
}
// The guard automatically releases the mutex at function exit
}
Ownership, lifetime of objects, and resource acquisition are fundamental concepts in C++ which we will cover later on in this book.
Exceptions versus error codes
In the mid 2000s, using exceptions in C++ affected performance negatively, even if they weren't thrown. Performance-critical code was often written using error code return values to indicate exceptions. Bloating the code base with returning error codes and error code handling was simply the only way of writing performance-critical and exception-safe code.
In modern C++ compilers, exceptions only affect the performance when thrown. Considering all the thrown exceptions are rare enough to quit the current process, we can safely use exceptions even in performance-critical systems and benefit from all the advantages of using exceptions instead of error codes.