Effective Modern C++, Chapter 3

Moving to Modern C++

Eleven items that form the backbone of everyday modern C++: braces, nullptr, scoped enums, deleted functions, override, const_iterators, noexcept, constexpr, thread-safe const, and the special member function rules.

Prerequisites: Chapters 1-2 (type deduction and auto).
11
Chapters
4+
Simulations

Chapter 0: Braces vs Parentheses

C++11 gave us three ways to initialize a variable:

c++
int x(0);      // parentheses
int y = 0;     // equals sign
int z{ 0 };     // braces (uniform initialization)

Braces can be used everywhere: variables, data members, default arguments, heap objects, even default member initializers inside a class. Neither parentheses nor the equals sign can claim that. This universality is why the Standard calls it uniform initialization.

Three superpowers of braces: (1) They work in every initialization context. (2) They prohibit implicit narrowing conversions. (3) They're immune to C++'s most vexing parse.
c++
double x = 3.14, y = 2.72;

int sum1{ x + y };  // ERROR: narrowing conversion
int sum2( x + y );  // OK: silently truncates
int sum3 = x + y;   // OK: silently truncates

And the most vexing parse — accidentally declaring a function when you wanted to default-construct an object — simply can't happen with braces:

c++
Widget w1(10);  // calls Widget ctor with arg 10
Widget w2();     // OOPS: declares a function named w2!
Widget w3{};     // calls Widget default ctor. No ambiguity.

But braces have a dark side. If a class has a constructor taking std::initializer_list, braces will strongly prefer that constructor — even when another constructor is a better match:

c++
std::vector<int> v1(10, 20);  // 10 elements, all value 20
std::vector<int> v2{10, 20};  // 2 elements: 10 and 20
Key insight: Braces and parentheses do the same thing unless a std::initializer_list constructor exists. When it does, braces hijack overload resolution to call it — even if narrowing conversions are required (causing a compile error) or another constructor is a perfect match.
Braces vs Parens: What Gets Called?

See which constructor gets called with each syntax.

What does std::vector<int> v{10, 20}; create?

Chapter 1: Prefer nullptr

The literal 0 is an int. NULL is typically defined as 0 or 0L. Neither is a pointer. C++ grudgingly interprets them as null pointers when it has no other choice, but in overload resolution, they behave as integers:

c++
void f(int);
void f(bool);
void f(void*);

f(0);       // calls f(int), NOT f(void*)
f(NULL);    // might not compile, but never calls f(void*)
f(nullptr); // calls f(void*) as intended

nullptr has type std::nullptr_t, which implicitly converts to all raw pointer types but never to any integral type. This eliminates the entire class of ambiguity bugs.

The template trap: When you pass 0 to a template, the compiler deduces int. Pass NULL, it deduces int (or long). Neither can convert to a smart pointer parameter. Only nullptr deduces std::nullptr_t, which converts to any pointer type.
c++
template<typename F, typename M, typename P>
auto lockAndCall(F func, M& mutex, P ptr) {
    std::lock_guard<M> g(mutex);
    return func(ptr);
}

lockAndCall(f1, f1m, 0);       // ERROR: int can't convert to shared_ptr
lockAndCall(f1, f1m, NULL);    // ERROR: same problem
lockAndCall(f1, f1m, nullptr); // OK: nullptr_t converts to shared_ptr
Why does passing 0 to a template that expects a pointer type fail?

Chapter 2: Alias Declarations over typedefs

Both do the same job — create a shorter name for a type — but using has one killer advantage: it can be templatized.

c++
// typedef: no template support
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;

// alias declaration: same thing, but clearer
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;

With function pointers, the alias is dramatically easier to read:

c++
// typedef: the name is buried in the middle
typedef void (*FP)(int, const std::string&);

// alias: name on the left, type on the right
using FP = void(*)(int, const std::string&);

The real power shows up with templates. A typedef can't be templatized directly — you have to wrap it in a struct. An alias template works cleanly:

c++
// Alias template: clean and simple
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

MyAllocList<Widget> lw;  // clean usage

// typedef hack: requires struct wrapper + typename
template<typename T>
struct MyAllocList {
    typedef std::list<T, MyAlloc<T>> type;
};

MyAllocList<Widget>::type lw;  // clunky
Key insight: Inside a template, the typedef version requires typename before the dependent type: typename MyAllocList<T>::type. The alias template version doesn't: MyAllocList<T>. Compilers know an alias template names a type, so no disambiguation needed.
What is the killer advantage of alias declarations (using) over typedef?

Chapter 3: Scoped Enums

C++98 enums leak their enumerator names into the enclosing scope. C++11 enum class keeps them scoped:

c++
// C++98: names leak into surrounding scope
enum Color { black, white, red };
auto white = false;  // ERROR: white already declared

// C++11: names scoped to the enum
enum class Color { black, white, red };
auto white = false;           // fine, no conflict
Color c = Color::white;        // must qualify

Scoped enums also prevent implicit conversions to integers — no more accidentally passing a Color to a math function:

c++
// Unscoped: implicit conversion to int
enum Color { black, white, red };
if (black < 14.5) { ... }  // compiles! comparing Color to double

// Scoped: no implicit conversion
enum class Color { black, white, red };
if (Color::black < 14.5) { ... }  // ERROR: can't compare
Forward declarations: Scoped enums can always be forward-declared because their underlying type defaults to int. Unscoped enums need an explicit underlying type to be forward-declared. Forward-declaring enums reduces compilation dependencies — changing an enumerator doesn't force a full recompile.
FeatureUnscoped (enum)Scoped (enum class)
Name scopeLeaks into enclosing scopeContained within enum
Implicit conversionYes (to int, double, etc.)No (requires static_cast)
Forward declarationOnly with explicit underlying typeAlways (defaults to int)
Underlying typeCompiler-chosenint (overridable)
Why can scoped enums always be forward-declared but unscoped enums cannot (by default)?

Chapter 4: Deleted Functions

In C++98, the trick to prevent copying was to declare the copy constructor and copy assignment operator private and leave them undefined. It worked, but errors only surfaced at link time. C++11 gives us = delete:

c++98
// C++98: private and undefined
class NoCopy {
private:
    NoCopy(const NoCopy&);             // not defined
    NoCopy& operator=(const NoCopy&);  // not defined
};
c++11
// C++11: deleted — errors at compile time
class NoCopy {
public:
    NoCopy(const NoCopy&) = delete;
    NoCopy& operator=(const NoCopy&) = delete;
};

Two improvements: errors at compile time instead of link time, and they're public by convention so compilers give clear error messages about deletion rather than access violations.

Beyond member functions: = delete works on any function, not just members. You can delete overloads to prevent unwanted implicit conversions:
c++
bool isLucky(int number);
bool isLucky(char) = delete;    // reject chars
bool isLucky(bool) = delete;    // reject bools
bool isLucky(double) = delete;  // reject doubles (and floats!)

isLucky(42);      // OK
isLucky('a');     // ERROR: deleted
isLucky(true);    // ERROR: deleted
isLucky(3.14);    // ERROR: deleted
What advantage does = delete have over the C++98 private-and-undefined trick?

Chapter 5: Declare Overrides override

Virtual function overriding is one of the most fundamental ideas in C++, and one of the easiest to get wrong silently. For overriding to happen, all of these must match between base and derived:

RequirementIf mismatched...
Function must be virtual in baseNew function, no override
Names must be identicalNew function, no override
Parameter types must matchNew function, no override
const qualification must matchNew function, no override
Return types must be compatibleWon't compile
Reference qualifiers must match (C++11)New function, no override

Can you spot the bugs? None of these derived functions override their base counterparts:

c++
class Base {
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    void mf4() const;     // not virtual!
};

class Derived : public Base {
public:
    virtual void mf1();               // missing const
    virtual void mf2(unsigned int x); // wrong param type
    virtual void mf3() &&;           // wrong ref-qualifier
    void mf4() const;                // base isn't virtual
};

Add override and the compiler catches every one of these bugs:

c++11
class Derived : public Base {
public:
    void mf1() const override;           // OK
    void mf2(int x) override;           // OK
    void mf3() & override;              // OK
    // void mf4() const override;       // ERROR: base not virtual
};
Rule: Always use override on every derived class function that's intended to override a base class virtual. It costs nothing and catches a class of bugs that are otherwise completely silent.
If a derived class function has a different parameter type than the base virtual, what happens without override?

Chapter 6: Prefer const_iterators

In C++98, const_iterators were impractical: hard to get from a non-const container, and insert/erase wouldn't accept them. C++11 fixes both problems:

c++98
// C++98: painful
typedef std::vector<int>::iterator iter;
typedef std::vector<int>::const_iterator cIter;
cIter ci = std::find(static_cast<cIter>(v.begin()),
                     static_cast<cIter>(v.end()), 1983);
v.insert(static_cast<iter>(ci), 1998);  // may not compile!
c++11
// C++11: trivial
auto it = std::find(v.cbegin(), v.cend(), 1983);
v.insert(it, 1998);  // just works
Practical rule: Use cbegin() and cend() everywhere you don't need to modify elements. In C++14, non-member cbegin and cend are also available for maximum generality.
What does C++11 fix about const_iterators?

Chapter 7: noexcept

Declaring a function noexcept tells the compiler and callers that the function won't emit exceptions. This isn't just documentation — it enables optimizations:

c++
RetType function(params) noexcept;  // most optimizable
RetType function(params) throw();    // less optimizable (C++98)
RetType function(params);             // less optimizable

In a noexcept function, the optimizer doesn't have to keep the runtime stack in an unwindable state, and it doesn't have to guarantee objects are destroyed in reverse construction order if an exception escapes. This alone is reason enough to declare functions noexcept when they won't throw.

The move operations story: std::vector::push_back needs a strong exception guarantee. When it reallocates, it can use move (fast) instead of copy (slow) only if the element's move constructor is noexcept. Without noexcept, the vector falls back to copying to maintain the guarantee. Declaring move operations noexcept can dramatically speed up container operations.
c++
// Without noexcept: vector copies elements on reallocation
Widget(Widget&& rhs);           // move ctor, might throw

// With noexcept: vector moves elements on reallocation (fast!)
Widget(Widget&& rhs) noexcept;  // move ctor, won't throw
Key insight: Functions like swap, move operations, and memory deallocation functions are natural candidates for noexcept. Don't slap noexcept on functions that might throw just for optimization — if a noexcept function throws, std::terminate is called.
Why does marking a move constructor noexcept speed up std::vector reallocation?

Chapter 8: constexpr

constexpr means "this value is known at compile time" for objects, and "this function can be evaluated at compile time" for functions. It is not the same as const:

c++
int sz;                              // non-constexpr variable
constexpr auto arraySize1 = 10;     // fine: 10 is compile-time const
std::array<int, arraySize1> data1;  // fine: arraySize1 is constexpr

const auto arraySize2 = sz;          // fine: const copy of sz
std::array<int, arraySize2> data2;  // ERROR: arraySize2 not known at compile time
All constexpr objects are const, but not all const objects are constexpr. If you need a compile-time constant, the tool is constexpr, not const.

constexpr functions are dual-mode: call them with compile-time arguments and they compute at compile time; call with runtime values and they behave like normal functions:

c++
constexpr int pow(int base, int exp) noexcept {
    auto result = 1;
    for (int i = 0; i < exp; ++i) result *= base;
    return result;
}

constexpr auto numConds = 5;
std::array<int, pow(3, numConds)> results;  // 3^5 = 243 at compile time!

In C++11, constexpr functions are limited to a single return statement. C++14 lifts this restriction dramatically, allowing loops, local variables, and multiple statements.

What happens when a constexpr function is called with non-compile-time arguments?

Chapter 9: Thread-Safe const

A const member function promises not to modify the object. Multiple threads can call it simultaneously without synchronization — at least, that's the expectation. But if the function uses mutable data members for caching, that contract breaks:

c++
class Polynomial {
public:
    using RootsType = std::vector<double>;

    RootsType roots() const {
        if (!rootsAreValid) {       // read of mutable
            // ... compute roots ...
            rootsAreValid = true;   // write to mutable
        }
        return rootVals;
    }
private:
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};

If two threads call roots() simultaneously, they race on rootsAreValid and rootVals. This is undefined behavior even though roots() is const.

Fix: Use std::mutex (for expensive computations) or std::atomic (for single variables like counters) to make const member functions with mutable data truly thread-safe.
c++
class Polynomial {
public:
    RootsType roots() const {
        std::lock_guard<std::mutex> g(m);  // lock
        if (!rootsAreValid) {
            // ... compute roots ...
            rootsAreValid = true;
        }
        return rootVals;
    }                                       // unlock
private:
    mutable std::mutex m;
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};
Why is a const member function with mutable data not thread-safe by default?

Chapter 10: Special Member Function Generation

C++98 had four special member functions the compiler could generate: default constructor, destructor, copy constructor, and copy assignment operator. C++11 adds two more: the move constructor and move assignment operator.

But the generation rules are subtle. The two copy operations are independent: declaring one doesn't suppress the other. The two move operations are not independent: declaring either suppresses the other.

The Rule of Three → Rule of Five: If you declare any of {copy ctor, copy assignment, destructor}, you should declare all of them. C++11 extends this: declaring any copy operation or destructor suppresses move generation. The Rule of Five adds the move constructor and move assignment operator to the list.

Move operations are generated only when all three conditions are true:

Condition 1
No copy operations declared
Condition 2
No move operations declared
Condition 3
No destructor declared
Result
Move operations are auto-generated
The = default idiom: When you want compiler-generated behavior but also declare a destructor (e.g., for logging), explicitly default the rest:
c++11
class Widget {
public:
    ~Widget();                                  // custom dtor
    Widget(const Widget&) = default;           // force generation
    Widget& operator=(const Widget&) = default;  // force generation
    Widget(Widget&&) = default;                // force generation
    Widget& operator=(Widget&&) = default;       // force generation
};
Special Member Generation Rules

Which declarations suppress which auto-generated functions?

If a class declares a destructor, which special members are not auto-generated?