4. 左值右值 / 移动语义 / 异常处理 / 断言¶
这一章里异常处理是高频考点(尤其"throw 时调用了几次析构")。移动语义偶尔考概念和填空。
4.1 左值 (lvalue) 与右值 (rvalue)¶
理解移动语义之前,先分清这两个词。最简单的判断法:能取地址(用 &)的就是左值,不能的就是右值。
左值 lvalue:有名字、有确定内存地址的东西
• 变量名 x
• arr[i] 下标表达式
• 返回引用的函数调用
→ 它能出现在赋值号左边,也能取地址 &x
右值 rvalue:临时的、没有稳定地址的东西,表达式结束就没了
• 字面量 42、true
• 算术表达式结果 a + b
• 返回值(非引用)的函数调用
→ 取不了地址
4.2 左值引用与右值引用¶
int a = 10;
int& refA = a; // 左值引用 T&,只能绑左值
// int& refB = 10; ❌ 不能绑右值
int&& refC = 10; // 右值引用 T&&(两个 &),专门绑右值(C++11 新增)
// int&& refD = a; ❌ 不能绑左值
4.3 移动语义:把资源"搬走"而不是"复制"¶
动机:深拷贝大对象(如装了一百万个元素的 vector)很慢。如果源对象是个"马上要死的临时对象",与其复制它的资源再把它销毁,不如直接把它的资源搬过来。
移动构造函数¶
class Buffer {
public:
int* data;
int size;
Buffer(int s) : size(s), data(new int[s]) {}
~Buffer() { delete[] data; }
// 移动构造:参数是右值引用 &&
Buffer(Buffer&& other) : data(other.data), size(other.size) {
other.data = nullptr; // ⚠️ 关键:把源对象指针置空
other.size = 0; // 否则源对象析构时会把搬走的内存 delete 掉
}
};
函数签名:
移动构造: ClassName(ClassName&& other)
移动赋值: ClassName& operator=(ClassName&& other)
(移动赋值同样要判断 other 是不是自己,且要先释放自己旧资源)
关键动作:搬完后把源对象置于"安全的空状态",防止它析构时释放已搬走的资源
std::move:把左值"伪装"成右值¶
正常情况下对象名是左值,直接赋值会走拷贝。想触发移动,要用 std::move:
⚠️ 易错点:
std::move本身不移动任何东西!它只是个强制类型转换,把左值转成右值引用,"告诉编译器:这个可以被搬走"。真正的搬运是移动构造/移动赋值做的。
4.4 ⭐异常处理:try / catch / throw¶
C 用"返回错误码"处理错误,问题是错误处理代码把真正的业务逻辑淹没了。C++ 用异常机制分离它们。
三个关键字:
double divide(double a, double b) {
if (b == 0)
throw invalid_argument("Division by zero!"); // 抛异常
return a / b;
}
try {
double result = divide(x, y);
cout << "Result: " << result << endl;
} catch (invalid_argument& e) { // 接住特定类型的异常
cerr << e.what() << endl; // e.what() 取异常信息
} catch (...) { // ... 接住所有其他类型的异常
cerr << "Unknown exception" << endl;
}
⭐关键机制:栈展开 (Stack Unwinding)¶
这是"throw 调用了几次析构"那类题的原理:
异常被抛出后,程序沿着【函数调用栈】向上找匹配的 catch。
在这个"往回退"的过程中:
• 所有已经构造的【局部对象】会被【自动析构】(这就是栈展开)
• throw 之后、同一作用域里的代码会被【跳过】(不执行)
如果一路退到顶都没找到匹配的 catch → 调用 std::terminate() 终止程序
catch 的匹配顺序(判断题考点)¶
• catch 块按【书写顺序】从上往下匹配,第一个匹配的就执行
• 如果基类和派生类异常都可能抛,【派生类 catch 必须写在基类 catch 前面】
(否则基类 catch 会先把派生类异常截走)
• catch(...) 要放最后(它接所有,放前面会挡住后面的)
判断题:try 块可以没有 catch 吗?题库口径给 T(注意:标准 C++ 实际上 try 必须配 catch,但本课题库按 T 记)。
⭐经典原题:异常 + 析构计数(2023-2024,必背)¶
class A {
public:
A() { cout << "A()\n"; }
~A() { cout << "~A()\n"; }
};
void foo(A a) { // ① 按值传参 → 拷贝出 1 个 A
A arr[5]; // ② 栈上 5 个 A
A* p = new A[3]; // ③ 堆上 3 个 A(不随栈展开自动析构)
throw p; // 抛出后开始栈展开
}
int main() {
try {
A a; // ④ main 里 1 个 A
foo(a);
} catch (A* p) {
delete[] p; // ⑤ 手动析构堆上 3 个
cout << "catched\n";
}
}
问:A 的析构 ~A() 被调用几次?答案:10
栈展开时析构:foo 里的 arr[5] → 5 次
foo 的形参 a → 1 次
delete[] p 析构堆上的 3 个 → 3 次
main 里的局部 a → 1 次
─────────────────────────────────────
合计 → 5 + 1 + 3 + 1 = 10 次
关键点:堆上 new A[3] 的 3 个对象不会随栈展开自动析构,只有手动 delete[] p 才析构。
经典原题:throw; 重抛 + 跳过后续(题库)¶
void f3() { double a = 0; try { throw a; } catch(double){ cout<<"OK3! "; throw; } cout<<"end3 "; }
void f2() { try { f3(); } catch(int){ cout<<"Ok2! "; } cout<<"end2 "; }
void f1() { try { f2(); } catch(char){ cout<<"OK1!"; } cout<<"end1 "; }
int main() { try { f1(); } catch(double){ cout<<"OK0! "; } cout<<"end0 "; }
输出:OK3! OK0! end0
f3 抛 double,自己的 catch(double) 接住 → 打印 "OK3! "
然后 throw; 重新抛出同一个 double 异常
→ f3 的 "end3" 被跳过(异常正在传播)
f2 的 catch(int) 不匹配 double → "end2" 被跳过
f1 的 catch(char) 不匹配 double → "end1" 被跳过
main 的 catch(double) 匹配! → 打印 "OK0! "
异常处理完毕,正常继续 → 打印 "end0"
记忆:throw;(不带参数)= 把当前异常原样再抛一次。异常传播路上的代码全部跳过。
4.5 断言 Assertion:抓"绝不该发生"的逻辑错误¶
断言用来验证"程序运行时是否满足某个条件"。条件为真就继续,为假就立刻报错退出。
断言 vs 异常 的区别(概念题):
断言 assert:抓【绝对不该发生】的程序逻辑 bug(如数组越界),只在开发期用,
生产环境会被关掉,一触发程序直接停
异常 exception:处理【可能遇到】的问题(如网络发送失败),生产环境照常用
运行时断言 assert(<cassert>)¶
编译期断言 static_assert(C++11)¶
4.6 本章自测¶
1. 怎么判断左值右值? → 能取地址 & 的是左值,不能的是右值
2. T&& 是什么引用?绑什么? → 右值引用,绑右值
3. std::move 真的移动东西吗? → 不!它只是把左值转成右值引用
4. 移动构造里为什么要把源对象指针置空? → 防止源对象析构时释放已搬走的内存
5. 栈展开过程中局部对象会怎样? → 被自动析构
6. throw; 不带参数是什么意思? → 把当前异常原样重新抛出
7. 派生类异常的 catch 该放在基类前还是后? → 前面(否则被基类先接走)
8. 堆上 new[] 的对象会随栈展开自动析构吗? → 不会,必须手动 delete[]
9. assert 和异常的核心区别? → assert 抓不该发生的逻辑 bug,开发期用;异常处理可能的问题