Chapter 5 Rvalue References, Move Semantics and Perfect Forwarding
Item 23 Understand std::move and std::forward
std::move
和std::forward
在运行时刻不做任何事(它们不会生成哪怕一个字节的可执行代码),只是在编译时刻转换参数的左右值属性:c++1
2
3
4
5
6template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}注意:作为返回值的右值引用是右值!
move
无条件地将参数转换成右值,而forward
可以根据模板参数来决定是否将参数转换成右值。尽管用forward
也可以实现move
的功能,但是需要多指定一个模板参数,并且语义上也不太合适:c++1
2
3A a;
std::move(a);
std::forward<A>(a);
Item 24 Distinguish universal reference from rvalue reference
universal reference 需要满足两个条件:有形如
T&&
的类型以及发生了类型推断。仅发生类型推断但没有
T&&
类型的情况:c++1
2template<typename T>
void f(std::vector<T>&& param); // rvalue reference注意必须是严格的
T&&
(当然不一定非得是T
),多一个const
也不行:c++1
2template<typename T>
void f(const T&& param); // rvalue reference仅有
T&&
类型但没有发生类型推断的情况:c++1
2
3
4
5template<class T, class Allocator = allocator<T>
class vector {
public:
void push_back(T&& x); // rvalue reference
};该情况中,实际调到
push_back
前类模板已经被实例化了,所以T
已经是一个具体的类型了,不会发生类型推断。
T&&
也可以是auto&&
:c++1
auto&& var2 = var1; // universal reference
这在 C++14 的 lambda 表达式中尤其有用:
c++1
2
3
4
5auto timeFuncInvocation = [](auto&& func, auto&&... params) { // universal reference
// start timer
std::forward<decltype(func)>(func)(std::forward<decltype(params)>(params)...);
// stop timer and record elapsed time
};universal reference 表现成 lvalue reference 还是 rvalue reference 由初始化表达式是左值还是右值来决定:
c++1
2
3
4
5
6template<typename T>
void f(T&& param); // param is a universal reference
Widget w;
f(w); // lvalue passed to f; param's type is Widget& (lvalue reference)
f(std::move(w)); // rvalue passed to f; param's type is Widget&& (rvalue reference)
Item 25 Use std::move on rvalue references, std::forward on universal references
对于函数入参,将
move
作用于 rvalue reference,将forward
作用于 universal reference,需要注意的是如果多次使用该入参,则只能将move
或forward
作用在最后一次上:c++1
2
3
4
5
6template<typename T>
void setSignText(T&& text) {
sign.setText(text);
auto now = std::chrono::system_clock::now();
signHistory.add(now, std::forward<T>(text));
}当然,这里说的 “最后一次” 也可以是返回值(如果函数的返回值是 return by value 的话):
c++1
2
3
4Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
lhs += rhs;
return std::move(lhs);
}这样做的好处在于省去一次 copy 的开销。
但是将
move
作用于返回值这一操作对于 local 对象来说,情况有所不同。首先需要明白为什么要将move
作用于返回值?因为想省下一次 copy 操作的开销。那么返回一个 local 对象真的会 copy 吗?其实大部分情况下是不会的,因为编译器做了 RVO(返回值优化),即直接在存放函数返回值的内存位置处构造这个 local 对象:c++1
2
3
4Widget makeWidget() {
Widget w; // 构造在存放函数返回值的内存位置,而非存放普通 local 对象的位置
return w;
}所以即使不加
move
,编译器也不会 copy。相反,如果加了move
,编译器将不会进行 RVO,因为 RVO 需要满足两个条件:local 对象类型要和返回值类型一样且 local 对象就是被返回的对象。而被返回的对象(std::move(w)
)是w
的引用,并非w
本身,所以不满足 RVO 的条件。那有的人也许会说,我加了
move
虽然放弃了 RVO 的机会(这里说是 “机会” 是因为不是说不加move
就一定会 RVO,仍然有很多其他情况阻止编译器做 RVO,比如有多处返回值返回不同的 local 变量),但至少保证了不会 copy。这个观点也是不对的,因为如果满足 RVO 的条件而编译器由于种种原因没有做 RVO 的话,它也会将返回值作为右值来处理,即看上去像是编译器帮你加了move
一样。总结一下就是,当函数满足 RVO 的条件时,不要将
move
作用于返回值。因为如果确实做了 RVO,那就白白多了一次 move 操作;而就算没有做 RVO,编译器也会将其当做右值处理,手动加上move
并没有任何优化。同样地,对于返回值是值传递进来的参数的情况,编译器也会将其当做右值处理(这种情况做不了 RVO),不用程序员加上
move
:c++1
2
3Widget makeWidget(Widget w) {
return w;
}
Item 26 Avoid overloading on universal references
尽量避免对 universal reference 进行重载:
c++1
2
3
4template<typename T>
void logAndAdd(T&& name);
void logAndAdd(int idx);在这个例子中,只要参数类型不是 int,对
logAndAdd
的调用都会被有 universal reference 的版本捕获,因为它可以实例化出一个完美匹配的模板函数。在 C++ 重载函数的版本选择中,类型能完美匹配的优于需要转型的,如果类型都能完美匹配,那么非模板函数优于实例化出来的模板函数。除此以外,当类的构造中含有 universal reference 时,情况会变得更糟。它会捕获所有非常量拷贝构造(编译器自动生成的拷贝构造参数具有
const
,不能算完美匹配了),还会捕获所有来自子类的拷贝构造(子类的类型被父类的拷贝构造接受需要转型)
Item 27 Familiarize yourself with alternatives to overloading on universal references
如果不得不利用重载 universal reference 提供的功能,有几种方式可以避免它带来的问题:
- 不重载,用不同名的函数。
- 不使用 universal reference,可以使用常量左值引用或值传递。
(以上两种方法都不是很好)
Tag Dispatch。重载 universal reference 带来的问题就是选择重载版本时,它往往会捕获到比我们想象的多得多的调用,那只要解决这个问题就可以了。注意到选择重载版本是以参数类型为依据的,universal reference 只是其中一个参数,我们可以使用另一个参数(tag)来决定调用(dispatch)哪个版本:
1
2
3
4
5
6
7
8
9
10
11
12
13template<typename T>
void logAndAdd(T&& name) {
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
// 使用 remove_reference 是因为 int& 不是 integral type
);
}
template<typename T>
void logAndAddImpl(T&& name, std::false_type);
void logAndAddImpl(int idx, std::true_type);std::true_type
和std::false_type
是编译时的 bool 类型。选择重载版本时它们能 “屏蔽” universal reference 的完美匹配(就好比是在 “完美匹配 + 完美不匹配” 和 “需要转型 + 完美匹配” 中选一个)。还有一种方法,用到模板可以在某些条件下被禁用的机制,
condition
不满足时,该模板就好像不存在一样:c++1
template<typename T, typename = typename std::enable_if<condition>::type>
为了不让 universal reference 捕获到和自定义类型仅 cvr 属性不同的参数、是自定义类型派生类的参数或者 integral type,可以这样指定自定义类型的含有 universal reference 的构造函数:
c++1
2
3
4
5
6
7
8template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value &&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) : name(std::forward<T>(n)) {}这种方法解决了 Tag Dispatch 的一个问题:即使是含有 universal reference 的构造函数也不能捕获所有调用(还存在其他重载版本,比如默认拷贝构造),所以就没办法作为一个 dispatcher。
universal reference 的确效率很高,但是往往存在易用性上的问题,比如报错信息太隐晦。
Item 28 Understand reference collapsing
reference collapsing 是说当编译器生成了一个引用的引用时(程序员是不能定义引用的引用的),会按照以下规则将其折叠成单引用:
1 | T& & -> T& |
有四种情况会发生 reference collapsing:
模板实例化(universal reference),这也是用的最多的一个情形:
c++1
2template<typename T>
void func(T&& param);当实参为左值时,
T
推断为左值引用&
,发生 reference collapsing(当实参为右值时,T
被推断为普通类型,没有 reference collapsing)auto 类型推断(universal reference),如 Item 24 中所述,这也是 universal reference:
c++1
auto&& w1 = w;
typedef 和类型别名,上面两种情况是根据参数的左右值属性来推断不同的
T
或auto
,推断的结果有两种:普通类型和左值引用,只有推断为左值引用时才会发生 reference collapsing,但是这种情况(以及下面那种情况)不同,它们没有发生类型推断:c++1
2
3
4
5
6
7
8template<typename T>
class Widget {
public:
typedef T&& RvalueRefToT;
};
Widget<int&> w1; // int&
Widget<int&&> w2; // int&&decltype,和第三种情况类似:
c++1
decltype(a) &&b;
Item 29 Assume that move operations are not present, not cheap and not used
move 操作并非总是优于 copy 操作,例如:
- 有些类不支持 move 操作;
- 对于小对象,copy 操作有时也会比 move 操作快(比如
std::string
的 SSO,Small String Optimization); - move 操作会抛出异常,而接口要求 noexcept 时。
Item 30 Familiarize yourself with perfect forwarding failure cases
perfect forwarding 是说有一个转发函数和目标函数,转发函数需要将其接受的参数原封不动地(保留类型,cvr 属性)传递给目标函数。而如果将这些参数传递给转发函数让其转发和直接传递给目标函数得到的行为不一样的话,perferct forwarding 就被认为失败了。
导致 perfect forwarding 失败可能有以下几种情况:
参数为 braced initializers,即花括号括起来的一组值。将 braced initializers 直接传递给目标函数没有问题,但是转发函数的参数是 universal reference,无法从 braced initializers 推断出类型。但是有一个 trick 可以解决这个问题:先用 braced initializers 初始化一个
auto
的 initializer_list 然后将其传进来。参数为希望被当成空指针的 0 或 NULL。当转发函数接受到 0 或 NULL 时,会优先转发给目标函数接受 integral type 的重载版本,而非接受指针的重载版本。
参数为未定义的 static const 成员变量。需要先明确一下,“定义” 成员变量是写在类外的,“声明” 成员变量才是类内的:
c++1
2
3
4
5
6class Widget {
public:
static const std::size_t MinVals = 28; // declaration
};
const std::size_t Widget::MinVals; // definition如果不对未定义的 static const 成员变量取地址,那不定义没有什么问题,但是作为转发函数的参数,需要引用这个成员变量,而对编译器生成的代码来说,引用和指针通常没什么区别。即不定义就不占内存,就没有地址,就没办法引用。
参数为有重载版本的函数名或者函数模板。将函数名或函数模板直接传递给目标函数时,可以通过目标函数的参数类型决定用哪个重载版本或模板实例,但是转发函数的参数类型是 universal reference,没有办法选择重载版本或模板实例。解决的方法是先将函数名赋值给一个新定义的确定类型的变量,然后将新定义的变量传递进来或者直接将函数名转型成指定的类型。
参数是位域。C++ 不允许非常量引用绑定到位域,因为没法直接修改 bit。解决的方法和之前类似,先用位域初始化一个
auto
变量,然后将这个新定义的变量传递进来。