假设移动操作不存在、代价高且不宜使用
本文最后更新于:April 20, 2022 am
本篇为介绍右值引用、移动语义和完美转发系列博客的第七篇,讨论移动语义不能提高性能的一些情形
右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发
正视这一特性
可以说,移动语义是C++11最重要的特性之一。部分开发者学习了移动语义之后,会非常开心,认为移动容器已经变得和拷贝一些指针一样简单,或者认为编译器对拷贝临时对象的优化已经登峰造极了,以至于甚至有人宣传道:在开发时致力于避免临时对象已经是过时的优化了。
的确,移动语义是如此重要的特性,它不仅让编译器能够将昂贵的复制操作替换成移动操作,事实上,它还要求编译器这么做。我们可以去找一份基于C++98标准的工程,然后用C++11的标准编译一次,就很有可能发现新的构建跑得比以前要快。
移动语义的确可以让这样的魔法发生,以至于它被冠以传说之名。但是,传说往往都是被夸大的产物,本博客将会讨论移动语义的局限性,从而让我们对它的期望回到合理水平。
不能提高性能的情形
不支持移动语义的类型
为了支持移动语义,在合适的地方避免拷贝,整个C++98标准库都被重新翻修了,库里的很多组件也都支持了移动语义。
但是在那些没有为C++11进行适配的远古工程,以及旧版的库文件中,很多类型并没有对移动语义做出适配,即使我们用上了支持新标准的编译器,在性能上的提升也很可能微乎其微。
C++11虽然会要求编译器为缺少移动操作的类生成相应代码,但那只出现在没有声明拷贝操作、没有声明移动操作以及析构函数的类别中(见Item17)。如果某一个类的成员变量,或者其基类禁用了移动操作,那么编译器也不会为这个类生成移动操作的代码。
对于没有为移动操作做显式适配,并且也不符合编译器自动生成移动操作的条件的类型,至少在性能提升上,我们不应该对C++11版本的构建抱太大的期望。
即使一些已经显式支持了移动操作的类型,性能可能也不会表现得和我们的预期一致。C++11以后的库中,所有的容器都支持了移动语义,但假如就此认为在所有类型的容器上做移动操作都是很方便快捷的,那问题就大了。对于某些容器来说,因为自身内存结构,移动就是一件很麻烦的事情。对于另一些容器来说,如果它们本身要支持快捷的移动操作,就需要额外附带一系列条件,而它们的元素恰好不满足。
有时,移动并不比拷贝快多少
考虑C++11引入的新容器std::array
,std::array
与其它的标准容器不同——诸如std::vector
的容器会将元素存储在堆空间上,因此它们的对象只会存储指向堆空间的指针和一些其它变量。正是由于std::vector
等容器让栈上的指针指向堆空间,移动这种容器才显得十分简便,因为我们只需要做几个简单的指针拷贝,外加一系列状态交换,然后将被移动的对象的指针设置为nullptr
。
1 |
|
std::array
和上述容器不一样,它直接把元素存在自己内部,没有指针从栈到堆的过程
1 |
|
当从aw1
移动到aw2
时,需要一一遍历aw1
的每一个元素,然后将之移动到aw2
中去
如果Widget
类型的移动速度比拷贝更快,那么从aw1
移动到aw2
会比拷贝一份aw1
更快,因此移动语义使得程序的性能提高。正因如此,std::array
理所当然地支持移动操作。但是在std::array
上的移动也好,拷贝也好,都是线性时间复杂度。对比std::vector
等容器的移动,std::array
上的移动可远比“改变几个指针”复杂多了!
考虑另一种类型,std::string
类型提供常数时间复杂度的移动操作和线性时间复杂度的拷贝操作。这使得它的移动操作听上去会比拷贝操作快得多,但实际上可能会有变数。许多字符串的实现都使用了小型字符串优化(Small String Optimization, SSO)的技术。在这种背景下,许多“小型”的字符串(比如15个字符以下的),都被存储在std::string
对象自身内部的缓冲区中,并没有使用堆空间。对这种字符串对象进行移动并不会比直接拷贝一份要来得快多少,原因还是一样,因为它没有用栈上的指针指向堆,指针交换的把戏没有效果!
移动或许不可用
对于一些支持快速移动的类型,在某些看上去万无一失的情境下,会导致意外的拷贝行为。Item14中,我们知道了标准库中的某些容器的操作提供严格的异常安全保证。从C++98升级到C++11时,为了确保这一点依赖不被破坏,如果元素的移动操作没有明显指出不会抛出异常(函数声明没有noexcept
),代码中的移动操作可能会被编译器替换成拷贝。
总结
本节的标题是,假设移动操作不存在,代价高且不宜使用[1]。我们需要明确这一忠告适用于普遍的代码场景,比如写模板时,因为我们不知道传入的类型参数到底是什么。在这样的情境下,开发者需要保守地对待拷贝对象一事——就像在移动语义尚不存在的C++98中一样。另外,当代码“不稳定”时,比如类型的特征经常改变时,同样应当如此。
当然更普遍的情况是,我们知道我们的代码所使用的类型,因此可以依赖这些类型特征的稳定性(比如某个类是否一如既往地提供快速移动接口)。这时,我们不需要做出假设,只需要简单地查看对应类型对于移动操作的支持代码就可以知晓具体情况。当然,当类型对移动语义有支持,并且提供了代价小的移动操作时,我们在上下文环境中如果碰上了需要使用它的地方,就可以借助移动语义来避免拷贝操作了。
总是假设移动操作不存在、代价高且不宜使用
当明确知道使用的类型对于移动语义的支持程度时,应当放弃这种假设
- 原文中还提到了一个移动语义无法增加效率的情景——“源对象是个左值:除了极少数的情况,只有右值可以作为移动操作的源。”
这里笔者暂时无法理解,在什么情况下移动语义的源对象会是一个左值呢? ↩
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!