C++的异常处理机制允许函数在遇到不可恢复的错误时抛出异常对象,调用方通过catch子句按照类型匹配进行捕获。标准库提供了一套以std::exception为根的异常层次结构,使得常规开发中可以统一通过catch(const std::exception& e)来捕获大多数标准异常。然而,C++语言规范并没有强制要求所有被抛出的对象都必须继承std::exception,因此在实际项目中,尤其是与历史遗留库、C语言接口或第三方模块交互时,经常会遇到直接抛出整数、字符串字面量、枚举值或未继承标准异常体系的自定义结构体的情况。这类非标准异常一旦没有被对应的catch捕获,程序就可能直接调用std::terminate退出,严重影响系统的稳定性和可维护性。因此有必要在函数设计层面引入一套机制,专门处理这些非标准异常,并将其纳入统一的异常处理流程。

什么是非标准异常
非标准异常指的是所有被throw抛出、但没有继承自std::exception的对象。C++的throw表达式可以抛出任何可复制类型,包括基础类型、指针、结构体等。它们与标准异常体系完全隔离,类型匹配时不会与std::exception产生任何联系。例如,一个函数抛出整数42,它只能被catch(int)或catch(...)捕获,catch(const std::exception& e)不会匹配。这种类型隔离导致调用方必须为每种可能的非标准类型分别编写catch分支,否则一旦异常穿透所有catch,运行时就会调用std::terminate终止进程。常见形式包括:
throw -1;throw "network fail";throw MyOldError();,其中MyOldError未继承std::exception
非标准异常通常来源于历史遗留代码。早期C++代码或从C迁移而来的接口往往习惯用返回码和错误码表示错误,当需要抛出异常时也可能直接抛出基础类型。例如一些网络库在出错时抛出C风格字符串,一些底层接口抛出整型错误编号。虽然这些对象也能触发栈展开和局部对象析构,但它们缺少统一的基类接口,调用方无法通过基类引用统一获取错误信息,也无法在多个模块之间形成一致的异常契约。正因为如此,在模块边界对这些非标准异常进行捕获和转换十分必要。
判断一个异常是否标准,可以通过类型检查或catch匹配来确定。标准异常都继承自std::exception,可以直接使用what()方法获取描述信息;而非标准异常则没有这些公共接口。因此处理策略需要先识别异常类型,再做转换或分发。
使用catch(...)捕获并转换
catch(...)是C++提供的全捕获语法,能够捕获任意类型的异常,无论它是否继承标准体系。利用这一特性,可以在包装函数中先尝试调用可能抛出非标准异常的旧接口,然后在catch链中进行分类处理。通常的写法是先捕获标准异常并原样重新抛出,再针对已知的非标准类型转换,最后用catch(...)兜底处理所有未知异常。这样可以保证标准异常不被吞没,同时将分散的非标准异常统一映射到标准异常体系中。
示例代码展示了一个safe_wrapper函数,它包装了old_api_call。在try块中调用旧接口,catch链首先处理const std::exception& e并使用throw;重新抛出原异常,以保留原始异常类型和上下文信息;接着捕获const char* msg,将其转换为std::runtime_error;最后用catch(...)捕获诸如int、自定义结构体等其他非标准异常,也转换为std::runtime_error。
#include <stdexcept>
#include <string>
void old_api_call();
void safe_wrapper() {
try {
old_api_call();
} catch (const std::exception& e) {
throw;
} catch (const char* msg) {
throw std::runtime_error(std::string("c str error: ") + msg);
} catch (...) {
throw std::runtime_error("unknown non-standard exception");
}
}
需要强调的是,使用throw;而不是throw e;可以避免异常对象被切片。切片会丢失派生类信息,使上层无法通过基类引用识别具体异常类型。另外,所有具体的catch分支必须放在catch(...)之前,否则全捕获分支会拦截一切异常,导致后续分支永远没有机会执行。
结合std::current_exception判断
有时我们并不希望立即在包装函数内决定异常的处理方式,而是想把异常暂时保存起来,延迟到更外层的统一处理器中根据类型进行分发。std::current_exception可以在catch(...)块中获取当前异常的std::exception_ptr,这个指针即使离开当前catch作用域也仍然有效。之后可以使用std::rethrow_exception将保存的异常重新抛出,并在新的try/catch结构中做更细致的类型匹配。
下面示例定义了一个handle_any函数,它主动抛出一个非标准异常整数42
。为了演示完整流程,我们还需要两个辅助函数:一个用于捕获并保存异常,另一个用于重新抛出和分类处理。
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>
void handle_any() {
throw 42;
}
void store_exception(std::exception_ptr& eptr) {
try {
handle_any();
} catch (...) {
eptr = std::current_exception();
}
}
void process_exception(const std::exception_ptr& eptr) {
if (!eptr) {
return;
}
try {
std::rethrow_exception(eptr);
} catch (const std::exception& e) {
std::cerr << "standard exception: " << e.what() << 'n';
} catch (int value) {
std::cerr << "non-standard int exception: " << value << 'n';
} catch (...) {
std::cerr << "unknown exceptionn";
}
}
在上面的代码中,store_exception 调用 handle_any,并在 catch(...) 块中通过 std::current_exception 获取当前异常的 std::exception_ptr。process_exception 先检查指针是否为空,再使用 std::rethrow_exception 重新抛出该异常,并在新的 try/catch 结构中按照具体类型进行分发。可以看到,即使原始异常是 int,重新抛出后依然能够匹配 catch (int value),而不是落入 catch(...) 分支。
异常指针的跨线程传播
在并发编程中,线程函数的异常无法直接跨线程传播。标准库提供了 std::promise 和 std::future 机制,内部正是使用 std::exception_ptr 来传递异常。工作线程通过 set_exception 将当前异常存入 promise,主线程在 future::get 时重新抛出。
std::promise<int> prom;
std::future<int> fut = prom.get_future();
std::thread worker([&prom]() {
try {
throw std::runtime_error("worker failure");
} catch (...) {
prom.set_exception(std::current_exception());
}
});
try {
int value = fut.get();
} catch (const std::exception& e) {
std::cerr << "caught from future: " << e.what() << 'n';
}
worker.join();
通过这种方式,异常对象的所有权被转移到 std::exception_ptr,可以安全地跨越线程边界,避免了直接在线程函数中抛出而导致的 std::terminate。
noexcept 与函数异常契约
现代 C++ 使用 noexcept 明确声明函数不会抛出异常。与旧的动态异常规范不同,noexcept 是类型系统的一部分,并且可以直接用于模板特化和函数指针比较。在移动构造函数、移动赋值运算符以及析构函数中,正确标记 noexcept 尤其重要,因为标准容器会根据这些属性选择更高效的路径。
void may_throw();
void never_throws() noexcept;
static_assert(!noexcept(may_throw()));
static_assert(noexcept(never_throws()));
class Buffer {
public:
Buffer() = default;
Buffer(Buffer&&) noexcept = default;
Buffer& operator=(Buffer&&) noexcept = default;
};
如果 noexcept 函数内部仍然抛出了异常,程序会直接调用 std::terminate。因此,在标记 noexcept 时,必须确保内部所有可能抛出异常的调用要么被捕获处理,要么确实不会抛出。
析构函数不应抛出异常
析构函数默认带有 noexcept(true) 属性。如果析构函数尝试抛出异常且没有在内部捕获,同样会触发 std::terminate。当类需要同时管理资源和提供可能失败的清理操作时,应当把可能抛出异常的部分设计为普通成员函数,由调用者显式调用,而析构函数只负责无条件释放资源,或者捕获所有异常并记录日志。
class Connection {
public:
void close() {
if (is_open_) {
if (::close(fd_) == -1) {
throw std::runtime_error("close failed");
}
is_open_ = false;
}
}
~Connection() noexcept {
if (is_open_) {
::close(fd_); // 忽略错误或记录日志
}
}
private:
int fd_;
bool is_open_;
};
这种设计既保证了析构函数不会因异常而终止程序,又把错误处理的责任明确交给了调用者。
异常安全的三个保证
编写健壮的代码时,应当明确函数在异常发生时的行为。通常将异常安全分为三个等级:
- 不抛出保证:操作保证不会抛出异常。例如析构函数、
swap操作等。 - 强保证:如果操作抛出异常,程序状态保持不变,如同操作从未发生。常用于赋值操作,通过先复制后交换(copy-and-swap)实现。
- 基本保证:如果操作抛出异常,程序仍然处于有效状态,不会泄漏资源,但数据可能被部分修改。
例如,一个安全的赋值运算符可以实现如下:
class SafeVector {
public:
SafeVector& operator=(const SafeVector& other) {
SafeVector temp(other); // 可能抛出,但此时原对象未变
swap(temp); // 不抛出
return *this;
}
void swap(SafeVector& other) noexcept { /* ... */ }
};
先构造临时副本,若构造过程抛出异常,原对象不受影响,从而获得强异常安全保证。交换操作通常被标记为 noexcept,因为它只交换内部指针或句柄,不会触发资源分配。
小结
异常处理不是仅仅 try/catch 的简单组合,它涉及异常对象的生命周期、类型信息的保留、跨线程传播以及函数异常契约等多个层面。正确使用 throw; 重新抛出可以避免切片;std::current_exception 和 std::exception_ptr 提供了暂存和跨线程传递异常的能力;noexcept 帮助编译器优化并明确函数契约;而析构函数和资源管理则要求我们遵守不抛出原则。理解这些机制,有助于编写出在异常情况下依然安全、可预测的 C++ 程序。
C++异常处理非标准异常std_exception修改时间:2026-07-29 01:36:24