Effective Modern C++ 条款29 假设移动操作是不存在的、不廉价的、不能用的

假设移动操作是不存在的、不廉价的、不能用的

有人争论,移动语义是C++11最重要的特性。“现在移动容器就像拷贝几个指针一样廉价!”你可能会听到过这个,“拷贝临时对象现在很高效,避免拷贝临时对象的代码相当于过早优化(premature optimization)!”这样的观点很容易理解。移动语义的确是个很重要的特性。它不只是允许编译器用相对廉价的移动来代替昂贵的拷贝操作,它实际上是要求编译器这样做(当满足条件时)。在你C++98的旧代码基础上,用适应C++11的编译器和C++11标准库重新编译,然后——释放洪荒之力——你的程序就变快了。

移动语义真的可以办到那事情,而那授予了这特性传奇般的光环。但是呢,传奇,一般都是被夸出来的。本条款的目的就是为了让你认清现实。

让我们从观察那些不支持移动的类型开始吧。因为C++11支持移动操作,而移动比拷贝快,所以C++98标准库被大改过,这些标准库的实现利用了移动操作的优势,但是你的旧代码没有为C++11而改过啊。对于在你应用里(或在你使用的库里)的一些类型,并没有为C++11进行过改动,所以编译器支持的移动操作对你这些类型可能一点帮助都没有。是的,C++11愿意为缺乏它们的类生成移动操作,但那只会发生在没有声明拷贝操作、移动操作、析构函数的类中(看条款17)。如果成员变量或基类禁止移动(例如,删除移动操作——看条款11),也会以致编译器生成移动操作。对于不是显式支持移动操作和没有资格让编译器生成移动操作的类型,没有理由期望C++11的性能比C++98好。

就算一些类型显式支持移动操作,它们也没有你想象中那样有益。例如,C++所有的容器都支持移动操作,但认为移动所有容器都是廉价的观点是错误的。这是因为,对于一些容器,移动它们的内容真心不廉价;而对于另外的容器,它们提供的廉价移动操作在元素不满足条件时会发出警告。

看下std::array,C++11的一个新容器。std::array本质上是个拥有STL接口的内置数组,它与其它标准容器不同,其它容器都把它的内容存储在堆上。这种容器类型(不同于std::array的容器)的对象,在概念上,只持有一个指针(作为成员变量),指向存储容器内容的堆内存。(实际情况更复杂,但为了这里的分析,区别不是很重要。)这个指针的存在使得用常量时间移动一个容器的内容成为可能:把指向容器内容的指针从源容器拷贝到目的容器,然后把源指针设置为空:

std::vector<Widget> vw1;

// 把数据放到vw1
...
// 把vw1移动到vw2,只需常量时间
//  只有vw1和vw2的指针被修改
auto vw2 = std::move(vw1);

这里写图片描述


std::array缺少这样的指针,因为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都需要线性时间的计算复杂度,因为容器中的每个元素都需要被移动或拷贝,这和我们听到的“现在移动一个容器就像拷贝几个指针一样廉价”的宣言相差很远啊。

另一方面,std::string提供常量时间的移动和线性时间的拷贝。听起来,移动比拷贝快,但实际上不是这样的。许多string的实现都使用了small string optimization(SSO),通过SSO,“small”string(例如,那些容量不超过15字符的string)会被存储到std::string对象内的一个缓冲区中;不需要使用堆分配的策略。移动一个基于SSO实现的small string不比拷贝它快,因为一般的移动操作拷贝单个指针的把戏在这里不适用。

SSO存在的动机是:有大量证据表明在大多数应用中普遍使用短字符串。使用内部缓冲区存储string的内容可以消除动态分配内存的需求,而这通常赢得效率。但是,这个实现移动不比拷贝快,也可以反过来说,对于这种string,拷贝不比移动慢。

尽管一些类型支持快速的移动操作,但是一些看似一定会使用移动的场合最终使用了拷贝。条款14解释过标准库一些容器操作提供异常安全保证,然后为了确保C++98旧代码依赖的保证不会因程序提升到C++11而被打破,只有当移动操作不抛异常时,才会把内部的拷贝当作替换成移动操作。结果就是:即使一个类提供移动操作,这个移动操作相对拷贝操作高效很多,即使在代码的某个位置,移动操作是合适的(例如,源对象是个右值),编译器可能仍然会使用拷贝操作,因为它对应的移动操作没有声明为noexcept


因此在下面几种情况下,C++11的移动语义对你没好处:

  • 没有移动操作。需要被移动的对象拒绝提供移动操作,结果是移动请求会变成拷贝请求。
  • 移动的速度不快。需要被移动的对象有移动操作,但是不比拷贝操作快。
  • 不能使用移动操作。在一些进行移动操作的上下文中,要求移动操作不能发出异常,但移动操作没有被声明为noexcept

还有一种情况,移动语义不会提升性能,在这里也值得被提起:

  • 源对象是个左值。只有右值才有可能作为移动操作的源对象,除去极少数例外。

不过,本条款的标题是假设移动操作是不存在的、不廉价的、不能用的。这指的是在通用代码的通常情况下,例如,当写模板的时候,因为你不知道该模板为哪些类型工作。在这种情况下,你必须像C++98那样(在移动语义出现之前)保守地拷贝对象。这也适用于“不稳固”的代码中,即被使用的类的特性会相对频繁改动的代码。

但是,你经常会知道代码使用的类型,然后你可以依赖它们不会改动的特性(例如,它们是否会支持不昂贵的移动操作)。当在这种情况下,你不需要做这个假设,你可以简单地查询你使用的类的移动细节。如果那些类提供廉价的移动操作,然后你使用的对象又在可以调用移动操作的语境,你可以安全地依赖移动语义,用开销更小的移动操作替换掉拷贝操作。


总结

需要记住的2点:

  • 假设移动操作是不存在的、不廉价的、不能用的。
  • 在知道类型或支持移动语义的代码中,不需要这个假设。
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页