Effective Modern C++ Notes (2)
RValue References, Move Semantics and Perfect Forwarding
Item 23: Understand
std::move and std::forward
std::move仅仅是一个类型转换,将实参转换为右值:
1 | template<typename _Tp> |
因此,使用std::move不代表触发移动语义。例如,对const对象使用std::move得到的是常量右值引用,它无法被匹配到移动构造函数,只能被拷贝构造:
语义上,
const对象也不应该被移动。因此不要将需要移动的对象声明为const。
1 | class Widget { |
std::forward<T>是一个有条件的右值转换。当T是原始类型或右值引用时,std::forward<T>是右值引用;当T是左值引用时,std::forward<T>是左值引用。std::forward<T>相当于static_cast<T&&>。
Item 24: Distinguish universal references from rvalue references
如果函数模板的形参类型形如T&&(T为模板参数,且需要推导得知),该形参是一个通用引用(universal
reference)。
如果
T不是模板参数,则仍然是右值引用。1
void foo(int &&i); // 右值引用
如果形参类型不是严格的
T&&,带有其他修饰,则仍然是右值引用:1
2
3
4
5template <typename T>
void f(std::vector<T>&& param); // 右值引用
template <typename T>
void g(const T&& param); // 右值引用如果
T是模板参数,但调用时并不需要推导,则仍然是右值引用。如std::vector::push_back:这里模板参数
value_type早在创建std::vector<value_type>对象时就已经被推导出来了。1
void push_back(value_type&& __x)
使用auto&&声明的变量也是一个通用引用(本质上,使用auto还是发生了模板类型推导):
1 | auto &&var = 1; |
Item
25: Use std::move on rvalue references,
std::forward on universal references
转发右值引用时,应该使用std::move无条件将其转换为右值;转发通用引用时,应该使用std::forward,仅在通用引用指向右值时将其转换为右值。
这个例子错误地在通用引用中使用了std::move,代价是即便Widget::setName接收了左值,它也会错误地被移动给Widget::name:
1 | class Widget { |
对于按值返回的函数,如果返回值是右值/通用引用的形参,应该用std::move/std::forward修饰:
1 | Matrix |
如果Matrix支持移动,operator+的返回值从右值引用构造,触发移动语义,减少一次形参到返回值的拷贝;如果Matrix不支持移动,返回值也能拷贝构造,不会有编译错误。
通用引用同理,使用std::forward包装返回值使我们可以在形参为右值时防止一次拷贝。
1 | class Fraction { |
然而,我们不应该错误地对按值返回的局部变量使用std::move:
1 | Fraction foo() { |
foo()的这种行为称为返回值优化(Return Value
Optimization),标准要求(但非强制)编译器在这种情况下直接在返回值的内存处构建Fraction对象,从而省去严格字面执行指令带来的拷贝。
视返回的局部变量是否有名称,RVO又被分为named RVO(NRVO,本例)和unnamed RVO(URVO)。C++17后,标准规定URVO优化必须强制进行。
NRVO的条件之一,就是返回值的类型与函数的返回类型(在忽略cv限定符后)一致,而return std::move(f)破坏了这一条件,因为现在返回值的类型是Fraction&&。此时NRVO无法进行,局部变量必须先被构造,然后移动到返回值所在的位置,产生了负优化。
上面提到NRVO不是强制的,但标准指出,只要NRVO的条件满足,哪怕编译器不能进行NRVO,它也必须把返回值看作右值。因此用std::move修饰返回值仍然是多余的。
Item 26: Avoid overloading on universal references
重载接收通用引用形参的函数,可能不会达到预期的结果,因为通用引用很可能被推导为一个精确匹配的类型,导致重载函数不被选择:
1 | vector<std::string> vs; |
这个例子中,我们的本意是用foo(T&&)处理字符串类型,用foo(int)处理整型。
- 如果用
2作为实参调用foo(),在选择重载版本时,foo(T&&)推导出T为int,与foo(int)都是精确匹配,此时优先选择更特化的版本(即非模板函数),符合预期。 - 但如果实参是
2ULL,foo(T&&)可以推导出T为unsigned long long,是精确匹配,foo(int)则需要做一次隐式类型转换,因此优先级不如foo(T&&),这样的行为不符合我们的预期。
尤其,不要使用接收通用引用形参的构造函数,如果实参是non-const左值,或者是派生类对象的引用,它们可能会劫持对普通拷贝/移动构造函数的调用。下面的例子中,Person可以从std::string构造,,我们错误地用一个通用引用构造函数代替接收左/右值引用的构造函数:
1 | class Person { |
使用Person对象p1构造Person会匹配到通用引用的版本,因为这是精确匹配,而调用拷贝构造函数的形参多一个const。
使用派生类Student的引用构造Person会匹配到通用引用的版本,因为这是精确匹配,而调用拷贝构造函数需要一个const Student&到const Person&的类型转换。
Item 27: Familiarize yourself with alternatives to overloading on universal references
tag
dispatch是一种重载通用引用的替代方案,foo()判断T是否为整型,设置一个tag值,并调用fooImpl()的不同重载版本:
1 | template <typename T> |
其实直接写成这样也可以:
1 | template <typename T> |
这个例子中,我们为不同类型的实参提供了统一的foo(T&&)API。但对于Item26中通用引用构造函数的例子,由于编译器会自动生成拷贝/移动构造函数,有些实参会匹配到自动生成的版本上,无法让所有用户都使用Person(T&&),再在该函数内部做分发。
这种情况下,我们可以用std::enable_if让通用引用函数模板有条件地启用:
1 | class Person { |
std::enable_if_t<B, T = void>当且仅当仅当B为true时返回类型T,否则不返回类型(这会导致模板推断失败,起到了有条件启用模板的效果)。
std::decay_t<T>移除类型T的引用和cv限定符。
std::is_base_of_v<Base, Derived>在Base是Derived的基类类型是返回true,否则返回false。
因此,以上的模板只在Person不是decay_t<T>的基类类型时才会启用,Item
26中的两个例子(传入Person&形参,传入const Student&形参)都不满足这一条件,不会匹配到通用引用函数模板,而会正常地匹配到拷贝构造函数。
最后,我们在Person(T&&)的实现中加入一个static_assert,保证在非预期地被调用时打印出供参考的错误信息。
Item 28: Understand reference collapsing
本节介绍了引用折叠,即编译器推导出引用的引用时,当且仅当两级引用均为右值引用,最终结果才是右值引用,否则是左值引用。
Item 29: Assume that move operations are not present, not cheap, and not used
移动语义并不是总有优势,因为有的对象不支持移动,有的对象虽然支持移动,但移动并不比拷贝快(例如std::array,拷贝和移动都是操作栈上对象)。
Item 30: Familiarize yourself with perfect forwarding failure cases
perfect forwarding在一些场景下不能正确转发参数:
1 | template <typename... T> |
花括号初始化。编译器不会将形如
{1, 2, 3}的实参推断为std::initializer_list,而是不进行推导报出编译错误。1
2
3
4
5void foo(std::vector<int> v) {}
fwd(std::vector<int>{1, 2, 3}); // 正确
fwd(std::initializer_list<int>{1, 2, 3}); // 正确
fwd({1, 2, 3}); // 错误0或者NULL作为空指针。它们会被推导成整型而不是指针类型,不能作为空指针被完美转发。应该使用nullptr。转发常量折叠(constant folding)的对象。如果编译期常量所在的表达式只使用了它的值,而没有使用地址,编译器允许该对象只有声明没有定义,在编译期用常量替换掉对该对象的使用。
1
2
3
4
5
6
7
8class Widget {
public:
static const std::size_t max_size = 100;
};
int main() {
cout << Widget::max_size << endl;
}但是转发常量折叠对象时,由于使用了通用引用,需要该对象的地址(即所谓odr-used),因此不能没有定义,否则会在链接时报错。此时需要加上定义:
1
2
3
4
5
6
7
8
9class Widget {
public:
static const std::size_t max_size = 100;
};
const std::size_t Widget::max_size;
int main() {
fwd(Widget::max_size);
}转发一个被重载的函数名称。编译器无法仅仅从一个函数名判断出是在指代哪个重载版本。
1
2
3
4
5
6
7
8
9
10void foo(void (*)(int)) {}
void bar(int) {}
void bar(double) {}
int main() {
foo(bar); //正确,可以通过形参类型判断使用哪个bar
fwd(bar); //错误,无法从模板形参出类Br); //错误,无法从模板形参判断出bar的类型
fwd(static_cast<void (*)(int)>(bar)); //正确
}bitfield无法被取址,自然无法被绑定到引用,因此无法被完美转发:
1
2
3
4
5
6
7
8
9
10
11
12void foo(std::size_t) {}
struct Header {
uint32_t a: 16;
uint32_t b: 16;
};
int main() {
Header h;
foo(h.a); // 正确
fwd(h.a); // 错误
}
Lambda Expressions
Lambda表达式的类型是一个闭包类ClosureType,这个闭包类将会有一个重载的operator (),包含lambda表达式中的代码。通过capture
list捕获的变量将会(以值或引用的形式)成为ClosureType的成员。
Item 31: Avoid default capture modes
默认捕获模式是指在捕获列表里写上=(按值捕获)或者&(按引用捕获)。
使用按引用捕获可能会导致空悬引用的问题:如果lambda创建的闭包生命周期超过了捕获的局部变量的生命周期,闭包中的引用会变成空悬引用。下面的例子中,lambda创建时引用捕获的divisor变量在使用时已经被销毁,导致未定义行为。
1 | std::vector<std::function<int(int)>> v; |
作者建议总是显式捕获局部变量/形参等可能超出生命周期的变量,这样虽然仍会出现空悬引用,但更容易被编程者注意到并修复bug。
使用默认按值捕获可以避免在上述场景下出现问题,但如果捕获的局部变量是一个指针,还是可能出现空悬指针的问题。
1 | std::vector<std::function<int(int)>> v; |
这个例子有点刻意,但是在下面的场景中,我们不经意间就会遇到默认按指捕获时的空悬指针:
1 | std::vector<std::function<int(int)>> filters; |
在Widget::addFilter()中,我们创建lambda,使用=进行默认值捕获,并在lambda体内使用了divisor变量,这段代码可以编译。
然而,默认捕获仅限于lambda创建时的作用域内的non-static变量。Widget::divisor是类的成员变量,因此根本不在默认捕获的范围之内。为什么代码可以编译呢?因为=捕获的其实是Widget::addFilter中的this指针的值,在类的成员函数作用域中,编译器认为divisor就是this->divisor。这样的问题是一旦lambda闭包在Widget对象的生命周期结束后被使用,就会带来空悬指针的问题:
1 | int main() { |
可以通过显式定义局部变量,再值捕获局部变量解决这个问题:
1 | void Widget::addFilter() const { |
C++14以后,还可以为捕获变量指定initializer,也可以解决这个问题:
1 | void Widget::addFilter() const { |
注意:默认捕获也不能捕获静态对象:
1 | void Widget::addFilter() const { |
Item 32: Use init capture to move objects into closures
Item 31已经展示了C++14所支持的初始化捕获,它可以初始化一个捕获变量。除了Item31的用途以外,它还可以用于以移动的方式捕获变量:
1 | int main() { |
这是C++11的lambda表达式所不支持的,但可以通过一些写法达到类似的目的:
手写一个函数对象,把需要移动捕获的对象作为右值引用传给该对象的某个成员,然后调用之。
1
2
3
4
5
6
7
8
9
10
11
12
13
14class func {
public:
func(std::vector<int>&& v) : _v(std::move(v)) {}
void operator() () {
for (auto i : _v) {
std::cout << i << std::endl;
}
}
private:
std::vector<int> _v;
};
func{std::move(v)}();使用
std::bind。以下的例子通过std::move将v移动到了bind对象中,调用bind对象的operator ()时,将bind对象中保存的std::vector<int>(这是一个移动构造出的左值)传给bind对象中保存的lambda对象。当lambda没有用
mutable声明时,它生成的闭包类的operator ()会带有const修饰,并且不能修改捕获的对象。因此,下面例子中std::bind使用的lambda表达式的形参使用const修饰,这样就不可以修改形参v,行为与上面的lambdaFunc一致。1
2
3
4
5auto func = std::bind([](const std::vector<int>& v) {
for (auto i : v) {
std::cout << i << std::endl;
}
}, std::move(v));
Item
33: Use decltype on auto&& parameters
to std::forward them
C++14支持用auto作为lambda的形参类型,这种lambda称为generic
lambda。
实现上,generic
lambda生成的闭包类中的operator ()是一个函数模板:
1 | auto f = [](auto x){}; |
以上f的声明方式是值传递,如果lambda体中需要把x转发给另一个函数,会丢失x的左/右值引用信息:
1 | void normalize(int& x) { cout << "lvalue" << endl; } |
我们希望使用perfect forwarding。一般来说,在perfect
forwarding的标准用法中,std::forward的类型会使用模板参数T,但在这个例子里,通用引用是以auto &&的形式出现的,并没有模板参数。此时我们使用decltype(x)作为std::forward实例化的类型:
1 | auto f = [](auto&& x){ |
这是做是正确的,因为:
- 如果
f的实参是左值,auto&&(即x的类型)在引用折叠后被推断为左值引用,std::forward<decltype(x)>(x)是标准用法,仍然为左值引用。 - 如果
f的实参是右值,auto&&(即x的类型)被推断为右值引用,std::forward使用右值引用实例化(标准用法是使用原始类型),由于引用折叠,std::forward<decltype(x)>(x)返回右值引用。
Item 34: Prefer lambdas to
std::bind
本节列举了一些可以被lambda替代的,晦涩的std::bind用法,指出在C++14后可以无脑用lambda替代std::bind。
在C++11中,lambda语义的不完善导致无法在以下两个场景中取代std::bind:
以移动语义捕获变量(Item 32中描述了),C++14允许lambda初始化捕获以解决这一问题。
接收任意类型的多态函数对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14class PolyWidget {
public:
template<typename T>
void operator()(const T& param) {}
};
int main() {
PolyWidget pw;
auto boundPW = std::bind(pw, std::placeholders::_1);
boundPW(1930);
boundPW("hello");
boundPW(5.5);
}C++14中的generic lambda(在Item 33中描述)也可以达到这一目的。
The Concurrency API
Item 35: Prefer task-based programming to thread-based
本节介绍了一些方法论:有时可以将函数传给std::async进行任务级并发,把线程管理丢给标准库做,不一定要丢进一个std::thread。详见原文。
Item
36: Specify std::launch::async if asynchronicity is
essential.
std::async使用的策略可以是以下两种状态的组合:
std::launch::async:函数必须在不同的线程中异步执行std::launch::deferred:函数惰性求值,仅在结果被请求时执行,否则不会执行。“结果被请求”:指的是在
std::async返回的std::future(或者它共享出去的std::shared_future)上调用get()或者wait()。
创建std::async(f)默认的策略是std::launch::async | std::launch::deferred,因此此时不能保证f能够并发执行,不知道f在哪个线程上执行,甚至不知道f是否会执行(如果没用到f的结果就不会执行)。
因此,如果希望任务被异步执行,应该使用std::async(std::launch::async, f)。
Item 37: Make
std::threads unjoinable on all paths
std::thread对象在离开作用域被析构时必须处于unjoinable的状态,否则会导致程序异常终止。
std::thread的以下状态是unjoinable:
- 默认初始化的
std::thread(未关联到线程)- 已经被移动走的
std::thread(线程现在关联到新的std::thread)- 已经被join或者detach的
std::thread
1 | int main() { |
这是设计是因为std::thread被析构时如果关联的线程还在运行,无法给出一个妥善的处理方案,索性禁止这种行为:
- 如果隐式join,析构函数就会阻塞,直到关联线程运行完。但
std::thread离开作用域往往意味着用户已经不希望关联线程继续运行了,这种行为很奇怪。 - 如果隐式detach,关联线程继续运行,如果使用了位于被析构的
std::thread所在作用域(已被销毁)的对象,将出现非法的内存访问。
可以用一个RAII类保证std::thread析构前一定处于unjoinable状态,见书中细节。
Item 38: Be aware of varying thread handle destructor behavior
std::thread,以及未使用std::launch::deferred的std::async创建的std::future,都关联到了特定的OS线程,称为OS线程的handle。但是std::future的析构行为与std::thread不同:
默认行为:销毁
std::future对象,即:- 如果是由non-deferred的
std::async创建的,相当于detach了关联的OS线程,(OS线程继续运行,但是与当前future无关) - 如果是由deferred的
std::async创建的,相当于关联的函数永远不会执行了。
- 如果是由non-deferred的
特殊行为:阻塞析构,直到关联的函数执行完成。这种情况相当于隐式
join了关联的OS线程,仅当满足下面条件时出现:future对象由std::async创建。创建时使用的策略是
std::launch::deferred。future对象其共享状态的最后一个引用者。(因为可能有std::shared_future存在)std::future关联到一个共享状态,该共享状态是堆上对象,存储了被调函数的执行结果。
Item
39: Consider void futures for one-shot event
communication
std::promise<void>和std::future<void可以起到类似条件变量的效果,检测线程在某事件发生时通知响应线程:
1 | std::promise<void> p; |
用条件变量和互斥锁的实现则是:
1 | std::condition_variable cv; |
但是,基于promise和future的方案只能进行一次通信,而且需要使用堆上内存共享状态。
Item
40: Use std::atomic for concurrency, volatile
for special memory
std::atomic保证变量被原子读写,同时(在sequential
consistency下)防止所有原子读写操作的重排序;volatile告知编译器不要优化掉对该变量的内存读写操作。它们是正交的,没有什么关系,可以同时使用。
Tweaks
Item 41: Consider pass by value for copyable parameters that are cheap to move and always copied
如果希望一个函数对左值实参和右值实参表现出不同的行为(如拷贝左值,移动右值),我们有几种可选的做法:
定义两个重载版本,分别接收左值和右值实参:
1
2
3
4
5
6
7
8
9
10
11
12
13class Widget {
public:
void addName(const Internal& name) {
cout << "lvalue" << endl;
a = name;
}
void addName(Internal&& name) {
cout << "rvalue" << endl;
a = std::move(name);
}
private:
Internal a;
};代价:若实参是左值则需要一次拷贝,若实参是右值则需要一次移动。
缺点:需要为类似的操作维护两份代码。
定义一个形参为通用引用的函数模板:
1
2
3
4
5
6
7
8
9
10class Widget {
public:
...
template<typename T>
void addName(T&& name) {
cout << "universal reference" << endl;
a = std::forward<T>(name);
}
...
}代价:若实参是左值则需要一次拷贝,若实参是右值则需要一次移动。
缺点:
- 有些实参类型无法被完美转发,见Item 30。
- 如果向
Widget::addName传入一个可以转换为Internal的类型,就会为该类型实例化一个新的模板函数,可能导致代码膨胀。
通用引用可能可以提升性能,它避免了仅仅为了符合形参声明的类型而创建临时对象:假如类型
U可以转换为Internal,同时Internal还有一个赋值运算符,可以直接接收类型U的对象,使用通用引用可以减少一次临时对象的构造和析构:- 将类型
U对象的右值引用作为实参调用addName时(如addName("Hello")),T直接被推断为U而不是Internal,形参name直接被赋给Widget::a。 - 如果不使用通用引用,由于
Widget::addName已经指定了形参类型是Internal的(左值/右值)引用,形参name将是一个从实参创建的Internal临时对象,然后被移动到Widget::a,最后被析构,带来了构造函数和析构函数的开销。
一个这样的例子是
Internal = std::string,U = const char *。按值传递,实参先被拷贝(左值)/移动(右值)到形参,然后再移动到
Widget::a。:1
2
3
4
5
6
7
8
9class Widget {
public:
...
void addName(Internal name) {
cout << "by value" << endl;
a = std::move(name);
}
...
}
代价:若实参是左值则需要一次拷贝+一次移动,若实参是右值则需要两次移动。
使用按值传递,我们可以不维护两个函数重载版本,同时避免通用引用带来的问题。但作为代价,不管实参是左值还是右值类型,我们都需要多做一次移动。作者指出,只有满足下列条件时,我们才应该做出这样的trade off并使用按值传递:
函数处理的不能是不可拷贝而只能移动的类型,对于这样的类型,我们不需要接收左值引用的重载版本,不存在维护两份代码的痛点,使用按值传递的优势不存在,反而白白多做了一次移动。
函数处理的类型的移动开销必须可以接受,否则多做的一次移动开销会很巨大。
形参在函数中必须总是被移动,假如形参只在某些分支中需要移动,那对于不需要移动的执行路径,相较于引用形参的版本,我们白白多做了一次拷贝/移动。
1
2
3
4void addName(Internal name) { // 此时不推荐按值传递
if (name.length() > 10)
a = std::move(name);
}具体情况具体分析,书中举了一个改密码的例子:
1
2
3
4
5
6
7
8
9
10class Password {
public:
explicit Password(std::string pwd)
: text(std::move(pwd)) {}
void changeTo(std::string newPwd)
{ text = std::move(newPwd); }
…
private:
std::string text;
};这里
changeTo是按值传递,新密码被存储在为newPwd分配的内存中,再被移动到text中。text的旧密码所用的内存被释放。然而如果采用引用传递,且新密码比旧密码更短,移动赋值可能可以直接把新密码放到旧密码所在的内存中,省去一次内存分配和释放。这种情况下,传值的代价就远远大于传引用,不应该按值传递。
函数处理的类型不能是某个继承体系中的基类,否则如果按值传递一个派生类对象进来,它的派生部分就被截断了。使用引用传递会触发动态绑定,不存在这个问题。
Item 42: Consider emplacement instead of insertion
一般来说,使用emplacement函数(如emplace_back)可以直接传入需要放入的对象的构造函数参数,并直接在目标位置上构造对象,因此比起接收对象的insert函数(如push_back)可以省去临时对象的构造和析构,往往可以获得性能提升。
如果容器不允许重复值,加入元素时往往需要先构造出来,再与已有元素比较是否重复,这种情况下emplacement没有性能优势。
使用emplacement需要关注异常安全的问题。在下面的例子中,使用push_back()时如果内存分配失败,临时构造的shared_ptr会被正常析构,不会有内存泄漏;但使用emplace_back()时如果内存分配失败,由于shared_ptr对象还没有构造出来,使用new分配出的内存泄漏了。本质上,这么使用违背了RAII原则,没有立刻将new出来的指针交给资源管理对象。
1 | std::vector<std::shared_ptr<int>> v; |
emplacement使用直接初始化,insert使用拷贝初始化,因此emplacement会使用explicit标记的构造函数而insert不会。
之所以拷贝初始化不能使用
explicit的函数,是因为语义上拷贝初始化先(隐式地,因此不能使用带explicit的构造函数)构造了临时对象,再拷贝给声明的对象。尽管编译器可能会优化这个过程,让它和直接初始化一致,但仍然需要满足字面执行所需的条件。
1 | // std::regex接收const char*参数的构造函数是explicit的 |