Main
- Preface
- Chapter 01: Towards Modern C++
- Chapter 02: Language Usability Enhancements
- Chapter 03: Language Runtime Enhancements
- Chapter 04: Containers
- Chapter 05: Smart Pointers and Memory Management
- Chapter 06: Regular Expression
- Chapter 07: Parallelism and Concurrency
- Chapter 08: File System
- Chapter 09: Minor Features
- Chapter 10: C++20
- Chapter 11: C++23
- Chapter 12: C++26 (Outlook)
- Appendix 1: Further Study Materials
- Appendix 2: Modern C++ Best Practices
- Appendix 3: Modern C++ Feature Index
Chapter 02: Language Usability Enhancements
When we declare, define a variable or constant, and control the flow of code, object-oriented functions, template programming, etc., before the runtime, it may happen when writing code or compiler compiling code. To this end, we usually talk about language usability, which refers to the language behavior that occurred before the runtime.
2.1 Constants
nullptr
(since C++11)
The purpose of nullptr appears to replace NULL. There are null pointer constants in the C and C++ languages,
which can be implicitly converted to null pointer value of any pointer type,
or null member pointer value of any pointer-to-member type in C++.
NULL is provided by the standard library implementation and defined as an implementation-defined null pointer constant.
In C, some standard libraries defines NULL as ((void*)0) and some define it as 0.
C++ does not allow to implicitly convert void * to other types, and thus ((void*)0) is not a valid implementation
of NULL. If the standard library tries to define NULL as ((void*)0), then compilation error would occur in the following code:
char *ch = NULL; |
C++ without the void * implicit conversion has to define NULL as 0.
This still creates a new problem. Defining NULL to 0 will cause the overloading feature in C++ to be confusing.
Consider the following two foo functions:
void foo(char*); |
Then the behavior of the foo(NULL); statement depends on how NULL is implemented: when NULL is defined as 0 (for example, on MSVC), it calls foo(int), which is counterintuitive; when NULL is defined as the GCC/Clang builtin __null, the call foo(NULL) becomes ambiguous between the char* and int overloads and fails to compile. In either case, NULL does not behave the way a proper null pointer should during overload resolution.
To solve this problem, C++11 introduced the nullptr keyword, which is specifically used to distinguish null pointers, 0. The type of nullptr is nullptr_t, which can be implicitly converted to any pointer or member pointer type, and can be compared equally or unequally with them.
You can try to compile the following code using clang++:
#include <iostream> |
The outputs are:
foo(int) is called |
From the output we can see that NULL is different from 0 and nullptr.
So, develop the habit of using nullptr directly.
In addition, in the above code, we used decltype and
std::is_same which are modern C++ syntax.
In simple terms, decltype is used for type derivation,
and std::is_same is used to compare the equality of the two types.
We will discuss them in detail later in the decltype section.
constexpr
(since C++11; relaxed in C++14)
C++ itself already has the concept of constant expressions, such as 1+2, 3*4. Such expressions always produce the same result without any side effects. If the compiler can directly optimize and embed these expressions into the program at compile-time, it will increase the performance of the program. A very obvious example is in the definition phase of an array:
#include <iostream> |
In the above example, char arr_4[len_2] may be confusing because len_2 has been defined as a constant.
Why is char arr_4[len_2] still illegal?
This is because the length of the array in the C++ standard must be a constant expression,
and for len_2, this is a const constant, not a constant expression,
so even if this behavior is supported by most compilers, but it is an illegal behavior,
we need to use the constexpr feature introduced in C++11, which will be introduced next,
to solve this problem; for arr_5, before C++98 The compiler cannot know that len_foo()
actually returns a constant at runtime, which causes illegal production.
Note that some compilers (e.g. GCC, Clang) have compiler extensions enabled by default, supporting a C feature called "variable-length arrays", which allows defining an array whose length is a non-constant expression and causes the above commented out illegal code to be compilable. To disable the extension, add the compilation option
-pedantic-errors(available for both GCC and Clang).
C++11 provides constexpr to let the user explicitly declare that the function or
object constructor will become a constant expression at compile time.
This keyword explicitly tells the compiler that it should verify that len_foo
should be a compile-time constant expression.
In addition, the function of constexpr can use recursion:
constexpr int fibonacci(const int n) { |
Starting with C++14, the constexpr function can use simple statements such as local variables, loops, and branches internally. For example, the following code cannot be compiled under the C++11 standard:
constexpr int fibonacci(const int n) { |
To do this, we can write a simplified version like this to make the function available from C++11:
constexpr int fibonacci(const int n) { |
2.2 Variables and initialization
if-switch
(since C++17)
In traditional C++, the declaration of a variable can declare a temporary variable int
even though it can be located anywhere, even within a for statement,
but there is always no way to declare a temporary variable in the if and switch statements.
E.g:
#include <iostream> |
In the above code, we can see that the itr variable is defined in the scope of
the entire main(), which causes us to rename the other when a variable need to traverse
the entire std::vector again. C++17 eliminates this limitation so that
we can do this in if(or switch):
// put the temporary variable into the if-statement |
Is it similar to the Go?
Initializer list
(since C++11)
Initialization is a very important language feature,
the most common one is when the object is initialized.
In traditional C++, different objects have different initialization methods,
such as ordinary arrays, PODs (Plain Old Data,
i.e. classes without constructs, destructors, and virtual functions)
Or struct type can be initialized with {},
which is what we call the initialization list.
For the initialization of the class object,
you need to use the copy construct,
or you need to use ().
These different methods are specific to each other and cannot be generic.
E.g:
#include <iostream> |
To solve this problem,
C++11 first binds the concept of the initialization list to the type
and calls it std::initializer_list,
allowing the constructor or other function to use the initialization list
like a parameter, which is the initialization of class objects provides
a unified bridge between normal arrays and POD initialization methods,
such as:
#include <initializer_list> |
This constructor is called the initialize list constructor, and the type with this constructor will be specially taken care of during initialization.
In addition to the object construction, the initialization list can also be used as a formal parameter of a normal function, for example:
public: |
Second, C++11 also provides a uniform syntax for initializing arbitrary objects, such as:
Foo foo2 {3, 4}; |
Structured binding
(since C++17)
Functions frequently need to "return several values at once" — for example, a computed result together with a status flag. In traditional C++ this is not elegant: we either define a dedicated struct for it, or pack the values into a std::tuple and return that, but getting the values back out is clumsy — unpacking with std::tie forces us to declare every variable in advance and to know exactly how many elements the tuple holds and the type of each, and any mismatch is an error.
C++17's structured bindings exist precisely to remove that clumsiness: they let us, in a single line, "unpack" a tuple, a std::pair, a raw array, or a struct with public data members, and bind the pieces directly to a set of named variables, with the types deduced by the compiler:
#include <iostream> |
Compared with std::tie, structured bindings need no prior declaration and no spelled-out types, and they work not only on tuples but also on raw arrays and aggregate structs. This is especially handy when iterating an associative container: we can bind each key/value pair to meaningful names instead of writing it->first / it->second:
#include <iostream> |
The auto type derivation is described in the
auto type inference section.
2.3 Type inference
In traditional C and C++, the types of parameters must be clearly defined, which does not help us to quickly encode, especially when we are faced with a large number of complex template types, we must indicate the type of variables to proceed. Subsequent coding, which not only slows down our development efficiency but also makes the code stinking and long.
C++11 introduces the two keywords auto and decltype to implement type derivation, letting the compiler worry about the type of the variable. This makes C++ the same as other modern programming languages, in a way that provides the habit of not having to worry about variable types.
auto
(since C++11)
auto has been in C++ for a long time, but it always exists as an indicator of a storage type, coexisting with register. In traditional C++, if a variable is not declared as a register variable, it is automatically treated as an auto variable. And with register being deprecated (used as a reserved keyword in C++17 and later used, it doesn't currently make sense), the semantic change to auto is very natural.
One of the most common and notable examples of type derivation using auto is the iterator. You should see the lengthy iterative writing in traditional C++ in the previous section:
// before C++11 |
When we have auto:
#include <initializer_list> |
Some other common usages:
auto i = 5; // i as int |
Since C++ 14, auto can even be used as function arguments in generic lambda expressions,
and such functionality is generalized to normal functions in C++ 20.
Consider the following example:
auto add14 = [](auto x, auto y) -> int { |
Note:
autocannot be used to derive array types yet:
auto auto_arr2[10] = {arr}; // illegal, can't infer array type
2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
auto auto_arr2[10] = {arr};
decltype
(since C++11)
The decltype keyword is used to solve the defect that the auto keyword
can only deduce the type of a variable. Its usage is very similar to typeof,
a non-standard extension long provided by some compilers (e.g. GCC and Clang)
that was eventually standardized in C23 but has never been part of standard C++:
decltype(expression) |
Sometimes we may need to calculate the type of an expression, for example:
auto x = 1; |
You have seen in the previous example that
decltype is used to infer the usage of the type.
The following example is to determine
if the above variables x, y, z are of the same type:
if (std::is_same<decltype(x), int>::value) |
Among them, std::is_same<T, U> is used to determine whether
the two types T and U are equal. The output is:
type x == int |
tail type inference
(since C++11)
You may think that whether auto can be used to deduce the return type of a function. Still consider an example of an add function, which we have to write in traditional C++:
template<typename R, typename T, typename U> |
Note: There is no difference between typename and class in the template parameter list. Before the keyword typename appears, class is used to define the template parameters. However, when defining a variable with nested dependency type in the template, you need to use typename to eliminate ambiguity.
Such code is very ugly because the programmer must explicitly
indicate the return type when using this template function.
But in fact, we don't know what kind of operation
the add() function will do, and what kind of return type to get.
This problem was solved in C++11. Although you may immediately
react to using decltype to derive the type of x+y,
write something like this:
decltype(x+y) add(T x, U y) |
But in fact, this way of writing can not be compiled.
This is because x and y have not been defined
when the compiler reads decltype(x+y).
To solve this problem, C++11 also introduces a trailing return type,
which uses the auto keyword to post the return type:
template<typename T, typename U> |
The good news is that from C++14 it is possible to directly derive the return value of a normal function, so the following way becomes legal:
template<typename T, typename U> |
You can check if the type derivation is correct:
// after c++11 |
decltype(auto)
(since C++14)
decltype(auto) is a slightly more complicated use of C++14.
To understand it you need to know the concept of parameter forwarding in C++, which we will cover in detail in the Language Runtime Enhancements chapter, and you can come back to the contents of this section later.
In simple terms, decltype(auto) is mainly used to derive
the return type of a forwarding function or package,
which does not require us to explicitly specify
the parameter expression of decltype.
Consider the following example, when we need to wrap the following
two functions:
std::string lookup1(); |
In C++11:
std::string look_up_a_string_1() { |
With decltype(auto), we can let the compiler do this annoying parameter forwarding:
decltype(auto) look_up_a_string_1() { |
2.4 Control flow
if constexpr
(since C++17)
As we saw at the beginning of this chapter, we know that C++11 introduces the constexpr keyword, which compiles expressions or functions into constant results. A natural idea is that if we introduce this feature into the conditional judgment, let the code complete the branch judgment at compile-time, can it make the program more efficient? C++17 introduces the constexpr keyword into the if statement, allowing you to declare the condition of a constant expression in your code. Consider the following code:
#include <iostream> |
At compile time, the actual code will behave as follows:
int print_type_info(const int& t) { |
Range-based for loop
(since C++11)
Finally, C++11 introduces a range-based iterative method, and we can write loops that are as concise as Python, and we can further simplify the previous example:
#include <iostream> |
A range-based for loop is essentially syntactic sugar. The compiler expands
for (range_declaration : range_expression) loop_statement |
roughly into (since C++17):
{ |
Therefore, any type that provides usable begin() and end() (member functions, or free functions found via ADL) whose returned iterators support !=, dereference *, and pre-increment ++ can be traversed by a range-based for loop — which is exactly how you make a custom container work with it.
2.5 Templates
C++ templates have always been a special art of the language, and templates can even be used independently as a new language. The philosophy of the template is to throw all the problems that can be processed at compile time into the compile time, and only deal with those core dynamic services at runtime, to greatly optimize the performance of the runtime. Therefore, templates are also regarded by many as one of the black magic of C++.
Extern templates
(since C++11)
In traditional C++, templates are instantiated by the compiler only when they are used. In other words, as long as a fully defined template is encountered in the code compiled in each compilation unit (file), it will be instantiated. This results in an increase in compile time due to repeated instantiations. Also, we have no way to tell the compiler not to trigger the instantiation of the template.
To this end, C++11 introduces an external template that extends the syntax of the original mandatory compiler to instantiate a template at a specific location, allowing us to explicitly tell the compiler when to instantiate the template:
template class std::vector<bool>; // force instantiation |
The ">"
(since C++11)
In the traditional C++ compiler, >> is always treated as a right shift operator. But actually we can easily write the code for the nested template:
std::vector<std::vector<int>> matrix; |
This is not compiled under the traditional C++ compiler, and C++11 starts with continuous right angle brackets that become legal and can be compiled successfully. Even the following writing can be compiled by:
template<bool T> |
Type alias templates
(since C++11)
Before you understand the type alias template, you need to understand the difference between "template" and "type". Carefully understand this sentence: Templates are used to generate types. In traditional C++, typedef can define a new name for the type, but there is no way to define a new name for the template. Because the template is not a type. E.g:
template<typename T, typename U> |
C++11 uses using to introduce the following form of writing, and at the same time supports the same effect as the traditional typedef:
Usually, we use
typedefto define the alias syntax:typedef original name new name;, but the definition syntax for aliases such as function pointers is different, which usually causes a certain degree of difficulty for direct reading.
typedef int (*process)(void *); |
Variadic templates
(since C++11)
The template has always been one of C++'s unique Black Magic. In traditional C++, both a class template and a function template could only accept a fixed set of template parameters as specified; C++11 added a new representation, allowing any number, template parameters of any category, and there is no need to fix the number of parameters when defining.
template<typename... Ts> class Magic; |
The template class Magic object can accept an unrestricted number of typename as a formal parameter of the template, such as the following definition:
class Magic<int, |
Since it is arbitrary, a template parameter with a number of 0 is also possible: class Magic<> nothing;.
If you do not want to generate 0 template parameters, you can manually define at least one template parameter:
template<typename Require, typename... Args> class Magic; |
The variable length parameter template can also be directly adjusted to the template function.
The printf function in the traditional C, although it can also reach the call of an indefinite number of formal parameters, is not class safe. In addition to the variable-length parameter functions that define class safety, C++11 can also make printf-like functions naturally handle objects that are not self-contained. In addition to the use of ... in the template parameters to indicate the indefinite length of the template parameters, the function parameters also use the same representation to represent the indefinite length parameters, which provides a convenient means for us to simply write variable length parameter functions, such as:
template<typename... Args> void printf(const std::string &str, Args... args); |
Then we define variable length template parameters, how to unpack the parameters?
First, we can use sizeof... to calculate the number of arguments:
#include <iostream> |
We can pass any number of arguments to the magic function:
magic(); // 0 |
Second, the parameters are unpacked. So far there is no simple way to process the parameter package, but there are two classic processing methods:
1. Recursive template function
Recursion is a very easy way to think of and the most classic approach. This method continually recursively passes template parameters to the function, thereby achieving the purpose of recursively traversing all template parameters:
#include <iostream> |
2. Variable parameter template expansion
You should feel that this is very cumbersome. Added support for variable parameter template expansion in C++17, so you can write printf in a function:
template<typename T0, typename... T> |
In fact, sometimes we use variable parameter templates, but we don't necessarily need to traverse the parameters one by one. We can use the features of
std::bindand perfect forwarding to achieve the binding of functions and parameters, thus achieving success. The purpose of the call.
3. Initialize list expansion
Recursive template functions are standard practice, but the obvious drawback is that you must define a function that terminates recursion.
Here is a description of the black magic that is expanded using the initialization list:
template<typename T, typename... Ts> |
In this code, the initialization list provided in C++11 and the properties of the Lambda expression (mentioned in the next section) are additionally used.
By initializing the list, (lambda expression, value)... will be expanded. Due to the appearance of the comma expression, the previous lambda expression is executed first, and the output of the parameter is completed.
To avoid compiler warnings, we can explicitly convert std::initializer_list to void.
Fold expression
(since C++17)
In C++ 17, this feature of the variable length parameter is further brought to the expression, consider the following example:
#include <iostream> |
Non-type template parameter deduction
(since C++17)
What we mainly mentioned above is a form of template parameters: type template parameters.
template <typename T, typename U> |
The parameters of the template T and U are specific types.
But there is also a common form of template parameter that allows different literals
to be template parameters, i.e. non-type template parameters:
template <typename T, int BufSize> |
In this form of template parameters, we can pass 100 as a parameter to the template.
After C++11 introduced the feature of type derivation, we will naturally ask, since the template parameters here.
Passing with a specific literal, can the compiler assist us in type derivation,
By using the placeholder auto, there is no longer a need to explicitly specify the type?
Fortunately, C++17 introduces this feature, and we can indeed use the auto keyword to let the compiler assist in the completion of specific types of derivation.
E.g:
template <auto value> void foo() { |
SFINAE and std::enable_if
(since C++11)
SFINAE stands for "Substitution Failure Is Not An Error". It describes the rule that, when substituting template arguments produces an invalid type or expression in the immediate context, the compiler does not raise an error but silently removes that candidate from the overload set. This was the main way to constrain template parameters before C++20's concepts.
The most common tool is std::enable_if from <type_traits>. The describe below is visible only for integral types:
#include <type_traits> |
Another common form is expression SFINAE, which uses decltype to probe whether an expression is valid, thereby detecting at compile time whether a type has a certain capability:
// participates only if c.size() is a valid expression |
SFINAE is powerful but obscure to write and produces verbose error messages. C++20's concepts were introduced precisely to express such constraints in a more intuitive and readable way, and can be regarded as the modern replacement for SFINAE.
2.6 Object-oriented
Delegate constructor
(since C++11)
A class often has several constructors that share a lot of the same initialization logic. Before C++11, the only ways to reuse that logic were to copy it into every constructor, or to extract a private init function — but the latter cannot initialize const members or reference members. C++11's delegating constructors let one constructor delegate its initialization to another constructor of the same class, removing that duplication:
#include <iostream> |
Inheritance constructor
(since C++11)
In traditional C++, if a derived class wants to reuse its base class's constructors, it must redeclare each of them and forward the arguments one by one to the base, which is tedious and error-prone. C++11 introduces inheriting constructors via the using keyword, letting a derived class inherit all of the base class's constructors at once:
#include <iostream> |
Explicit virtual function overwrite
(since C++11)
In traditional C++, it is often prone to accidentally overloading virtual functions. E.g:
struct Base { |
SubClass::foo may not be a programmer trying to overload a virtual function, just adding a function with the same name. Another possible scenario is that when the virtual function of the base class is deleted, the subclass owns the old function and no longer overloads the virtual function and turns it into a normal class method, which has catastrophic consequences.
C++11 introduces the two keywords override and final to prevent this from happening.
override
When overriding a virtual function, introducing the override keyword will explicitly tell the compiler to overload, and the compiler will check if the base function has such a virtual function with consistent function signature, otherwise it will not compile:
struct Base { |
final
final is to prevent the class from being continued to inherit and to terminate
the virtual function to continue to be overloaded.
struct Base { |
Explicit delete default function
(since C++11)
In traditional C++, if the programmer does not provide it, the compiler will default to generating default constructors, copy constructs, assignment operators, and destructors for the object. Besides, C++ also defines operators such as new delete for all classes. This part of the function can be overridden when the programmer needs it.
This raises some requirements: the ability to accurately control the generation of default functions cannot be controlled. For example, when copying a class is prohibited, the copy constructor and the assignment operator must be declared as private. Trying to use these undefined functions will result in compilation or link errors, which is a very unconventional way.
Also, the default constructor generated by the compiler cannot exist at the same time as the user-defined constructor. If the user defines any constructor, the compiler will no longer generate the default constructor, but sometimes we want to have both constructors at the same time, which is awkward.
C++11 provides a solution to the above requirements, allowing explicit declarations to take or reject functions that come with the compiler. E.g:
class Magic { |
Strongly typed enumerations
(since C++11)
In traditional C++, enumerated types are not type-safe, and enumerated types are treated as integers, which allows two completely different enumerated types to be directly compared (although the compiler gives the check, but not all), ** Even the enumeration value names of different enum types in the same namespace cannot be the same**, which is usually not what we want to see.
C++11 introduces an enumeration class and declares it using the syntax of enum class:
enum class new_enum : unsigned int { |
The enumeration thus defined implements type safety. First, it cannot be implicitly converted to an integer, nor can it be compared to integer numbers, and it is even less likely to compare enumerated values of different enumerated types. But if the values specified are the same between the same enumerated values, then you can compare:
if (new_enum::value3 == new_enum::value4) { // true |
In this syntax, the enumeration type is followed by a colon and a type keyword to specify the type of the enumeration value in the enumeration, which allows us to assign a value to the enumeration (int is used by default when not specified).
And we want to get the value of the enumeration value, we will have to explicitly type conversion, but we can overload the << operator to output, you can collect the following code snippet:
#include <iostream> |
At this point, the following code will be able to be compiled:
std::cout << new_enum::value3 << std::endl |
2.7 Other Language Features
Inline variables
(since C++17)
Before C++17, a non-const static data member of a class had to be defined separately outside the class, and defining a global variable in a header would cause duplicate-definition link errors when the header was included by multiple translation units. C++17 introduces inline variables, which allow a variable (including a static data member) to be defined in a header without violating the One Definition Rule (ODR), even when included by multiple translation units:
struct Widget { |
This greatly simplifies writing header-only libraries.
Nested namespace definitions
(since C++17)
C++17 allows writing nested namespace definitions in a single line using ::, instead of indenting level by level:
// before C++17 |
constexpr lambda
(since C++17)
Since C++17, a lambda expression that satisfies the requirements of a constant expression is implicitly constexpr (and may also be explicitly marked constexpr), so it can be evaluated at compile time:
constexpr auto add = [](int a, int b) { return a + b; }; |
Single-argument static_assert
(since C++17)
static_assert performs a compile-time assertion. Before C++17 it required a diagnostic message as its second argument; since C++17 that message is optional:
static_assert(sizeof(int) >= 2); // C++17: message optional |
New aggregate rules
(since C++17)
C++17 relaxed the definition of an aggregate: an aggregate may now have public base classes (which must themselves be aggregates), and the base subobject can be brace-initialized along with the rest:
struct Base { int a; }; |
Boolean logic metafunctions
(since C++17)
C++17 added std::conjunction, std::disjunction, and std::negation to <type_traits> for composing other type traits with logical AND/OR/NOT at compile time (and conjunction/disjunction short-circuit):
#include <type_traits> |
__has_include
(since C++17)
C++17 standardized the preprocessor operator __has_include, which checks at compile time whether a header is available, enabling portable conditional inclusion:
#if __has_include(<optional>) |
This is very useful when supporting different standard-library versions, or providing a fallback when a feature might be missing.
Conclusion
This section introduces the enhancements to language usability in modern C++, which I believe are the most important features that almost everyone needs to know and use:
- Auto type derivation
- Scope for iteration
- Initialization list
- Variable parameter template
Exercises
Using structured binding, implement the following functions with just one line of function code:
#include <string>
#include <map>
#include <iostream>
template <typename Key, typename Value, typename F>
void update(std::map<Key, Value>& m, F foo) {
// TODO:
}
int main() {
std::map<std::string, long long int> m {
{"a", 1},
{"b", 2},
{"c", 3}
};
update(m, [](std::string key) {
return std::hash<std::string>{}(key);
});
for (auto&& [key, value] : m)
std::cout << key << ":" << value << std::endl;
}Try to implement a function for calculating the mean with Fold Expression, allowing any arguments to be passed in.
Refer to the answer see this.
Changkun Ou © 2016-2026. The book is licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0, code is open sourced under the MIT License.
If you like the book, you could donate the author.