☰
C++异常处理 try,catch,throw,finally的用法
2026/10/20 5:52:14 网站建设 项目流程

前言


先把题面里最关键的误解纠正掉: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。做到这三点,栈展开不但不会让程序失控,反而是最可靠的清理机制。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询