Effective Modern C++ Study Notes
This article is translated from Chinese to English by ChatGPT. There might be errors.
This article contains some study notes from Effective Modern C++.
Type Deduction Rules #1 #2
For code of the form
template<typename T>
void f(ParamType param);
f(expr);
T and ParamType are deduced from expr. There are three cases:
ParamTypeis a pointer or reference type, but not a universal reference (of the formT&&);ParamTypeis a universal reference;ParamTypeis neither a pointer nor a reference.
Basic rules:
- When
ParamTypeis a reference, the reference is ignored when deducingT, whileconstandvolatileare preserved; - When
ParamTypeis a universal reference, ifexpris an lvalue,Tandparamwill be deduced as lvalue reference types; ifexpris an rvalue, the reference is ignored when deducingT, andparamis deduced as an rvalue reference; - When
ParamTypeis neither a reference nor a pointer, references are ignored during deduction, andconstandvolatileare also ignored; - When
expris an array or a function pointer, ifParamTypeis not a reference, the deducedTis a pointer type; ifParamTypeis a reference, the deducedTis an array type (for examplechar[5]) or a function reference (for examplevoid (&)(int)).
In short, T is deduced as an lvalue reference type (such as int&) only in one situation: when ParamType is a universal reference and expr is an lvalue.
decltype #3
In most cases, decltype returns the exact type of an expression, including reference types and const volatile.
In C++14, decltype(auto) can be used to deduce a function’s return type automatically.
Note that for a variable name whose type is T, decltype yields T; but for an expression of type T that is an lvalue, decltype yields the reference type T&. For example, with int x = 0;, decltype((x)) yields int&.
auto #5 #6
Using auto for type deduction reduces typing, avoids some subtle type issues, and lowers refactoring costs. In C++14, auto can be used in lambda parameter lists.
When declaring variables with auto, be careful about interfaces that use the Proxy design pattern, which may not return the actual object you want. In these cases you can use static_cast to convert the type, for example:
auto x = static_cast<type_you_want>(proxy_expr);
Uniform Initialization (Brace Initialization) #7
Initialization forms include:
- Copy (=) initialization:
int y = 0;. Non-copyable objects cannot use copy initialization; for examplestd::atomic<int> x = 0;is ill-formed; - Direct (parentheses) initialization:
int y(0);. Cannot be used to specify default values for data members; - Uniform (brace) initialization (with or without
=):int y{ 0 };. Can be used in all situations.
When a class has a constructor overload taking initializer_list<T>, the compiler will strongly prefer this overload, even if other constructors would match the arguments better. Note the difference between the following two statements:
std::vector<int> x1(10, 20);
std::vector<int> x2{10, 20};
When writing templates, you must choose between these two construction forms.
Using an empty initializer list calls the default constructor, for example Widget w{};. Parentheses initialization (Widget w();) cannot be used here, because the compiler tends to interpret the statement as a function declaration, which is the C++ “most vexing parse” problem. To select the initializer_list constructor, you can use Widget w{{}};.
In addition, auto can do two-step deduction with initializer_list<T>, i.e. auto x = { 1, 2 }; is directly deduced as initializer_list<int>. But for template argument deduction, the C++ standard forbids deducing initializer_list<T> directly from an initializer list. When you need this, the template function should be declared as:
template<typename T>
void func(const initializer_list<T>& il);
When initializing an object with = and with parentheses, there is a difference (#42):
std::regex r1 = nullptr; // copy initialization
std::regex r2(nullptr); // direct initialization
The C++ standard forbids calling explicit constructors for copy initialization, but allows them for direct initialization.
nullptr #8
nullptr is better than NULL in avoiding overload ambiguities, for example:
void f(int);
void f(void *);
f(NULL); // may fail to compile, or choose f(int)
f(nullptr); // ok, chooses f(void *)
Using using for Type Aliases #9
Use using instead of typedef to define aliases; advantages:
usingsupports templates, for example:
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
- When using a type defined with
usinginside templates, you usually don’t need thetypenamekeyword, because the compiler knows that ausingdeclaration always introduces a type.
Some type utilities in <type_traits> still use typedef in C++11; C++14 adds using-based versions, for example:
std::remove_const<T>::type // C++11
std::remove_const_t<T> // C++14
Scoped Enums #10
For enums declared with enum class, enumerators must be qualified with the enum’s scope, for example enum class Color { black, red }; auto c = Color::red;.
Enumerators declared with enum class are not implicitly convertible to fundamental types (int, double, etc.); explicit casts are required when needed.
Both old-style enum and enum class have an underlying type. The default underlying type for enum class is int, and both forms can specify an underlying type:
enum class Status : std::uint32_t;
enum Color : std::uint8_t;
After specifying an underlying type, both support forward declarations. You can use std::underlying_type<E>::type in <type_traits> to obtain an enum’s underlying type.
delete #11
delete can be used not only on member functions, but on any function, including specialized template functions, for example:
template<typename T>
void func(T t);
template<>
void func<int>(int t) = delete;
New Function Specifiers #12 #14
C++11 adds the following function specifiers:
&and&&for member functions, enabling overloads that are selected depending on whether*thisis an lvalue or rvalue;overrideexplicitly informs the compiler that a function overrides a base-class method. Because overriding requires many conditions to hold, it’s easy to write code that appears to override but actually doesn’t;overridelets the compiler catch such errors;finalapplied to a class or function, indicating that the class cannot be derived from, or the function cannot be overridden;noexceptdeclares that a function does not throw exceptions. In C++11, the only really useful information about exceptions is “whether a function may throw.” Many functions are designed with the requirement that callees must not throw, such asstd::vector::push_back; it will use the move constructor only when the element type’s move constructor is declarednoexcept, otherwise for exception safety it will use the copy constructor. Many STL functions depend on this property. Destructors arenoexceptby default. If a function markednoexceptthrows, the program is terminated.
Rules for Generating Special Member Functions #17
The Big Five principle: if any of the copy operations, move operations, or destructor has user-defined behavior, the compiler should not assume its auto-generated bitwise operations are correct. C++11 designs move operations according to this principle, but in C++98, the principle was not yet clear when specifying copy operations, so their behavior differs somewhat.
- Default constructor: generated automatically if the user does not define one;
- Destructor: generated automatically if the user does not define one; implicitly
noexcept, and by default notvirtual; - Copy constructor: generated automatically if the user does not define a copy constructor or any move operations; the presence of a user-defined destructor does not prevent generation of the copy constructor, but this behavior is deprecated;
- Copy assignment operator: generated automatically if the user does not define a copy assignment operator or any move operations; the presence of a user-defined destructor does not prevent generation of the copy assignment operator, but this behavior is deprecated;
- Move constructor and move assignment operator: generated automatically only if the user does not define any copy operations, move operations, or a destructor;
- Member function templates have no effect on the generation of these special member functions.
Smart Pointers #18–#22
std::unique_ptrhas performance comparable to raw pointers. When using the default deleter (delete) or a lambda expression as the deleter, the size ofstd::unique_ptrdoes not change, because the deleter is part of thestd::unique_ptr’s type information. Using a function pointer or function object increases its size;std::unique_ptrhas a partial specialization for arrays:std::unique_ptr<T[]>. The array version does not supportoperator*andoperator->, but does supportoperator[](the non-array version does not support indexing);std::shared_ptrandstd::weak_ptrtypically occupy the size of two pointers, one pointing to the object and one to the control block. The control block contains the strong reference count and weak reference count. When the strong count reaches 0, the object is destroyed and its memory is reclaimed (unless it was not allocated viastd::make_shared); when both strong and weak counts are 0, the control block itself is deallocated. All reference count operations are atomic, which affects performance. The control block also stores custom allocators (viastd::allocate_shared) and deleters. Astd::shared_ptr’s deleter is specified via its constructor and is not part of the template type;- If a member function needs to obtain a
std::shared_ptrtothis, the class must inherit fromstd::enable_shared_from_this<T>and then callshared_from_this(). Before calling this function, the instance must already have an associated control block, i.e. at least onestd::shared_ptrhas been created for it; otherwise an exception is thrown; - When using the pImpl idiom, if you manage resources with
std::unique_ptr, the deleter is part of thestd::unique_ptr’s type, and thus the implementation type must be complete, so you must declare the Big Five in the header and define them in the implementation file; if you usestd::shared_ptr, this is not required; - Use the
make_xxxfamily with care or not at all in the following situations:- When a custom deleter is required;
- When the object must be constructed with
std::initializer_list; - When the class defines custom
operator newandoperator delete; - When the object itself is large and weak references will live for a long time (because the control block and object are allocated together, the object’s memory lifetime matches that of the control block).
Move Semantics and Perfect Forwarding #23–#30
A simple C++14 implementation of std::move:
template<typename T>
decltype(auto) move(T&& param) {
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
A simple C++14 implementation of std::forward:
template<typename T>
T&& forward(remove_reference_t<T>& param) {
return static_cast<T&&>(param);
}
std::move always returns an rvalue reference to param; std::forward preserves the value category of param: if param is an lvalue, it returns an lvalue reference; if param is an rvalue, it returns an rvalue reference.
Guidelines:
- Call
std::moveon variables that are rvalue references; - Call
std::forwardon variables that are universal references (in templates); - Apply the above only when you are sure the variable will not be used afterward, to avoid accidentally moving from an object that is still needed.
Situations where perfect forwarding via std::forward may fail:
- Brace initialization;
- Using
NULLor0for null pointers, which can conflict with overloads for integer types; usenullptrinstead; - Forwarding
static constvariables that are declared but not defined. Perfect forwarding forwards references and may require taking the variable’s address. Undeclared definitions have no address, leading to link errors. The variable must be defined; - Forwarding overloaded functions, because it is unclear which overload is intended, leading to ambiguity. Specify the function signature explicitly;
- Using bitfields. C++ explicitly forbids binding non-
constreferences to bitfields. Even if the parameter is aconstreference, the bitfield is first copied into an integer, and the reference binds to that. The workaround is to copy into a variable first and then call the function.
Reference Collapsing
Reference collapsing rules: if any of the references being collapsed is an lvalue reference, the result is an lvalue reference; only if all references are rvalue references is the result an rvalue reference.
Reference collapsing can occur:
- During template argument instantiation;
- When deducing variable types with
auto; - When using
typedeffor types in templates; - When using
decltype.
New definition of universal references: a universal reference is, effectively, an rvalue reference in a context where both of the following hold:
- Type deduction distinguishes between lvalues and rvalues: an lvalue of type
Tis deduced asT&, an rvalue of typeTasT; - Reference collapsing occurs.
Universal References and Overloading
When universal references are combined with overloading, things can get tricky, because the compiler tends to instantiate a template to create a perfect match instead of choosing another overload that requires a conversion. Possible issues:
- Numeric types are not converted (e.g.,
shortpromoted toint); instead, a template is instantiated to create a perfect match; - When calling a copy constructor with a non-
constobject, the copy constructor may not be chosen, because a non-constconstructor generated from a template is a better match; - When a derived class calls a base-class constructor, the constructor generated from a template may be chosen because its type match is better.
Possible solutions:
- Avoid overloading; use different function names instead. This does not apply to constructors;
- Pass variables by
const T&. This forfeits the performance benefits of universal references (moves, constructing from string literals without creating temporarystd::strings, etc.); - Use tag dispatch. Overload resolution is based on matching all function parameters and arguments, so you can use an extra parameter to influence overload selection, such as
std::true_typeandstd::false_type. These parameters are used only for overload resolution, need no variable name, and are not part of the runtime code, hence “tags”; - Constrain templates with
std::enable_if. Using SFINAE (Substitution Failure Is Not An Error), substitution failures do not cause errors. For example, code to address the three issues above:
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n);
explicit Person(int idx);
};
If this kind of code fails, compilers can emit a huge amount of diagnostics. You can use static_assert to localize the error:
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
Lambda Expressions #31–#34
Each lambda expression generates a unique lambda class; a closure is an instance of that class.
Notes on lambda captures:
- Default capture captures the
thispointer, which may cause dangling pointer issues; - Static-storage-duration variables, such as those declared with
static, cannot be captured.
Since C++14, init-capture is supported, for example:
[pw = std::move(pw)]() {
...
}
Init-capture allows moving objects into the closure. C++11 does not support init-capture; to move parameters into a closure, you must simulate it with std::bind().
Since C++14, generic lambdas are supported; you can use auto in parameter types, implemented internally with templates. When you need perfect forwarding, use decltype on the parameter name to deduce its type, for example:
auto f = [] (auto&& param) {
return func(std::forward<decltype(param)>(param));
};
Concurrency Programming #35–#40
std::async()
std::async() provides a task-based concurrency model. Its built-in scheduler avoids problems caused by manually using std::thread, such as too many threads, frequent context switching, and portability issues (when writing your own scheduler). It also lets you retrieve return values or exceptions from the associated std::future. When you need low-level APIs (CPU affinity, thread priority, etc.), you must use std::thread.
std::async() supports two launch policies: concurrent execution (std::launch::async) and deferred execution (std::launch::deferred). With deferred execution, the task body runs in the caller’s thread when std::future::get() is called. The default policy is both (std::launch::async | std::launch::deferred), leaving the scheduler to choose at runtime based on system load. Because of this nondeterminism, you should use the default only when all of the following hold; otherwise specify the launch policy explicitly:
- The task does not need to run concurrently with the thread calling
getorwait; - You don’t need to read or write
thread_localvariables of a specific thread; - Unless you can guarantee that
get()orwait()will be called on thestd::futurereturned bystd::async(), the task might never run (if the scheduler choosesdeferredunder heavy load); - When calling
wait_for()orwait_until(), consider thestd::future_status::deferredstate.
std::thread
When a std::thread is destroyed, it must be in an unjoinable state, i.e., detached from the underlying OS thread. A std::thread is unjoinable in the following situations:
- Default-constructed
std::thread; - A
std::threadthat has been moved from; - A
std::threadon whichjoin()has already been called; - A
std::threadon whichdetach()has already been called.
std::future
std::future is movable but not copyable, and get() can be called only once; calling get() again is an error. Calling std::future::share() creates a std::shared_future, which is copyable and allows multiple calls to get(), enabling safe sharing among threads; concurrent access through separate std::shared_future objects is thread-safe.
std::promise can also create a std::future; they communicate via a heap-allocated shared state that also contains a reference count.
The destructor of std::future usually just destroys its data members, but when the std::future was created by std::async() with the async policy (which creates a new thread) and is the last future pointing to the shared state, its destructor will block until the task is finished (i.e., it effectively calls join() on the thread).
std::future<void> can be used for one-time communication between threads.
volatile
The volatile keyword prevents the compiler from optimizing away accesses to a specific memory location. For example, code like:
auto y = x;
y = x; // redundant loads
x = 10;
x = 20; // dead stores
is likely to be optimized to:
auto y = x;
x = 20;
Using volatile disables such optimizations.
emplace Functions #42
The emplace family is not always faster than push/insert. Performance is usually better only when all of the following conditions hold:
- The value is constructed directly in the container, not inserted via assignment (
operator=) (e.g., inserting at the front of avector); - The argument types differ from the container’s element type (so
push/insertwould need to construct a temporary object first); - The container is unlikely to reject inserts due to duplicate values.
When inserting resource-managing objects (e.g. std::shared_ptr) into containers, you must create the resource-managing object first. With emplace, there may be a gap between acquiring a resource and creating its managing object; if an exception occurs in this interval (e.g. vector reallocation fails due to insufficient memory), the resource may leak. The following code has potential leakage risk:
std::list<std::shared_ptr<Widget>> ptrs;
ptrs.emplace_back(new Widget, killWidget);
With emplace, arguments are perfectly forwarded to the constructor, which means direct initialization is used and explicit constructors are allowed. This does not happen with push/insert, because the compiler does not allow explicit constructors to be used implicitly when creating temporaries.
