#|《Effective Modern C++》学习笔记 - Item 29: 移动操作的“坑点”(它们可能不存在,开销不小或不会被调用)

  • 移动语义无疑是C++11的一个重要特性,然而人们容易对其期待过高。本节就来介绍一些移动操作并不能带来性能提升的情景,目的是使我们对移动操作的作用范围有更加理性的认识。以下所述的移动操作均为移动构造函数和移动赋值运算符两项的集合。
  • 首先,很多类型不支持移动语义。虽然C++11的STL实现已经针对移动操作做了改进,但你正在使用的其它符合C++98标准的库可能并非如此。的确,编译器会自动为类生成移动操作,但根据 Item 17,只有当该类没有声明拷贝操作、(另一种)移动操作和析构函数时才会如此。对于没有显式声明移动操作,而又不满足自动生成条件的类,没有理由期待使用C++11会比C++98有性能优势。
  • 即使类型显式声明了移动操作,它们的性能可能也不像你预期的那么优秀。“移动所有容器的开销都仅有拷贝一个指针那么小”,这样的观念是错误的。例如,C++11 STL中所有容器都支持移动,但对于 std::array,移动的时间复杂度是 O(n) 而非 O(1)。这是因为 std::array 本质是一个带STL接口的原生数组,它的元素是直接存储在 std::array 对象中的,而不像其它容器是在堆上分配内存,对象内只保留一根指针。那类容器(如 std::vector)在移动时仅需要将指针拷贝过来,并将原容器指针置为 null 即可,但 std::array 的移动只能对容器内的元素逐个移动。当然,如果 std::array 存储的元素类型支持高效的移动操作,那么对整个 std::array 进行移动的效率还是高于拷贝的。
#|《Effective Modern C++》学习笔记 - Item 29: 移动操作的“坑点”(它们可能不存在,开销不小或不会被调用)
文章图片
#|《Effective Modern C++》学习笔记 - Item 29: 移动操作的“坑点”(它们可能不存在,开销不小或不会被调用)
文章图片
  • 另一个例子是 std::string。许多实现都会采用 SSO(Small String Optimization,短字符串优化),即对于较短的字符串,std::string 不会在堆上分配内存,而是直接存储在对象内部的缓冲区中。跟上面同样的理由,此时移动操作并不会比拷贝更快(笔者注:个人感觉这个例子不是很有代表性,因为短字符串的拷贝和移动开销都很小,这里更多是理解SSO这个特性)。
  • 在需要异常安全保证的上下文中,即使类支持高效的移动操作,但如果其没有声明为 noexcept,那么编译器也只能调用拷贝函数。
    注:关于这点,笔者测试了以下代码:
    class Widget { public: Widget() {} Widget(const Widget& w) noexcept { cout << "copy" << endl; } Widget(Widget&& w) { cout << "move" << endl; } }; void f() noexcept { Widget w; Widget w1 = std::move(w1); } int main() { f(); return 0; }

【#|《Effective Modern C++》学习笔记 - Item 29: 移动操作的“坑点”(它们可能不存在,开销不小或不会被调用)】奇怪的是打印信息显示 f 中调用的是移动而非拷贝函数,即使在移动构造中真的写一个抛出异常的语句也如此,或许是笔者没有正确理解作者想表达的场景。不过这里我们可以学到的一个准则是:对拷贝和移动函数都尽量使用 noexcept 修饰,它们本身就不应该抛出异常。
  • 以上所有讨论都是在提醒移动操作可能无效的场景,你应该在写模板这种不确定要处理什么类型的场景中注意它们。如果你很确定要打交道的是什么类型,那么就可以无视本条建议,根据它们的源码具体情况具体分析即可。

    推荐阅读