Modern Effective C++ 条款二十九三十:移动语义和完美转发失败的情况
条款二十九:假定移动操作不存在,成本高,未被使用
移动语义可以说是C++11最主要的特性。"移动容器和拷贝指针一样开销小","拷贝临时对象现在如此高效,“写代码避免这种情况简直就是过早优化"。很多开发者认为通过采用移动语义可以极大地优化代码效率,甚至有时会听到“移动容器与拷贝指针一样开销小”这样的说法,以及建议不必过分担心临时对象的复制问题,因为移动语义已经让这个过程变得非常高效。然而移动语义有失败的情况。
移动语义允许通过移动操作来提高程序性能。然而,并非所有类型都支持移动操作,且即使支持,其带来的性能提升也未必如预期般显著。本文将讨论C++11中移动语义的局限性及其在不同类型中的表现。
1. 容器差异
案例1:std::array
并非所有容器都能从移动语义中获得相同程度的好处。例如std::vector等基于堆分配内存的容器可以通过简单地转移指针实现高效移动。std::array直接存储元素而非指向动态分配内存的指针,因此其移动操作涉及每个元素的单独处理,开销为线性时间复杂度。
考虑一下std::array
(C++11中的新容器)>。std::array
本质上是具有STL接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本身只保存了指向堆内存中容器内容的指针(实现更复杂,基本逻辑是这样)。这个指针的存在使得在常数时间移动整个容器成为可能,只需要从源容器拷贝保存指向容器内容的指针到目标容器,然后将源指针置为空指针就可以了:
std::vector<Widget> vw1;
//把数据存进vw1.
//把vw1移动到vw2.
//以常数时间运行.只有vw1和vw2中的指针被改变
auto vw2 = std::move(vw1);
std::array
没有这种指针实现,数据就保存在std::array
对象中:
std::array<Widget, 10000> aw1;
//把数据存进aw1
//把aw1移动到aw2。以线性时间运行
//aw1中所有元素被移动到aw2
auto aw2 = std::move(aw1);
aw1
中的元素被移动到了aw2
中。假定Widget
类的移动操作比复制操作快,移动Widget
的std::array
就比复制要快。所以std::array
确实支持移动操作。但是使用std::array
的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷贝或移动一次,这与“移动一个容器就像操作几个指针一样方便”的含义相去甚远。
案例2:std::string
尽管std::string提供了常数时间复杂度的移动操作与线性时间复杂度的复制操作,但小字符串优化(SSO)使得对于短字符串来说,移动并不比复制更高效。SSO允许短字符串直接存储在std::string对象缓冲区,避免了额外的堆内存分配。在这种情况下,移动这样的字符串不会比复制更快。S大量证据表明,短字符串是大量应用使用的习惯。使用内存缓冲区存储而不分配堆内存空间,是为了更好的效率。
2. 异常安全与移动操作
异常安全性:C++标准库中的某些容器为了提供强大的异常安全保障,要求移动操作必须是noexcept。如果一个类提供的移动操作没有声明为noexcept,即使它实际上更高效,编译器也可能选择使用复制操作以确保代码的异常安全性。这限制了移动语义在这些情况下的应用。
情况分析
(1)没有移动操作:如果要移动的对象不支持移动操作,则移动表达式会退化为复制操作。
(2)移动不比复制快:即使存在移动操作,如果移动的成本不低于复制,那么移动可能并不会带来性能上的提升。
(3)移动不可用:在需要保证移动操作不会抛出异常的情况下,若移动操作未声明为noexcept,则编译器将不得不使用复制操作。
(4)源对象是左值:通常只有右值可以作为移动操作的来源;左值作为来源时,除非特别设计,否则一般采用复制而非移动。
通用编程中的考虑
编写泛型代码或模板时,由于无法预知具体类型是否支持高效的移动操作,应谨慎地依赖于复制操作。这类不稳定的代码经常变更,导致类型特性变化,也应采取保守策略。当对所使用的类型有充分了解,并且该类型确实支持快速移动操作时,可以在合适的上下文中利用这一点来替换复制操作,从而提高性能。
条款三十:熟悉完美转发失败的情况
C++11的完美转发是非常好用,但是只有当你愿意忽略一些误差情况(完美转发失败的情况),这个条款就是使你熟悉这些情形。
完美转发(perfect forwarding)意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const
还是volatile
。结合到我们会处理引用形参,意味着将使用通用引用,因为通用引用形参被传入实参时才确定是左值还是右值。
假定有一些函数f
,编写一个转发给它的函数(事实上是一个函数模板)。我们需要的核心看起来像是这样:
template<typename T>
void fwd(T&& param)//接受任何实参{
f(std::forward<T>(param)); //转发给f
}
转发函数是通用的。例如fwd
模板,接受任何类型的实参,并转发得到的任何东西。这种通用性的逻辑扩展是,转发函数不仅是模板,而且是可变模板,因此可以接受任何数量的实参。fwd
的可变形式如下:
template<typename... Ts>
void fwd(Ts&&... params)//接受任何实参{
f(std::forward<Ts>(params)...); //转发给f
}
这种形式你会在容器emplace functions中(item42)和 智能指针的工厂函数std::make_unique
和std::make_shared
中(item21)看到,当然还有其他一些地方。
给定我们的目标函数f
和转发函数fwd
,如果f
使用某特定实参会执行某个操作,但是fwd
使用相同的实参会执行不同的操作,完美转发就会失败。导致这种失败的实参种类有很多。知道它们是什么以及如何解决它们很重要,因此让我们来看看无法做到完美转发的实参类型。
花括号初始化器
假定f
这样声明
void f(conststd::vector<int>& v);
用花括号初始化调用f
通过编译
f({ 1, 2, 3 }); //可以,“{1, 2, 3}”隐式转换为std::vector<int>
但是传递相同的列表初始化给fwd不能编译
fwd({ 1, 2, 3 }); //错误!不能编译
这是完美转发失效的一种情况。当通过调用函数模板fwd
间接调用f
时,编译器不再把调用地传入给fwd
的实参和f
的声明中形参类型进行比较。而是推导传入给fwd
的实参类型,然后比较推导后的实参类型和f
的形参声明类型。当下面情况任何一个发生时,完美转发就会失败:
编译器不能推导出fwd
的一个或者多个形参类型。 这种情况下代码无法编译。
编译器推导“错”了fwd
的一个或者多个形参类型。
"错误"可能意味着fwd
的实例将无法使用推导出的类型进行编译,但是也可能意味着使用fwd
的推导类型调用f
,与用传给fwd
的实参直接调用f
表现出不一致的行为。
这种不同行为的原因可能是因为f
是个重载函数的名字,并且由于是“不正确的”类型推导,在fwd
内部调用的f
重载和直接调用的f
重载不一样。
在上面的fwd({ 1, 2, 3 })
例子中,将花括号初始化传递给未声明为std::initializer_list
的函数模板形参。着编译器不准在对fwd
的调用中推导表达式{ 1, 2, 3 }
的类型,因为fwd
的形参没有声明为std::initializer_list
。对于fwd
形参的推导类型被阻止,编译器只能拒绝该调用。
item2说明了使用花括号初始化的auto
的变量的类型推导是成功的。这种变量被视为std::initializer_list
对象,在转发函数应推导出类型为std::initializer_list
的情况,这提供了一种简单的解决方法:使用auto
声明一个局部变量,然后将局部变量传进转发函数:
auto il = { 1, 2, 3 }; //il的类型被推导为std::initializer_list<int>
fwd(il); //可以,完美转发il给f
(这个地方没看懂)
0或者NULL作为空指针
item8说明当试图传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。
结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
(参考item8)。
仅有声明的整型static const数据成员
通常无需在类中定义整型static const
数据成员;声明就可以了。这是因为编译器会对此类成员实行常量传播(const propagation),因此消除了保留内存的需要。
声明与定义的区别
告诉编译器某个实体(变量,函数,类等)的名称和类型,不为其分配存储空间。不仅告诉编译器实体的名称和类型,还为其分配存储空间,并可能提供初始化值或实现。
static const 整型数据成员的特殊情况。
对于static const整型数据成员,可以在类内部进行初始化,但这实际上是声明的一部分。编译器会将这个常量的值传播到所有使用它的地方,从而不需要为它分配实际的存储空间。例如:
class Widget {
public:
static const std::size_t MinVals = 28; // 声明并初始化
};
MinVals被声明为static const整型数据成员,并且在类内部进行了初始化。编译器会在所有使用Widget::MinVals的地方直接替换为28,因此不需要为MinVals分配内存。
为什么这仍然是声明?
编译器优化:编译器会对static const整型数据成员进行常量传播,这意味着它可以直接将值28插入到所有使用MinVals的地方,而不需要实际的存储空间。由于不需要实际的存储空间,MinVals不会在对象文件中生成符号,因此不会产生外部链接。
如果需要通过引用或指针传递MinVals,或者需要获取其地址,那么必须提供一个定义。
// 在Widget的.cpp文件中
const std::size_t Widget::MinVals; // 定义
引用和指针需要实际的内存地址来指向,而不仅仅是值。如果没有定义,编译器将无法找到实际的内存地址,导致链接错误。
class Widget{
public:
static const std::size_t MinVals = 28; //声明并初始化
};
//使用
void printValue(std::size_t val) {
std::cout << "Value: " << val << std::endl;
}
template<typename T>
void fwd(T&& arg) {
printValue(std::forward<T>(arg));
}
int main() {
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);//使用MinVals
printValue(Widget::MinVals);//可以,视为“printValue(28)”
//下面的调用会导致链接错误,因为fwd需要实际的内存地址
//fwd(Widget::MinVals); // 错误!不应该链接
return 0;
}
为了修复链接错误,需要在.cpp文件中提供定义:
// 在Widget的.cpp文件中
const std::size_t Widget::MinVals; // 定义
尽管代码中没有使用MinVals
的地址,但是fwd
的形参是通用引用,引用在编译器生成的代码中,通常被视作指针。在程序的二进制底层代码中指针和引用是一样的。在这个水平上,引用只是可以自动解引用的指针。通过引用传递MinVals
实际上与通过指针传递MinVals
是一样的,因此,必须有内存使得指针可以指向。通过引用传递的整型static const
数据成员,通常需要定义它们,这个要求可能会造成在不使用完美转发的代码成功的地方,使用等效的完美转发失败。
根据标准,通过引用传递MinVals
要求有定义。但不是所有的实现都强制要求这一点。所以,取决于你的编译器和链接器,你可能发现你可以在未定义的情况使用完美转发,恭喜你,但是这不是那样做的理由。为了具有可移植性,只要给整型static const
提供一个定义:
const std::size_t Widget::MinVals; //在Widget的.cpp文件
注意定义中不要重复初始化(这个例子中就是赋值28)。但是不要忽略这个细节。如果你忘了,并且在两个地方都提供了初始化,编译器就会报错,提醒你只能初始化一次。
重载函数与模板名称在完美转发中的问题
假设有一个函数f,接受一个函数指针作为参数,并且这个函数指针的类型是int (*)(int)。可以通过传递一个符合条件的函数来定制f的行为。
void f(int (*pf)(int)); // pf = "process function"
// 或者使用更简单的非指针语法
void f(int pf(int));
重载函数的问题
如果有一个重载函数processVal
int processVal(int value);
int processVal(int value, int priority = 1);
可以直接将processVal传递给f,因为编译器可以根据f的参数类型推断出需要哪个版本的processVal:
f(processVal); //可以,编译器选择正确的processVal版本
当使用一个完美转发函数fwd时,问题出现了。fwd是一个模板函数,没有具体的类型信息,因此编译器无法确定应该传递哪个版本的processVal。
template<typename T>
void fwd(T&& arg) {
f(std::forward<T>(arg));
}
fwd(processVal); // 错误!哪个processVal?
函数模板的问题
同样的问题也出现在函数模板上。假设有一个函数模板workOnVal:
template<typename T>
T workOnVal(T param) {
// 处理值的模板
}
尝试将workOnVal传递给fwd也会失败,因为编译器不知道应该实例化哪个版本的workOnVal:
fwd(workOnVal); // 错误!哪个workOnVal实例?
解决方法
为了使完美转发能够处理重载函数或函数模板,需要显式地指定要传递的具体函数或函数模板实例。这可以通过创建一个具体类型的函数指针来实现。(这个没看懂)
定义类型别名:
定义一个类型别名,表示所需的函数指针类型。
using ProcessFuncType = int (*)(int); // 定义类型别名
创建函数指针:
使用该类型别名创建一个函数指针,并将其初始化为所需的重载函数或函数模板实例。
ProcessFuncType processValPtr = processVal; // 指定所需的processVal签名
传递函数指针:
将创建的函数指针传递给fwd。
fwd(processValPtr); // 可以
处理函数模板:
对于函数模板,可以使用static_cast来显式地实例化并传递。
fwd(static_cast<ProcessFuncType>(workOnVal)); // 也可以
#include <iostream>
// 目标函数
void f(int (*pf)(int)) {
std::cout << "f called with: " << pf(42) << std::endl;
}
// 重载函数
int processVal(int value) {
return value * 2;
}
int processVal(int value, int priority) {
return value + priority;
}
// 函数模板
template<typename T>
T workOnVal(T param) {
return param * 3;
}
// 完美转发函数
template<typename T>
void fwd(T&& arg) {
f(std::forward<T>(arg));
}
int main() {
// 定义类型别名
using ProcessFuncType = int (*)(int);
// 创建函数指针
ProcessFuncType processValPtr = processVal;
// 传递函数指针
fwd(processValPtr); // 正确
// 传递函数模板实例
fwd(static_cast<ProcessFuncType>(workOnVal<int>)); // 正确
return 0;
}
位域与完美转发的问题
位域(bit-fields)是一种特殊的成员变量,用于节省内存空间。它们通常用来表示结构体或类中的小整数。然而,当涉及到完美转发时,位域会带来一些特殊的问题。位域可能只占用一个机器字的部分位,例如32位整型中的某些位。这些位无法直接寻址,因此不能通过指针或引用直接访问。C++标准明确禁止非const引用绑定到位域上,因为位域可能不是对齐的,且不支持直接寻址。
假设有一个IPv4头部结构体定义如下:
struct IPv4Header {
std::uint32_t version:4,IHL:4,DSCP:6,ECN:2,totalLength:16;
//其他字段...
};
如果有一个函数f接收一个std::size_t类型的参数,并且希望使用IPv4Header对象的totalLength字段调用它,那么直接调用是可行的:
void f(std::size_t sz); //要调用的函数
IPv4Header h;
// 填充h.totalLength
f(h.totalLength); //可以正常工作
但是,如果你希望通过一个转发函数fwd来调用f,并且fwd的参数是引用类型,那么就会出现问题。
template<typename T>
void fwd(T&& arg) {
f(std::forward<T>(arg));
}
IPv4Header h;
// 填充h.totalLength
fwd(h.totalLength); // 错误!non-const引用不能绑定到位域
解决方法
为了使完美转发能够处理位域,可以采取以下几种方法:
(1)按值传递
将位域的值复制到一个新的变量中,然后将这个新变量传递给目标函数。
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // 正确,传递的是副本
(2)传const引用
使用const引用传递位域的值。根据C++标准,const引用实际上绑定到一个包含位域值的临时整型对象。
void f(const std::uint16_t& sz); //修改f的参数为const引用
template<typename T>
void fwd(T&& arg){
f(std::forward<T>(arg));
}
IPv4Header h;
//填充h.totalLength
fwd(h.totalLength); //正确,const引用绑定到临时对象
(3)显式创建临时对象
在调用转发函数之前,显式地创建一个临时对象,然后传递这个临时对象。
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // 正确,传递的是副本
请记住:
- 当模板类型推导失败或者推导出错误类型,完美转发会失败。
- 导致 完美转发失败的实参种类有 花括号初始化,作为 空指针的
0
或者NULL
,仅有声明的整型static const
数据成员,模板和重载函数的名字,位域。