文章

条款29:认识移动操作的缺点

移动可能不可用或效率低,需注意noexcept声明和类型支持。

条款29:认识移动操作的缺点

条款29:认识移动操作的缺点

  • 假定移动操作不存在,成本高,未被使用

移动语义简介

C++11 引入移动语义,允许用开销低的移动操作替代高成本的复制操作,从而提升性能。编译器会在满足条件时默认生成移动构造函数和移动赋值操作。

但是:

  • 并不是所有类型都支持移动操作。
  • 有些移动操作并不比复制操作快。
  • 移动操作若未声明 noexcept,可能导致编译器退化使用复制操作以保证异常安全。

为什么要假定移动操作“不存在”?

在泛型代码或模板中,无法保证传入的类型支持移动操作。很多旧代码和部分第三方库仍未提供移动操作。此时,应该假定移动操作不可用或开销较高,避免对移动的依赖。

移动开销示例对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <string>
#include <vector>
#include <utility>

class Widget {
public:
    Widget() { std::cout << "Widget 默认构造\n"; }
    Widget(const Widget&) { std::cout << "Widget 拷贝构造\n"; }
    Widget(Widget&&) noexcept { std::cout << "Widget 移动构造\n"; }
};

std::vector<Widget> vw1(3);

template<typename T>
void logAndAdd(std::vector<Widget> vec, T&& item) {
    vec.emplace_back(std::forward<T>(item));
    std::cout << "添加元素\n";
}

int main() {
    Widget w;

    // 传入左值,调用拷贝构造
    logAndAdd(vw1, w);

    // 传入右值,调用移动构造
    logAndAdd(vw1, Widget());

    // 假设 Widget 不支持移动,右值会退化为拷贝
}
  • 传入左值:调用拷贝构造,性能开销较大。
  • 传入右值且支持移动:调用移动构造,效率更高。
  • 不支持移动或移动开销大:即使传入右值,也会调用拷贝构造。

标准容器中的移动差异

  • std::vector:数据存储在堆上,移动操作只需要指针的复制和清空,开销非常低,常数时间。
  • std::array:数据存储在对象内部,移动操作需要逐个元素移动,开销是线性时间。

小字符串优化(SSO)对移动的影响

  • 许多 std::string 实现采用 SSO,将短字符串存储在内部缓冲区,不分配堆内存。
  • 移动这类短字符串的操作与拷贝开销相近,移动不一定更快。

总结与建议

  • 通用代码(如模板)应假定移动操作不可用,不依赖移动优化,做好拷贝准备。
  • 已知类型或自己控制的代码,可安全使用移动语义提升性能。
  • 对于支持移动的类型,确保移动操作声明为 noexcept,以允许编译器使用移动而非拷贝。

万能引用相关说明

万能引用(又称通用引用)允许函数模板接受左值和右值,通过 std::forward 实现完美转发。示例如下:

1
2
3
4
template<typename T>
void func(T&& param) {
    process(std::forward<T>(param)); // 保持参数左值/右值属性
}

在调用时:

  • 传入左值,T 推导为左值引用,param 实际类型是左值引用。
  • 传入右值,T 推导为非引用类型,param 实际类型是右值引用。

但在实际使用中,若 param 的类型不支持移动,转发右值仍可能退化为拷贝。

本文由作者按照 CC BY 4.0 进行授权