前言
先把题面里最关键的误解纠正掉:C++ 标准没有finally关键字。finally是 Java、C#、Python、JavaScript 等语言的语法。在 C++ 里写try { ... } finally { ... }无法通过编译;C++ 也从未在标准草案里加入过它。
C++ 的替代方案是RAII(resource acquisition is initialization,资源获取即初始化):把资源交给一个局部对象管理,在它的析构函数里释放。throw触发栈展开(stack unwinding)时,作用域内所有已构造的局部对象都会按构造的逆序析构,于是「无论正常返回还是异常退出都要执行的清理」自然就发生了。std::lock_guard、std::unique_ptr、std::fstream都是这个思路。
第二个常见误解是「析构函数里可以抛异常」。析构函数默认是noexcept的(除非显式声明noexcept(false)),异常一旦逃出析构函数,运行时会调用std::terminate。栈展开过程中再抛出一个异常,同样会终止程序。
第三个误解是catch (std::exception e)按值捕获没问题。按值捕获会发生对象切片(slicing):派生类部分被切掉,只剩基类子对象,派生类附加的信息全部丢失。正确写法是catch (const std::exception& e)。
本文讲throw与栈展开的语义、catch的匹配规则、用 RAII 替代finally的几种写法,以及异常安全等级和noexcept。
一、throw 与栈展开
throw的表达式会被复制(或移动)成一个异常对象,存放在编译器管理的特殊存储区,生命周期一直延续到最后一个匹配的catch结束。控制流随即离开当前作用域,沿途销毁所有自动存储期的局部对象,直到找到匹配的处理器。
// 适用于 C++17
#include <stdexcept>
#include <string>
void open_config(const std::string& path) {
if (path.empty()) {
throw std::invalid_argument("empty path"); // 构造异常对象
}
// ...
}如果一路展开到main都没有匹配的catch,标准规定调用std::terminate。默认的 terminate 处理器通常会调用std::abort,程序直接终止,不会执行栈上对象的析构之外的额外清理。
展开过程中析构的局部对象,其析构函数必须是「不抛异常」的。这也是为什么标准库容器的析构、智能指针的析构都保证不抛。
二、catch 的匹配与写法
catch的匹配规则大致是:异常对象的类型与处理器声明的类型相同,或是其派生类,或是可以经过标准指针转换/限定转换的目标,或者处理器是catch (...)。匹配按书写顺序进行,第一个匹配的处理器生效,后面的不再尝试。
// 适用于 C++17
#include <iostream>
#include <stdexcept>
int main() {
try {
throw std::out_of_range("index 10 >= size 3");
} catch (const std::out_of_range& e) { // 先匹配派生类
std::cerr << "range: " << e.what() << '\n';
} catch (const std::exception& e) { // 再匹配基类
std::cerr << "std: " << e.what() << '\n';
} catch (...) {
std::cerr << "unknown\n";
throw; // 原样重新抛出
}
}几个要点:
- 按 const 引用捕获。按值捕获会切片,还会多一次拷贝;按非 const 引用捕获虽然能改异常对象,但没有必要。
- 派生类在前,基类在后。如果
std::exception写在前面,std::out_of_range永远轮不到,编译器通常会给警告。 throw;只能在catch块(或从它调用的函数)里使用,它重新抛出当前正在处理的异常。没有活动异常时执行throw;会调用std::terminate。catch (...)会连非std::exception派生的类型一起捕获,比如throw 42;。捕获后如果想保留信息,必须throw;而不是throw e;。what()返回的const char*指向异常对象内部的存储,异常对象销毁后失效,不要长期保存这个指针。
异常对象在catch块结束时销毁。如果catch参数是引用,引用指向的就是那个异常对象;如果按值,则是它的一个副本。
三、没有 finally,清理怎么写
3.1 用标准库的 RAII 类型
绝大多数资源都有现成的包装:
| 资源 | RAII 包装 | 头文件 |
|---|
| 堆内存 | std::unique_ptr/std::shared_ptr | <memory> |
| 文件 | std::ifstream/std::ofstream/std::fstream | <fstream> |
| 互斥量 | std::lock_guard/std::unique_lock | <mutex> |
| 动态数组 | std::vector | <vector> |
| 字符串缓冲区 | std::string | <string> |
// 适用于 C++17
#include <fstream>
#include <stdexcept>
#include <string>
std::string read_first_line(const std::string& path) {
std::ifstream in(path);
if (!in) {
throw std::runtime_error("cannot open " + path);
}
std::string line;
std::getline(in, line);
return line; // 无论正常返回还是抛异常,in 都会关闭
}这里没有finally,也不需要:in是局部对象,函数以任何方式退出,它的析构函数都会关闭文件。
3.2 自己写一个作用域守卫
当清理动作不是「释放某个标准资源」,而是一段自定义逻辑(回滚事务、恢复标志位、打印日志),可以写一个通用守卫:
// 适用于 C++17
#include <utility>
template <class F>
class ScopeGuard {
public:
explicit ScopeGuard(F f) : f_(std::move(f)) {}
~ScopeGuard() { if (active_) f_(); }
void dismiss() noexcept { active_ = false; }
ScopeGuard(const ScopeGuard&) = delete;
ScopeGuard& operator=(const ScopeGuard&) = delete;
private:
F f_;
bool active_ = true;
};用法:
// 适用于 C++17
void update(int& state) {
state = 1;
ScopeGuard guard([&state] { state = 0; }); // 退出时恢复
// ... 可能抛异常的操作 ...
guard.dismiss(); // 正常路径取消清理
}C++17 的类模板实参推导让ScopeGuard guard(...)不必写模板参数。注意析构函数里调用的f_()必须不抛异常;如果它抛了,析构函数默认noexcept,程序会直接std::terminate。
更完善的作用域守卫会检查std::uncaught_exceptions(),只在确实有异常正在传播时执行回滚,正常返回时不执行。这需要<exception>,并且要区分「进入守卫时已有几个未捕获异常」和「退出时变成了几个」。
3.3 为什么不用模拟的 finally
有人用「析构函数 + 标志位」模拟finally,也有人用宏拼出一个FINALLY块。前者就是 ScopeGuard,没问题;后者的问题在于宏展开后的作用域、return/break/goto的行为都很难控制,而且调试信息差。能用 RAII 表达时,不要用宏。
四、异常安全与 noexcept
异常安全通常分三档:
| 等级 | 保证 | 典型实现 |
|---|
| 不抛保证(nothrow) | 操作一定成功,不抛异常 | swap、移动构造、noexcept函数 |
| 强保证(strong) | 失败时对象保持操作前的状态 | 先构造副本,成功后swap |
| 基本保证(basic) | 失败后对象仍有效、可析构,但状态可能改变 | 大多数容器操作 |
「拷贝并交换」(copy-and-swap)是实现强保证的常用手法:
// 适用于 C++17
#include <string>
#include <utility>
class Config {
public:
Config& operator=(Config other) { // 按值传参:拷贝发生在进入函数前
swap(*this, other);
return *this;
}
friend void swap(Config& a, Config& b) noexcept {
using std::swap;
swap(a.name_, b.name_);
swap(a.retries_, b.retries_);
}
private:
std::string name_;
int retries_ = 0;
};如果拷贝构造抛异常,赋值根本没开始,原对象不受影响;如果swap不抛(它对std::string和int都不抛),整个赋值就是强保证。
noexcept不只是文档,它会影响优化和容器行为:std::vector在扩容时,如果元素的移动构造函数声明了noexcept,通常会选择移动而不是拷贝;否则为了强异常保证,可能退回拷贝。因此移动构造和移动赋值应当尽量标noexcept。
反过来,给一个可能抛异常的函数硬加noexcept,一旦真的抛出,程序会调用std::terminate,比让异常正常传播更糟。
完整示例
把「RAII 负责清理」和「按类型捕获」放在一个程序里:文件对象在构造函数失败时抛异常,在析构时自动关闭;解析函数把底层库的异常转换成自己的错误信息;main按从派生到基类的顺序捕获。
// 适用于 C++17
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
class File {
public:
explicit File(const std::string& path) : in_(path) {
if (!in_) {
throw std::runtime_error("cannot open " + path);
}
}
std::string first_line() {
std::string line;
if (!std::getline(in_, line)) {
throw std::runtime_error("empty file");
}
return line;
}
private:
std::ifstream in_; // 析构时自动关闭,无需 finally
};
int parse_port(const std::string& s) {
std::size_t pos = 0;
int value = 0;
try {
value = std::stoi(s, &pos);
} catch (const std::invalid_argument&) {
throw std::runtime_error("port is not a number");
}
if (pos != s.size()) {
throw std::runtime_error("trailing characters in port");
}
if (value < 1 || value > 65535) {
throw std::out_of_range("port out of range");
}
return value;
}
int main() {
try {
File f("config.txt");
int port = parse_port(f.first_line());
std::cout << "port = " << port << '\n';
} catch (const std::out_of_range& e) {
std::cerr << "range error: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "error: " << e.what() << '\n';
}
return 0;
}std::stoi自己也可能抛std::out_of_range(数字超出int范围),这里没有捕获它,它会继续向上传播,最终由main的std::out_of_range处理器接住。File的析构函数在try块退出时一定执行,无论parse_port是否抛异常——这就是 C++ 版「finally」的全部机制。
常见坑点
1. 写finally
❌try { ... } finally { cleanup(); }✅ 把cleanup()放进局部对象的析构函数;C++ 没有finally。
2. 按值捕获异常
❌catch (std::exception e) { ... }✅catch (const std::exception& e) { ... },避免切片与多余拷贝。
3. 基类处理器写在派生类前面
❌ 先catch (const std::exception&)再catch (const std::out_of_range&)。 ✅ 派生类在前,基类在后,最后才是catch (...)。
4. 在析构函数里抛异常
❌~Widget() { if (bad) throw std::runtime_error("bad"); }✅ 析构函数里捕获并记录,或提供显式的close()让调用方处理错误。
5. 在catch之外写throw;
❌void f() { throw; }且没有活动异常。 ✅ 只在catch块内重新抛出;否则直接抛一个新异常对象。
6. 捕获指针
❌catch (std::exception* e)想接住throw std::runtime_error(...)。 ✅ 抛的是对象,不是指针;按引用捕获。抛指针还要自己管理释放。
7. 让异常穿过noexcept函数
❌void f() noexcept { throw std::runtime_error("x"); }✅ 去掉noexcept,或在函数内捕获并处理;违反noexcept会std::terminate。
8. 用异常做普通控制流
❌ 循环里靠throw/catch判断「没找到」。 ✅ 用返回值、std::optional(C++17)或std::expected(C++23)表达可预期的失败。
总结
| 需求 | Java/C# 写法 | C++ 写法 |
|---|
| 必定执行的清理 | finally | 局部对象析构(RAII) |
| 捕获任意异常 | catch (Exception e) | catch (const std::exception&) |
| 捕获所有类型 | catch (Throwable t) | catch (...) |
| 重新抛出 | throw e; | throw;(保留原异常对象) |
| 声明不抛 | throws声明 | noexcept |
| 资源释放 | try-with-resources/using | 智能指针、容器、lock_guard |
把finally从脑子里删掉,换成「这个资源由哪个对象负责释放」,C++ 的异常处理就顺了。真正需要记住的只有三条:按 const 引用捕获、析构函数不要抛、清理逻辑交给 RAII。做到这三点,栈展开不但不会让程序失控,反而是最可靠的清理机制。