0%

Effective Modern C++读书笔记(四)

本文包含《Effective Modern C++》中第5章内容的个人笔记,讨论C++11标准中引入的右值引用相关问题。

第5章 右值引用、移动语义和完美转发

条款23: 理解std::move和std::forward

move只是将实参强制转换为右值,其行为大致为:

1
2
3
4
5
6
template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}

需要注意的是move的实现并没有对实参常量性做任何修改,即如果传入的是const&得到的会是const&&,而如果对const&&实施构造则会因为常量性问题去调用复制构造而不是移动构造。
forward和move一样只是进行了强制类型转换,而不同的是forward只在实参是使用右值完成初始化时,才会执行向右值型别的强制型别转换,forward具体实现见条款28笔记。

条款24: 区分万能引用和右值引用

对于函数模板中出现的T&&,如果T的型别是经由推到而来的,或者是使用auto&&声明的对象,也就是经由编译器推导的且使用&&声明的对象,有别于右值引用,这种引用称为万能引用。
万能引用不同于右值引用,仅在使用右值初始化该变量时才是右值引用,采用左值来初始化则是左值引用。
未通过推导的直接使用type&&就代表右值引用。

条款25: 针对右值引用实施std::move,针对万能引用实施std::forward

由于万能引用既可能是左值引用也可能是右值引用,所以在转发万能引用时需要使用std::forward实现完美转发,forward的具体实现和引用折叠相关内容在条款28中细说;右值引用则需要使用std::move来将自身移动出去,考虑到移动构造新对象后自身对象已无意义,所以应在函数最后一次使用右值引用和万能引用时使用move和forward。
将入参(右值引用和万能引用)按值返回的场景同样需要使用move和forward来返回。
需要注意的是编译器可执行返回值优化的场景(return value optimization,RVO)不应该使用move或者forward,应该直接返回值交由编译器直接返回局部对象。
编译器执行RVO的两个前提条件:

  1. 局部对象型别和函数返回值型别相同。
  2. 返回的就是局部对象本身

条款26: 避免依万能引用型别进行重载

一句话概括本条款就是万能引用拥有比其他重载更宽泛的匹配,导致万能引用重载会在不经意间被调用。如书上举例的万能引用构造对于非常量左值比复制构造拥有更佳匹配,在子类型的复制构造和移动构造中劫持父类型的构造。

条款27: 熟悉万能引用型别进行重载的替代方案

继续条款26中万能引用重载的替代方案讨论,替代方案包括舍弃万能引用改用const T&和传值,这两个方案甚至不能说是替代方案算是“舍弃”方案。
在条款26中书上提到过这么个问题,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::multiset<std::string> names;
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx);
void logAndAdd(int idx)
{
auto now = std::chrono::system_clock::now();
log(now, “logAndAdd”);
names.emplace(nameFromIdx(idx));
}

以上代码在如下调用

1
2
short nameIdx;
logAndAdd(nameIdx);

由于万能引用生成的short入参签名版本会比int版本拥有更好的匹配,造成编译时报错(无法将short类型emplace进入multiset)。
依然使用万能引用的方案中我们可以使用标签分派解决如上问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>
);
}
template<typename T>
void logAndAddImpl(T&& name, std::flase_type)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
name.emplace(std::forward<T>(name));
}


std::string nameFromIdx(int six);
void logAndAdd(int six, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}

即通过类型的不同标签将万能引用重载实现分派到不同方法上。

进一步的可以使用enable_if来限制万能引用重载方法的范围,从而保证编译过程的正确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person {
public:
template<
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))
{
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can’t be used to construct a std::string"
);
}
};

这里之所以使用static_assert进行编译错误提示,是由于依万能引用进行推倒得出的编译错误信息通常较复杂。
在我看来,条款26、27讨论的万能引用模版方法造成问题的原因,主要是当前开发者对万能引用的不够熟悉以及编译器错误提示信息不够完善。

条款28: 理解引用折叠

编译器不允许引用的引用存在,在出现这种情况将会发生引用折叠。
在模版实例化、auto型别生成、typedef和decltype语境时,编译器对于出现的引用的引用情况将会执行引用折叠。
引用折叠按照如下规则进行:
如果任一引用为左值引用,则结果为左值引用。否则(即两个皆为右值引用),结果为右值引用。
较为明显的例子是std::forward的实现问题,如书上所写的forward的一种实现:

1
2
3
4
5
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}

在如下代码中

1
2
3
4
5
template<typename T>
void f(T&& fParam)
{
someFunc(std::forward<T>(fParam));
}

当传入左值Widget时,T被推导为Widget&,则forward模版实例化为

1
2
3
4
Widget& && forward(typename remove_reference<T&>::type& param)
{
return static_cast<Widget& &&>(param);
}

在remove_reference和引用折叠后变为:

1
2
3
4
Widget& forward(Widget& param)
{
return static_cast<Widget&>(param);
}

static_cast不进行任何转换,即forward传入左值引用得到左值引用;
对于传入右值的情况,T被推导为Widget,forward最终实例化为:

1
2
3
4
Widget&& forward(Widget& param)
{
return static_cast<Widget&&>(param);
}

具名右值引用在参数传递时仍是个左值,所以forward的Widget&形参可以匹配上,然后通过static_cast强转为右值引用,即f(T&& fParam)当传入右值内部forward继续传递右值;亦即当forward<T>的模板参数T为右值引用或者“按值”时返回右值引用,否则返回左值引用。

条款29: 假定移动操作不存在,成本高,未使用

在从旧标准过度到新标准的阶段中,会存在未针对新语法修改的代码,那么就会存在没有移动操作的类型,也会存在未对移动操作做优化的类型或者是类型本身移动操作不比复制快,亦或者是由于noexcept声明导致移动操作不可用的情况。
这都会导致移动语义不能带来任何好处,所以当面对未知或者通用类型的场景时,可以假定移动操作不存在。
而对于移动语义支持情况已知的类型则无需做出该假定。

条款30: 熟悉完美转发的失败情形

本条款主要关注完美转发失败的集中情况,也就是形如:

1
2
3
4
5
6
7
template<typename T>
void fwd(T&& param)
{
f(std::forward<T>(param));
}
f(expression);
fwd(expression);

f与fwd行为不一致的情况。
本条款讨论的情况大致可分为两种:

  1. 编译器无法为一个或多个fwd的形參推导出型别结果
  2. 编译器为一个或多个fwd的形參推导出了“错误的”型别结果

大括号初始化物

属于第一种情况,标准委员会规定向未声明为std::initializer_list的函数模板传递大括号初始物为“非推导语境”,将拒绝从该initializer_list出发推导类型。从而导致如f函数形參为vector,f可以工作,fwd无法通过编译的情况。

0和NULL用作空指针

属于第二种情况,0和NULL在类型推导时将会是整型而非指针。

仅有声明的整形static const成员变量

属于之前提到的第一种情况,由于仅有声明的整形static const成员变量编译器通常会直接替换变量为相应值,导致无法将引用形参绑定到一个不具有内存实体的变量上。当然这个问题也取决于编译器对于仅有声明的static const整形成员的实现。

重载的函数名字和模板名字

仍属于第一种情况,对于有多个重载的函数来说,fwd函数模板无法单纯通过函数名来推导型别,也就导致fwd无法正常工作。

位域

仍属于第一种情况,位域无法工作的原因仍然是指针的问题,位域的地址可能是第一个字节里的任意比特,而指针最小只能指涉到单个char上,所以位域无法绑定到和指针等价的引用变量上。
想要位域情况的fwd正常工作,可以先将位域按值传递出来再做完美应用传递。