5. 什么时候会触发移动构造?什么时候会触发拷贝构造?
这个问题,说白了就是C++11之后每个写代码的人都要面对的灵魂拷问。我记得刚接触移动语义那会儿,总觉得「移动」是个玄学——明明代码看起来差不多,编译器却会做出不同的选择。今天我就把这块掰开揉碎了讲清楚。
5.1 核心判断标准:左值 vs 右值
触发拷贝还是移动,最根本的判断依据只有一个:实参是左值还是右值。
- 左值(有名字、可取地址)→ 触发拷贝构造
- 右值(临时对象、即将销毁的值)→ 触发移动构造
嗯,这里要注意:右值又分纯右值和将亡值。不过日常开发中你只需要记住——std::move()能把左值变成右值引用,从而「骗」编译器走移动构造。
一句话口诀:左值拷贝,右值移动。想强制移动?用 std::move 把左值「伪装」成右值。
5.2 具体场景拆解
我习惯把触发场景分成三类,这样好记。咱们一个个看。
场景一:直接构造/赋值
std::string a = "hello"; // 构造,不涉及拷贝/移动
std::string b = a; // a是左值 → 拷贝构造
std::string c = std::move(a); // std::move(a)是右值 → 移动构造
std::string d = std::string("world"); // 临时对象是右值 → 移动构造(可能被RVO优化掉)
我在项目中遇到过有人觉得 std::move(a) 之后 a 就「空了」,其实不是。移动后的对象处于「有效但未指定」状态,你仍然可以调用 a.size() 或给 a 重新赋值。但千万别假设它的内容还在。
场景二:函数传参
void func(std::string s); // 值传参
std::string str = "data";
func(str); // 左值 → 拷贝构造
func(std::move(str)); // 右值 → 移动构造
func(getString()); // 函数返回的临时对象 → 移动构造
你想想看,如果函数参数是 const std::string& 或 std::string&&,那根本不会触发任何构造——只是绑定引用而已。只有值传参才会触发拷贝或移动。
我的建议:如果函数内部需要「拥有」这个对象(比如存到成员变量里),直接用值传参。调用方用左值就拷贝,用右值就移动,编译器帮你选最优路径。
场景三:函数返回值
std::string makeString() {
std::string local = "temp";
return local; // 局部变量 → 优先移动(C++11起)
}
std::string result = makeString(); // 这里可能触发RVO,连移动都省了
这里有个坑:返回局部变量时,编译器会优先尝试移动,而不是拷贝。但前提是类型有移动构造函数。如果移动构造被 = delete 了,那就退回到拷贝。
我曾经踩过的坑:返回 std::array 这种没有移动构造的类型时,即使你写了 return std::move(local),它还是老老实实拷贝。因为 std::array 的元素是内嵌在对象里的,没法「偷」指针。
5.3 特殊情况:哪些类型「没有移动」?
| 类型特征 | 示例 | 移动构造行为 |
|---|---|---|
| 没有动态资源的类型 | int, double, std::pair<int,int> |
移动 = 拷贝(逐字节复制) |
| 显式 delete 移动构造 | struct A { A(A&&) = delete; }; |
退回到拷贝构造(如果拷贝构造可用) |
| 没有定义移动构造的类 | 自定义类,只定义了拷贝构造 | 编译器不会自动生成移动构造,走拷贝 |
| const 对象 | const std::string s; |
无法移动(移动构造需要修改源对象) |
说白了,移动构造的本质是「资源所有权的转移」。如果类型没有堆上分配的资源(比如只包含 int、double),那移动和拷贝在二进制层面没区别。
5.4 一个容易忽略的点:异常安全
我记得有一次 review 代码,看到有人写了这样的类:
class MyBuffer {
char* data_;
size_t size_;
public:
MyBuffer(MyBuffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
};
注意那个 noexcept。为什么重要?因为标准库容器(比如 std::vector)在重新分配内存时,如果移动构造不保证不抛异常,它宁愿选择拷贝——因为拷贝失败可以回滚,移动失败就全乱了。
关键结论:如果你的移动构造可能抛异常,标准库会「敬而远之」,老老实实走拷贝。所以移动构造一定要标记 noexcept,否则你写的移动语义可能根本不会被调用。
5.5 知识体系图
5.6 实战建议
最后给几条我自己的经验:
- 别滥用 std::move——只有当你确定源对象不再需要时才用。我见过有人对 const 对象用 std::move,结果还是拷贝,白费功夫。
- 移动构造一定要加 noexcept——否则 vector 扩容时不会用你的移动构造,性能直接打折扣。
- 写类时遵循「三五法则」——如果定义了析构函数、拷贝构造或拷贝赋值,通常也需要定义移动构造和移动赋值。
- 用
= default让编译器帮你生成——如果成员都是可移动的,编译器生成的移动构造通常没问题。
一个小技巧:调试时可以用 static_assert(std::is_move_constructible_v<MyType>) 来确认你的类型真的支持移动构造。我曾经被一个第三方库坑过,它把移动构造声明了但没实现,链接时才发现——嗯,从那以后我养成了加这个断言的习惯。
总结一下:左值拷贝,右值移动,这是铁律。但编译器优化(RVO/NRVO)和异常安全要求会让实际行为更复杂。理解这些场景,你就能写出既高效又正确的现代 C++ 代码。