《EffectiveModernC-》第五章-移动语义,完美转发
第五章 右值引用,移动语义,完美转发
Item 23:理解std::move
和std::forward
这里是一个C++11的std::move
的示例实现。它并不完全满足标准细则,但是它已经非常接近了。
1 |
|
std::move
接受一个对象的引用,准确的说是一个万能引用(universal reference)。去除对象类型的引用后统一加上&&
来变成右值类型ReturnType,再把param强转为这个右值类型。
在C++14中可以被更简单地实现。多亏了函数返回值类型推导(见Item3)和标准库的模板别名std::remove_reference_t
(见Item9),std::move
可以这样写:
1 |
|
如你所见,move名字叫做move但是他并不移动任何资源,只是简单的把类型强转为右值,这样看来,也许叫做rvalue_cast
更适合一些。所以对一个对象使用std::move
就是告诉编译器,这个对象很适合被移动,move的出现(其实就是右值的出现)帮助我们更容易指定可以被移动的对象。如果你看过auto_ptr
的实现,知道他为什么不如unqiue_ptr
,你就知道右值的出现解决了很多棘手问题。
下面我们看个例子:
1 |
|
当复制text
到一个数据成员的时候,为了避免一次复制操作的代价,你仍然记得来自Item41的建议,把std::move
应用到text
上,因此产生一个右值:
1 |
|
到了这里可能会让你失望了,text
并不是被移动到value
,而是被拷贝。诚然,text
通过std::move
被转换到右值,但是text
被声明为const std::string
,所以在转换之前,text
是一个左值的const std::string
,而转换的结果是一个右值的const std::string
,但是纵观全程,const
属性一直保留。
当编译器决定哪一个std::string
的构造函数被调用时,考虑它的作用,将会有两种可能性:
1 |
|
在类Annotation
的构造函数的成员初始化列表中,std::move(text)
的结果是一个const std::string
的右值。这个右值不能被传递给std::string
的移动构造函数,因为移动构造函数只接受一个指向non-const
的std::string
的右值引用。这很容易解释,因为non-const的引用类型不会限制修改内容,这最终会导致你修改了const的内容,这显然是不会被编译器允许的。换个更易懂的角度来看,移动操作本质就是对资源的移动,一个const的右值类型怎么做资源移动呢?这显然非常不合理,因此会调用到拷贝构造。另外要提一点,拷贝构造函数的const左值引用对左右值都可以适配。
从这个例子中,可以总结出两点。
第一,不要在你希望能移动对象的时候,声明他们为
const
。对const
对象的移动请求会悄无声息的被转化为拷贝操作。第二点,
std::move
不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。关于std::move
,你能确保的唯一一件事就是将它应用到一个对象上,你能够得到一个右值。
再来讨论下std::forward
,
关于std::forward
的故事与std::move
是相似的,但是与std::move
总是无条件的将它的实参为右值不同,std::forward
只有在满足一定条件的情况下才执行转换。std::forward
是有条件的转换。
最常见的情景是一个模板函数,接收一个通用引用形参,并将它传递给另外的函数:
1 |
|
考虑两次对logAndProcess
的调用,一次左值为实参,一次右值为实参:
1 |
|
在logAndProcess
函数的内部,形参param
被传递给函数process
。函数process
分别对左值和右值做了重载。当我们使用左值来调用logAndProcess
时,自然我们期望该左值被当作左值转发给process
函数,而当我们使用右值来调用logAndProcess
函数时,我们期望process
函数的右值重载版本被调用。
当且仅当传递给函数logAndProcess
的用以初始化param
的实参是一个右值时,param
会被转换为一个右值。这就是std::forward
做的事情。这就是为什么std::forward
是一个有条件的转换:它的实参用右值初始化时,转换为一个右值。
请记住:
std::move
执行到右值的无条件的转换,但就自身而言,它不移动任何东西。std::forward
只有当它的参数被绑定到一个右值时,才将参数转换为右值。std::move
和std::forward
在运行期什么也不做。
Item 24: 区分通用引用与右值引用
T&&
和auto&&
我们一般认为是万能引用的标准形式,但是有一些corner case你需要注意下,比如:
不要在T和auto前加const,这会导致变成右值引用。即使一个简单的
const
修饰符的出现,也足以使一个引用失去成为通用引用的资格:1
2template <typename T>
void f(const T&& param); //param是一个右值引用类型推导的T/auto
+&&
才是通用引用,当类里看见了一个函数形参类型为“T&&
”,他不一定是通用引用,因为T在类定义是可能已经推导完成了:来自
std::vector
:1
2
3
4
5
6
7template<class T, class Allocator = allocator<T>> //来自C++标准
class vector
{
public:
void push_back(T&& x);
…
}push_back
函数的形参当然有一个通用引用的正确形式,然而,在这里并没有发生类型推导。因为push_back
在有一个特定的vector
实例之前不可能存在,而实例化vector
时的类型已经决定了push_back
的声明。作为对比,
std::vector
内的概念上相似的成员函数emplace_back
,却确实包含类型推导: 如你所见Args实实在在的进行了类型推导。1
2
3
4
5
6
7template<class T, class Allocator = allocator<T>> //依旧来自C++标准
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);
…
};
这里提一句,万能引用的底层本质是引用折叠,我们后面iten28会提到这个概念。
item25:对右值引用使用std::move
,对通用引用使用std::forward
在右值引用上使用std::forward也是可以的,但是代码长语义不够清晰,一个简单的例子:
1 |
|
你要写成:
1 |
|
这实在有些自找麻烦,因为:
- forward你得写上classType,多写东西
- move看起来语义很清晰,你知道
std::move
后的东西一定是一个右值,对于forward,你可能还需要稍微考虑下。
因此对于右值引用,请直接使用std::move()
。
对于万能引用来说,使用std::forward
则是一个更好的选择
观察如下代码:
1 |
|
对于右值来说,使用move没什么问题,因为右值代表了你想使用移动语义。而对于一个左值n来说,因为move函数导致了左值n强转为右值,newname因为移动语义把自身的资源转移给了name。这会导致你完全不知道的情况下改变了你的左值。
你可能会指出,如果为const
左值和为右值分别重载setName
可以避免整个问题,比如这样:
1 |
|
这样的话,当然可以工作,但是有缺点。
首先编写和维护的代码更多(两个函数而不是单个模板);
其次,效率下降。
考虑这样一段代码
1
w.setName("Adela Novak");
对于通用引用的版本来说,字面字符串
"Adela Novak"
传递给setName的参数时,T会被推倒出const char[]
类型,然后引用到这个字面字符串,再传递给setName内部的赋值运算,这个过程中没有任何的临时变量被创建。但是对于分别重载左右值的版本,你会发现由于参数写的是string类型,因此你需要隐式构造一个临时对象,然后让string&&右值引用
来引用这个临时对象。可以发现我们需要多创建一次临时对象,这显然影响了性能。 当然你可以再添加一对
const char[]
版本的setName
, 然后也分别做左右值引用两个版本就好了。但是可以显而易见,这种代码编写和维护会变得很困难。如果有n个参数,每个参数都有两种可能,你就要写$2^n$种重载函数, 这也太难维护代码了(比如
make_shared
这种使用了variadic template,常常参数包里有多个参数)。
因此以上三条殊途同归都在说的一个问题就是:编写和维护起来很困难。
现在我想已经说服了你,你应该,就像标题说的一样:对右值引用使用std::move
,对通用引用使用std::forward
下面讨论一些其他的场景,比如在某些情况,你可能需要在一个函数中多次使用绑定到右值引用或者通用引用的对象,并且想确保在完成其他操作前,这个对象不会被移动,也就是内部资源不会被其他操作移动走。
1 |
|
我们想要确保text
的值不会被sign.setText
改变,因为我们想要在signHistory.add
中继续使用。因此std::forward
只在最后使用,前面sign.setText(text);
的text只是一个右值引用变量, 是一个左值,因此不会调用到移动操作。
如果你在按值返回的函数中,返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move
或者std::forward
。要了解原因,考虑两个矩阵相加的operator+
函数,左侧的矩阵为右值(可以被用来保存求值之后的和):
1 |
|
通过在return
语句中将lhs
转换为右值(通过std::move
),lhs
可以移动到返回值的内存位置。如果省略了std::move
调用,
1 |
|
lhs
是个左值的事实,会强制编译器拷贝它到返回值的内存空间。假定Matrix
支持移动操作,并且比拷贝操作效率更高,在return
语句中使用std::move
的代码效率更高。
如果Matrix
不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix
的拷贝构造函数拷贝(见Item23)。如果Matrix
随后支持了移动操作,operator+
将在下一次编译时受益。就是这种情况,通过将std::move
应用到按值返回的函数中要返回的右值引用上,不会损失什么(还可能获得收益)。
你可能听过rvo/nrvo的技术,这种优化警告我们不要在返回值使用move,这是否和上面违背了呢?
如下代码
1 |
|
可能想要“优化”代码,把“拷贝”变为移动:
1 |
|
我的注释告诉你这种想法是有问题的,但是问题在哪?
这是错的,因为对于这种优化,标准化委员会远领先于开发者。早就为人认识到的是,makeWidget
的“拷贝”版本可以避免复制局部变量w
的需要,通过在分配给函数返回值的内存中构造w
来实现。这就是所谓的返回值优化(return value optimization,RVO),这在C++标准中已经实现了。如果局部对象可以被返回值优化消除,就绝不使用std::move
或者std::forward
。
请记住:
- 最后一次使用时,在右值引用上使用
std::move
,在通用引用上使用std::forward
。 - 对按值返回的函数要返回的右值引用和通用引用,执行相同的操作。
- 如果局部对象可以被返回值优化消除,就绝不使用
std::move
或者std::forward
。
Item26:避免在通用引用上重载
上一个item讲到对通用引用使用std::forward
的原因,我们可以写出如下代码
1 |
|
比如我们现在想重载一下logAndAdd
:
1 |
|
之后的两个调用按照预期工作:
1 |
|
但是当这样的代码出现时:
1 |
|
有两个重载的logAndAdd
。使用通用引用的那个推导出T
的类型是short
,因此可以精确匹配。对于int
类型参数的重载也可以在short
类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。所有这一切的原因就是对于short
类型通用引用重载优先于int
类型的重载。
通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在Item30中介绍)。这也是把重载和通用引用组合在一块是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的实参类型。
一个更容易掉入这种陷阱的例子是写一个完美转发构造函数。
1 |
|
就像在logAndAdd
的例子中,传递一个不是int
的整型变量(比如std::size_t
,short
,long
等)会调用通用引用的构造函数而不是int
的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person
中存在的重载比肉眼看到的更多。在Item17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内。如果拷贝和移动构造被生成,Person
类看起来就像这样:
1 |
|
这种实现会导致不符合人类直觉的结果,如下:
1 |
|
这里我们试图通过一个Person
实例创建另一个Person
,显然应该调用拷贝构造即可。但是这份代码不是调用拷贝构造函数,而是调用完美转发构造函数。然后,完美转发的函数将尝试使用Person
对象p
初始化Person
的std::string
数据成员,编译器就会报错。
编译器的理由如下:实例化之后,Person
类看起来是这样的
1 |
|
显然non-const
的p会匹配到完美转发的构造函数,因为这种匹配优先级更高, 如果是一个const的就会匹配到拷贝构造函数了。
1 |
|
当继承纳入考虑范围时,完美转发的构造函数与编译器生成的拷贝、移动操作之间的交互会更加复杂。尤其是,派生类的拷贝和移动操作的传统实现会表现得非常奇怪。来看一下:
1 |
|
如同注释表示的,派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数!为了理解原因,要知道派生类将SpecialPerson
类型的实参传递给其基类,然后通过模板实例化和重载解析规则作用于基类Person
。最终,代码无法编译,因为std::string
没有接受一个SpecialPerson
的构造函数。
我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用形参的函数进行重载。
请记住:
- 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
- 完美转发构造函数是糟糕的实现,因为对于non-
const
左值,它们比拷贝构造函数而更匹配,而且会劫持派生类对于基类的拷贝和移动构造函数的调用。
item27:熟悉通用引用重载的替代方法
item26讨论了大量你不应该对万能引用作为形参的函数重载 的原因。
这个条款探讨了几种,通过避免在万能引用上重载的设计,或者通过限制万能引用可以匹配的参数类型,来实现所期望行为的方法。
1.放弃重载
第一种就是放弃重载,logAndAdd
是许多函数的代表,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd
函数,可以分别改名为logAndAddName
和logAndAddNameIdx
。
可惜的是,这种方式不能用在第二个例子,Person
构造函数中,因为构造函数的名字被语言固定了。
2.传递const T&
一种替代方案是退回到C++98,然后把传递万能引用替换为传递lvalue-refrence-to-const
。我们知道常左值引用比较特殊,它不仅可以对左值,还可以对右值进行引用。事实上,这是Item26中首先考虑的方法。缺点是效率不高。现在我们知道了通用引用和重载的相互关系,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
3.传值
通常在不增加复杂性的情况下提高性能的一种方法是,将按传引用形参替换为按值传递,这是违反直觉的。该设计遵循Item41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考那个条款来详细讨论如何设计与工作,效率如何。这里,在Person
的例子中展示:
1 |
|
因为没有std::string
构造函数可以接受整型参数,所有int
或者其他整型变量(比如std::size_t
、short
、long
等)都会使用int
类型重载的构造函数。相似的,所有std::string
类似的实参(还有可以用来创建std::string
的东西,比如字面量“Ruth
”等)都会使用std::string
类型的重载构造函数。
没有意外情况。我想你可能会说有些人使用0
或者NULL
指代空指针会调用int
重载的构造函数让他们很吃惊,但是这些人应该参考Item8反复阅读直到使用0
或者NULL
作为空指针让他们恶心。
4.标签分发tag dispatch
下面是原来的代码,以免你再分心回去查看:
1 |
|
我们在logAndAdd中对不同的情况做标签分发:
比如对于接受idx的版本,我们判断一下T的类型是不是整数即可,如果是整数,就进入到整数的那个实现版本里
1 |
|
但这里是有瑕疵的,比如对于一个右值整数作为logAndAdd的参数,T会推导出int,这没什么问题,但是对于一个左值整数,T会推导出int&
(推导规则请看第一章),而int&
并不会被std::is_integral<T>()
的结果认为是true,因此我们需要去掉一下引用符,利用std::remove_reference
即可
1 |
|
在这个设计中,类型std::true_type
和std::false_type
是“标签”(tag),其唯一目的就是强制重载解析按照我们的想法来执行。
5.约束使用万能引用的模板
可以先了解下SFINAE , 博客链接: https://chillstepp.github.io/2022/07/24/C-SFINAE/#%E7%BC%96%E8%AF%91%E6%9C%9F%E6%88%90%E5%91%98%E6%A3%80%E6%B5%8B-%E6%9C%89%E6%95%88-%E6%97%A0%E6%95%88
在iterm26还讨论了一种情形是:万能引用作为形参的构造函数 和 编译器自动生成的构造函数 的问题,他们的重载也导致了一些不符合直觉的后果,本着苦了谁都不能苦调用者的精神,我们不希望那种情况出现。
我们可以写出一个最基本的版本:
其中decay会移除T的引用和cv限定符
1 |
|
假定从Person
派生的类以常规方式实现拷贝和移动操作:
1 |
|
此时Person(rhs)
这一句rhs是SpecialPerson类型的,因此会导致我们的EnableIf并不会通过,因此要做点修改要认为是子类即可,同时可以简化成_t
版本的,如下:
1 |
|
加入此时有重载函数的出现,比如通过id来构造一个person:
此时我们就在enable的condition里加上新限制即可。
1 |
|
请记住:
- 通用引用和重载的组合替代方案包括使用不同的函数名,通过lvalue-reference-to-
const
传递形参,按值传递形参,使用tag dispatch。 - 通过
std::enable_if
约束模板,允许组合通用引用和重载使用,但它也控制了编译器在哪种条件下才使用通用引用重载。 - 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌。
Item28:理解引用折叠
1 |
|
我们首先要明确再c++中引用的引用是非法的,编译器却可以这样做。
1 |
|
引用折叠的一个best practice是std::forward
std::forward
可以简单实现为:
1 |
|
考虑当传入的实参是个左值,那么推导如下,左值被T推导为Widget&
1 |
|
根据引用折叠规则,返回值和强制转换可以化简,最终版本的std::forward
调用就是:
1 |
|
也就是当实参是左值,最后会推导出左值引用,左值引用也是一个左值,左值返回左值,这很好。
当实参是右值时:
1 |
|
此时就会返回右值,右值返回右值,这也很好。
到此我们可以发现通过forward利用引用折叠完美转发了实参的类型。
Item29:移动操作的缺点
存在几种情况,移动语义并无优势:
- 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
- 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快。
- 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为
noexcept
。
Item30:熟悉完美转发失败的情况
1.花括号初始化器
1 |
|
在对f
的直接调用(例如f({ 1, 2, 3 })
),编译器看看调用地传入的实参(即{1,2,3}
),看看f
声明的形参类型。它们把调用地的实参和声明的实参进行比较,看看是否匹配,并且必要时执行隐式转换操作使得调用成功。在上面的例子中,从{ 1, 2, 3 }
生成了临时std::vector<int>
对象,因此f
的形参v
会绑定到std::vector<int>
对象上。
当通过调用函数模板fwd
间接调用f
时,编译器不再把传入给fwd
的实参和f
的声明中形参类型进行比较。而是推导传入给fwd
的实参类型,然后比较推导后的实参类型和f
的形参声明类型。当下面情况任何一个发生时,完美转发就会失败:
- 编译器不能推导出
fwd
的一个或者多个形参类型。这种情况下代码无法编译。 - 编译器推导“错”了
fwd
的一个或者多个形参类型。在这里,“错误”可能意味着fwd
的实例将无法使用推导出的类型进行编译,但是也可能意味着使用fwd
的推导类型调用f
,与用传给fwd
的实参直接调用f
表现出不一致的行为。这种不同行为的原因可能是因为f
是个重载函数的名字,并且由于是“不正确的”类型推导,在fwd
内部调用的f
重载和直接调用的f
重载不一样。
将花括号初始化传递给未声明为std::initializer_list
的函数模板形参,被判定为——就像标准说的——“非推导上下文”。简单来讲,这意味着编译器不准在对fwd
的调用中推导表达式{ 1, 2, 3 }
的类型(注意花括号和std::initializer_list
不是一个东西),因为fwd
的形参没有声明为std::initializer_list
。对于fwd
形参的推导类型被阻止,编译器只能拒绝该调用。
使用花括号初始化的auto
的变量的类型推导是成功的。这种变量被视为std::initializer_list
对象,在转发函数应推导出类型为std::initializer_list
的情况,这提供了一种简单的解决方法——使用auto
声明一个局部变量,然后将局部变量传进转发函数,这就是上面第三次可以调用ok的原因。
2.0
或者NULL
作为空指针
会推导成int,请使用nullptr
3.仅有声明的整型static const数据成员
1 |
|
因为编译器会对此类成员实行常量传播(const propagation),因此消除了保留内存的需要,编译器通过常量传播将值28放入所有提到MinVals
的位置来补充缺少的定义(就像它们被要求的那样)。没有为MinVals
的值留存储空间是没有问题的。如果要使用MinVals
的地址(例如,有人创建了指向MinVals
的指针),则MinVals
需要存储(这样指针才有可指向的东西),尽管上面的代码仍然可以编译,但是链接时就会报错,直到为MinVals
提供定义。
尽管代码中没有使用MinVals
的地址,但是fwd
的形参是通用引用,而引用,在编译器生成的代码中,通常被视作指针。在程序的二进制底层代码中(以及硬件中)指针和引用是一样的。通过引用传递MinVals
实际上与通过指针传递MinVals
是一样的,因此,必须有内存使得指针可以指向。通过引用传递的整型static const
数据成员,通常需要定义它们,这个要求可能会造成在不使用完美转发的代码成功的地方,使用等效的完美转发失败。
因此我们需要定义MinVals,只要给整型static const
提供一个定义,比如这样:
1 |
|
不过都到了现代cpp时代吗,不如直接用C++17的内敛变量,这样就不用在cpp定义了:
1 |
|
4.重载函数的名称和模板名称
假定我们的函数f
(我们想通过fwd
完美转发实参给的那个函数)可以通过向其传递执行某些功能的函数来自定义其行为。假设这个函数接受和返回值都是int
,f
声明就像这样:
1 |
|
值得注意的是,也可以使用更简单的非指针语法声明。这种声明就像这样,含义与上面是一样的:
1 |
|
无论哪种写法都可,现在假设我们有了一个重载函数,processVal
:
1 |
|
我们可以传递processVal
给f
,
1 |
|
但是我们会发现一些吃惊的事情。f
要求一个函数指针作为实参,但是processVal
不是一个函数指针或者一个函数,它是同名的两个不同函数。但是,编译器可以知道它需要哪个:匹配上f
的形参类型的那个。因此选择了仅带有一个int
的processVal
地址传递给f
。
工作的基本机制是f
的声明让编译器识别出哪个是需要的processVal
。但是,fwd
是一个函数模板,没有它可接受的类型的信息,使得编译器不可能决定出哪个函数应被传递:
1 |
|
单用processVal
是没有类型信息的,所以就不能类型推导,完美转发失败。
解决方法很简单,指定一下你到底说的是哪儿个processVal即可:
1 |
|
5.位域
IPv4的头部有如下模型:
1 |
|
1 |
|
为什么呢?问题在于fwd
的形参是引用,而h.totalLength
是non-const
位域。这看起来也没什么,但是C++标准非常清楚地谴责了这种组合:non-const
引用不应该绑定到位域。禁止的理由很充分。位域可能包含了机器字的任意部分(比如32位int
的3-5位),但是这些东西无法直接寻址。也就是说没有办法绑定引用到任意bit上,这种修改就不可能完成,因此non-const
引用不应该绑定到位域。
解决方法很简单,把位域的数据拷贝出来成副本,用副本做后面的操作就好了。
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!