C++ 17 指南

第一章 结构化绑定

结构化绑定允许你使用对象的成员或者说元素来初始化多个变量。

举个例子,假如你定义了一个包含两个不同成员的结构:

1
2
3
4
5
6
struct MyStruct {
int i = 0;
std::string s;
};

MyStruct ms;

只需使用下面的声明,你就可以将这个结构体的成员直接绑定到新名字上
1
auto [u,v] = ms;

在这里,名字u和v就被称为结构化绑定(structured bindings)。在某种程度上,它们分解了对象并用来初始化自己(在有些地方它们也被称为分解声明(decompose declarations))。

结构化绑定对于那些返回结构体或者数组的函数来说尤其有用。举个例子,假设你有一个返回结构体的函数:

1
2
3
MyStruct getStruct() {
return MyStruct{42, "hello"};
}

你可以直接为函数返回的数据成员赋予两个局部名字:
1
auto[id,val] = getStruct(); // id and val name i and s of returned struct

在这里,id和val分别表示返回的数据成员i和s。它们的类型分别是int和std::string ,可以当新变量使用。
1
2
3
if (id > 30) {
std::cout << val;
}

使用结构化绑定的好处是可以直接通过名字访问值,并且由于名字可以传递语义信息,使得代码可读性也大大提高。

下面的示例展示了结构化绑定如何改善代码可读性。在没有结构化绑定的时候,要想迭代处理std::map<>的所有元素,需要这么写:

1
2
3
for (const auto& elem : mymap) {
std::cout << elem.first << ": " << elem.second << '\n';
}

代码中的elem是表示键和值的std::pair,它们在std::pair中分别用first和second表示,你可以使用这两个名字去访问键和值。使用结构化绑定后,代码可读性大大提高:
1
2
3
for (const auto& [key,val] : mymap) {
std::cout << key << ": " << val << '\n';
}

我们可以直接使用每个元素的键和值,key和value清晰的表示了它们的语义。

1.1 结构化绑定的细节

为了理解结构化绑定,了解其中设计的一个匿名变量是很重要的。结构化绑定引入的新名字都是指代的这个匿名变量的成员/元素的。

绑定到匿名变量

初始化代码的最精确的行为:

1
auto [u,v] = ms;

可以看成我们初始化一个匿名变量e,然后让结构化绑定u和v成为这个新对象的别名,类似下面:
1
2
3
auto e = ms;
aliasname u = e.i;
aliasname v = e.s;

注意u和v不是e.ie.s的引用。它们只是这两个成员的别名。因此,decltype(u)的类型与成员i的类型一致,decltype(v)的类型与成员s的类型一致。因为匿名变量e没有名字,所以我们不能直接访问这个已经初始化的变量。所以
1
std::cout << u << ' ' << v << ✬\n✬;

输出e.ie.s的值,它们是ms.ims.s的一份拷贝。

e和结构化绑定的存活时间一样长,当结构化绑定离开作用域时,e也会析构。

这样做的后果,除非使用引用,否则修改通过结构化绑定的值不会影响到初始化它的对象(反之亦然):

1
2
3
4
5
6
MyStruct ms{42,"hello"};
auto [u,v] = ms;
ms.i = 77;
std::cout << u; // prints 42
u = 99;
std::cout << ms.i; // prints 77

u和ms.i地址是不一样的。

当对返回值使用结构化绑定的时候,上面的规则一样成立。下面代码的初始化:

1
auto [u,v] = getStruct();

和我们使用getStruct()的返回值初始化匿名变量e,然后用u和v作为e的成员别名效果一样,类似下面:
1
2
3
auto e = getStruct();
aliasname u = e.i;
aliasname v = e.s;

换句话说,结构化绑定将绑定到一个新的对象,它由返回值初始化,而不是直接绑定到返回值本身。

对于匿名变量e,内存地址和对齐也是存在的,以至于如果成员有对齐,结构化绑定也会有对齐。比如:

1
2
auto [u,v] = ms;
assert(&((MyStruct*)&u)->s == &v); // OK

((MyStruct*)&u)会产生一个指向匿名变量的指针。

使用修饰符

我们在结构化绑定过程中使用一些修饰符,如const和引用。再次强调,这些修饰符修饰的是匿名变量e。虽说是对匿名变量使用修饰符,但是通常也可以看作对结构化绑定使用修饰符,尽管存在一些额例外。

下面的例子中,我们对结构化绑定使用const引用:

1
const auto& [u,v] = ms; // a reference, so that u/v refer to ms.i/ms.s

这里,匿名变量被声明为const引用,这意味着对ms使用const引用修饰,然后再将u和v作为i和s的别名。后续对ms成员的修改会直接影响到u和v:
1
2
ms.i = 77;      // affects the value of u
std::cout << u; // prints 77

如果使用非const引用,你甚至可以通过对结构化绑定的修改,影响到初始化它的对象:
1
2
3
4
5
6
MyStruct ms{42,"hello"};
auto& [u,v] = ms; // the initialized entity is a reference to ms
ms.i = 77; // affects the value of u
std::cout << u; // prints 77
u = 99; // modifies ms.i
std::cout << ms.i; // prints 99

如果初始化对象是临时变量,对它使用结构化绑定,此时临时值的生命周期会扩展:
1
2
3
4
MyStruct getStruct();
...
const auto& [a,b] = getStruct();
std::cout << "a: " << a << '\n'; // OK

修饰符并非修饰结构化绑定

如题,修饰符修饰的是匿名变量。它们没必要修饰结构化绑定。事实上:

1
const auto& [u,v] = ms;  // a reference, so that u/v refer to ms.i/ms.s

u和v都没有声明为引用。上面只是对匿名变量e的引用。u和v的类型需要ms的成员一致。根据我们最开始的定义可以知道,decltype(u)是int,decltype(v)std::string

当指定对齐宽度的时候也有一些不同。

1
alignas(16) auto [u,v] = ms;

在这里,我们将初始化后的匿名对象对齐而不是结构化绑定u和v。这意味着u作为第一个成员,被强制对齐到16位,而v不是。

同样的原因,尽管使用了auto,结构化绑定的类型也不会类型退化(术语退化(decay)描述的是当参数值传递的时候发生的类型转换,这意味着数组会转换为指针,最外面的修饰符如const和引用会被忽略)。例如,如果我们有一个包含多个原生数组的结构体:

1
2
3
4
struct S{
const char x[6];
const char y[3];
};

然后
1
2
S s1{};
auto [a, b] = s1; // a and b get the exact member types

a的类型仍然是const char[6]。原因仍然是修饰符并非修饰结构化绑定而是修饰初始化结构化绑定的对象。这一点和使用auto初始化新对象很不一样,它会发生类型退化:
1
auto a2 = a;    // a2 gets decayed type of a

移动语义

即将介绍到,结构化绑定也支持移动语义。在下面的声明中:

1
2
MyStruct ms = { 42, "Jim" };
auto&& [v,n] = std::move(ms); // entity is rvalue reference to ms

结构化绑定v和n指向匿名变量中的成员,该匿名变量是ms的右值引用。ms仍然持有它的值:
1
std::cout << "ms.s: " << ms.s << '\n'; // prints "Jim"

但是你可以移动赋值n,它与ms.s关联:
1
2
3
4
std::string s = std::move(n); // moves ms.s to s
std::cout << "ms.s: " << ms.s << '\n'; // prints unspecified value
std::cout << "n: " << n << '\n'; // prints unspecified value
std::cout << "s: " << s << '\n'; // prints "Jim"

通常,移动后的对象的状态是有效的,只是包含了未指定的值(unspecified value)。因此,输出它的值是没有问题的,但是不能断言输出的东西一定是什么。

这一点和直接移动ms的值给匿名变量稍有不同:

1
2
MyStruct ms = { 42, "Jim" };
auto [v,n] = std::move(ms); // new entity with moved-from values from ms

此时匿名对象是一个新对象,它用移动后的ms的值来初始化。所以ms失去了他们的值:
1
2
std::cout << "ms.s: " << ms.s << '\n'; // prints unspecified value
std::cout << "n: " << n << '\n'; // prints "Jim"

你仍然可以移动n并赋值,或者用它赋予一个新的值,但是不会影响ms.s
1
2
3
4
5
std::string s = std::move(n); // moves n to s
n = "Lara";
std::cout << "ms.s: " << ms.s << '\n'; // prints unspecified value
std::cout << "n: " << n << '\n'; // prints "Lara"
std::cout << "s: " << s << '\n'; // prints "Jim"

1.2 结构化绑定可以在哪使用

原则上,结构化绑定可以用于公有成员,原始C-style数组,以及“似若tuple”的对象:

  • 如果结构体或者类中,所有非静态数据成员都是public,那么你可以使用结构化绑定来绑定非静态数据成员
  • 对于原生数组,你可以使用结构化绑定来绑定每个元素
  • 对于任何类型,你都可以使用似若tuple的API来进行绑定。对于类型type,API可以粗糙的概括为下列内容:
    • std::tuple_size<type>::value返回元素数量
    • std::tupel_element<idx,type>::type返回第idx个元素的类型
    • 一个全局的或者成员函数get<idx>()返回第idx个元素的值

如果结构体或者累提供这些似若tuple的API,那么就可以使用它们。

任何情况下都要求元素或者数据成员的数量必须匹配结构化绑定的名字的个数。你不能跳过任何一个元素,也不能使用同一个名字两次。但是你可以看使用非常段的名字如”_”(很多程序员倾向于用下划线,但是也有些人讨厌它,不允许它出现在全局命名空间中),但是在一个作用域它也只能出现一次:

1
2
auto [_,val1] = getStruct(); // OK
auto [_,val2] = getStruct(); // ERROR: name _ already used

嵌套或者非平坦的对象分解是不支持的。(译注:指的是形如OCaml等语言的这种let a,(b,c) = (3,(4,2));;模式匹配能力)

接下来的章节讨论本节列表提到的各种情况。

1.2.1 结构体和类

到目前为止,已经演示了很多关于结构体和类的简单示例了。

如果类和结构体用到了继承,那么结构化绑定的使用就很受限了。所有非静态数据成员必须出现在同一个类。(换句话说,这些数据成员要么全是该类的,要么全是基类的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct B {
int a = 1;
int b = 2;
};

struct D1 : B {
};
auto [x, y] = D1{}; // OK

struct D2 : B {
int c = 3;
};

auto [i, j, k] = D2{}; // Compile-Time ERROR

1.2.1 原生数组

下面的代码使用有两个元素的C-style数组初始化x和y:

1
2
3
int arr[] = { 47, 11 };
auto [x, y] = arr; // x and y are ints initialized by elems of arr
auto [z] = arr; // ERROR: number of elements doesn’t fit

这种方式只能出现在数组长度已知的情况下。如果将数组作为参数传递,这样写就行不通,因为数组作为参数传递会发生类型退化,变成指针类型。

C++允许我们返回带长度的数组引用,如果有函数返回这种带长度的数组引用,那么也可以使用结构化绑定:

1
2
3
auto getArr() -> int(&)[2]; // getArr() returns reference to raw int array
...
auto [x, y] = getArr(); // x and y are ints initialized by elems of returned array

你也可以对std::array使用结构化绑定,但是这需要使用似若tuple的API,这也是下一节的内容。

1.2.3 std::paor,std::tuplestd::array

结构化绑定是可扩展的,你可以为任何类型添加结构化绑定机制。标准库为std::paor,std::tuplestd::array都添加了该机制。

std::array

举个例子,下面的getArray()将返回四个元素的std::array<>,并用它初始化i,j,k和l。

1
2
3
std::array<int,4> getArray();
...
auto [i,j,k,l] = getArray(); // i,j,k,l name the 4 elements of the copied return value

i,j,k和l分别绑定到getArray()返回的四个元素上。

写操作也是支持的,但这要求用来初始化结构化绑定的值不是一个临时的返回值:

1
2
3
4
std::array<int,4> stdarr { 1, 2, 3, 4 };
...
auto& [i,j,k,l] = stdarr;
i += 10; // modifies std::array[0]

std::tuple

下面的代码使用getTuple()返回有三个元素的std::tuple<>来初始化a,b和c:

1
2
3
std::tuple<char,float,std::string> getTuple();
...
auto [a,b,c] = getTuple(); // a,b,c have types and values of returned tuple

std::pair

另一个例子是处理关联型/无序型容器的insert()调用的返回值,使用结构化绑定使代码可读性更强,可以清晰的表达自己的意图,而不是依赖于std::tuple通用的first和second:

1
2
3
4
5
6
7
std::map<std::string, int> coll;
...
auto [pos,ok] = coll.insert({"new",42});
if (!ok) {
// if insert failed, handle error using iterator pos:
...
}

在C++17之前,必须使用下面的代码检查返回数据:
1
2
3
4
5
auto ret = coll.insert({"new",42});
if (!ret.second){
// if insert failed, handle error using iterator ret.first
...
}

注意,在这个例子中,C++17甚至还提供一种表达力更强的带初始化的if:

为pair和tuple的结构化绑定赋值

在声明了结构化绑定之后,通常你不能一次性修改全部结构化绑定,因为结构化绑定是一次性声明所有而不是一次性使用所有。然而,如果重新赋的值是std::pair<>或者std::tuple<>那么你可以使用std::tie()

也就是说,你可以写出下面的代码:

1
2
3
4
5
std::tuple<char,float,std::string> getTuple();
...
auto [a,b,c] = getTuple(); // a,b,c have types and values of returned tuple
...
std::tie(a,b,c) = getTuple(); // a,b,c get values of next returned tuple

这种方式在实现循环调用且每次循环赋予一对返回值的过程中尤其有用,比如下面子啊循环中使用searcher的代码:
1
2
3
4
5
6
std::boyer_moore_searcher bm{sub.begin(), sub.end()};
for (auto [beg, end] = bm(text.begin(), text.end());
beg != text.end();
std::tie(beg,end) = bm(end, text.end())) {
...
}

1.3 为结构化绑定提供似若tuple的API

前面提到过,只要你的类型实现了似若tuple的API,那么就可以针对该类型使用结构化绑定,就和标准库的std::pair<>,std::tuple<>std::array<>意义。

只读结构化绑定

下面的代码展示了如何为类型Customer添加结构化绑定功能,Customer的定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// lang/customer1.hpp
#include <string>
#include <utility> // for std::move()
class Customer {
private:
std::string first;
std::string last;
long val;
public:
Customer (std::string f, std::string l, long v)
: first(std::move(f)), last(std::move(l)), val(v) {
}
std::string getFirst() const {
return first;
}
std::string getLast() const {
return last;
}
long getValue() const {
return val;
}
};

我们可以提供似若tuple的API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// lang/structbind1.hpp
#include "customer1.hpp" #include <utility> // for tuple-like API
// provide a tuple-like API for class Customer for structured bindings:
template<>
struct std::tuple_size<Customer> {
static constexpr int value = 3; // we have 3 attributes
};
template<>
struct std::tuple_element<2, Customer> {
using type = long; // last attribute is a long
};
template<std::size_t Idx>
struct std::tuple_element<Idx, Customer> {
using type = std::string; // the other attributes are strings
};
// define specific getters:
template<std::size_t> auto get(const Customer& c);
template<> auto get<0>(const Customer& c) { return c.getFirst(); }
template<> auto get<1>(const Customer& c) { return c.getLast(); }
template<> auto get<2>(const Customer& c) { return c.getValue(); }

代码Customer有三个成员,还有为三个成员准备的getter:

  • 表示first name的成员,std::string类型
  • 表示last nane的成员,std::string类型
  • 表示value的成员,long类型

获取Customer成员个数的函数是std::tuple_size的特化:

1
2
3
4
template<>
struct std::tuple_size<Customer> {
static constexpr int value = 3; // we have 3 attributes
};

获取成员类型的函数是std::tuple_element的特化:
1
2
3
4
5
6
7
8
template<>
struct std::tuple_element<2, Customer> {
using type = long; // last attribute is a long
};
template<std::size_t Idx>
struct std::tuple_element<Idx, Customer> {
using type = std::string; // the other attributes are strings
};

第三个成员类型是long,需要为它(index 2)编写全特化代码。其它成员是std::stinrg类型,部分特化(比全特化优先级低)即可。这里指定的类型与decltype产生的类型一致。

最终,我们在同一个命名空间为Customer类型定义相应的get<>()函数重载:

1
2
3
4
template<std::size_t> auto get(const Customer& c);
template<> auto get<0>(const Customer& c) { return c.getFirst(); }
template<> auto get<1>(const Customer& c) { return c.getLast(); }
template<> auto get<2>(const Customer& c) { return c.getValue(); }

在这里,我们声明了模板函数,然后为所有情况都写出来对应的全特化形式。

注意,模板函数的全特化必须与模板函数的签名一致(也包括一致的返回类型)。原因是我们只提供了特定的“实现”,而不是声明新的函数。下面的代码不能通过编译:

1
2
3
4
template<std::size_t> auto get(const Customer& c);
template<> std::string get<0>(const Customer& c) { return c.getFirst(); }
template<> std::string get<1>(const Customer& c) { return c.getLast(); }
template<> long get<2>(const Customer& c) { return c.getValue(); }

通过使用新的编译时if特性,我们可以所有特化形式的get<>()组合到一个函数里面:
1
2
3
4
5
6
7
8
9
10
11
12
template<std::size_t I> auto get(const Customer& c) {
static_assert(I < 3);
if constexpr (I == 0) {
return c.getFirst();
}
else if constexpr (I == 1) {
return c.getLast();
}
else { // I == 2
return c.getValue();
}
}

有了这些API,就能对Customer的对象使用结构化绑定了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
int main()
{
Customer c("Tim", "Starr", 42);
auto [f, l, v] = c;
std::cout << "f/l/v: " << f << ' ' << l << ' ' << v << '\n';
// modify structured bindings:
std::string s = std::move(f);
l = "Waters";
v += 10;
std::cout << "f/l/v: " << f << ' ' << l << ' ' << v <<'\n';
std::cout << "c: " << c.getFirst() << ' '
<< c.getLast() << ' ' << c.getValue() << '\n';
std::cout << "s: " << s << '\n';
}

和往常一样,结构化绑定f,l和v是新的匿名变量的成员的别名,新的匿名变量经由c初始化。初始化为每个成员调用相应的getter函数。因此,在初始化后,修改c不会影响到结构化绑定(反之亦然)。所以,程序的输出如下:
1
2
3
4
f/l/v: Tim Starr 42
f/l/v: Waters 52
c: Tim Starr 42
s: Tim

你也可以在迭代一个由Customer元素构成的vector的过程中使用结构化绑定:
1
2
3
4
5
std::vector<Customer> coll;
...
for (const auto& [first, last, val] : coll) {
std::cout << first << ' ' << last << ": " << val << '\n';
}

对结构化绑定使用decltype仍然回产出它的类型,而不是匿名变量的类型。这意味着decltype(first)const std::string

允许针对结构化绑定的写操作

似若tuple的API可以可以使用产生引用的函数。这使得我们可以允许针对结构化绑定的写操作发生。考虑下面的代码,它为Customer提供了读取和修改成员的API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// lang/customer2.hpp
#include <string>
#include <utility> // for std::move()
class Customer {
private:
std::string first;
std::string last;
long val;
public:
Customer (std::string f, std::string l, long v)
: first(std::move(f)), last(std::move(l)), val(v) {
}
const std::string& firstname() const {
return first;
}
std::string& firstname() {
return first;
}
const std::string& lastname() const {
return last;
}
std::string& lastname() {
return last;
}
long value() const {
return val;
}
long& value() {
return val;
}
};

要支持读写操作,我们还得为常量引用和非常量引用准备getter重载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// lang/structbind2.hpp
#include "customer2.hpp"
#include < utility> // for tuple-like API
// provide a tuple-like API for class Customer for structured bindings:
template <> struct std::tuple_size<Customer> {
static constexpr int value = 3; // we have 3 attributes
};
template <> struct std::tuple_element<2, Customer> {
using type = long; // last attribute is a long
};
template <std::size_t Idx> struct std::tuple_element<Idx, Customer> {
using type = std::string; // the other attributes are strings
};
// define specific getters:
template <std::size_t I> decltype(auto) get(Customer &c) {
static_assert(I < 3);
if constexpr (I == 0) {
return c.firstname();
} else if constexpr (I == 1) {
return c.lastname();
} else { // I == 2
return c.value();
}
}
template <std::size_t I> decltype(auto) get(const Customer &c) {
static_assert(I < 3);
if constexpr (I == 0) {
return c.firstname();
} else if constexpr (I == 1) {
return c.lastname();
} else { // I == 2
return c.value();
}
}
template <std::size_t I> decltype(auto) get(Customer &&c) {
static_assert(I < 3);
if constexpr (I == 0) {
return std::move(c.firstname());
} else if constexpr (I == 1) {
return std::move(c.lastname());
} else { // I == 2
return c.value();
}
}

你应该写出这三个重载,来处理常量对象,非常量对象,以及可移动对象。为了返回引用,你应该使用decltype(auto)

还是之前那样,我们可以使用新的编译时if特性,来简化我们的实现,尤其是getter的返回类型不一样时,它更有用。没有编译时if特性,我们只能写出所有的全特化:

1
2
3
4
template<std::size_t> decltype(auto) get(Customer& c);
template<> decltype(auto) get<0>(Customer& c) { return c.firstname(); }
template<> decltype(auto) get<1>(Customer& c) { return c.lastname(); }
template<> decltype(auto) get<2>(Customer& c) { return c.value(); }

模板函数声明的签名必须与全特化的一致(包括返回类型)。下面的代码不能编译:
1
2
3
4
template<std::size_t> decltype(auto) get(Customer& c);
template<> std::string& get<0>(Customer& c) { return c.firstname(); }
template<> std::string& get<1>(Customer& c) { return c.lastname(); }
template<> long& get<2>(Customer& c) { return c.value(); }

做完这些后,你就能使用结构化绑定读取或者修改Customer的成员了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "structbind2.hpp" 
#include <iostream>
int main() {
Customer c("Tim", "Starr", 42);
auto [f, l, v] = c;
std::cout << "f/l/v: " << f << ' ' << l << ' ' << v << '\n';
// modify structured bindings via references:
auto &&[f2, l2, v2] = c;
std::string s = std::move(f2);
f2 = "Ringo";
v2 += 10;
std::cout << "f2/l2/v2: " << f2 << ' ' << l2 << ' ' << v2 << '\n';
std::cout << "c: " << c.firstname() << ' ' << c.lastname() << ✬ ✬ << c.value() << '\n';
std::cout << "s: " << s << '\n';
}

它会输出:
1
2
3
4
f/l/v: Tim Starr 42
f2/l2/v2: Ringo Starr 52
c: Ringo Starr 52
s: Tim

1.4 后记

结构化绑定最初由Herb Sutter,Bjarne Stroustrup和Gabriel Dos Reis在https://wg21.link/p0144r0中提出,当时使用花括号而不是方括号。最后这个特性的公认措辞是由Jens Maurer在https://wg21.link/p0217r3中给出。

第二章 带初始化的if和switch

现在ifswitch控制结构允许我们在普通的条件语句或者选择语句之外再指定一个初始化语句。

比如,你可以这样写:

1
2
3
if (status s = check(); s != status::success) {
return s;
}

其中初始化语句是:
1
status s = check();

它初始化s,然后用if判断s是否是有效状态。

2.1 带初始化的if

任何在if语句内初始化的值的生命周期都持续到then代码块或者else代码块(如果有的话)的最后。比如:

1
2
3
4
5
6
7
8
9
if (std::ofstream strm = getLogStrm(); coll.empty()) {
strm << "<no data>\n";
}
else {
for (const auto& elem : coll) {
strm << elem << '\n';
}
}
// strm no longer declared

strm的析构函数回在then代码块或者else代码块的最后调用。

另一个例子是执行一些依赖某些条件的任务的时候使用锁:

1
2
3
if (std::lock_guard<std::mutex> lg{collMutex}; !coll.empty()) {
std::cout << coll.front() << '\n';
}

因为有类模板参数推导,也可以这样写:
1
2
3
if (std::lock_guard lg{collMutex}; !coll.empty()) {
std::cout << coll.front() << '\n';
}

任何情况下,上面的代码都等价于:
1
2
3
4
5
6
{
std::lock_guard<std::mutex> lg{collMutex};
if (!coll.empty()) {
std::cout << coll.front() << '\n';
}
}

区别在于lg是在if语句的作用域中定义的,因此与条件在相同的作用域(声明性区域)中,就像for循环中初始化的情况一样。

任何被初始化的对象都必须有一个名字。否则,初始化语句会长久一个立即销毁大的临时值。举个例子,初始化一个没有名字的lock guard,其后的条件检查不是在加锁环境下进行的:

1
2
3
4
if (std::lock_guard<std::mutex>{collMutex}; // run-time ERROR:
!coll.empty()) { // - no longer locked
std::cout << coll.front() << '\n'; // - no longer locked
}

一般来说,一个_作为名字也是可以的(一些程序员喜欢它,另一些讨厌它因为它污染全局命名空间):
1
2
3
4
if (std::lock_guard<std::mutex> _{collMutex}; // OK, but...
!coll.empty()) {
std::cout << coll.front() << '\n';
}

接下来是第三个例子,考虑一段代码,插入新元素到map或者unordered map。你可以检查操作是否成功,就像下面一样:
1
2
3
4
5
6
7
std::map<std::string, int> coll;
...
if (auto [pos, ok] = coll.insert({"new", 42}); !ok) {
// if insert failed, handle error using iterator pos:
const auto &[key, val] = *pos;
std::cout << "already there: " << key << '\n';
}

这段代码还是用了结构化绑定,给返回值和元素插入的位置pos分别赋予了名字,而不是first和second。在C++17前,上面相应的检查必须像下面一样规范:
1
2
3
4
5
6
auto ret = coll.insert({"new", 42});
if (!ret.second) {
// if insert failed, handle error using iterator ret.first
const auto &elem = *(ret.first);
std::cout << "already there: " << elem.first << '\n';
}

注意这种带if的初始化也能用于编译时if特性。

2.2 带初始化的switch

使用带初始化的switch语句允许我们在检查条件并决定控制流跳转到哪个case执行之前初始化一个对象。

比如,我们可以先初始化一个文件系统路径,再根据路径的类型选择对应的处理方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using namespace std::filesystem;
...
switch (path p(name); status(p).type()) {
case file_type::not_found:
std::cout << p << " not found\n";
break;
case file_type::directory:
std::cout << p << ":\n";
for (auto &e : std::filesystem::directory_iterator(p)) {
std::cout << "- " << e.path() << '\n';
}
break;
default:
std::cout << p << " exists\n";
break;
}

初始化的p能在整个switch语句中使用。

2.3 后记

带初始化的if和switch最初由Thomas Koppe在https://wg21.link/p0305r0中提出,当时只有带初始化的if没有带初始化的switch。最后这个特性的公认措辞是由Thomas Koppe在https://wg21.link/p0305r1中给出。

第三章 内联变量

C++的一个优点是它支持header-only(译注:即只有头文件)的库。然而,截止C++17,header-only的库也不能有全局变量或者对象出现。

C++17后,你可以在头文件中使用inline定义变量,如果这个变量被多个翻译单元(translation unit)使用,它们都会指向相同对象:

1
2
3
4
5
class MyClass {
static inline std::string name = ""; // OK since C++17
...
};
inline MyClass myGlobalObj; // OK even if included/defined by multiple CPP files

3.1 内联变量的动机

C++不允许在class内部初始化非const静态成员:

1
2
3
4
class MyClass {
static std::string name = ""; // Compile-Time ERROR
...
};

在class外面定义这个变量定义这个变量,且变量定义是在头文件中,多个CPP文件包含它,仍然会引发错误:
1
2
3
4
5
class MyClass {
static std::string name; // OK
...
};
MyClass::name = ""; // Link ERROR if included by multiple CPP files

根据一处定义规则(one definition 入了,ODR),每个翻译单元只能定义变量最多一次。

即便有预处理保护(译注:也叫头文件保护,header guard)也没有用:

1
2
3
4
5
6
7
8
#ifndef MYHEADER_HPP
#define MYHEADER_HPP
class MyClass {
static std::string name; // OK
...
};
MyClass.name = ""; // Link ERROR if included by multiple CPP files
#endif

不是因为头文件可能被包含多次,问题是两个不同的CPP如果都包含这个头文件,那么MyClass.name可能定义两次。

同样的原因,如果你在头文件中定义一个变量,你会得到一个链接时错误:

1
2
3
4
class MyClass {
...
};
MyClass myGlobalObject; // Link ERROR if included by multiple CPP files

临时解决方案

这里有一些临时的应对措施:

  • 你可以在class/struct内初始化一个static const整型数据成员:
    1
    2
    3
    4
    class MyClass {
    static const bool trace = false;
    ...
    };
  • 你可以定义一个返回局部static对象的内联函数:
    1
    2
    3
    4
    inline std::string getName() {
    static std::string name = "initial value";
    return name;
    }
  • 你可以定义一个static成员函数返回它的值:
    1
    2
    3
    4
    std::string getMyGlobalObject() {
    static std::string myGlobalObject = "initial value";
    return myGlobalObject;
    }
  • 你可以使用变量模板(C++14及以后):
    1
    2
    template<typename T = std::string>
    T myGlobalObject = "initial value";
  • 你可以继承一个包含static成员的类模板:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename Dummy>
    class MyClassStatics
    {
    static std::string name;
    };
    template<typename Dummy>
    std::string MyClassStatics<Dummy>::name = "initial value";
    class MyClass : public MyClassStatics<void> {
    ...
    };
    但是这些方法都有不小的负载,可读性也比较差,想要使用全局变量也比较困难。除此之外,全局变量的初始化可能会推迟到它第一次使用的时候,这使得应用程序不能在启动的时候把对象初始化好。(比如用一个对象监控进程)。

3.2 使用内联变量

现在,有了inline,你可以在头文件中定义一个全局可用的变量,它可以被多个CPP文件包含:

1
2
3
4
5
class MyClass {
static inline std::string name = ""; // OK since C++17
...
};
inline MyClass myGlobalObj; // OK even if included/defined by multiple CPP files

初始化发生在第一个包含该头文件的翻译单元。

形式化来说,在变量前使用inline和将函数声明为inline有相同的语义:

  • 如果每个定义都是相同的,那么它可以在多个翻译单元定义
  • 它必须在使用它的每个翻译单元中定义

两者都是通过包含来自同一头文件的定义来实现的。最终程序的行为就像是只有一个变量。

你甚至可以在头文件中定义原子类型的变量:

1
inline std::atomic<bool> ready{false};

注意,对于std::atomic,通常在定义它的时候你还得初始化它。

这意味着,你仍然必须保证在你初始化它之前类型是完全的(complete)。比如,如果一个struct或者class有一个static成员,类型是自身,那么该成员只能在该类型被声明后才能使用。

1
2
3
4
5
6
7
8
9
struct MyType {
int value;
MyType(int i) : value{i} {
}
// one static object to hold the maximum value of this type:
static MyType max; // can only be declared here
...
};
inline MyType MyType::max{0};

参见另一个使用内联变量的例子,它会使用头文件跟踪所有new调用

3.3 constexpr隐式包含inline

对于static数据成员,constexpr现在隐式包含inline的语义,所以下面的声明在C++17后会定义static数据成员n:

1
2
3
4
struct D {
static constexpr int n = 5; // C++11/C++14: declaration
// since C++17: definition
};

换句话说,它与下面的代码一样:
1
2
3
struct D {
inline static constexpr int n = 5;
};

在C++17之前,有时候你也可以只声明不定义。考虑下面的声明:
1
2
3
struct D {
static constexpr int n = 5;
};

如果不需要D::n的定义,这就足够了,例如,D::n只通过值传递的话:
1
std::cout << D::n; // OK (ostream::operator<<(int) gets D::n by value)

如果D::n是传引用到非内联函数,并且/或者函数调用没有优化,那么就是无效的。比如:
1
2
int inc(const int& i);
std::cout << inc(D::n); // usually an ERROR

这段代码违背了一处定义规则(ODR)。当使用带优化的编译器构建时,它可能正常工作,或者抛出链接时错误指出缺少定义。当使用不带优化的编译器时,几乎可以确定这段代码会由于缺少D::n的定义而拒绝编译:

因此,在C++17前,你不得不在相同的翻译单元定义D::n

1
2
constexpr int D::n; // C++11/C++14: definition
// since C++17: redundant declaration (deprecated)

当使用C++17构建,在class中的声明本身就是一个定义,所以这段代码就算没有前面的定义也是有效的。前面的定义也是可以的,但是已经废弃。

3.4 内联变量和thread_local

使用thread_local你可以让每个线程拥有一个内联变量:

1
2
3
4
5
6
struct ThreadData {
inline static thread_local std::string name; // unique name per thread
...
};

inline thread_local std::vector<std::string> cache; // one cache per thread

为了演示一个完整的例子,考虑下面的头文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// lang/inlinethreadlocal.hpp
#include <string>
#include <iostream>

struct MyData {
inline static std::string gName = "global"; // unique in program
inline static thread_local std::string tName = "tls"; // unique per thread
std::string lName = "local"; // for each object
...
void print(const std::string& msg) const {
std::cout << msg << '\n';
std::cout << "- gName: " << gName << '\n';
std::cout << "- tName: " << tName << '\n';
std::cout << "- lName: " << lName << '\n'; }
};

inline thread_local MyData myThreadData; // one object per thread

你可以在有main()的翻译单元使用它:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// lang/inlinethreadlocal1.cpp
#include "inlinethreadlocal.hpp"
#include <thread>
void foo();

int main()
{
myThreadData.print("main() begin:");
myThreadData.gName = "thread1 name";
myThreadData.tName = "thread1 name";
myThreadData.lName = "thread1 name";
myThreadData.print("main() later:");
std::thread t(foo);
t.join();
myThreadData.print("main() end:");
}

你可以在另一个定义foo()的翻译单元使用头文件,其中foo()被不同的线程调用:
1
2
3
4
5
6
7
8
9
10
11
// lang/inlinethreadlocal2.cpp
#include "inlinethreadlocal.hpp"

void foo()
{
myThreadData.print("foo() begin:");
myThreadData.gName = "thread2 name";
myThreadData.tName = "thread2 name";
myThreadData.lName = "thread2 name";
myThreadData.print("foo() end:");
}

程序的输出如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
main() begin:
- gName: global
- tName: tls
- lName: local
main() later:
- gName: thread1 name
- tName: thread1 name
- lName: thread1 name
foo() begin:
- gName: thread1 name
- tName: tls
- lName: local
foo() end:
- gName: thread2 name
- tName: thread2 name
- lName: thread2 name
main() end:
- gName: thread2 name
- tName: thread1 name
- lName: thread1 name

3.5 后记

David Krauss的文档https://wg21.link/n4147是内联变量产生的动机。内联变量最初由Hal Finkel和Richard Smith在https://wg21.link/n4424中提出。最后这个特性的公认措辞是由Hal Finkel和Richard Smith在 https://wg21.link/p0386r2中给出。

第四章 聚合扩展

C++中有一种初始化对象的方式叫做聚合初始化(aggregate initialization),它允许用花括号聚集多个值来初始化。

1
2
3
4
5
6
struct Data {
std::string name;
double value;
};

Data x{"test1", 6.778};

从C++17开始,聚合还支持带基类的数据结构,所以下面这种数据结构用列表初始化也是允许的:
1
2
3
4
struct MoreData : Data {
bool done;
};
MoreData y{{"test1", 6.778}, false};

正如你看到的,聚合初始化现在支持嵌套的花括号传给基类的成员来初始化。

对于带有成员的子对象的初始化,如果基类或子对象只有一个值,则可以跳过嵌套的大括号:

1
MoreData y{"test1", 6.778, false};

4.1 扩展聚合初始化的动机

如果没有这项特性的话,继承一个类之后就不能使用聚合初始化了,需要你为新类定义一个构造函数:

1
2
3
4
5
6
7
struct Cpp14Data : Data {
bool done;
Cpp14Data (const std::string& s, double d, bool b)
: Data{s,d}, done{b} {
}
};
Cpp14Data y{"test1", 6.778, false};

现在,有了这个特性我们可以自由的使用嵌套的花括号,如果只传递一个值还可以省略它:
1
2
MoreData x{{"test1", 6.778}, false}; // OK since C++17
MoreData y{"test1", 6.778, false}; // OK

注意,因为它现在是聚合体,其它初始化方式也是可以的:
1
2
MoreData u; // OOPS: value/done are uninitialized
MoreData z{}; // OK: value/done have values 0/false

如果这个看起来太危险了,你还是最好提供一个构造函数。

4.2 使用扩展的聚合初始化

关于这个特性的常见用法是列表初始化一个C风格的数据结构,该数据结构继承自一个类,然后添加了一些数据成员或者操作。比如:

1
2
3
4
5
6
7
8
9
10
11
12
struct Data {
const char* name;
double value;
};
struct PData : Data {
bool critical;
void print() const {
std::cout << ✬[✬ << name << ✬,✬ << value << "]\n"; }
};

PData y{{"test1", 6.778}, false};
y.print();

这里里面的花括号会传递给基类Data的数据成员。

你可以跳过一些初始值。这种情况下这些元素是零值初始化(zero initalized)(调用默认构造函数或者将基本数据类型初始化为0,false或者nullptr)。比如:

1
2
3
4
PData a{};          // zero-initialize all elements
PData b{{"msg"}}; // same as {{"msg",0.0},false}
PData c{{}, true}; // same as {{nullptr,0.0},true}
PData d; // values of fundamental types are unspecified

注意使用空的花括号和不使用花括号的区别。

  • a零值初始化所有成员,所以name被默认构造,double value被初始化为0.0,bool flag被初始化为false。
  • d只调用name的默认构造函数。所有其它的成员都没用被初始化,所以值是未指定的(unspecified)。

你也可以继承非聚合体来创建一个聚合体。比如:

1
2
3
4
5
6
7
8
9
10
struct MyString : std::string {
void print() const {
if (empty()) {
std::cout << "<undefined>\n"; }
else {
std::cout << c_str() << '\n'; } }
};

MyString x{{"hello"}};
MyString y{"world"};

甚至还可以继承多个非聚合体:
1
2
3
4
5
template<typename T>
struct D : std::string, std::complex<T>
{
std::string data;
};

然后使用下面的代码初始化它们:
1
2
3
4
5
D<float> s{{"hello"}, {4.5,6.7}, "world"};        // OK since C++17
D<float> t{"hello", {4.5, 6.7}, "world"}; // OK since C++17
std::cout << s.data; // outputs: ”world”
std::cout << static_cast<std::string>(s); // outputs: ”hello”
std::cout << static_cast<std::complex<float>>(s); // outputs: (4.5,6.7)

内部花括号的值(initializer_lists)会传递给基类,其传递顺序遵循基类声明的顺序。

这项新特性还有助于用很少的代码定义lambdas重载

4.3 聚合体定义

总结一下,C++17的聚合体(aggregate)定义如下:

  • 是个数组
  • 或者是个类类型(class,struct,union),其中
    • 没有用户声明的构造函数或者explicit构造函数
    • 没有使用using声明继承的构造函数
    • 没有private或者protected的非static数据成员
    • 没有virtual函数
    • 没有virtual,private或者protected基类

为了让聚合体可以使用,还要求聚合体没有private或者protected基类成员或者构造函数在初始化的时候使用。

C++17还引入了一种新的type trait即is_aggregate<>来检查一个类型是否是聚合体:

1
2
3
4
5
6
template<typename T>
struct D : std::string, std::complex<T> {
std::string data;
};
D<float> s{{"hello"}, {4.5,6.7}, "world"}; // OK since C++17
std::cout << std::is_aggregate<decltype(s)>::value; // outputs: 1 (true)

4.4 向后不兼容

注意,下面示例中的代码将不再能通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// lang/aggr14.cpp
struct Derived;
struct Base {
friend struct Derived;
private:
Base() {
}
};
struct Derived : Base {
};
int main()
{
Derived d1{}; // ERROR since C++17
Derived d2; // still OK (but might not initialize)
}

C++17之前,Derived不是一个聚合体,所以:
1
Derived d1{};

调用Derived隐式定义的默认构造函数,它默认调用基类Base的默认构造函数。虽然基类的默认构造函数是private,但是通过子类的默认构造函数调用它是有效的,因为子类被声明为一个friend类。

C++17开始,Derived是一个聚合体,没有隐式的默认构造函数。所以这个初始化被认为是聚合初始化,聚合初始化不允许调用基类的默认构造函数。不管基类是不是friend都不行。

4.5 后记

内联变量最初由Oleg Smolsky在https://wg21.link/n4404中提出。最后这个特性的公认措辞是由Oleg Smolsky在 https://wg21.link/p0017r1中给出。

新的type trait即std::is_aggregate<>最初作为美国国家机构对C++ 17标准化的评论而引入。(参见https://wg21.link/lwg2911

第五章 强制拷贝消除或者传递unmaterialized对象

本章的主题可以从两个角度来看:

  • C++17引入了新的规则,在确定条件下可以强制消除拷贝:以前临时对象传值或者返回临时对象期间发生的拷贝操作的消除是可选的,现在是强制的。
  • 因此,我们处理传递未具体化对象的值以进行初始化
    我将从技术上介绍这个特性,然后讨论具体化(materialization)的效果和相关术语。

5.1 临时量强制拷贝消除的动机

标准伊始,C++就明确允许一些拷贝操作可以被省略(消除),不调用拷贝构造函数会失去可能存在的副作用,从而可能影响程序的行为,即便这样也在所不惜。强制拷贝消除的场景之一是使用临时对象初始化新对象。这个情况经常发生,尤其是以值传递方式将临时对象传递给一个函数,或者函数返回临时对象。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClass
{
...
};
void foo(MyClass param) { // param is initialized by passed argument
...
}
MyClass bar() {
return MyClass(); // returns temporary
}

int main()
{
foo(MyClass()); // pass temporary to initialize param
MyClass x = bar(); // use returned temporary to initialize x
foo(bar()); // use returned temporary to initialize param
}

但是,由于这些拷贝消除优化不是强制的,要拷贝的对象必须提供隐式或显式的拷贝或移动构造函数。也就是说,尽管拷贝/移动构造函数一般不会调用,但是也必须存在。如果没有定义拷贝/移动构造函数,那么代码不能通过编译。

因此,下面MyClass的定义的代码编译不了:

1
2
3
4
5
6
7
8
9
class MyClass
{
public:
...
// no copy/move constructor defined:
MyClass(const MyClass&) = delete;
MyClass(MyClass&&) = delete;
...
};

这里没有拷贝构造函数就足够了,因为仅当没有用户声明的拷贝构造(或者拷贝赋值运算符)时移动构造函数才隐式可用。

C++17后,临时变量初始化新对象期间发生的拷贝是强制消除的。事实上,在后面我们会看到,我们简单的传值作为实参初始化或者返回一个值,该值会接下来用于具体化(materalize)一个新对象。

这意味着就算MyClass类完全没有表示启用拷贝操作,上面的例子也能通过编译。

然而,请注意其他可选的拷贝消除仍然是可选的,仍然要求一个可调用的拷贝或者移动构造函数,比如:

1
2
3
4
5
6
MyClass foo()
{
MyClass obj;
...
return obj; // still requires copy/move support
}

在这里,foo()里面的obj是一个带名字的变量(即左值(lvalue))。所以会发生命名的返回值优化(named return value optimization,NRVO),它要求类型支持拷贝或者移动操作。即便obj是一个参数也仍然如此:
1
2
3
4
5
MyClass bar(MyClass obj) // copy elision for passed temporaries
{
...
return obj; // still requires copy/move support
}

传递一个临时量(即纯右值(prvalue))到函数作为实参,不会发生拷贝/移动操作,但是返回这个参数仍然需要拷贝/移动操作,因为返回的对象有名字。

作为这一改变的部分,值范畴(value categories)修改和新增了很多术语。

5.2 临时量强制拷贝消除的好处

强制拷贝消除的一个好处是,很明显,如果拷贝操作开心较大时会得到更好的性能。虽然移动语言显著减少了拷贝开销,但是完全不执行拷贝能极大的提示性能。这可能会减少使用出参(译注:所谓出参即可out parameter,是指使用参数来传递返回信息,通常是一个指针或者引用)代替返回一个值(假设这个值是由返回语句创建的)的需求。

另一个好处是现在只要写一个工厂函数它总是能工作,因为现在的工厂函数可以返回对象,即便对象不允许拷贝/移动。比如,考虑下面的泛型工厂函数:

1
2
3
4
5
6
7
8
// lang/factory.hpp
#include <utility>
template <typename T, typename... Args>
T create(Args&&... args)
{
...
return T{std::forward<Args>(args)...};
}

这个函数现在甚至可以用于std::atomic<>这种类型,该类型既没有定义拷贝构造函数也没有定义移动构造函数:
1
2
3
4
5
6
7
8
9
10
// lang/factory.cpp
#include "factory.hpp"
#include <memory>
#include <atomic>

int main() {
int i = create<int>(42);
std::unique_ptr<int> up = create<std::unique_ptr<int>>(new int{42});
std::atomic<int> ai = create<std::atomic<int>>(42);
}

这个特性带来的另一个效果是,如果类有显式delete的移动构造函数,你现在可以返回临时值,然后用它初始化对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CopyOnly {
public:
CopyOnly() {
}
CopyOnly(int) {
}
CopyOnly(const CopyOnly&) = default;
CopyOnly(CopyOnly&&) = delete; // explicitly deleted
};

CopyOnly ret() {
return CopyOnly{}; // OK since C++17
}

CopyOnly x = 42; // OK since C++17

x的初始化代码在C++17之前是无效的,因为拷贝初始化需要将42转换为一个临时对象,然后临时对象原则上需要提供一个移动构造函数,尽管不会用到它。()

5.3 值范畴的解释

强制拷贝消除带来的额外工作是值范畴(value categories)的一些修改。

5.3.1 值范畴

在C++中的每个表达式都有一个值范畴。这个值范畴描述了表达式可以做什么。

值范畴的历史

从C语言历史的角度来看,在赋值语句中只有lvalue(左值)和rvalue(右值):

1
x = 42;

表达式x是lvalue,因为它可以出现在赋值语句的左边,表达式42是rvalue,因为它只能出现在赋值语句的右边。但是因为ANSI-C,事情变得更复杂一些,因为x如果声明为const int就不能在赋值语句的左边了,但是它仍然是个(不具可修改性的)lvalue。

C++11我们有了可移动的对象,这些对象在语义上是只能出现在赋值语句右边,但是可以被修改,因为赋值语句可以盗取它们的值。基于这个原因,新的值范畴xvalue被引入,并且之前的值范畴rvalue有了新名字即prvalue。

C++11的值范畴

C++11后,值范畴如图5.1描述的那样:我们的核心值范畴是lvalue,prvalue(pure rvalue,纯右值),xvalue(eXpiring value,将亡值)。组合得到的值范畴有:glvalue(generalized lvalue,泛化左值,是lvalue和xvalue的结合)以及rvalue(是xvalue和prvalue的结合)。

图5.1 C++11后的值范畴

lvalue的例子有:

  • 一个表达式只包含变量,函数或者成员的名字
  • 一个表达式是字符串字面值
  • 内置一元操作符*的结果(即对原生指针解引用)
  • 返回左值引用(type&)的函数的返回值

prvalue的例子有:

  • 除字符串字面值外的其他字面值(或者用户定义的字面值,其中与之关联的字面值操作符的返回类型标示值的范畴)
  • 内置一元操作符&的结果(即获取表达式地址)
  • 内置算术运算符的结果
  • 返回值的函数的返回值

xvalue的例子有:

  • 返回右值引用(type&&,尤其是返回std::move())的函数的返回值
  • 右值引用到对象类型的转换

大概来说:

  • 所有使用名字的表达式是lvalue
  • 所有字符串字面值表达式是lvalue
  • 所有其他字面值(4.2,true,nullptr)是prvalue
  • 所有临时变量(尤其是返回值的函数返回的对象)是prvalue
  • std::move()的结果是xvalue

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class X {
};

X v;
const X c;

void f(const X&); // accepts an expression of any value category
void f(X&&); // accepts prvalues and xvalues only, but is a better match

f(v); // passes a modifiable lvalue to the first f()
f(c); // passes a non-modifiable lvalue to the first f()
f(X()); // passes a prvalue to the second f()
f(std::move(v)); // passes an xvalue to the second f()

值得强调的是,严格来说,glvalue,prvalue和xvalue是针对表达式的, 不是针对值的(这意味着这些值用词不当)。举个例子,一个变量本身不是一个lvalue,只有一个变量放到表达式里才标示这个变量是lvalue:
1
2
int x = 3; // x here is a variable, not an lvalue
int y = x; // x here is an lvalue

第一个语句中3是prvalue,它用来初始化变量x(不是lvalue)。第二个语句中x是lvalue(对它求值会会发现它包含值3)。然后作为lvallue的x转换为prvalue,用来初始化变量y。

5.3.2 C++17的值范畴

C++17没有改变既有的值范畴,但是阐述了它们的语义(如图5.2所示)

图5.1 C++17后的值范畴

现在解释值范畴的主要方式是认为我们有两类表达式:

  • glvalue:对象/函数位置的表达式
  • prvalue:初始化表达式
    xvalue被认为是一个特殊的位置,表示有一个变量它的资源可以重用(通常因为它接近它的生命周期结尾)。

C++17引入了一个新术语,具体化(materialization),表示在某个时刻一个prvalue成为临时对象。因此,临时变量具体化转换(temporary materialization conversion)是指prvalue到xvalue的转换。

任何时刻,期待出现glvalue(lvalue或xvalue)的地方出现prvalue都是有效的,创建一个临时变量并通过prvalue初始化,然后prvallue被替换为xvalue。因此在上面的例子中,严格来说:

1
2
3
void f(const X& p); // accepts an expression of any value category,
// but expects a glvalue
f(X()); // passes a prvalue materialized as xvalue

因为例子中的f()有一个引用参数,它期待一个glvalue实参。然而,表达式X()是一个prvalue。临时具体化规则因此生效,表达式X()转换为一个xvalue并使用默认构造函数初始化临时变量。

注意具体化不意味着我们创建了一个新的/不同的对象。lvalue引用仍然绑定xvalue和prvalue,虽然后者总是转换到xvalue。

在这些改变后,拷贝消除意义非凡,因为prvalue不再要求可移动,我们只传递一个初始值,这个值迟早会具体化然后初始化一个对象。

5.4 未具体化返回值传递

未具体化返回值传递是指所有形式的返回临时对象(prvalue)的值:

  • 当返回一个不是字符串字面值的字面值:
    1
    2
    3
    int f1() {    // return int by value
    return 42;
    }
  • 当返回类型为临时变量的值或者使用auto:
    1
    2
    3
    4
    auto f2() {   // return deduced type by value
    ...
    return MyType{...};
    }
  • 当返回临时对象,并且类型用decltype(auto)推导:
    1
    2
    3
    4
    decltype(auto) f3() {   // return temporary from return statement by value
    ...
    return MyType{...};
    }
    记住如果用于初始化的表达式(这里是返回语句)会创建一个临时变量(prvalue),那么用decltype(auto)声明的类型是值。

上述所有形式我们都返回一个prvalue的值,我们不需要任何拷贝/移动的支持。

5.5 后记

强制拷贝消除最初由Richard Smith在https://wg21.link/p0135r0中提出。最后这个特性的公认措辞是由Richard Smith在https://wg21.link/p0135r1中给出。

第六章 Lambda扩展

C++11引入了lambda,C++14引入了泛型lambda,这是一个成功的故事。lambda允许我们将功能指定为参数,这让定制函数的行为变得更加容易。

C++ 17进一步改进,允许lambda用在更多的地方。

6.1 constexpr lambda

自C++17后,只要可能,lambda就隐式地用constexpr修饰。也就是说,任何lambda都可以用于编译时上下文,前提是它使用的特性对编译时上下文有效(例如,仅字符串字面值,无静态变量,无virutal变量,无try/catch,无new/delete)。

举个例子,你可以传一个值给lambda,然后用计算的结果作为编译时的std::array<>大小:

1
2
3
4
auto squared = [](auto val) { // implicitly constexpr since C++17
return val*val;
};
std::array<int,squared(5)> a; // OK since C++17 => std::array<int,25>

如果在不允许constexpr的上下文使用这个特性就不行,但是你仍然可以在运行时傻姑娘上下文使用lambda:
1
2
3
4
5
6
7
8
he lambda in run-time contexts:
auto squared2 = [](auto val) { // implicitly constexpr since C++17
static int calls = 0; // OK, but disables lambda for constexpr contexts
...
return val*val;
};
std::array<int,squared2(5)> a; // ERROR: static variable in compile-time context
std::cout << squared2(5) << '\n'; // OK

要知道是否一个lambda在一个编译时上下文有效,你可以将它声明为constexpr:
1
2
3
auto squared3 = [](auto val) constexpr {    // OK since C++17
return val*val;
};

还可以指定返回类型,语法如下:
1
2
3
auto squared3i = [](int val) constexpr -> int { // OK since C++17
return val*val;
};

constexpr对于函数的一般规则仍然有效:如果lambda在运行时上下文中使用,相应的功能在运行时执行。

然而,在不允许编译时上下文的地方使用constexpr lambda会得到一个编译时错误:

1
2
3
4
5
auto squared4 = [](auto val) constexpr {
static int calls=0; // ERROR: static variable in compile-time context
...
return val*val;
};

如果lambda式显式或隐式的constexpr,那么函数调用操作符也会是constexpr。换句话说,下面的定义:
1
2
3
auto squared = [](auto val) { // implicitly constexpr since C++17
return val*val;
};

会转换为闭包类型:
1
2
3
4
5
6
7
8
class CompilerSpecificName {
public:
...
template<typename T>
constexpr auto operator() (T val) const {
return val*val;
}
};

生成的闭包类型的函数调用操作符是自动附加constexpr的。在C++17中,如果lambda显式定义为constexpr或者隐式定义为constexpr(就像这个例子),那么生成的函数调用运算符也会是constexpr。

6.2 传递this的拷贝到lambda

当在成员函数中使用lambda时,你不能隐式的访问调用这个成员函数的对象的成员。也就是说,在lambda内部,如果不捕获this,那么你不能使用这个对象的成员:

1
2
3
4
5
6
7
8
9
10
11
class C {
private:
std::string name;
public:
...
void foo() {
auto l1 = [] { std::cout << name << '\n'; }; // ERROR
auto l2 = [] { std::cout << this->name << '\n'; }; // ERROR
...
}
};

C++11和C++14中可以传this引用或者传this值:
1
2
3
4
5
6
7
8
9
10
11
12
class C {
private:
std::string name;
public:
...
void foo() {
auto l1 = [this] { std::cout << name << '\n'; }; // OK
auto l2 = [=] { std::cout << name << '\n'; }; // OK
auto l3 = [&] { std::cout << name << '\n'; }; // OK
...
}
};

然而,问题是即使是传递this的值,其底层捕获的仍然是引自对象(即只有指针被拷贝)。如果lambda的生命周期超过了对象的生命周期,这就会出现问题。一个重要的例子是当用lambda为新线程定义task,它应该使用对象的拷贝来避免任何并发或者生命周期问题。另一个原因可能只是传递一个对象的副本当前状态。

C++14有一个临时的解决方案,但是它读起来不好,工作起来也不好:

1
2
3
4
5
6
7
8
9
10
class C {
private:
std::string name;
public:
...
void foo() {
auto l1 = [thisCopy=*this] { std::cout << thisCopy.name << '\n'; };
...
}
};

举个例子,就算使用=&捕获了对象,开发者仍然可能不小心用到this
1
2
3
4
auto l1 = [&, thisCopy=*this] {
thisCopy.name = "new name";
std::cout << name << '\n'; // OOPS: still the old name
};

C++17开始,你可以显式地通过*this说明你想捕获当前对象的复制:
1
2
3
4
5
6
7
8
9
10
class C {
private:
std::string name;
public:
...
void foo() {
auto l1 = [*this] { std::cout << name << '\n'; };
...
}
};

捕获*this意味着当前对象的复制传递到了lambda。

在捕获了*this的情况下你仍然可以捕获其他this,只要没有与其他的发生冲突:

1
2
auto l2 = [&, *this] { ... };     // OK
auto l3 = [this, *this] { ... }; // ERROR

这里一个完整的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// lang/lambdathis.cpp
#include <iostream>
#include <string>
#include <thread>

class Data {
private:
std::string name;
public:
Data(const std::string& s) : name(s) {
}
auto startThreadWithCopyOfThis() const {
// start and return new thread using this after 3 seconds:
using namespace std::literals;
std::thread t([*this] {
std::this_thread::sleep_for(3s);
std::cout << name << '\n';
});
return t;
}
};

int main()
{
std::thread t;
{
Data d{"c1"};
t = d.startThreadWithCopyOfThis();
} // d is no longer valid
t.join();
}

lambda用*this获取对象拷贝,即d。因此,即便是d的析构函数被调用后线程再使用传递的对象也没有问题。

如果我们使用[this],[=][&]捕获this,线程会产生未定义行为,因为在lambda打印name时,lambda使用的是已经析构后的对象的成员。

6.3 捕获引用

通过使用新的utility库函数,你现在可以捕获const对象引用

6.4 后记

constexpr最初由 Faisal Vali, Ville Voutilainen和Gabriel Dos Reis在https://wg21.link/n4487中提出。最后这个特性的公认措辞是由Faisal Vali, Jens
Maurer和Richard Smith在https://wg21.link/p0170r1中给出。

捕获*this最初由H. Carter Edwards, Christian Trott, Hal Finkel, Jim Reus, Robin Maffeo和Ben Sander在https://wg21.link/p0018r0中提出。最后这个特性的公认措辞是由 H. Carter Edwards, Daveed Vandevoorde, Christian Trott, Hal Finkel,
Jim Reus, Robin Maffeo和Ben Sander在https://wg21.link/p0180r3中给出。

第七章 新属性和属性相关特性

C++11开始,你可以指定属性(attribute,一种规范的注解,可以启用或者禁用一些warning)。C++17还引入了新的属性。此外,属性现在可以在更多的地方使用,并且有一些额外的便利。

7.1 [[nodiscard]]属性

新属性[[nodiscard]]用于鼓励编译器,当发现函数返回值没有被使用的时候,产生一个warning。

通常,这个属性可以用于通知一些返回值没有使用的错误行为。错误行为可能是:

  • 内存泄漏,比如没有使用已经分配并返回的内存
  • 不符合期望,或者非直观行为,比如没有使用返回值时候可能产生的一些不同寻常/不符合期望的行为
  • 不必要的负载,比如如果没有使用返回值,这个调用过程相当于无操作。

这是一些例子,它们展示了这个属性的是有用的:

  • 分配资源必须由另一个函数释放的函数应标记为
    [[nodiscard]]。 一个典型的例子是分配内存的函数,例如malloc()或分配器的成员函数allocate()
    但是请注意,某些函数可能会返回一个值,后续无需再针对这个值做其他调用。 例如,程序员调用大小为零字节的C函数realloc(0以释放内存,这个函数的返回值就不必保存以后再调用free()
  • 一个关于不使用返回值那么函数的行为将会改变的例子是std::async(由C++11引入)。它的目的是异步启动任务,并返回一个句柄以等待其结束(并使用结果)。当返回值没使用时,这个调用会成为同步调用,因为未使用的返回值的析构函数会立即调用,即立刻开始等待任务结束。 因此,不使用返回值会与std::async()的设计目的相矛盾。 这种情况下用[[nodiscard]]让编译器对此发出警告。
  • 另一个例子是成员函数empty(),它检查对象是否没有元素。程序员有时候可能错误的调用这个函数来清空容器(译注:即误以为empty做动词)
    1
    cont.empty();
    这种对empty()的误用可以被检查出来,因为它的返回值没有被使用。将成员函数标注这个属性即可:
    1
    2
    3
    4
    5
    6
    class MyContainer {
    ...
    public:
    [[nodiscard]] bool empty() const noexcept;
    ...
    };
    尽管这个是C++17引入的,但是标准库至今都没有使用它。对于C++17来说,应用此功能的建议来得太晚了。因此关于这个特性的关键动机,即为std::async()的声明添加现在都没有完成。对于上述所有示例,下一个C++标准将附带相应的修复程序(具体参见已经接受的提案https://wg21.link/p0600r1)。为了使代码更具可移植性,你应该使用它,而不是使用不可移植的方式(比如gcc或者clang的[[gnu:warn_unused_result]])来标注函数。当定义operator new()时你应该为函数标记[[nodiscard]]

7.2 [[maybe_unused]]属性

新属性[[maybe_unused]]可以用来避免编译器为未被使用的名字或者对象发出警告。

这个属性可以用在类声明上、类型定义typedef或者using上、变量、非静态数据成员、函数、枚举类型或者枚举值。

这个属性的一个应用是标记那些不是必要的参数:

1
2
3
4
5
6
7
void foo(int val, [[maybe_unused]] std::string msg)
{
#ifdef DEBUG
log(msg);
#endif
...
}

另一个例子是标记可能不会使用的成员
1
2
3
4
5
6
class MyStruct {
char c;
int i;
[[maybe_unused]] char makeLargerSize[100];
...
};

注意,你不能为一个语句标注[[maybe_unused]]。基于这个原因,你不能使用让[[maybe_unused]][[nodiscard]]相见:
1
2
3
4
5
6
int main()
{
foo(); // WARNING: return value not used
[[maybe_unused]] foo(); // ERROR: attribute not allowed here
[[maybe_unused]] auto x = foo(); // OK
}

7.3 [[fallthrough]]属性

新属性[[fallthrough]]可以让编译器不警告那些switch中的某个case没有break,导致其他case被相继执行的情况。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void commentPlace(int place)
{
switch (place) {
case 1:
std::cout << "very ";
[[fallthrough]];
case 2:
std::cout << "well\n";
break;
default:
std::cout << "OK\n";
break;
}
}

传递1会输出
1
very well

同时执行了case 1和case 2。

注意这个属性必须被用在空语句中。因此,你需要在它尾巴上加个分号。

在switch的最后一条语句使用这个属性是不允许的。

7.4 通用属性扩展

下面的特性在C++17zhong被启用:

  1. 现在允许为namespace标记属性。比如,你可以像下面代码一样弃用一个命名空间:
    1
    2
    3
    namespace [[deprecated]] DraftAPI {
    ...
    }
    也可以用于inline namespace和匿名namespace。
  2. 枚举值现在也可以标注属性。

比如,你可以引入新的枚举值代替原有的枚举值,然后弃用原有枚举值:

1
2
3
4
enum class City { Berlin = 0,
NewYork = 1,
Mumbai = 2, Bombay [[deprecated]] = Mumbai,
... };

Mumbai和Bombay都表示相同的city数值,但是Bombay已经弃用。注意标记枚举值时,语法上需要将属性放到枚举值名字的后面。

  1. 用户定义的属性它们通常在自己的namespace定义,你现在可以使用using来避免重复书写namespace。换句话说,以前写法是:
    1
    [[MyLib::WebService, MyLib::RestService, MyLib::doc("html")]] void foo();
    现在你可以这么写:
    1
    [[using MyLib: WebService, RestService, doc("html")]] void foo();
    注意用了using之后再书写namespace前缀会出错的:
    1
    [[using MyLib: MyLib::doc("html")]] void foo(); // ERROR

7.5 后记

这三个属性最初由Andrew Tomazos在https://wg21.link/p0068r0中提出。最后[[nodiscard]]的公认措辞是由Andrew Tomazos在https://wg21.link/p0189r1中给出。[[maybe_unused]]的公认措辞是由Andrew Tomazos在https://wg21.link/p0212r1中给出。[[fallthrough]]的公认措辞是由Andrew Tomazos在https://wg21.link/p0188r1中给出。

允许namespace和枚举值标注属性这个特性最初由 Richard Smith在https://wg21.link/n4196中提出。最后的公认措辞是由 Richard Smith在https://wg21.link/n4266中给出。

属性允许使用using这个特性最初由J. Daniel Garcia, Luis M. Sanchez, Massimo
Torquati, Marco Danelutto和Peter Sommerlad在https://wg21.link/p0028r0中提出。最后的公认措辞是由J. Daniel Garcia and Daveed Vandevoorde在https://wg21.link/P0028R4中给出。

第八章 其他语言特性

有一些小的C++核心语言特性改动,它们会在本章描述。

8.1 嵌套命名空间

最早这个提案是在2003年提出的,C++标准委员会现在终于最终接受了它:

1
2
3
namespace A::B::C {
...
}

它等价于:
1
2
3
4
5
6
7
namespace A {
namespace B {
namespace C {
...
}
}
}

嵌套的inline命名空间还不支持。这是因为如果用了inline就不知道到底inline是针对最后一个还是对所有命名空间使用。

8.2 定于表达式求值顺序

很多代码库和C++书籍包含的代码首先给出符合直觉的假设,然后代码上看起来是有效的,但是严格来讲,这些代码可能产生未定义行为。一个例子是使用寻找并替换子字符串:

1
2
3
std::string s = "I heard it even works if you don't believe";
s.replace(0,8,"").replace(s.find("even"),4,"sometimes")
.replace(s.find("you don✬t"),9,"I");

直觉上看起来这段代码是有效的,它将前8个字符替换为空,“even”替换为“sometimes”,将“you don’t”替换为“I”:
1
it sometimes works if I believe

然而,在C++17之前,结果是不保证的,因为,虽然find()调用返回从何处开始替换,但是当整个语句执行并且在结果被需要之前,这个调用可能在任何时候执行。实际上,所有find(),即计算待替换的起始索引,都可能在任何替换发生前被执行,因此结果是:
1
it sometimes works if I believe

其他结果也是可能的:
1
2
3
it sometimes workIdon’t believe
it even worsometiIdon’t believe
it even worsometimesf youIlieve

另一个例子是使用输出运算符来打印计算后的表达式的值:
1
std::cout << f() << g() << h();

通常的假设是f()g()之前被调用,两者又都在h()之前被调用。然而,这个假设是错误的。f()g()h()可以按任意顺序调用,这可能导致一些奇怪的,甚至是糟糕的结果,尤其是当这些调用互相依赖时

具体来说,考虑下面的例子,在C++17之前,这段代码会产生未定义行为:

1
2
i = 0;
std::cout << ++i << ' ' << --i << '\n';

在C++17之前,他可能输出1 0,也可能输出0 -1,甚至是0 0。不管i是int还是用户定义的类型,都可能这样。(对于基本类型,一些编译器至少会warning这个问题)。

要修复这个未定义行为,一些运算符/操作符的求值被挑战,因此现在它们有确定的求值顺序:

  • 对于
    • e1 [ e2 ]
    • e1 . e2
    • e1 .* e2
    • e1 ->* e2
    • e1 << e2
    • e1 >> e2
      e1保证在e2之前求值,它们的求值顺序是从左至右。

然而,相同函数的不同实参的求值顺序仍然是未定义的。即:

1
e1.f(a1,a2,a3)

e1保证在a1 a2 a3之前求值。但是a1 a2 a3的求职顺序仍然是未定义的。

  • 所有赋值运算符
    • e2 = e1
    • e2 += e1
    • e2 *= e1
    • ...
      右手边的e1会先于左手变的e2被求值。
  • 最后,new表达式中
    • new Type(e)
      分配行为保证在e之前求值,初始化新的值保证在任何使用初始化的值之前被求值。

上述所有保证对基本类型和用户定义类型都有效。

这样做的效果是,C++17后:

1
2
3
std::string s = "I heard it even works if you don't believe";
s.replace(0,8,"").replace(s.find("even"),4,"sometimes")
.replace(s.find("you don✬t"),9,"I");

保证会改变s的值,变成:
1
it always works if you use C++17

因此,每个find()之前的替换都会在find()之前被求值。

另一个结果是,下面的语句

1
2
i = 0;
std::cout << ++i << ' ' << --i << '\n';

其输出保证是1 0

然而,对于其他大多数运算符而言,求值顺序仍然未定义。举个例子:

1
i = i++ + i; // still undefined behavior

这里右手变的i可能在递增之前或者递增之后传递给左手变。

另一个使用new表达式求值顺序的例子是在传值之前插入空格的函数

向后兼容

新的求值顺序的保证可能影响既有程序的输出。这不是理论上可能,是真的。考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <vector>

void print10elems(const std::vector<int>& v)
{
for (int i=0; i<10; ++i) {
std::cout << "value: " << v.at(i) << '\n';
}
}

int main()
{
try {
std::vector<int> vec{7, 14, 21, 28};
print10elems(vec);
}
catch (const std::exception& e) { // handle standard exception
std::cerr << "EXCEPTION: " << e.what() << '\n'; }
catch (...) { // handle any other exception
std::cerr << "EXCEPTION of unknown type\n";
}
}

因为这里的vector<>只有4个元素,程序会在print10elems()的循环中,调用at()时遇到无效索引抛出异常:
1
std::cout << "value: " << v.at(i) << "\n";

在C++17之前,可能输出:
1
2
3
4
5
value: 7
value: 14
value: 21
value: 28
EXCEPTION: ...

因为at()可以在”value “输出之前求值,所以对于错误的索引可能直接跳过不输出”value “。

自C++17之后,保证输出:

1
2
3
4
5
value: 7
value: 14
value: 21
value: 28
value: EXCEPTION: ...

因为”value “一定在at()调用之前执行。

8.3 宽松的基于整数的枚举初始化

对于有固定基本类型的枚举,C++17允许你使用带数值的列表初始化。

1
2
3
4
5
6
7
8
9
10
11
12
// unscoped enum with underlying type:
enum MyInt : char { };
MyInt i1{42}; // C++17 OK (C++17之前错误)
MyInt i2 = 42; // 仍然错误
MyInt i3(42); // 仍然错误
MyInt i4 = {42}; // 仍然错误

enum class Weekday { mon, tue, wed, thu, fri, sat, sun };
Weekday s1{0}; // C++17 OK (C++17之前错误)
Weekday s2 = 0; // 仍然错误
Weekday s3(0); // 仍然错误
Weekday s4 = {0}; // 仍然错误

类似的,如果Weekday有基本类型:
1
2
3
4
5
6
// scoped enum with specified underlying type:
enum class Weekday : char { mon, tue, wed, thu, fri, sat, sun };
Weekday s1{0}; // C++17 OK (C++17之前错误)
Weekday s2 = 0; // 仍然错误
Weekday s3(0); // 仍然错误
Weekday s4 = {0}; // 仍然错误

对于没有指定基本类型的未限域枚举(不带class的enum),你仍然不能使用带数值的列表初始化:
1
2
enum Flag { bit1=1, bit2=2, bit3=4 };
Flag f1{0}; // 仍然错误

注意,列表初始化还是不允许变窄(narrowing),因此你不能传递浮点值:
1
2
enum MyInt : char { };
MyInt i5{42.2}; // 仍然错误

之所以提出这个特性,是想实现一种技巧,即基于原有的整数类型定义另一种新的枚举类型,就像上面MyInt一样。

实际上,C++17的标准库中的std::byte也提供这个功能,它直接使用了这个特性。

8.4 修复带auto和直接列表初始化一起使用产生的矛盾行为

C++11引入了统一初始化后,结果证明它和auto搭配会不幸地产生反直觉的矛盾行为:

1
2
3
4
int x{42};      // initializes an int
int y{1,2,3}; // ERROR
auto a{42}; // initializes a std::initializer_list<int>
auto b{1,2,3}; // OK: initializes a std::initializer_list<int>

这些使用直接列表初始化(direct list initialization,不带=的花括号)造成的前后不一致行为已经得到修复,现在程序行为如下:
1
2
3
4
int x{42};      // initializes an int
int y{1,2,3}; // ERROR
auto a{42}; // initializes an int now
auto b{1,2,3}; // ERROR now

注意这是一个非常大的改变,甚至可能悄悄的改变程序的行为。出于这个原因,编译器接受这个改变,但是通常也提供C++11版本的模式。对于主流编译器,比如Visual Studio 2015,g++5和clang3.8同时接受两种模式。

还请注意拷贝列表初始化(copy list initialization,带=的花括号)的行为是不变的,当使用auto时初始化一个std::initializer_list<>

1
2
auto c = {42}; // still initializes a std::initializer_list<int>
auto d = {1,2,3}; // still OK: initializes a std::initializer_list<int>

因此,现在的直接列表初始化(不带=)和拷贝列表初始化(带=)有另一个显著区别:
1
2
auto a{42}; // initializes an int now
auto c = {42}; // still initializes a std::initializer_list<int>

推荐的方式是总是使用直接列表初始化(不带=的花括号)来初始化变量和对象。

8.5 十六进制浮点字面值

C++17标准化了十六进制的浮点值字面值(有些编译器早已在C++17之前就支持了)。这种方式尤其适用于要求精确的浮点表示(对于双精度浮点值,没法保证精确值的存在)。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// lang/hexfloat.cpp
#include <iostream>
#include <iomanip>

int main() {
// init list of floating-point values:
std::initializer_list<double> values{
0x1p4, // 16
0xA, // 10
0xAp2, // 40
5e0, // 5
0x1.4p+2, // 5
1e5, // 100000
0x1.86Ap+16, // 100000
0xC.68p+2, // 49.625
};

// print all values both as decimal and hexadecimal value:
for (double d : values) {
std::cout << "dec: " << std::setw(6) << std::defaultfloat << d
<< " hex: " << std::hexfloat << d << '\n';
}
}

这个程序使用不同的方式定义了不同的浮点值,其中包括使用十六进制浮点记法。新的记法是base为2的科学表示法:

  • significant/mantissa写作十六进制方式
  • exponent写作数值方式,解释为base为2

比如说,0xAp2是指定数值40(10乘以2的次方)。这个值也可以表示为0x1.4p+5,表示1.25乘以32(0.4是十六进制的四分之一,2的5次方是32)。

程序输出如下:

1
2
3
4
5
6
7
8
dec: 16     hex: 0x1p+4
dec: 10 hex: 0x1.4p+3
dec: 40 hex: 0x1.4p+5
dec: 5 hex: 0x1.4p+2
dec: 5 hex: 0x1.4p+2
dec: 100000 hex: 0x1.86ap+16
dec: 100000 hex: 0x1.86ap+16
dec: 49.625 hex: 0x1.8dp+5

如你说见,这个例子的浮点记法早已在C++11的std::hexfloat操作符上就已经支持了。

8.6 UTF-8字符串字面值

C++11支持以u8前缀表示的UTF-8字符串字面值。然而,这个前缀对于字符是不支持的。C++17修复了这个问题,你现在可以这样写:

1
char c = u8'6'; // character 6 with UTF-8 encoding value

样可以保证字符值是UTF-8中字符‘6’的值。你可以使用所有的7bits US-ASCII字符,对于这些字符,UTF-8代码具有相同的值。换句话说,用这个指定的值和US-ASCII、ISO Latin-1、ISO-8859-15和基本Windows字符集的值都是一样的。通常,你的源代码的字符都会被解释为US-ASCII/UTF-8,所以前缀不是很重要。变量c的值几乎总是54(十六进制的36)。

对于源码中的字符和字符串字面值,C++标准化了你可以使用哪些字符,但是没有标准化这些字符对应的值。这些值取决于源代码字符集。当编译器生成可执行程序时,它会使用运行时字符集。源代码字符集集合总是7bits的US-ASCII,并且运行时字符集通常和源代码字符集一样。对于任何C++程序,有没有u8前缀这些字符和字符串字面值都是一样的。但是在很少见的情况下,可能不是这样。比如老式的IBM主机,仍然使用EBCDIC字符集,在这个字符集中字符‘6’的值是246(十六进制F6)。如果程序使用EBCDIC字符集,那么c的值将会是246而不是54,并且在UTF-8编码的平台上运行该程序时可能输出”¨o”,因为它对应ASCII值的246.在这种情况下前缀可能是必要的。

注意u8只能用于单个字符和UTF-8单字节字符。下面的初始化:

是不被允许的,因为这个德语字符在UTF-8是双字节,即195和182(十六进制C3 B6)。

总结来熟哦,所有允许的字符和字符串字面值如下:

  • 单字节US-ASCII和UTF-8可以使用u8
  • 双字节的UTF-16可以使用u
  • 四字节的UTF-32可以使用U
  • 没有指定编码的宽字符可以使用l,它可能是两字节也可能是四字节

8.7 异常声明成为类型的一部分

C++17开始异常处理声明成为一个函数的类型的一部分。也就是说,下面的两个函数现在有不同的类型:

1
2
void f1();
void f2() noexcept; // different type

在C++17之前,这两个函数的类型是相同的。

这样的后果是,现在的编译器会检查是否你将不抛异常的函数传递给抛异常的函数指针:

1
2
3
void (*fp)() noexcept;  // pointer to function that doesn’t throw
fp = f2; // OK
fp = f1; // ERROR since C++17

给抛异常的函数指针传递不抛异常的函数仍然有效:
1
2
3
void (*fp2)();  // pointer to function that might throw
fp2 = f2; // OK
fp2 = f1; // OK

所以,这个新的特性不会破坏哪些没有使用noexcept作为函数指针的一部分的那些程序。

异常声明有无不能作为重载函数的依据:

1
2
void f3();
void f3() noexcept; // ERROR

注意,其他规则是不受影响的。举个例子,下面的代码中你还是不能忽略基类noexcept声明:
1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
virtual void foo() noexcept;
...
};

class Derived : public Base {
public:
void foo() override; // ERROR: does not override
...
};

子类的foo()的类型与基类的foo()类型不一致,所以不允许重载,这个代码不能通过编译。即便没有指定override修饰符,还是不能编译,因为我们不能用更宽松的抛异常的版本来重载不抛异常的严格版本。

使用条件异常声明

当使用条件异常声明时,函数的类型取决于条件为true还是false:

1
2
3
4
void f1();
void f2() noexcept;
void f3() noexcept(sizeof(int)<4); // same type as either f1() or f2()
void f4() noexcept(sizeof(int)>=4); // different type than f3()

在这里,当代码编译时f3()的类型取决于条件:

  • 如果sizeof(int)为4(或者更多),最终的签名是
    1
    void f3() noexcept(false);    // same type as f1()
  • 如果sizeof(int)小于4,最终签名是:
    1
    void f3() noexcept(true);     // same type as f2()
    因为f4()的异常条件与f3()相反,所以f4()的类型总是与f3()不一样(即保证f3()抛异常它就不抛,f3()不抛它就抛)。

老式的空异常声明仍然可以使用,但是C++17已经标为废弃:

1
void f5() throw(); // same as void f5() noexcept but deprecated

动态的异常声明已经不再支持(它们在C++11时已经标为废弃):
1
void f6() throw(std::bad_alloc); // ERROR: invalid since C++17

对泛型库的影响

让noexcept成为类型的一部分可能对一些泛型库造成影响。

比如,下面的程序截止C++14是有效的,但是在C++17中无法编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// lang/noexceptcalls.cpp
#include <iostream>

template<typename T>
void call(T op1, T op2)
{
op1();
op2();
}

void f1() {
std::cout << "f1()\n";
}

void f2() noexcept {
std::cout << "f2()\n";
}

int main()
{
call(f1, f2); // ERROR since C++17
}

原因是C++17中f1()f2()的类型不一样,编译器在实例化模板调用call()的时候不能为两个类型找到相同的类型T。

在C++17下,你不得不用两个类型:

1
2
3
4
5
6
7
8
9
10
11
template<typename T1, typename T2>
void call(T1 op1, T2 op2)
{
op1();
op2();
}
````
如果你想,或者不得不重载所有可能的函数类型,你需要付出双倍。来看`std::is_function<>`,主要的函数模板定义如下,通常T不是函数:
```cpp
// primary template (in general type T is no function):
template<typename T> struct is_function : std::false_type { };

这个模板继承自std::false_type,所以is_function<T>::value通常产生false。

对于那些的确是函数的类型,需要偏特化,它继承自std::true_type,所以成员value的值是true:

1
2
3
4
5
6
7
8
9
// partial specializations for all function types:
template<typename Ret, typename... Params>
struct is_function<Ret (Params...)> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) &> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const &> : std::true_type { };

C++17之前,它已经有24个偏特化来,因为函数可能有const和volatile修饰符,也可能有lvalue和rvalue引用修饰符,你重载的函数需要可变参数模板类型。

C++17后,偏特化的数量将会翻倍,因为有了新的noexcept修饰符,所以现在有48个:

1
2
3
4
5
6
7
8
9
10
...
// partial specializations for all function types with noexcept:
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) noexcept> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const noexcept> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) & noexcept> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const& noexcept> : std::true_type { };

没有实现noexcept重载的库可能编译不了一些代码,因为它们可能用了noexcept。

8.8 单参数的static_assert

C++17开始,之前static_assert()必须传的错误消息参数现在变成可选了。这意味着最后的诊断性消息完全平台特定。比如:

1
2
3
4
5
6
7
8
9
10
11
#include <type_traits>

template<typename T>
class C {
// OK since C++11:
static_assert(std::is_default_constructible<T>::value,
"class C: elements must be default-constructible");
// OK since C++17:
static_assert(std::is_default_constructible_v<T>);
...
};

没有传消息的断言使用了新的type trait后缀_v

8.9 预处理条件__has_include

C++17扩展了预处理起,可以检查一个特定的头文件是否被include。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#if __has_include(<filesystem>)
# include <filesystem>
# define HAS_FILESYSTEM 1
#elif __has_include(<experimental/filesystem>)
# include <experimental/filesystem>
# define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#elif __has_include("filesystem.hpp") # include "filesystem.hpp" # define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#else
# define HAS_FILESYSTEM #if __has_include(<filesystem>)
# include <filesystem>
# define HAS_FILESYSTEM 1
#elif __has_include(<experimental/filesystem>)
# include <experimental/filesystem>
# define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#elif __has_include("filesystem.hpp") # include "filesystem.hpp" # define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#else
# define HAS_FILESYSTEM 0
#endif0
#endif

如果#include成功则__has_include(...)会求值为1(true)。如果不成功则没有什么影响。

8.10 后记

嵌套namespace定义最初由Jon Jagger在2003年于https://wg21.link/n1524提出。Robert Kawulak在2014年于https://wg21.link/n4026提出了新的提案。最后这个特性的公认措辞是由Robert Kawulak 和 Andrew Tomazos在https://wg21.link/n4230中给出。

重新定义后的求值顺序最初由Gabriel Dos Reis, Herb Sutter和Jonathan Caves在https://wg21.link/n4228中提出。最后这个特性的公认措辞是由Gabriel Dos Reis, Herb Sutter和Jonathan Caves在https://wg21.link/p0145r3中给出。

更宽松的枚举初始化最初由Gabriel Dos Reis在https://wg21.link/p0138r0中提出。最后这个特性的公认措辞是由Gabriel Dos Reis在https://wg21.link/p0138r2中给出。

修复带auto和直接列表初始化一起使用产生的矛盾行为最初由Ville Voutilainen在 https://wg21.link/n3681https://wg21.link/3912中提出。最后这个特性的公认措辞是由 James Dennett在https://wg21.link/n3681中给出。

十六进制浮点值最初由Thomas Koppe在https://wg21.link/p0245r0中提出。最后这个特性的公认措辞是由Thomas Koppe在https://wg21.link/p0245r1中给出。

UTF-8字符串字面值最初由 Richard Smith在https://wg21.link/n4197中提出。最后这个特性的公认措辞是由 Richard Smith在https://wg21.link/n4267中给出。

异常声明成为类型的一部分最初由Jens Maurer在https://wg21.link/n4320中提出。最后这个特性的公认措辞是由Jens Maurer在https://wg21.link/p0012r1中给出。

单参数的static_assert的公认措辞是由Walter E. Brown在https://wg21.link/n3928中给出。

预处理条件__has_include最初由Clark Nelson和RichardSmith在https://wg21.link/p0061r0中作为其中一部分提出。最后这个特性的公认措辞是由Clark Nelson和RichardSmith在https://wg21.link/p0061r1中给出。

第九章 类模板参数推导

C++17之前,你必须显式指定类模板的所有模板参数类型。比如,你不能忽略这里的double:

1
std::complex<double> c{5.1,3.3};

也不能忽略第二次的std::mutex
1
2
std::mutex mx;
std::lock_guard<std::mutex> lg(mx);

C++17开始,必须显式指定类模板的所有模板参数类型这个限制变得宽松了。有了类模板参数推导(class template argument deduction,CTAD)技术,如果构造函数可以推导出所有模板参数,那么你可以跳过显式指定模板实参。

比如:

  • 你可以这样声明:
    1
    std::complex c{5.1,3.3}; // OK: std::complex<double> deduced
  • 你可以这样实现:
    1
    2
    std::mutex mx;
    std::lock_guard lg{mx}; // OK: std::lock_guard<std_mutex> deduced
  • 你甚至可以让容器推导其元素的类型:
    1
    2
    std::vector v1 {1, 2, 3} // OK: std::vector<int> deduced
    std::vector v2 {"hello", "world"}; // OK: std::vector<const char*> deduced

9.1 使用类模板参数推导

只要传给构造函数的实参可以用来推导类型模板参数,那么就可以使用类模板参数推导技术。该技术支持所有初始化方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::complex c1{1.1, 2.2}; // deduces std::complex<double>
std::complex c2(2.2, 3.3); // deduces std::complex<double>
std::complex c3 = 3.3; // deduces std::complex<double>
std::complex c4 = {4.4}; // deduces std::complex<double>
````
c3和c4的初始化方式是可行的,因为你可以传递一个值来初始化`std::complex<>`,这对于推导出模板参数T来说足够了,它会被用于实数和虚数部分:
```cpp
namespace std {
template<typename T>
class complex {
constexpr complex(const T& re = T(), const T& im = T());
...
}
};

假设有如下声明
1
std::complex c1{1.1, 2.2};

编译器会在调用的地方找到构造函数
1
constexpr complex(const T& re = T(), const T& im = T());

因为两个参数T都是double,所以编译器推导出T是double,然后编译下面的代码:
1
2
complex<double>::complex(const double& re = double(),
const double& im = double());

注意模板参数必须是无歧义、可推导的。因此,下面的初始化是有问题的:
1
std::complex c5{5,3.3}; // ERROR: attempts to int and double as T

对于模板来说,不会在推导模板参数的时候做类型转换。

对于可变参数模板的类模板参数推导也是支持的。比如,std::tuple<>定义如下:

1
2
3
4
5
6
7
8
namespace std {
template<typename... Types>
class tuple;
public:
constexpr tuple(const Types&...);
...
};
};

这个声明:
1
std::tuple t{42, 'x', nullptr};

推导出的类型是std::tuple<int, char, std::nullptr_t>

你也可以推导出非类型模板参数。举个例子,像下面例子中传递一个数组,在推导模板参数的时候可以同时推导出元素类型和数组大小:

1
2
3
4
5
6
7
8
template<typename T, int SZ>
class MyClass {
public:
MyClass (T(&)[SZ]) {
...
}
};
MyClass mc("hello"); // deduces T as const char and SZ as 6

SZ推导为6,因为模板参数类型传递了一个六个字符的字符串字面值。

你甚至可以推导出用作基类的lambda的类型,或者推导出auto模板参数类型。

9.1.1 默认拷贝

如果类模板参数推导发现一个行为更像是拷贝初始化,它就倾向于这么认为。比如,在用一个元素初始化std::vector后:

1
std::vector v1{42}; // vector<int> with one element

用这个vector去初始化另一个vector:
1
std::vector v2{v1}; // v2 also is vector<int>

v2会被解释为vector<int>而不是vector<vector<int>>

又比如,这个规则适用于下面所有初始化形式:

1
2
3
std::vector v3(v1); // v3 also is vector<int>
std::vector v4 = {v1}; // v4 also is vector<int>
auto v5 = std::vector{v1}; // v5 also is vector<int>

如果传递多个元素时,就不能被解释为拷贝初始化,此时initializer list的类型会成为新vector的元素类型:
1
std::vector vv{v, v}; // vv is vector<vector<int>>

那么问题来了,如果传递可变参数模板,那么类模板参数推导会发生什么:
1
2
3
4
5
6
7
8
template<typename... Args>
auto make_vector(const Args&... elems) {
return std::vector{elems...};
}

std::vector<int> v{1, 2, 3};
auto x1 = make_vector(v, v); // vector<vector<int>>
auto x2 = make_vector(v); // vector<int> or vector<vector<int>> ?

当前,不同的编译器有不同的处理方式,这个问题还在讨论中。

9.1.2 推导lambda的类型

有了类模板参数推导,我们现在终于可以用lambda的类型实例化类模板类。举个例子,我们可以提供一个泛型类,然后包装一下callback,并统计调用了多少次callback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// tmpl/classarglambda.hpp
#include <utility> // for std::forward()

template<typename CB>
class CountCalls
{
private:
CB callback; // callback to call
long calls = 0; // counter for calls
public:
CountCalls(CB cb) : callback(cb) {
}
template<typename... Args>
auto operator() (Args&&... args) {
++calls;
return callback(std::forward<Args>(args)...);
}
long count() const {
return calls;
}
};

这里,构造函数接受一个callback,然后包装一下,用它的类型来推导出模板参数CB。比如,我们可以传一个lambda:
1
2
3
CountCalls sc([](auto x, auto y) {
return x > y;
});

这意味着sc的类型被推导为CountCalls<TypeOfTheLambda>

通过这种方式,我们可以计算传递给排序函数的sc的调用次数:

1
2
3
std::sort(v.begin(), v.end(),
td::ref(sc));
std::cout << "sorted with " << sc.count() << " calls\n";

包装后的lambda通过引用的方式传递给排序函数,因为如若不然std::sort()只会计算传递给他的lambda的拷贝的调用,毕竟是传值的方式。

然而,我没可以传递包装后的lambda给std::for_each,因为这个算法可以返回传递给他的callback的拷贝:

1
2
3
4
5
auto fo = std::for_each(v.begin(), v.end(),
CountCalls([](auto i) {
std::cout << "elem: " << i << '\n';
}));
std::cout << "output with " << fo.count() << " calls\n";

9.1.3 非部分类模板参数推导

不像函数模板那样,类模板参数不能部分推导(显示模板参数的一部分)。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T1, typename T2, typename T3 = T2>
class C {
public:
C (T1 x = T1{}, T2 y = T2{}, T3 z = T3{}) {
...
}
...
};
// all deduced:
C c1(22, 44.3, "hi"); // OK: T1 is int, T2 is double, T3 is const char*
C c2(22, 44.3); // OK: T1 is int, T2 and T3 are double
C c3("hi", "guy"); // OK: T1, T2, and T3 are const char*
// only some deduced:
C<string> c4("hi", "my"); // ERROR: only T1 explicitly defined
C<> c5(22, 44.3); // ERROR: neither T1 not T2 explicitly defined
C<> c6(22, 44.3, 42); // ERROR: neither T1 nor T2 explicitly defined
// all specified:
C<string,string,int> c7; // OK: T1,T2 are string, T3 is int
C<int,string> c8(52, "my"); // OK: T1 is int,T2 and T3 are strings
C<string,string> c9("a", "b", "c"); // OK: T1,T2,T3 are strings

因为第三个模板参数类型有默认值,所以如果已经指定了第二个就可以省略第三个。

如果i想知道为什么不支持偏特化,下面是造成这个抉择的原因:

1
std::tuple<int> t(42, 43); // still ERROR

std::tuple是一个可变参数模板,所以你可以指定任意数量的参数。在这种情况下,到底是认为这是只指定了一个类型的而导致的错误还是有意为之很难说清。看起来是有问题的。后期有更多考量后,偏特化也有可能加入C++标准。尽管目前没有。

不幸的是,缺少部分特化就不能解决一个常见代码需求。对于关联容器的排序规则,或者无序容器的hash函数,我们仍然不能简单的传一个lambda:

1
2
3
std::set<Cust> coll([](const Cust& x, const Cust& y) { // still ERROR
return x.name() > y.name();
});

我们还是得指定lambda的类型,因此需要像下面这样写:
1
2
3
4
auto sortcrit = [](const Cust& x, const Cust& y) {
return x.name() > y.name();
};
std::set<Cust, decltype(sortcrit)> coll(sortcrit); // OK

9.1.4 类模板参数推导代替便捷的工具函数。

有了类模板参数推导,我们可以不再使用那些目的仅是推导传的参数的类型的便捷工具函数。

最明显的是make_pair,他允许我们不指定传的参数的类型。比如,对于v:

1
std::vector<int> v;

我们可以使用
1
auto p = std::make_pair(v.begin(), v.end());

来代替
1
std::pair<typename std::vector<int>::iterator,typename std::vector<int>::iterator> p(v.begin(), v.end());

现在,make_pair()不再需要了,可以直接这么写:
1
std::pair p(v.begin(), v.end());

第十一章 折叠表达式

自C++17起, 其特性有支持带一个(可带有初始值的)参数包(parameter pack)的所有实参能使用二元操作符并计算结果.

例如, 下列的函数能返回所有传入实参的和:

1
2
3
4
template <typename ...T>
auto foldSum(T... args) {
return (... + args); // ((arg1 + arg2) + arg3)...
}

注意, return 表达式里的括号是折叠表达式的一部分并且不能省略.

函数调用 foldSum(47, 11, val, -1); 使模版实例化并执行: return 47 + 11 + val + -1;.

函数调用 foldSum(std::string("hello"), "world", "!"); 使模版实例化为: return std::string("hello") + "world" + "!";

还要注意, 折叠表达式实参的次序可以不同并且效果也不一样 (可能看起有点反直觉): 例如写成 (... + args) 的结果则是 ((arg1 + arg2) + arg3)..., 该含义是重复地“往后添加”(post-adds)东西. 你也可以写成 (args + ...), 该含义是重复地“往前添加”(pre-adds)东西, 因此其结果为: (arg1 + (arg2 + arg3))....

11.1 折叠表达式的目的

折叠表达式避免了需要递归地去实例化模版并作用于一个参数包的所有形参. 在 C++17 之前, 你必须这样实现:

1
2
3
4
5
6
7
8
template <typename T>
auto foldSumRec(T arg) {
return arg;
}
template <typename T1, typename ...Ts>
auto foldSumRec(T1 arg1, Ts... otherArgs) {
return arg1 + foldSumRec(otherArgs...);
}

这样的一种实现不仅写起来繁琐, 并且它也给 C++ 编译器造成负担. 使用

1
2
3
4
template <typename ...T>
auto foldSum(T... args) {
return (... + args); // ((arg1 + arg2) + arg3)...
}

对于程序员和编译器双方的工作明显有所减少.

11.2 折叠表达式的使用

给定形参 args 和一个操作符 op, C++17 允许我们写成

  • 要么是一元左折叠(unary left fold)
    ( ... op args), 它将展开为: (...(arg1 op arg2) op ... argN-1) op argN)
  • 要么是一元右折叠(unary right fold)
    (args op ...), 它将展开为: (arg1 op (arg2 op ... (argN-1 op argN)...)

其中括号是必需的. 但是, 括号和省略号 (…) 不必用空格隔开.

比起知道左和右折叠表达式的预期结果, 理解两者的差别更重要. 例如, 甚至在使用 + 操作符时就有可能出现不同的效果. 在使用左折叠表达式时:

1
2
3
4
template <typename ...T>
auto foldSumL(T... args) {
return (... + args); // ((arg1 + arg2) + arg3)...
}

调用 foldSumL(1, 2, 3) 则计算出 ((1 + 2) + 3). 这也意味着下列示例代码是能被编译的:

1
std::cout << foldSumL(std::string("hello"), "world", "!") << "\n"; // 编译通过.

记住操作符 + 用于标准字符串类型则至少有一个操作数是 std::string 类型. 因为使用了左折叠表达式, 则函数第一次调用将计算 std::string("hello") + "world", 其返回结果为一个 std::string 类型的字符串, 因此再加上字面形式的字符串 "!" 也是有效的.

然而, 以下的函数调用:

1
std::cout << foldSumL("hello", "world", std::string("!")) << "\n"; // 编译报错.

将不能被编译, 因为其计算得到 (("hello" + "world") + std::string("!")), 而两个字面形式的字符串是不允许用操作符 + 进行拼接的.

然而, 我们可以将实现改成:

1
2
3
4
template <typename ...T>
auto foldSumL(T... args) {
return (args + ...); // (arg1 + (arg2 + arg3))...
}

调用 foldSumL(1, 2, 3) 则计算出 (1 + (2 + 3)). 这意味着下列示例代码就不再能被编译:

1
std::cout << foldSumL(std::string("hello"), "world", "!") << "\n"; // 编译报错.

而以下的函数调用现在能被编译:

1
std::cout << foldSumL("hello", "world", std::string("!")) << "\n"; // 编译通过.

因为几乎在所有情况下, 计算的次序都是从左至右, 通常, 参数包的左折叠语法(参数在末尾)应该更受青睐(除非它没有作用):

1
(... + args); // 更受青睐的折叠表达式语法

11.2.1 空参数包的处理

如果一个折叠表达式使用了空参数包, 则应用以下规则:

  • 如果使用了操作符 &&, 则其值为 true.
  • 如果使用了操作符 ||, 则其值为 false.
  • 如果使用了操作符 ,, 则其值是 void().
  • 其他操作符的调用则是不良形式 (ill-formed).

对于所有其他情况 (一般而言) 你可以添加一个初始值: 给定一个参数包 args, 一个初始值 value 和一个操作符 op, C++17 也允许我们写成:

  • 要么一个二元左折叠(binary left fold)
    (value op ... op args), 它将展开为: ((...((value op arg1) op arg2) op ... op argN-1) op argN)
    — 要么一个二元右折叠(binary right fold)
    (args op ... op value), 它将展开为: (arg1 op (arg2 op ... op (argN-1 op (argN op value))...))

在省略号两边的操作符 op 必须相同.

例如, 下列定义允许传递一个空参数包

1
2
3
4
template <typename ...T>
auto foldSum(T... s) {
return (0 + ... + s); // sizeof...(s) == 0 的情况也可行
}

在概念上, 不论我们添加 0 作为首个操作数或最后一个操作数应该都无所谓.

1
2
3
4
template <typename ...T>
auto foldSum(T... s) {
return (s + ... + 0); // sizeof...(s) == 0 的情况也可行
}

但对于一元折叠表达式其不同的计算次序则比预期结果更重要, 而二元左折叠表达式则更受青睐:

1
(value + ... + args); // 更受青睐的二元折叠表达式语法

还有, 首个操作数可能是特别的, 比如这个例子:

1
2
3
4
5
template <typename ...T>
void print(const T&... args)
{
(std::cout << ... << args) << "\n";
}

这里, 重要的是首次调用是传递给 print() 的第一个实参的输出, 其返回的输出流作用于其它输出的调用. 其它实现可能无法编译甚至得到发生无法预料的事情. 例如, 使用

1
std::cout << (args << ... << "\n");

调用print(1) 将编译通过但打印出的值 1 会向左移10位 ('\n' 的值通常为 10), 因此输出的结果为 1024.

注意, 在这个例子 print() 中没有空格分隔参数包的各个元素. 这样的调用 print("hello", 42, "world") 将会打印 hello42world.

为了用空格将传入的元素分隔开, 你需要一个helper函数以确保除了第一个实参之外在打印前加上空格. 例如, 用以下 helper 函数模版 spaceBefore() 可以办到:

1
2
3
4
5
6
7
8
9
10
11
12
// tmpl/addspace.hpp
template <typename T>
const T& spaceBefore(const T& arg) {
std::cout << ' ';
return arg;
}

template <typename First, typename... Args>
void print(const First& firstarg, const Args&... args) {
std::cout << firstarg;
(std::cout << ... << spaceBefore(args)) << '\n';
}

这里, (std::cout << ... << spaceBefore(args)) 这个折叠表达式展开成: (std::cout << spaceBefore(arg1) << spaceBefore(arg2) << ...)

因此, 在参数包 args 中每个元素都调用一个helper函数, 在返回被传递的实参之前打印出一个空格字符, 写入输出流 std::cout 里. 为了确保这不会应用到第一个实参, 我们添加了额外的首个形参并且不对其使用 spaceBefore().

注意, 参数包的输出的计算需要所有输出在左边.

我们也能在print()里面使用lambda来定义spaceBefore():

1
2
3
4
5
6
7
8
9
template <typename First, typename ...Args>
void print(const First& firstarg, const Args&... args) {
std::cout << firstarg;
auto spaceBefore = [](const auto& arg) {
std::cout << '';
return arg;
};
(std::cout << ... << spaceBefore(args)) << '\n';
}

然而, 注意 lambda 通过值返回对象, 这意味着将创建传入实参的没必要的拷贝. 避免不必要拷贝的方式是通过显式声明lambda的返回类型要为const auto&decltype(auto):

1
2
3
4
5
6
7
8
9
template <typename First, typename ...Args>
void print(const First& firstarg, const Args&... args) {
std::cout << firstarg;
auto spaceBefore = [](const auto& arg) -> const auto& {
std::cout << '';
return arg;
};
(std::cout << ... << spaceBefore(args)) << '\n';
}

如果你不能够将这些语句组合成这样一条语句, 那你用的C++就不能称为真正的C++:

1
2
3
4
5
6
7
8
template <typename First, typename ...Args>
void print(const First& firstarg, const Args& ...args) {
std::cout << firstarg;
(std::cout << ... << [](const auto& arg) -> decltype(auto) {
std::cout << ' ';
return arg;
}(args)) << '\n';
}

不过, 一种更简单实现print()的方式是使用一个lambda打印空格和实参并将其传递给一个一元折叠表达式(脚注: 感谢 Barry Revzin 提出来):

1
2
3
4
5
6
7
8
9
template <typename First, typename ...Args>
void print(First first, const Args& ...args) {
std::cout << first;
auto outWithSpace = [](const auto& arg) {
std::cout << ' ' << arg;
};
(..., outWithSpace(args));
std::cout << '\n';
}

通过使用一个额外的用auto声明的模版参数, 我们可以使print()更灵活地将字符类型的分隔符, 字符串或任意其它可打印的类型参数化.

11.2.2 已支持的操作符

除了., ->, 和 [] 这些操作符之外, 你可以使用所有二元操作符作用于折叠表达式.

折叠的函数调用

折叠表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// tmpl/foldcalls.cpp
#include <iostream>

// 可变数目的基类模版
template <typename ...Bases>
class MultiBase : private Bases...
{
public:
void print() {
// 调用所有基类的 print()
(..., Bases::print());
}
};

struct A {
void print() { std::cout << "A::print()\n"; }
};

struct B {
void print() { std::cout << "B::print()\n"; }
};

struct C {
void print() { std::cout << "C::print()\n"; }
};

int main()
{
MultiBase<A, B, C> mb;
mb.print();
}

这里,

1
2
3
4
5
template <typename ...Bases>
class MultiBase : private Bases...
{
...
};

允许我们用可变数目的基类初始化对象:

1
MultiBase<A, B, C> mb;

并且使用

1
(..., Base::print());

这个折叠表达式被展开为调用每一个基类的print. 这个折叠表达式展开后如下所示:

1
(A::print(), B::print(), C::print());

然而, 注意到,操作符的性质与我们使用左折叠表达式或右折叠表达式没什么关系. 这些函数总是从左往右被调用. 使用

1
(Base::print(), ...);

这个括号只是将调用组合起来, 因此第一个print()和其它两个print()的结果组合了一起如下所示:

1
A::print(), (B::print(), C::print());

但因为,操作符的计算次序总是从左向右, 仍然是在括号里面两个为一组的函数调用之前先调用第一个函数, 并且仍然是中间的函数在右边函数之前调用.

尽管如此, 这就像左表达式的结果并且能跟其计算次序匹配上, 还是建议在折叠多个函数调用时使用左折叠表达式.

组合Hash函数

一个使用,操作符组合Hash值的例子. 这个例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
void hashCombine(std::size_t& seed, const T& val)
{
seed ^= std::hash<T>()(val) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}

template <typename ...Type>
std::size_t combineHashValue(const Type& ...args)
{
std::size_t seed = 0; // 初始种子
(..., hashCombine(seed, args)); // hashCombine() 调用链
return seed;
}

通过调用

1
std::size_t combinedHashValue("Hello", "World", 42);

中间的这条语句展开成:

1
(hashCombine(seed, "Hello"), hashCombine(seed, "World")), hashCombine(seed, 42));

使用这个定义, 我们可以容易地为一个某个类型的对象定义一个新的Hash函数, 例如 Customer:

1
2
3
4
5
6
struct CustomerHash
{
std::size_t operator()(const Customer& c) const {
return combineHashValue(c.getFirstname(), c.getLastname(), c.getValue());
}
};

这样我们就可以将 Customers 放入一个 std::unordered_set 的容器:

1
std::unordered_set<Customer, CustomerHash> coll;

折叠的路径遍历

你也可以使用折叠表达式去遍历一个二叉树的路径通过操作符->*:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// tmpl/foldtraverse.cpp
// 定义二叉树结构和用于遍历的helper函数.
struct Node {
int value;
Node* left;
Node* right;
Node(int i = 0) : value(i), left(nullptr), right(nullptr) {}
...
};
auto left = &Node::left;
auto right = &Node::right;

// 使用折叠表达式遍历树:
template <typename T, typename ...TP>
Node* traverse(T np, TP... paths) {
return (np ->* ... ->* paths); // np ->* path1 ->* path2 ...
}

int main()
{
// 初始二叉树的结构:
Node* root = new Node{0};
root->left = new Node{1};
root->left->right = new Node{2};
...
// 遍历二叉树:
Node* node = traverse(root, left, right);
...
}

这里,

1
(np ->* ... ->* paths)

使用一个折叠表达式从np开始去遍历可变数目的paths的元素. 当调用:

1
traverse(root, left, right);

这个折叠表达式的调用展开成:

1
root->left->right

11.2.3 使用折叠表达式作用于类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// tmpl/ishomogeneous.hpp
#include <type_traits>

// 检查传递的类型是否为同一类:
template <typename T1, typename ...TN>
struct IsHomogeneous {
static constexpr bool value = (std::is_same<T1, TN>::value && ...);
};

// 检查传递的实参是否有相同类型:
template <typename T1, typename ...TN>
constexpr bool isHomogeneous(T1, TN...)
{
return (std::is_same<T1, TN>::value && ...);
}

这个类型 trait IsHomogeneous<> 可被使用如下:

1
IsHomogeneous<int, Size, decltype(42)>::value

此情况下, 这个初始化成员变量value的折叠表达式展开成:

1
std::is_same<int, MyType>::value && std::is_same<int, decltype(42)>::value

这个函数模版isHomogeneous<>() 可被使用如下:

1
isHomogeneous(43, -1, "hello", nullptr)

此情况下, 这个初始化成员变量value的折叠表达式展开成:

1
std::is_same<int, int>::value && std::is_same<int, const char*>::value && std::is_same<int, std::nullptr_t>::value

通常, 操作符&&是短路的(第一false则终止计算).

在标准库里的std::arary<>的推导规则使用这种特性.

11.3 后记

折叠表达式最初由Andrew Sutton和Richard Smith在https://wg21.link/n4191中提出. 最后这个特性的公认措辞由Andrew Sutton和Richard Smith在https://wg21.link/n4295中制定的. Thibaut Le Jehan 在 https://wg21.link/n0036 中提出了删除对操作符*, +, &|支持空参数包的情况.