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::thread
s 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的 |