In the previous article we looked at how to avoid falling into some of the pitfalls of Modern C++. For this article we’ll cover some of the more advanced areas of Modern C++, with explanations of how to avoid problems that novice programmers often encounter.
Understand how templates work
Even novice programmers are introduced to templates via the Standard Library classes such as std::string
and std::vector<>
. (In fact, std::string
is actually a typedef for std::basic_string<char>
.) In order to use template code, a number of steps can take place, including:
- Template argument deduction: the template type parameter(s) can be deduced from an assignment, for example
std::vector v{ 1, 2, 3, 4, 5 };
is implicitlystd::vector<int>
- Template specialization: code is (attempted to be) generated based on the needs of the client code; only member function(s) of
std::vector<int>
which are used will be generated and compiled - SFINAE: invalid specializations are discarded for more advanced generic constructs, allowing the complier to “choose” between two (or more) possibilities
No one claims that compilations which involve templates being instantiated are fast; this convenience does come at some cost. In cases where the template code rarely changes, it is possible to explicitly instantiate the entire template, most usefully into a separate source file, and compile this separately. Then, in the client code an extern template
directive is used, with the template object file linked in later. The uses of the template parameter(s) must be exactly the same in all three uses: the extern template
, explicit instantiation and client code.
As an example, the following is an explicit instantiation of std::vector<double>
:
#include <vector>
template std::vector<double>;
The resulting object file can be linked to the following main program at compile time:
#include <vector>
#include <iostream>
extern template std::vector<double>;
int main() {
std::vector<double> v{ 1.1, 2.2, 3.3 };
for (auto d : v) {
std::cout << d << ' ';
}
std::cout << '\n';
}
If the instantiated object file rarely needs recompilation, compilation times can be improved for the file which provides an extern template
directive.
Use the appropriate string type
Modern C++ has a number of string types and associated literals to choose between:
std::string
: should be used for strings which are either “owned” or mutable, including as return values from functionsstd::string_view
: should be used where a string is accepted as a parameter, as it is cheaply constructed from all string and literal types, or where a (moveable) window over existing character data is requiredchar[]
,const char[]
,char*
,const char*
: should only be used where absolutely necessary"..."s
,"..."sv
: literals are available withusing namespace std::literals;
which work well withauto
and have exactly the same semantics as astd::string
orstd::string_view
object created with a constructor (useconst auto s = "..."s;
for a read-onlystd::string
)"..."
: C-string literals still have a place in Modern C++ as read-only local entities or return values from functions
It is important to understand that a std::string_view
is merely a pointer-plus-length to existing string data. If this existing data goes out of scope, or becomes corrupted or deallocated for some reason, the view itself becomes invalid. For example:
std::string_view f() {
// bad: temporary return value is unsuitable for assigning to vw
std::string_view vw = returns_a_string();
// possible problem: ensure that what vw references is not going out of scope
return vw;
}
void g(std::string_view vw) { // good: call with "abc", std::string, std::string_view etc.
// ...
Make base class destructors virtual
Often compilers can warn about this C++ pitfall, but understanding the warning is also important. C++ classes were always designed to be as efficient as possible, taking up no more space than the equivalent C struct
, thus they do minimal housekeeping by default. With inheritance the possibility of a derived class heap object being deallocated from a pointer to the base class also implies a memory leak:
class B {
public:
~B() { std::cout << "~B()\n"; }
};
class D : public B {
public:
~D() { std::cout << "~D()\n"; }
};
int main() {
B* derived = new D;
delete derived;
}
This will output:
~B()
With the derived class destructor not being called, we could be leaking resources. The fix is small and simple:
class B {
public:
virtual ~B() { std::cout << "~B()\n"; }
Making the output now what we expected (inherited classes are destructed in reverse order to construction):
~D()
~B()
Use friend functions with care
The parts of a C++ class declared private:
or protected:
are not accessible to users of the class (derived classes can access protected:
parts). To work around this restriction, functions, other classes and even member functions can be declared as friend
s. This involves modifying the original class definition, so is not always desirable or even possible (a header describing the friend
class may be needed, for example).
With more experience of C++ being gained, the need for friend
declarations is often viewed as the product of poor class design, so they should always be used with caution and only when necessary. Better solutions include writing public:
getters and setters (which maintain class invariants), and stream output utility functions.
You should be aware that friend
functions and classes can modify at will any part of the class it is friends with. Therefore for code organization the source code should share the same module, or even file, in order to ensure code cohesion should one or the other be updated.
Understand polymorphism
This C++ concept is not always easy to understand for programmers coming from non-statically-typed languages. Essentially there are two types of polymorphism in C++:
- Static polymorphism: the selection between two code paths is performed at compile time
- Dynamic polymorphism: this selection happens at runtime
Examples of static polymorphism include overloaded functions and generic functions, for example:
void f(double d) {
std::cout << "f(double)\n";
}
void f(int i) {
std::cout << "f(int)\n";
}
f(-1); // output: 'f(int)'
Here the choice between the functions is resolved based on the parameter type at the call site. Due to the fact that the decision is made at compile time, code for the other overload(s) may not even be present in the final executable.
Dynamic polymorphism is restricted to the dispatch of virtual
functions of C++ classes, and involves writing a class hierarchy, often with an Abstract Base Class (ABC). Here is an example of selecting between the method named type()
declared in three classes in a hierarchy:
#include <iostream>
class Number {
public:
virtual void type() = 0;
virtual ~Number() {}
};
class Double : public Number {
public:
virtual void type() override { std::cout << "Double.type()\n"; }
};
class Int : public Number {
public:
virtual void type() override { std::cout << "Int.type()\n"; }
};
int main() {
Number *d = new Double();
d->type();
delete d;
}
This will output:
Double.type()
More usefully, the two derived classes could have member variables (of differing types) together with constructor(s) and/or setters.
Remember the Principle of Least Astonishment
You should never write code which produces surprising side effects. Functions, classes, even variables should be given long, logical names which clearly and accurately describe their purpose. Code which does not adhere to the the Principle of Least Astonishment should be refactored or removed.
Class designers have the freedom to overload operators as they see fit, however in practice operator methods should only be overloaded with logical functionality. For example overloading operator !
to delete the object’s resources is clearly bad practice and should be discouraged.
Always assume that your code could be read by someone else one day. Make it an advert for your skills while avoiding writing “clever”, unmaintainable, write-only code that would be difficult to decipher or modify in the future. Prioritize clear, understandable code over “crufty” or inscrutable hieroglyphics.
That concludes our look at how to avoid some of Modern C++’s pitfalls. Hopefully this mini-series has managed to give some useful advice on how to write better, more bug-free, code. If you have any further advice to give, please leave a comment.