【C++ 面试真题】聊聊 C++ 的移动语义与右值引用
上篇讲了拷贝------"复制一份房子"。可如果源对象马上就要没了(比如函数返回的临时对象),再老老实实盖一栋新房子、把家具一件件搬过去,纯属浪费 。C++11 的移动语义就是来"偷资源"的:直接把源对象的内部指针搬过来,源置空,O(1) 完成。本文讲清右值引用、
std::move的真相、为什么移动要noexcept,还有完美转发的概念。
一、先说结论:移动 = "偷资源,不复制"
❓ 移动语义到底解决了什么问题?
✅ 一句话:让"从临时对象身上转移资源"变得既合法又廉价------把昂贵的深拷贝变成 O(1) 的指针搬家。
对比拷贝和移动:
cpp
struct Buffer {
int* data_;
int n_;
// 拷贝构造:分配 + 复制(O(n))
Buffer(const Buffer& o);
// 移动构造:偷指针(O(1))
Buffer(Buffer&& o) noexcept;
};
cpp
Buffer make(int n) {
return Buffer(n); // 返回一个临时对象
}
Buffer b = make(1000);
// 没有移动:深拷贝 1000 个 int
// 有移动:偷指针,瞬间完成 ✅
💡 思想 :临时对象反正在下一行就析构了,它的资源"借"给新对象,比复制一份划算得多。移动语义就是给这种"借"提供合法途径。
二、什么是左值 / 右值?
❓ 经常听到"右值引用",到底什么是右值?
✅ 精简版口诀:
- 左值(lvalue) :有名字、能取地址、能反复用。比如变量
x。 - 右值(rvalue) :临时的、没名字、用完就没了。比如字面量
42、表达式x+1、函数返回的临时对象。
cpp
int x = 1;
int* p = &x; // ✅ x 是左值,能取地址
// int* q = &(x+1); // ❌ x+1 是右值,取不到地址
int&& r = x + 1; // ✅ && 绑右值
// int& r2 = x+1; // ❌ 普通引用绑不了右值
引用体系:
| 引用类型 | 能绑什么 | 写法 |
|---|---|---|
| 非常量左值引用 | 只左值 | T& |
| 常量左值引用 | 左值 + 右值 | const T& |
| 右值引用 | 只右值 | T&& |
⚠️ 别钻牛角尖 :C++11 其实把右值细分成 prvalue、xvalue 等(合称 value category),面试一般不深挖。记住"能取地址=左值,临时将亡=右值"就够用了。
三、移动构造与移动赋值怎么写?
❓ 移动构造/赋值具体长什么样?
✅ 把源对象的资源"偷"过来:把源指针赋给自己,再把源指针置空(防止源析构时把资源 delete 掉)。
cpp
struct Buffer {
Buffer(int n)
: data_(new int[n]), n_(n) {}
// 移动构造:偷
Buffer(Buffer&& o) noexcept
: data_(o.data_), n_(o.n_) {
o.data_ = nullptr; // ✅ 关键:源置空
o.n_ = 0;
}
// 移动赋值
Buffer& operator=(Buffer&& o) {
if (this == &o) return *this;
delete[] data_; // 释放旧的
data_ = o.data_; // 偷
n_ = o.n_;
o.data_ = nullptr;
o.n_ = 0;
return *this;
}
~Buffer() { delete[] data_; }
int* data_;
int n_;
};
对比拷贝和移动:
| 操作 | 拷贝 | 移动 |
|---|---|---|
| 复杂度 | O(n)(逐个复制) | O(1)(换指针) |
| 源对象 | 不变 | 变成空壳 |
| 资源归属 | 两份各持一份 | 转移到目标 |
四、std::move 到底干了什么?
❓ std::move 是不是真的"移动"了对象?
✅ 不是! 这是最常见的误解。std::move 什么都不移动 ,它只是一个类型转换(cast) ------把一个左值强制"伪装"成右值,好让重载决议选到移动构造/赋值。
cpp
template<class T>
T&& move(T& x) noexcept {
return static_cast<T&&>(x);
}
真正干活的,是移动构造或移动赋值。std::move 只是发了个"我允许你被偷"的信号。
cpp
Buffer a(100);
Buffer b = a; // 拷贝(a 还是左值)
Buffer c = std::move(a); // 移动(a 被偷了)
// 之后 a 已是空壳,别再用 a.data_
⚠️ 关键认知 :
std::move无开销、不分配、不复制、不调用任何东西 ------它纯粹是个编译期的类型转换。如果对应类型没有移动构造,std::move就退化成拷贝。
💡 自测题 :std::move(const T&)会发生什么?答案:源是const,匹配不到T&&(移动要求非常量),重载会退到const T&的拷贝构造------给 const 对象std::move等于没 move。这是真实项目里常见的"以为移动了其实拷贝了"的坑。
五、为什么移动构造要 noexcept?(核心考点)
❓ 移动构造为什么要加 noexcept?不加会怎样?
✅ 不加 noexcept,很多场景下移动根本不会被触发------容器会退回拷贝 。最经典的就是 std::vector 扩容。
原理:vector 扩容时要把老元素搬到新内存。搬的时候它有选择:
- 元素的移动构造是
noexcept→ 安全搬,直接移动(快); - 否则 → 为保"强异常保证 "(搬一半失败要能回滚),退回拷贝(慢,但拷贝失败可回滚)。
cpp
struct Bad {
Bad(Bad&&); // ❌ 没 noexcept
};
struct Good {
Good(Good&&) noexcept; // ✅
};
cpp
std::vector<Bad> v1;
v1.push_back(Bad{}); // 扩容时 → 拷贝
std::vector<Good> v2;
v2.push_back(Good{}); // 扩容时 → 移动 ✅
🎯 结论 :凡是"偷资源、不抛异常"的移动构造/赋值,一律加
noexcept。这不是优化,而是"能不能用上移动"的前提。面试官最爱问这个。
六、移动后源对象什么状态?
❓ std::move(a) 之后,a 还能用吗?
✅ 标准的规定是:valid but unspecified(有效但未指定) ------也就是"能用,但别假设里面还有什么内容"。
具体说,移动后:
a仍然是个合法对象(能正常析构、能被赋新值);- 但内部值未知(可能是空,可能不是);
- 只能调用不依赖具体值的操作 :赋值(
a = ...)、析构。
cpp
string s = "hello";
string t = std::move(s);
// s 状态未指定,但合法
s = "world"; // ✅ 允许
cout << s[0]; // ❌ 别假设 s 还有内容
⚠️ 别从源对象身上"读" ,但"写一个新值进去"是安全的。日常代码里,
std::move之后尽量别再碰那个源对象。
七、完美转发简介
❓ 听说过"完美转发",是干什么的?
✅ 一句话:让函数模板把参数"原封不动地"传给下一个函数------保持它的左值/右值属性、const 属性不变。
cpp
template<class T>
void wrapper(T&& x) { // && 在模板里是"转发引用"
target(std::forward<T>(x)); // 原样转发
}
wrapper(左值)→target收到左值引用;wrapper(右值)→target收到右值引用(触发移动)。
💡 为什么要它 :写库的人经常要做"转发壳 "------比如
make_unique、emplace_back、各种工厂函数,它们自己不构造对象,只是把参数递给真正的构造函数。如果不会完美转发,递一个右值进去、里面按左值拷贝了,性能就白丢。
🎯 这里只点个概念。完美转发在"代码复用/库设计"里是大杀器------写自己的容器、工厂、装饰器都靠它,那是另一个大话题。
八、Rule of Five 再回顾
❓ 加上移动后,三五法则变成什么样?
✅ Rule of Three (析构 + 拷贝构造 + 拷贝赋值)再加上移动构造、移动赋值两个,就是 Rule of Five:
cpp
struct Buffer {
~Buffer(); // 1. 析构
Buffer(const Buffer&); // 2. 拷贝构造
Buffer& operator=(const Buffer&);// 3. 拷贝赋值
Buffer(Buffer&&) noexcept; // 4. 移动构造
Buffer& operator=(Buffer&&); // 5. 移动赋值
};
⚠️ 常见错误 :写了移动构造,以为可以不用写拷贝构造 。恰恰相反------C++ 规定:一旦你声明了移动构造,编译器就不再合成拷贝构造 。所以要么五个都写,要么用 RAII 成员实现 Rule of Zero(容器/智能指针会自动支持移动,连写都不用写)。
九、面试高频追问
❓ Q1:std::move 之后那个对象还能用吗?
✅ 处于"有效但未指定"状态------能赋值、能析构,但别读它的值。最佳实践是 move 完就别再碰。
❓ Q2:什么类型的 std::move 其实等于没 move?
✅ 没有移动构造的类型 (比如只写了个默认拷贝的旧类)、const 对象 (std::move(const T&) 退化成拷贝)、基本类型(int/double 没有资源可偷,move 就是复制)。
❓ Q3:返回局部对象时该不该写 return std::move(t);?
✅ 千万别! 这是经典坑。局部对象返回时本可以触发 NRVO (直接在调用方构造,零拷贝);你 std::move 一下,反而把 NRVO 关掉了 ,强制走一次移动构造。return t; 永远比 return std::move(t); 好,编译器会自动判断该不该移动。
❓ Q4:移动构造里抛异常会怎样?
✅ 灾难。移动到一半抛异常,目标对象不完整、源对象已被偷空------两边都坏了,没法回滚。所以移动构造必须 noexcept------偷资源这种操作本来就不该失败。
❓ Q5:T&& 一定是右值引用吗?
✅ 在模板里不是 !模板里的 T&& 叫转发引用(forwarding reference) ,能同时绑左值和右值(通过引用折叠推导)。只有在非模板、类型确定的场合 (如 Buffer&&),&& 才是纯右值引用。这点完美转发时最绕。
十、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 移动的本质 | 偷指针,O(1),源置空 |
| 左值/右值 | 能取地址=左值;临时将亡=右值 |
std::move |
只是 cast,不移动任何东西 |
const 对象 + move |
退化成拷贝 |
| 移动构造 noexcept | vector 扩容要用,必须加 |
| 移动后源状态 | 有效但未指定,别读 |
| 完美转发 | T&& + std::forward 原样转发 |
| Rule of Five | 三大法则 + 移动构造 + 移动赋值 |
| 返回局部对象 | 别 std::move,会挡 NRVO |
一句话回顾
移动是"偷资源",
std::move只是发"允许偷"的信号 ;偷资源的移动构造必须noexcept------否则连vector扩容都不会用它。返回局部对象时别画蛇添足加std::move,编译器比你想得聪明。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。到这里,构造、析构、拷贝、移动这"对象生命周期四件套"就齐了。下期我们正式聊面向对象的重头戏------虚函数与多态:虚函数表长什么样、对象内存里存了什么、构造析构里调虚函数会发生什么,敬请关注 👋