【C++】C++11 异常

张开发
2026/4/9 3:29:29 15 分钟阅读

分享文章

【C++】C++11 异常
目录1. 异常处理1.1 核心概念1.2 异常捕获规则1.3 异常执行流程1.4 总结2. 栈展开Stack Unwinding2.1 核心概念2.2 清理范围2.3 异常安全问题2.4 栈展开的重要意义保障异常安全2.5 总结3. noexcept 关键字3.1 noexcept 作为说明符3.2 合规的两种写法3.3 noexcept 作为运算符3.4 noexcept 的核心作用3.5 noexcept 在STL 容器中的经典应用场景3.6 强烈建议使用 noexcept 的三大场景4. 重新抛出异常4.1 语法形式4.2 常见使用场景5. 异常多态与标准库异常5.1 标准异常体系结构5.2 自定义异常类1. 异常处理1.1 核心概念C 异常处理是专门用来处理程序运行时错误的机制让程序在出错时不会直接崩溃还能优雅地释放资源、提示错误。核心三个关键字try尝试执行可能出错的代码块throw抛出异常发现错误时触发catch捕获并处理异常接住抛出的错误基本语法结构int main() { // 1. try 包裹可能出错的代码 try { int a 10, b 0; if (b 0) { // 2. 主动抛出异常const char*类型 throw 除数不能为 0; } cout a / b endl; } // 3. catch 捕获并处理异常 catch (const char* err) { cout 捕获到异常 err endl; } return 0; }1.2 异常捕获规则try必须搭配catch不能单独使用。抛出什么类型就捕获什么类型。抛int→catch(int)抛string→catch(string)抛自定义类 →catch(类名)一个try可以对应多个catch捕获不同类型异常。可以用catch(...)捕获任意类型异常兜底必须写在所有catch的最后不能写在前面。如果 throw 抛出的异常全程没有任何 catch 接住 → 程序会直接终止崩溃代码示例int main() { try { int num; cin num; if (num 0) throw 零错误; // 抛 const char* } catch (int err) { cout 整数异常 err endl; } catch (const char* err) { cout 字符串异常 err endl; } catch (...) // 兜底捕获所有没被匹配的异常 { cout 未知异常 endl; } return 0; }1.3 异常执行流程当执行throw时throw后面的所有代码立刻停止执行程序直接跳转沿着函数调用链往上回溯逐层退出函数触发栈展开自动销毁局部对象直到找到第一个类型匹配的catch块控制权完全转移到catch块执行如果全程找不到匹配的catch程序直接调用std::terminate()终止崩溃void funcB() { cout 进入funcB函数 endl; throw 10; // 抛异常立刻跳转 cout 嘻嘻嘻 endl; // 这句话永远不会执行 } void funcA() { cout 进入funcA函数 endl; funcB(); // 调用funcB cout 哈哈哈 endl; // 这句话也不会执行 } int main() { try { funcA(); } catch (int x) // 最终跳到这里 { cout 捕获异常 x endl; } cout 程序继续运行\n; return 0; }跳转逻辑funcB → funcA → main找到匹配的 catch 并进去执行运行结果1.4 总结抛出异常后throw 之后代码不再执行沿调用链向上回溯选择第一个类型匹配的 catch找不到 catch → 调用terminate程序直接退出2.栈展开Stack Unwinding栈展开是 C 异常处理的核心机制它保证了程序在抛出异常时能安全清理资源、避免资源泄漏是 C 异常安全的基石。2.1 核心概念当程序抛出一个异常且当前函数没有立即捕获它时程序不再往下执行开始沿着函数调用链一层层退出函数在退出每一层函数时自动销毁该函数内已构造完成的所有局部对象调用析构函数直到找到匹配的catch这个“回溯并清理”的过程就叫栈展开。代码实例struct A { A() { cout A 构造\n; } ~A() { cout A 析构\n; } }; struct B { B() { cout B 构造\n; } ~B() { cout B 析构\n; } }; void funcB() { A a; // 局部对象 cout funcB 抛异常\n; throw 1; // 抛异常 → 触发栈展开 cout funcB 结束\n;// 永远不执行 } void funcA() { B b; // 局部对象 funcB(); cout funcA 结束\n;// 永远不执行 } int main() { try { funcA(); } catch (int) { cout 捕获异常\n; } return 0; }运行结果2.2 清理范围只处理栈上局部对象。不处理堆上 new 出来的对象所以裸指针会泄漏这也是为什么必须使用智能指针RAII来管理堆内存因为智能指针本身是栈对象其析构函数会自动 delete。不处理全局 / 静态变量。2.3 异常安全问题析构函数绝对不能向外抛异常别让异常逃离析构函数栈展开一定会执行析构函数当程序因异常进行栈展开时如果析构函数又抛出新异常运行时会面临“两个异常同时存在”的困境导致直接调用std::terminate()立即崩溃。为了从根源杜绝这种风险C11 规定析构函数默认隐式为noexcept这意味着编译器会默认认为析构函数不会抛出异常无需手动标注若析构函数实际抛出了未被内部捕获的异常会直接触发std::terminate()程序强制终止该规则强制要求开发者在析构函数内部处理所有可能的错误彻底阻断异常逃逸的可能。2.4 栈展开的重要意义保障异常安全栈展开是 C异常安全的核心保障无栈展开异常直接跳转资源无法释放造成泄漏、死锁。有栈展开自动析构局部对象安全释放内存、文件、锁等资源避免程序异常崩溃或卡死。栈展开是RAII资源获取即初始化的核心基础通过将资源封装为栈上对象利用栈展开自动调用析构函数的特性实现异常安全的资源管理。2.5 总结抛出异常后throw 后面代码立刻停止执行程序沿函数调用链向上回溯找 catch。回溯过程中会自动销毁每一层函数的局部对象调用析构函数这个过程就是栈展开。找到匹配的 catch 后栈展开停止程序执行 catch 内代码之后继续正常运行。3. noexcept 关键字noexcept 是 C11 引入的一个关键字用于替代旧式的throw()它既是一个承诺说明符也是一个查询工具运算符。3.1 noexcept 作为说明符当 noexcept 修饰函数时它向编译器和调用者承诺“本函数绝不会向外抛出异常”。语法形式void f() noexcept; // 等同于 void f() noexcept(true); void f() noexcept(表达式); // 根据表达式结果决定是否为 noexcept核心规则函数承诺不向外抛异常。如果一个noexcept函数实际抛出了未被内部捕获的异常C会立即调用std::terminate()终止程序的执行这个过程不会进行正常的栈展开因此可能会导致资源泄漏。在C11及以后析构函数默认就是noexcept的不需要手动写。3.2 合规的两种写法要正确地使用 noexcept函数实现必须确保异常不逃逸。主要有两种方式1. 内部完全不抛出异常推荐这是最常见和推荐的做法。函数内部只调用其他noexcept函数或执行绝对不会失败的操作。// 一个简单的数学计算函数肯定不会抛异常 int add(int a, int b) noexcept { return a b; }2. 抛出异常但必须在函数内部完全捕获和处理如果在noexcept函数内部调用了可能抛出异常的代码必须使用try-catch块将所有异常捕获并消化掉绝不能让异常“逃逸”出去。// 一个可能会抛出异常的函数 void mightThrow() { throw 来自 mightThrow 的异常; } void safeWrapper() noexcept { try { mightThrow(); // 调用可能抛出异常的函数 } catch (const char* err) // 捕获所有异常并进行处理如记录日志 { // 异常在这里被成功捕获和处理 cout 捕获到异常并已处理: err endl; } // 函数正常结束履行了 noexcept 承诺 }3.3 noexcept 作为运算符noexcept还可以作为一个运算符去检测一个表达式是否会抛异常。noexcept (表达式)返回值true表达式保证不抛出异常函数声明为noexcept或throw()。false表达式可能抛出异常未声明异常规格或声明了throw(...)。代码示例void f1() { // 可能抛异常 } void f2() noexcept { // 保证不抛异常 } int main() { // 检测表达式会不会抛异常 cout boolalpha; //开启文字布尔值 cout noexcept(f1()) endl; // false cout noexcept(f2()) endl; // true cout noexcept(1 2) endl; // true基础运算保证不抛异常 return 0; }3.4 noexcept 的核心作用1、性能优化当编译器知道一个函数绝对不会抛出异常时它可以生成更高效、更紧凑的机器码。减少开销编译器不需要为该函数生成异常处理表Exception Tables和栈展开Stack Unwinding的代码这能减小二进制文件的体积并提高运行速度。启用移动语义关键场景这是noexcept最经典的应用。标准库容器如std::vector在扩容时为了保证强异常安全即操作要么完全成功要么回滚到原状态会检查元素的移动构造函数是否标记为noexcept。如果是noexcept容器会放心地使用移动操作效率极高。如果不是noexcept容器会认为移动操作可能失败为了防止移动过程中抛出异常导致数据丢失容器会退而求其次使用拷贝操作导致性能大幅下降。2. 异常安全与程序稳定性保护析构函数C 规定如果在栈展开即处理一个异常的过程中析构函数又抛出了新异常程序会直接崩溃。因此析构函数默认都是noexcept的。防止异常逃逸对于某些关键操作如swap交换函数如果抛出异常可能会导致数据状态不一致。标记为noexcept可以强制要求在函数内部处理所有潜在错误保证操作的原子性。3.5 noexcept 在STL 容器中的经典应用场景std::vector 等容器在扩容时会通过编译期类型检测在性能和异常安全之间做最优权衡。核心逻辑原理容器扩容的本质是分配新的更大内存块将旧元素迁移到新内存再释放旧内存。如果直接用移动构造效率极高但如果移动操作抛异常会导致旧元素已被破坏、新元素未完全构造数据状态损坏。如果全程用拷贝构造绝对安全如果中途抛异常原数据还在旧内存里完好无损但性能开销大尤其是大对象、大容器场景。vector 扩容时的元素迁移逻辑伪代码// vector扩容时的元素迁移大致逻辑 template typename T, typename Alloc void vector_uninitialized_copy(T* new_begin, T* new_end, T* old_begin, Alloc alloc) { T* dst new_begin; try { // 编译期判断如果T的移动构造是noexcept的或者T根本不能拷贝只能移动 if constexpr (std::is_nothrow_move_constructible_vT || !std::is_copy_constructible_vT) { // 移动构造高效 for (auto it old_begin; it ! new_end; it, dst) { alloc.construct(dst, std::move(*it)); } } else { // 拷贝构造移动可能抛异常为了安全降级为拷贝 for (auto it old_begin; it ! new_end; it, dst) { alloc.construct(dst, *it); } } } catch (...) { // 异常处理强异常安全保证回滚机制 // 发现异常撤销所有已完成的操作 for (auto it new_begin; it ! dst; it) { alloc.destroy(it); //销毁已构造对象 } alloc.deallocate(new_begin, new_end - new_begin);//释放分配的内存 throw; //重新抛出异常让上层处理 } }3.6 强烈建议使用 noexcept 的三大场景1. 移动构造函数和移动赋值运算符为了性能让容器敢于移动。2. 析构函数在 C11 及以后析构函数默认就是noexcept的。3. 交换函数 (swap) 为了强异常安全保证和原子性避免数据状态不一致。4. 重新抛出异常在异常传播的过程中我们经常需要做一些日志记录、资源清理或异常类型转换的处理然后把异常继续 “甩” 给上层调用者处理这就是异常的重新抛出。4.1 语法形式在catch块内部再次调用 throw即可。方式一原样重新抛出推荐try { // 可能抛异常的代码 } catch (const char* msg) { // 第一步本地处理如打印日志 cout 记录日志 msg endl; // 第二步重新抛出 throw; //空 throw它会将当前正在处理的同一个异常对象再次抛出保留原始类型和信息。 }方式二抛出一个新的异常异常包装/转换如果想把捕获到的异常替换成一个新的异常抛给上层直接写try { // ... } catch (int err) { // 把 int 类型的异常转换成一个标准的 runtime_error 抛出去 throw runtime_error(发生了整数异常 to_string(err)); }4.2 常见使用场景场景1异常日志记录在框架或中间层代码中我们可能无法处理具体的业务错误但需要记录错误的“踪迹”以便排查问题。void logAndRethrow() { try { // 调用可能抛异常的函数 riskyOperation(); } catch (...) { // 捕获所有异常 std::cerr [致命错误] 捕获到未知异常正在记录... std::endl; // 记录完日志后原样抛出让上层处理 throw; } }场景2包装异常异常类型转换底层抛出的是简单的错误码如int但上层业务逻辑需要统一的异常类如MyException。此时可以在中间层 “包装” 一下。try { …… // 可能抛 int 错误码 } catch (int errCode) { // 把简单的错误码包装成业务含义明确的异常对象再抛 throw DatabaseException(查询失败, errCode); }场景3局部资源清理虽然现代C推荐使用RAII智能指针来自动管理资源但在某些遗留代码或极端性能场景下可能需要手动管理资源。如果发生异常必须在抛出前释放资源否则会造成资源泄漏。void manualResourceManage() { int* data new int[100]; // 手动申请堆内存 std::mutex mtx; mtx.lock(); // 手动加锁 try { riskyOperation(); // 可能抛异常的操作 } catch (...) { // 异常发生手动回滚、释放资源 mtx.unlock(); delete[] data; // 清理完成后重新抛出异常通知调用者 throw; } // 正常流程操作成功后手动释放资源 mtx.unlock(); delete[] data; }5. 异常多态与标准库异常C语法上允许抛出任意类型的异常比如throw 1; 或 throw “error”;这在语言上属于过度灵活实际工程中几乎不会这样用甚至可以认为是一种设计上的宽松缺陷。在真实的项目开发中统一且规范的做法是只抛异常类对象且该类通常继承自标准库的std::exception基类。多态优势通过继承我们可以利用C的多态特性只用一个 catch(const std::exception e) 就能捕获所有派生类的异常无需为每种异常类型编写单独的catch块大幅简化异常处理逻辑。5.1 标准异常体系结构C 标准库在exception、stdexcept等头文件中定义了一套成熟的异常继承体系。工程实践中通常直接复用或继承这些类或者在此基础上进行扩展。关键函数所有标准异常的根类std::exception提供了统一的虚函数接口virtual const char* what() const noexcept;该函数用于返回异常描述信息的C风格字符串所有派生类都会重写该方法从而在捕获基类引用时能够正确地调用派生类的实现实现多态调用。标准异常继承层次图利用多态性来捕获标准库中的异常代码示例多态捕获void func() { std::vectorint vec { 1, 2, 3 }; try { // 场景1故意制造一个越界访问 (触发点at() 函数会检查边界这里会立即抛出 std::out_of_range) // vec.at(10) 4; // 场景2故意制造一个无效参数错误 (抛出 std::invalid_argument) throw invalid_argument(参数值无效); } catch (const std::exception e) { // 多态优势无论是 out_of_range 还是 invalid_argument它们都继承自std::exception因此都能被这里捕获 cout 捕获到标准异常: e.what() endl; } } int main() { func(); return 0; }5.2 自定义异常类真实项目中标准库的异常往往不够具体我们需要继承std::exception或其子类来定义自己的错误类型。1. 直接继承std::exception不推荐虽然理论上可以继承std::exception但这通常不是最佳选择。因为std::exception是一个非常基础的抽象类它没有成员变量不存储任何错误信息它的what()函数返回的通常是固定的字符串。如果直接继承它我们需要手动实现字符串的存储和what()函数的重写。代码示例class MyException : public exception { private: string msg; // 需要自己维护错误信息字符串 public: MyException(const string s) : msg(s) {} // 重写 what() const char* what() const noexcept override { return msg.c_str(); } }; void test() { throw MyException(自定义异常抛出); } int main() { try { test(); } catch (const exception e) //基类捕获 { cout e.what() endl; } catch (...) // 兜底捕获非标准异常 { cout 未知异常 endl; } return 0; }缺点分析这种做法不仅繁琐而且容易出错。我们需要自己管理std::string的生命周期确保c_str()返回的指针在what()被调用时依然有效。2. 继承std::runtime_error推荐std::runtime_error内部已经完整实现了what()方法并且拥有一个std::string成员变量来存储错误信息。只需在构造函数的初始化列表中调用父类的构造函数传入错误信息即可无需手动重写what()。此外我们还可以在自定义异常中添加额外的成员变量例如错误码以携带更多的上下文信息。// 1.定义基类业务异常,继承自 std::runtime_error class BusinessException : public std::runtime_error { private: int errCode_; // 扩展功能 自定义成员错误码 public: // 构造函数初始化父类错误信息和子类错误码 BusinessException(const string msg, int code) : runtime_error(msg) , errCode_(code) {} // 获取错误码的接口 int GetCode() const noexcept { return errCode_; } }; // 2.定义具体的派生异常 // 数据库相关异常 class DatabaseException : public BusinessException { public: DatabaseException(const string sqlState) : BusinessException(数据库错误 [ sqlState ], 1001) {} }; // 网络相关异常 class NetworkException : public BusinessException { public: NetworkException(const std::string reason) : BusinessException(网络错误: reason, 2002) {} }; // 3.业务函数模拟抛出异常 void queryDatabase() { throw DatabaseException(连接超时); } // 4.主函数异常捕获与处理 int main() { try { queryDatabase(); } // 捕获顺序先捕获子类再捕获基类 // 捕获具体的自定义业务异常 catch (const BusinessException e) { cout 捕获业务异常 endl; cout 错误码: e.GetCode() endl; cout 信息: e.what() endl; } // 捕获其他标准异常非业务异常 catch (const exception e) { cout 标准异常: e.what() endl; } // 捕获所有非标准异常兜底 catch (...) { cout 未知异常 endl; } return 0; }

更多文章