Effective Modern C++读书笔记(三)
右值引用,移动语义和完美转发
*移动语义(Move semantics)*往往可以用来替代copy操作,减小开销。而且还要某些对象(比如
std::unique_ptr
,std::futrue
,std::thread
)只允许move,禁止copy。可是在某些情况下,它的开销并不一定比copy小。*完美转发(Perfect forwarding)*可以让模板接受参数,然后转发给其它函数,保持参数的类型不变。在某些时候,它也并不是完美的。
而*右值引用(Rvalue reference)*就是使上述两个看似不相干的特性连接起来的胶水。它是支撑着移动语义和完美转的潜在语言机制。
Item 23
std::move
不移动任何东西。std::forward
不转发任何东西。在运行期间,不产生可执行的代码,不产升任何字节。它们只是是执行类型转换的函数模板(function templates)。
std::move
std::mvoe
无条件地将它的参数转化成右值。可以看下一个接近标准库的简单实现1
2
3
4
5
6
7template<typename T>
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}并且在c–14中,,
std::move
还能实现得更精炼:1
2
3
4
5
6template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}由于
std::move
只做了将参数转化成右值的工作,甚至有人建议将它的名字改成ralue_cast
。当然,右值是可移动的,所以将std::move
用于一个对象,可以告诉编译器这个对象是可被移动的。可能这就是命名为std::move
的原因。std::mvoe
并不一定真正能引发移动操作。举个例子:1
2
3
4
5
6
7
8class Annotation {
public:
explicit Annotation(const std::string text)
: value(std::move(text)) { }
private:
std::string value;
}你可能预想
value(std::move(text))
讲引发std::string
的移动构造函数。但是,这里却发生了copy。原因在于text
是个const对象。std::move
作用于const对象时,返回值是个const限定的右值。而std::string构造函数的签名却大概是如下这样的:1
2
3
4
5
6
7class string { // typedef for std::basic_string<char>
public:
std::string(const string& rhs); // copy ctor
std::string(string&& rhs); // move ctor
// ...Other code
};const std::string
的右值不能传给rvalue reference to a non-const std::string
(即不会调用移动构造函数),却可以传给lvalue-reference-to-const
(即会调用拷贝构造函数)。因为移动一个对象会改变它的某些值,所以c–不允许const
对象传给可能会改变它们值的函数。所以,有两个教训:
- 如果你想要移动某个对象,不要将它声明为const。否则实际将会引发copy操作。
std::move
不移动任何东西。它只是保证它转化的对象将会有资格被移动,即被转化成右值。
std::forward
std::forward
的转化只在某些特定情况下工作。它最常用的场景是在universal reference
作为参数的函数模板(function template)中,用于将参数传给另一个函数。比如说:1
2
3
4
5
6
7
8
9
10
11
12
13
14void process(const Widget& lvalArg);
void process(Widget&& rvalArg);
template<typename T>
void logAndProcess(T&& param)
{
auto now = std::chrono::system_clock::now();
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}
Widget W;
logAndProcess(w); // (1) call with lvalue
logAndProcess(std::move(w)); // (2) call with rvalue试想一下,如果没有
std::forward
会如何。param会被推断成左值或者右值的引用(详情见第一章)。但是,无论如何,param本身都是一个左值,所以调用process的参数匹配的时候,都会调用参数是lvalue reference的重载版本。我们需要一个机制,只有当传给param
的对象是右值的时候,将param
从rvalue reference(是左值)转换右值。事实上,这就是上述提到的希望std::forward
工作的特定情况。BTW,你可能会奇怪
std::forward
如何区分传给它的对象被初始化之前的类型。秘密就藏在模板参数T里面。后面Item 28会详细讲。事实上,某些时候,我们可以用
std::forward
来代替std::move
:1
2
3
4
5
6
7
8
9
10class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s)) { }
// 等价于下面这么写
Widget(Widget&& rhs)
: s(std::forward<std::string>(rhs)) { }
private:
std::string s;
};注意到
std::forward
的模板参数是std::string
,这可以让它的返回值是右值。但是这么写很不方便。所以还是用std::move
来得好。
Item 24
univsersialcv reference
可以绑定到任意类型,const或volatile限定的rvalue或lvalue。通常有两个运用的地方,并且都伴随着类型推断:模板中:
1
2template <typename T>
void f(T&& param); // param is a universal referenceauto 表达式
1
auto&& var2 = val1; // var2 is a universal reference
如果加了const,就会绑定到rvalue reference
1
2template<typename T>
void f(const T&& param); // param is a rvalue reference不要在模板里看到
T&&
就以为是universal reference
。比如在vector中:1
2
3
4
5
6
7
8template<typename T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x); // x is a reference to rvalue
template<class... Args>
void emplace_back(Args&&... args); // args are universial references
}C++14的lambda表达式可以声明
auto&&
参数:1
2
3
4
5
6
7
8
9auto timeFuncInvocation =
[](auto&& func, auto&&... params)
{
//start timer;
std::forward<decltype(func)>(func)(
std::forward<decltype(params)>(params)...
)
//stop timer and record elapased time
}universal reference
只是一层抽象,背后的原理是reference collapsing
(引用折叠)。
Item 25
合理运用
universal reference
可以有许多好处。考虑下以下两端代码: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// With universial reference
class Widget{
public:
template<typename T>
void setName(T&& newName)
{
name = std::forward<T>(newName);
}
// ... Other codes
private:
std::string name;
}
// With two override functions
class Widget{
public:
void setName(const std::string& newName)
{
name = newName;
}
void setName(std::string&& newName)
{
new = newName;
}
// ... Other codes
private:
std::string name;
}前者就比后者好一些。首先,后者增加了源代码的编写以及要维护的代码量。其次,它可能更加低效。比如说:
1
w.setname("cyyzero");
使用
universal reference
版本,那么它被转发给std::string
,并且只调用std::string
的一次赋值函数。而对于两个重载版本的setName
,将会创建一个临时的std::string
对象,然后临时对象移动,最后析构。Last but not least,对于后者不仅源代码体积膨胀和执行期的效率低下,而且它是一种可拓展性很差的设计。可能需要重载的数量会很多。在某些情况下,你想要用绑定到 rvalue reference 或者 universal reference 的值多次,那么在最后一次才用
std::move
或者std::forward
.RVO, 即
return value optimization
,是指编译器对于返回值需要copy 的情况进行了优化,让copy避免。通常当local对象和返回值类型相同,并且这个local对象被返回,则会进行RVO优化。如果不符合消除拷贝的条件,返回值会被视为右值。所以对于return std::move(ret);
这样试图对返回局部变量进行优化的情况,属于画蛇添足。它不会帮到编译器,还可能阻碍了优化(因为所返回对象的类型变成了rvalue reference)。对于返回值是传值,但返回的对象是左值或者右值引用的情况,那么把
std::move
用在右值引用上,std::forward
用在 universal reference 上。
Item 26
对参数是 universal reference 的模板函数的重载,将会导致几乎都只调用参数是 universal reference 的版本。
完美转发的构造函数一般会导致很多问题,因为对于参数是
non-const lvalue
的拷贝构造,和它的继承类调用它的拷贝或移动构造函数的时候,都会调用它。总而言之,尽量别对参数是universal reference的函数重载。
Item 27
对于上述的问题,有一些办法可以解决。
放弃重载。定义多个不同的函数。
通过
const T&
传参数。缺陷就在于效率可能会降低(因为不能move)。但是有时候选择放弃效率带来代码的简单,不失为一种方案。传值。这可能是反直觉的,因为传值意味着拷贝对象,很多时候都会带来很大的开销。但是知道需要拷贝的时候,就可以考虑传值,然后再将副本move。
用Tag dispath(标签分派)。函数A参数设置为universal reference,然后通过对参数类型的分类,分派给其他函数。举个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25std::vector<std::string> names;
std::string nameFromIdx(int idx);
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}
template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
auto now = std::chrno::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_referene<T>::type>()
);
}约束接收universal reference作为参数的模板。这是后需要用到
std::enable_if
。它的工作原理基于SFINAE。配合type traits,可以出色地完成工作: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
26class Person
{
public:
template
<
typename T,
typename = typename std::enable_if
<
!std::is_base_of
<
Person,
typename std::decay<T>::type
>::type
&&
!std::is_integeral<typename std::remove_reference<T>::type>::value
>::type
>
Person(T&& t)
:name(std::forward<T>(t)) { }
Person(int Idx)
:name(nameFromIdx(Idx)) { }
private:
std::string name;
}不太好理解的地方:
std::enable_if
,std::is_base_of
,std::decay
,std::is_integeral
.权衡。使用universal reference通常能减小开销,但是随之而来的也有许多劣势。一来有些参数不能玩么转发。二来有时候完美转发后的出错信息可读性差。比如说上面的例子,传给Person的参数不能构造
std::string
,此时的报错将很感人。所以最好用static_assert
确定它时候符合要求。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
31class Person
{
public:
template
<
typename T,
typename = typename std::enable_if
<
!std::is_base_of
<
Person,
typename std::decay<T>::type
>::type
&&
!std::is_integeral<typename std::remove_reference<T>::type>::value
>::type
>
Person(T&& t)
:name(std::forward<T>(t))
{
static_assert
(
std::is_constructible<std::string, T>::value,
"Parameter n can;t be usedd to construct a std::string"
)
}
private:
std::string name;
}
Item 28
C++禁止声明引用的引用。但是编译器可能在特定上下文,以及模板实例化的时候遇到它们。这时候,引用折叠(reference collapsing)就派上用场了。引用折叠大概的规则就是:如果有一个是左值引用,结果就是左值引用。否则(即都是右值引用),结果为右值引用。
- T&& && => T&&
- T&& & => T&
- T& && => T&
- T& & => T&
引用折叠主要在四种情况中出现:
模板实例化。其实主要就是参数是universal reference的函数模板实例化的时候。universal reference配上
std::forward
,主要就是引用折叠的规则才使得完美转发成为可能。比如说有个完美转发的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21//std::forward的简单实现
template<typename T>
T&& forward(typename remove_refrence<T>::type& param)
{
return static_cast<T&&>(param);
}
template<typename T>
void f(T&& fParam)
{
someFunc(forward<T>(fParam));
}
//返回Widget对象的工厂函数
Widget getWidget();
int main()
{
Widget w;
f(w); // 情况(1)
f(getWidget()); // 情况(2)
}对于情况(1),f函数和forward函数会被推断成这样:
1
2
3
4
5
6
7
8
9void f(Widget& && fParam)
{
someFunc(forward<Widget&>(fParam));
}
Widget& && forward(Widget ¶m)
{
return static_cast<Widget& &&>(param);
}正是由于引用折叠的存在,它事实上是这样的:
1
2
3
4
5
6
7
8
9void f(Widget& fParam)
{
someFunc(forward<Widget&>(fParam));
}
Widget& forward(Widget ¶m)
{
return static_cast<Widget&>(param);
}函数返回左值引用,返回值是个左值。所以 forward 返回值是 Widget 左值。于是完美转发成功。
对于情况(2), f 函数和 forward 函数会被推断成这样:
1
2
3
4
5
6
7
8
9void f(Widget&& fParam)
{
someFunc(forward<Widget>(fParam));
}
Widget&& forward(Widget& param)
{
static_cast<Widget&&>(param);
}*函数返回右值引用,返回值是 xvalue (一种右值)*。所以forward的返回值是 Widget 右值。完美转发也成功了。
ps:一开始我对于在f函数体内somefunc函数匹配规则不太理解,不太理解引用作为参数模板怎么推断。后来查了下,从函数调用推导模板参数 P 的时候,若 P 是引用类型,则用 P 所引用的类型推导。那么一切问题都迎刃而解了。
auto类型推导的时候。这个也不难想到,因为 auto 推导规则和模板类型推导差不多(见 Item 3)。还是来看一些例子:
1
2
3
4
5auto&& wi = w;
// 等价于 Widget& && w1 = w; => Widget &w1 = w;
auto&& w2 = getWidget();
// 等价于 Widget&& w2 = getWidget();typedef 和 alias declarations。在模板中声明等价类型的时候,也经常运用引用折叠。
1
2
3
4
5template<typename T>
class Widget
{
typedef T&& RvalueRefToT; // RvalueRefToT类型可能和预期的右值引用不太一样,由于T类型未知,如果发生引种折叠,它还可能是左值引用
}decltype。当使用decltype配合引用的时候,也可可能发生引用折叠。
最后总结:universal reference
能工作就是依靠两点,一是类型推导能区分左值和右值,二是引用折叠的作用。
Item 29
有时候,移动不一定比拷贝快。举个例子:
std::array
。std::array
的数据不会存放在堆上,基本就是内置数组类型的简单封装。所以移动也是O(n)复杂度。- 很多
std::string
的实现都采用了 *small string optimization(SSO)*。比较短的字符串可能不会在存在堆上。这样移动的话效率也不会比拷贝高。
有些时候,为了保证老代码在C++98下的强异常安全,如果有移动操作但移动操作没有声明
noexcept
,编译器也会选择采用拷贝操作。
Item 30
1 | template<typename... T> |
这是一个很简答的完美转发的例子。但是实际上,它有时候并非如“完美”。在以下情况中完美转发会失败:
- 编译器无法推断类型。
- 推断的类型并非预期。这可能会造成编译失败,或者匹配重载函数的其他版本。
当参数比较特殊的收就很容易出错。举一些例子:
Branced initializers 。假设 f 声明为如下:
1
2
3void f(const std::vector<int>& v);
f({1,2,3}); // Ok, 列表隐式转化为向量
fwd({1,2,3}); // 编译失败这是因为如果直接调用 f ,编译器将会对实参和形参类型匹配,但必要的时候会进行隐式转化。而通过 fwd 调用的时候,标准规定不能推断表达式
{1,2,3}
的类型(std::initializer_list
是 “non-deduced context”)。只有fwd的参数被声明为std::initializer_list
的时候,才可以这么传参数。而auto
就不受这个限制了,可以这么写:1
2auto il = {1,2,3};
fwd(il); // It works.0 或者 NULL。很可能会被腿短成int类型,而非空指针。所以用
nullptr
代替它们。只有声明的 static const 成员变量。建议写上定义(不要重复初始化)。否则可能会在参数是引用的函数传参时链接失败。
重载函数的名字或者模板的名字。这通常对于 f 参数是函数指针的时候,给 f 传重载函数的函数名,编译器会选择签名相同函数传指针过去。而对于 fwd ,来说,如果给它的参数是重载函数或者模板的名字,就无法推断,编译出错。解决的办法就是先定义一个函数指针,然后再作为参数传递给 fwd 。
位域(Bitfields)。因为位域的成员是无法引用的,所以将它作为参数传递给 fwd 就会出问题。(原因在于位域的成员可能是任意的bit,比如说一个32位int类型的第3到第5个bit,这就无法寻址)。