Modern C++ is powerful and versatile, and the STL draws heavily upon those qualities. The best practices in this chapter will help you avoid common pitfalls and improve the clarity, maintainability, and performance of your code:
In this chapter, we'll explore some simple strategies to ensure our code is elegant, stable, and scalable.
One of the more significant differences between C and C++ is the distinction between object construction and initialization. Because C++ has classes, object construction and initialization are necessarily more complex than in C, which does not have classes. As we dive more deeply into the STL, it is vitally important to understand this distinction and to write code that expresses it clearly.
Source code is the bridge between the programmer and the compiler. That makes it necessary to write code that is both effective and readable. If we fail to understand how our objects are constructed, from both classes and primitive types, we invite confusion, which can lead to maddeningly elusive bugs.
Modern C++ provides several ways to initialize objects. The two most common are copy initialization and braced initialization :
std::string name = "Jimi Hendrix"; // copy initialization std::string name {"Jimi Hendrix"}; // braced initialization Copy initialization uses the assignment operator = . Traditionally, we think of this as an assignment operator , but in this context, it serves as a converting constructor . This misunderstanding can lead to unintended type conversions with loss of data, which can be extremely difficult to debug.
Braced initialization uses the list initialization operator {} (introduced in C++11) to avoid those side effects. It's a good habit to get into and you'll see it a lot in this book.
It's also worth noting that the special case of T{} is guaranteed to be zero-initialized:
T x; // uninitialized bad :( T x = 0; // zero (copy initialized) good :) T x {}; // zero (zero-initialized) best :D Zero initialization with empty braces offers a useful shortcut for initializing new variables.
Let's start by looking at some primitive initializations. Here's a simple int initialization:
int x {}; print("x is {}\n", x); The empty braces {} are a special case which are guaranteed to initialize the int to zero.
The output is as expected:
x is 0 Now, let's consider this traditional initialization:
int x = 42; This uses the = (assignment) operator to assign the value 42 to the int x , which gives us the result:
x is 42 Now let's assign an incompatible type:
int x = 73.9; This gives us no error and silently results in a wrong value:
x is 73 This effect is called a narrowing conversion . The literal 73.9 is a double , which is incompatible with type int . Under the legacy rules of copy initialization, the compiler narrows the value by quietly discarding the fractional part. While usually an undesirable effect, this behavior is retained in modern C++ for compatibility with legacy code.
Braced initialization doesn't allow narrowing. If we use braced initialization with an incompatible type, we get an error:
int x {73.9}; Now we get this error from GCC:
error: narrowing conversion of '7.3900000000000006e+1' from 'double' to 'int' [-Wnarrowing] (Error messages may differ with different compilers.)
Using braced initialization, incompatible initialization is not allowed. Braced initialization is simpler, safer, and the rules are more general and less ambiguous.
To better understand the rules of initialization in C++, let's illustrate with a simple vector . First, we'll need a function to print the values of a vector :
void pvec(const auto& v) { if (v.empty()) { println("[empty]"); } else { for (const auto& i : v) print("{} ", i); println(""); } } This is an abbreviated function template that prints the values of a vector (or any compatible container) using the C++23 println() function.
Now we initialize our vector . We'll start with an empty set of braces:
vector<int> v {}; pvec(v); This gives us the result:
[empty] The empty braces zero-initialize the vector according to the rules of the std::vector class.
We can use a comma-separated list of values within the braces:
vector<int> v {1, 2, 3, 4, 5}; The result is as expected:
1 2 3 4 5 The comma-separated list in braces is interpreted as a std::initializer_list object. The vector constructor uses it to create a vector object with the specified values.
Now, consider the case of two values in braces:
vector<int> v {5, 5}; This gives us the result:
5 5 This is still interpreted as an initializer_list . But if we use parentheses instead of braces:
vector<int> v (5, 5); Our result is very different:
5 5 5 5 5 It is vitally important to understand this distinction.
In the context of initializing a class, the list of values in braces {} is a literal initializer_list object, which invokes an initializer_list constructor, that is, a constructor which takes an initializer_list object as a parameter.
On the other hand, the values in parentheses are treated as parameters for a different constructor. In the case of the vector class, it calls a constructor with this signature:
vector( size_type count, const T& value ); The first parameter is a count, and the second is a value. This constructs a vector object with five integer 5 's.
For this reason, it's generally a bad idea to initialize values with parentheses. While this may work as expected:
int x(5); It does not translate to class constructors, and in many contexts, it will be parsed as a function call. On the other hand, the braced initialization
int x{5}; will work in all contexts. It's a good habit to get into and we'll use it throughout this book.
Initializing auto variables
One notable exception to the rule is the auto type declaration.
Because auto uses type deduction , it cannot always distinguish between a literal initializer_list and braced initialization.
Prior to C++17, If braces were used with an auto type declaration, it would create an initializer_list object:
auto x {42}; // C++11‑14 creates an initializer_list
To avoid ambiguity, and for compatibility with older standards, unless we mean to create an initializer_list object, it's best practice to use the assignment operator = when declaring an auto variable:
auto x = 42; // for auto declarations
A C-array is a primitive structure for storing a contiguous sequence of scalar data. As a simple primitive, it has no bounds checking. The std::span class is a simple wrapper that creates a view over a contiguous sequence of objects. A span object is inherently safer than a C-array as it carries size information which can be used for bounds checking of the underlying data. The span doesn't own any of its own data. It refers to the data in the underlying structure, which may be a C-array, a vector , or an STL array . Think of it as string_view for arrays.
We can create a span from any compatible contiguous-storage structure. The most common use case will involve a C-array. For example, if we pass a C-array directly to a function, the array is passed as a pointer, and the function has no easy way to know the size of the array:
void parray(int* a); // loses size information If we define the function with a span parameter, we can still pass the C-array and it will be promoted to span . Here's a template function that takes a span and prints out the size in elements and in bytes:
template<typename T> void pspan(span<T> s) { print("number of elements: {}\n", s.size()); print("size of span: {}\n", s.size_bytes()); for(auto e : s) print("{} ", e); print("\n"); } We can pass a C-array to this function and it's automatically promoted to span :
int main() { int carray[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; pspan<int>(carray); } Output:
number of elements: 10 number of bytes: 40 1 2 3 4 5 6 7 8 9 10 The purpose of span is to encapsulate raw data of a contiguous array to provide a measure of safety and utility, with a minimum of overhead.
The span class itself doesn't own any data. The data belongs to the underlying data structure. The span is essentially a view over the underlying data. It also provides some useful member functions.
Defined in the <span> header, the span class looks something like this:
template<typename T, size_t Extent = std::dynamic_extent> class span { T * data; size_t count; public: ... }; The Extent template parameter is a constexpr of type size_t , which is computed at compile time. It's either the number of elements in the underlying data or the default std::dynamic_extent constant, which indicates that the size is variable. This allows span to use an underlying structure like a vector , which may not always be the same size.
All member functions are constexpr and const qualified. Member functions include:
| Public member function | Return value |
|---|---|
| | The first element |
| | The last element |
| | An indexed element |
| | A pointer to the beginning of the sequence |
| | An iterator to the first element |
| | An iterator following the last element |
| | A reverse iterator to the first element |
| | A reverse iterator following the last element |
| | A |
| | A |
| | A |
| | A |
| | The number of elements in the sequence |
| | The number of bytes in the sequence |
| | True if empty |
| | A sub-span consisting of the first count elements of the sequence |
| | Returns a sub-span of the last count elements |
| | Returns a sub-span consisting of count elements beginning at offset |
Table 2.1: span class member functions
The span class [] operator performs no bounds checking. If we try to access element n+1 in a span of n elements, the result is undefined , which is tech for, "Bad. Don't do that."
Structured binding makes it easy to unpack the values of a structure into separate variables, improving both the readability and safety of our code.
With structured binding we can directly assign member values to variables like this:
std::pair<int,int> things_pair {47, 9}; auto [thing1, thing2] = things_pair; println("{} {}", thing1, thing2); Output:
47 9 Structured binding works with aggregated values, such as pair , tuple , array , and struct , or with your own custom types. Beginning with C++20, this also works with bit-fields.
int nums[] { 1, 2, 3, 4, 5 }; auto [ a, b, c, d, e ] = nums; println("{} {} {} {} {}", a, b, c, d, e); Output:
1 2 3 4 5 auto . The names of the individual variables are within the square brackets: auto [ a, b, c, d, e ] = x; For example, consider a C-array with five int values. These five values are assigned to the variables ( a , b , c , d , and e ) using structured binding.
int carray[] {1, 2, 3, 4, 5}; auto [ a, b, c, d, e ] = carray; println("{} {} {} {} {}", a, b, c, d, e); Output:
1 2 3 4 5 array object: array<int,5> nums { 1, 2, 3, 4, 5 }; auto [ a, b, c, d, e ] = nums; println("{} {} {} {} {}", a, b, c, d, e); Output:
1 2 3 4 5 tuple : tuple<int, double, string> nums {1, 2.7, "three"}; auto [ a, b, c ] = nums; println("{} {} {}", a, b, c); Output:
1 2.7 three struct it will take the variables in the order they're defined: struct Things { int i{}; double d{}; string s{}; }; Things nums {1, 2.7, "three"}; auto [ a, b, c ] = nums; println("{} {} {}", a, b, c); Output:
1 2.7 three array<int,5> nums {1, 2, 3, 4, 5}; auto& [ a, b, c, d, e ] = nums; println("{} {}", nums[2], c); c = 47; println("{} {}", nums[2], c); Output:
3 3 47 47 c and it will change the value in the array as well ( nums[2] ) We can declare the array const to prevent values from being changed:
const array<int,5> nums { 1, 2, 3, 4, 5 }; auto& [ a, b, c, d, e ] = nums; c = 47; // this is now an error Or we can declare the binding const for the same effect, while allowing the array to be changed elsewhere and still avoid copying data:
array<int,5> nums { 1, 2, 3, 4, 5 }; const auto& [ a, b, c, d, e ] = nums; c = 47; // this is also an error Structured binding uses automatic type deduction to unpack the structure into our variables. It determines the type of each value independently and assigns a corresponding type to each variable.
Because structured binding uses automatic type deduction , we cannot specify a type for the binding. We must use auto . We should get a reasonable error message if we try to use a type for the binding, for example:
array<int,5> nums { 1, 2, 3, 4, 5 }; int [ a, b, c, d, e ] = nums; Output:
error: structured binding declaration cannot have type 'int' note: type must be cv-qualified 'auto' or reference to cv-qualified 'auto' Above is the error from GCC when I try to use int with the structured binding declaration.
It's common to use structured binding for the return type from a function:
struct div_result { long quo; long rem; }; div_result int_div(const long & num, const long & denom) { struct div_result r{}; r.quo = num / denom; r.rem = num % denom; return r; } int main() { auto [quo, rem] = int_div(47, 5); println("quotient: {}, remainder {}", quo, rem); } Output:
quotient: 9, remainder 2 Because the map container classes return a pair for each element, it can be convenient to use structured binding to retrieve key/value pairs:
map<string, uint64_t> inhabitants { { "humans", 7000000000 }, { "pokemon", 17863376 }, { "klingons", 24246291 }, { "cats", 1086881528 } }; // I like commas string make_commas(const uint64_t num) { string s = std::to_string(num); for (auto i = s.size(); i > 3; i -= 3) { s.insert(i - 3, ","); } return s; } int main() { for(const auto & [creature, pop] : inhabitants) { println("there are {} {}", make_commas(pop), creature); } } Output:
there are 1,086,881,528 cats there are 7,000,000,000 humans there are 24,246,291 klingons there are 17,863,376 pokemon Using structured binding to unpack structures should make our code clearer and easier to maintain.
Beginning with C++17, if and switch have initialization syntax, much like the for loop has had since C99. This allows us to limit the scope of variables to within the condition.
You may be accustomed to code like this:
const string artist {"Jimi Hendrix"}; size_t pos {artist.find("Jimi")}; if(pos != string::npos) { println("found"); } else { println("not found"); } This leaves the variable pos exposed outside the scope of the conditional statement, where it needs to be managed to mitigate collisions with symbols in the rest of the code.
Now we can put the initialization expression inside the if condition:
if(size_t pos {artist.find("Jimi")}; pos != string::npos) { println("found"); } else { println("not found"); } This confines the scope of the pos variable to the scope of the conditional, which keeps our namespace clean and manageable.
The initializer expression can be used in either if or switch statements. Here are some examples of each.
if statement: if (auto var {init_value}; condition) { // var is visible } else { // var is visible } // var is NOT visible The variable defined in the initializer expression is visible within the scope of the entire if statement, including the else clause. Once control flows out of the if statement scope, the variable will no longer be visible, and any relevant destructors will be called.
switch statement: switch (auto var {init_value}; var) { case 1: ... case 2: ... case 3: ... ... default: ... } // var is NOT visible The variable defined in the initializer expression is visible within the scope of the entire switch statement, including all the case clauses and the default clause, if included. Once control flows out of the switch statement scope, the variable will no longer be visible, and any relevant destructors will be called.
One interesting use case is to limit the scope of a lock_guard that's locking a mutex. This becomes simple with an initializer expression:
if (lock_guard<mutex> lg {my_mutex}; condition) { // interesting things happen here } The lock_guard locks the mutex in its constructor and unlocks it in its destructor. Now the lock_guard will be automatically destroyed when it runs out of the scope of the if statement. In the past we would have had to delete it or enclose the whole if statement in an extra block of braces.
Another use case could be using a legacy interface that has output parameters, like this one from SQLite :
if (sqlite3_stmt** stmt, auto rc = sqlite3_prepare_v2(db, sql, -1, &_stmt, nullptr); !rc) { // do SQL things } else { // handle the error // use the error code return 0; } Here I can keep the statement handle stmt and the error code rc localized to the scope of the if statement. Otherwise, I would need to manage those objects globally.
Using initializer expressions will help keep our code tight and uncluttered, more compact, and easier to read. Refactoring and managing our code will also become easier.
Template argument deduction occurs when template types are clear enough to be understood by the compiler without the use of template arguments. There are certain rules to this feature, but it's mostly intuitive.
Template argument deduction happens automatically when we use a template with clearly compatible arguments. Let's consider some examples.
template<typename T> const char * f(const T a) { return typeid(T).name(); } int main() { println("T is {} ", f(47)); println("T is {}", f(47L)); println("T is {}", f(47.0)); println("T is {}", f("47")); println("T is {}", f("47"s)); } Output:
T is int T is long T is double T is char const * T is class std::basic_string<char... Because the types are easily discernible, there is no reason to specify a template parameter like f<int>(47) in the function call. The compiler can deduce the <int> type from the argument. The simpler function calls are intuitive and easy to read.
The above output shows meaningful type names where most compilers will use shorthand, like i for int and PKc for const char* , and so on.
template<typename T1, typename T2> string f(const T1 a, const T2 b) { return format("{} {}", typeid(T1).name(), typeid(T2).name()); } int main() { println("T1 T2: {}", f(47, 47L)); println("T1 T2: {}", f(47L, 47.0)); println("T1 T2: {}", f(47.0, "47")); } Output:
T1 T2: int long T1 T2: long double T1 T2: double char const * Here the compiler is deducing types for both T1 and T2 .
pair p(47, 47.0); // deduces to pair<int, double> tuple t(9, 17, 2.5); // deduces to tuple<int, int, double> This eliminates the need for std::make_pair() and std::make_tuple() as we can now initialize these classes directly without the explicit template parameters. The std::make_* helper functions remain available for backward compatibility.
Let's define a class so we can see how this works:
template<typename T1, typename T2, typename T3> class Thing { T1 v1{}; T2 v2{}; T3 v3{}; public: explicit Thing(T1 p1, T2 p2, T3 p3) : v1{p1}, v2{p2}, v3{p3} {} string s() { return format("{}, {}, {}\n", typeid(v1).name(), typeid(v2).name(), typeid(v3).name() ); } }; This is a template class with three types and three corresponding data members. It has a function s() which returns a formatted string with the three type names.
Without template parameter deduction, I would have to instantiate an object of this type like this:
Thing<int, double, string> thing1{1, 47.0, "three" } Now I can do it more like this:
Thing thing1{1, 47.0, "three" } This is both simpler and more reliable.
When I call the s() method on the thing1 object, I get this result:
println("thing1: {}", thing1.s()); Output:
thing1: int, double, char const * Of course, your compiler may report something different but equivalent.
Before C++17, template parameter deduction didn't apply to classes, so we needed a helper function, which may have looked like this:
template<typename T1, typename T2, typename T3> Thing<T1, T2, T3> make_thing(T1 p1, T2 p2, T3 p3) { return Things<T1, T2, T3>(p1, p2, p3); } ... auto thing2(make_thing(1, 47.0, "three")); println("thing2: {}", thing2.s()); Output:
thing2 int, double, char const * The STL includes a few of these helper functions, like make_pair() and make_tuple() , etc. These are now obsolescent but will be maintained for compatibility with older code.
Consider the case of a constructor with a parameter pack:
template <typename T> class Sum { T v{}; public: template <typename... Ts> Sum(Ts&& ... values) : v {(values + ...)} {} const T& value() const { return v; } }; Notice the fold expression in the constructor (values + ...) . This is a C++17 feature that applies an operator to all the members of a parameter pack. In this case, it initializes v to the sum of the parameter pack.
The constructor for this class accepts an arbitrary number of parameters, where each parameter may be a different class. For example, we could call it like this:
Sum s1 { 1u, 2.0, 3, 4.0f }; // unsigned, double, int, // float Sum s2 { "abc"s, "def" }; // std::string, c-string This, of course, doesn't compile. The template argument deduction fails to find a common type for all those different parameters. We get an error message to the effect of:
cannot deduce template arguments for 'Sum' We can fix this with a template deduction guide . A deduction guide is a helper pattern to assist the compiler with a complex deduction. Here's a guide for our constructor:
template <typename... Ts> Sum(Ts&& ... ts) -> Sum<std::common_type_t<Ts...>>; This tells the compiler to use the std::common_type_t trait, which attempts to find a common type for all the parameters in the pack. Now our argument deduction works, and we can see what types it settled on:
Sum s1 { 1u, 2.0, 3, 4.0f }; // unsigned, double, int, // float Sum s2 { "abc"s, "def" }; // std::sring, c-string auto v1 = s1.value(); auto v2 = s2.value(); println("s1 is {} {}, s2 is {} {}", typeid(v1).name(), v1, typeid(v2).name(), v2); Output:
s1 is double 10, s2 is class std::string abcdef The term, Resource Acquisition Is Initialization (RAII), refers to a code design pattern where each class is responsible for managing its own resources, often memory resources.
Resource leaks are a common source of both bugs and security breaches. By allocating resources during initialization (object construction), we guarantee that those resources are completely under the control of the class. And by freeing those resources during object destruction, we guarantee that those resources are properly released. This prevents a dominant source of resource leaks and cybersecurity attacks.
RAII in C++ relies on the fact that class destructors are always called when objects go out of scope. For example, here's a class which prints a message when it's constructed and destroyed:
class C { int i {}; public: C() { println("default constructor"); } ~C() { println("destructor"); } }; This function creates an object that goes out of scope when the function returns:
void f1() { C a {}; println("this is function f1()"); } When we call this function, we get constructor and destructor messages:
int main() { println("call f1()"); f1(); println("return from f1()"); } This gives us the output:
call f1() default constructor this is function f1() destructor return from f1() This example shows the object being constructed in function f1() and then calling its destructor upon going out of scope, when function f1() completes its execution.
To implement RAII, we leverage this behavior to ensure that any resources acquired (initialized) by an object are automatically released in its destructor.
It's worth noting that, while we may associate resource leakage with memory leaks, this also applies to files, sockets, databases, locks, and other resources that require management.
A common example of an RAII class is a smart pointer. When we create a smart pointer, it allocates memory and returns a handle for access:
std::unique_ptr<int> p = std::make_unique<int>(); The std::make_unique function constructs a std::unique_ptr object p . The unique_ptr constructor allocates memory for an object of type int . When p goes out of scope, its destructor frees up the memory allocated by its constructor.
Let's start with a rudimentary unique pointer class:
template <typename T> class uptr { T* m{}; public: uptr() { println("uptr constructor"); m = new T; } ~uptr() { println("uptr destructor"); delete m; } T& operator*() const { return *m; } T& operator*(const T& v) { *m = v; return *m; } }; Our class is called uptr . The constructor allocates memory for an object of type T using the new operator. The destructor uses delete to free up that memory and return it to the pool. The constructor and destructor print messages indicating when the object is created and destroyed.
We can now create an object of type uptr in a function with limited scope:
void f2() { uptr<int> p {}; *p = 42; println("p is {}", *p); } When we call this function, we can see how the smart pointer is created and destroyed:
int main() { println("calling f2()"); f2(); println("returned from f2()"); } Our output:
calling f2() uptr constructor p is 42 uptr destructor returned from f2() The constructor is called when the object is created in f2() , and the destructor is called when we return from f2() . This ensures that the associated memory is always managed properly without intervention.
Whenever an object is created, its class constructor is invoked. When the object goes out of scope, it is automatically destroyed and its destructor is invoked. By allocating resources in the constructor and freeing those resources in the destructor, we mitigate the need to manage those resources manually. This sequence of events provides the framework for RAII.
An object cannot be created in C++ without calling a constructor. We can ensure that our resources are properly initialized by always specifying a constructor:
class uptr { T* m{}; public: uptr() { // default constructor m = new T; } }; C++ also guarantees that the destructor is called upon the destruction of any object. By specifying a destructor, we may complete the RAII pattern and guarantee that our resources are properly released:
~uptr() { // destructor delete m; } A class may have many constructors but only one destructor. It is important to ensure that our data is always properly initialized in all of our constructors:
uptr(const T& v) { println("uptr(value) constructor"); m = new T; *m = v; } This maintains the integrity of the RAII pattern and its protections.
Scan the QR code (or go to ). Search for this book by name, confirm the edition, and then follow the steps on the page.
Note: Keep your invoice handy. Purchases made directly from Packt don't require an invoice.