文章目录
- 异常处理
- 基本概念
- 定义
- 关键字
- 用法
- 代码块
- 代码块
- C++标准异常类
- 层次结构
- 标准异常类层次结构
- 示例
- 自定义异常类
- 示例
- noexcept
- 验证函数是否 noexcept?
- 什么时候使用 noexcept?
- 异常安全性
- 异常跨函数传播示例
- Copy-and-Swap 惯用法与异常安全
- 基本模式
- 异常安全流程拆解
- 关键要点
首先,effective c++中条款第29提到:请记住:
1. 异常安全函数 (Exception-safe functions)即使发生异常也不会泄露资源或允许任何数据类型败坏。这样的函数区分为三种可能的保证:基本型, 强烈型, 不抛异常型
2. “强烈保证” 往往能够以 copy-and-swap 实现出来, 但 “强烈保证” 并非对所有函数都可实现或具备现实意义
3. 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的 “异常安全保证”中的最弱者
我需要告诉你,本篇甚至大部分的异常处理都围绕基本型异常。
强烈型异常基本就用copy-and-swap,见文章最后即可
不抛异常很简单,就是noexcept关键字。详细见下文
(注意,别小看以上内容,如果你要真正成为一名程序员,一定需要先成为一位异常处理大师)
本系列所有内容都如同本篇一样优秀喔!欢迎点赞关注!!
异常处理
异常处理是一项重要的特性,它允许程序在运行时遇到错误条件时能够优雅地恢复或终止执行,而不是简单地崩溃。
C++通过try、catch和throw三个关键字来实现异常处理机制。本文会详细介绍。
基本概念
定义
异常是指在程序执行过程中发生的、不符合程序正常流程的事件。在C++中,异常通常是由于某些错误条件触发的,如除以零、数组越界、内存分配失败等。
关键字
- try:用于标记可能会抛出异常的代码块,称为保护代码。
- throw:当检测到异常条件时,使用throw关键字抛出一个异常。throw后面可以跟任意表达式,它的类型决定了抛出的异常类型。
- catch:用于捕获并处理异常。catch块紧跟在try块之后,并指定了要捕获的异常类型。
用法
下面是一个简单的例子,演示了如何使用try、throw和catch来处理除以零的异常:
代码块
#include<iostream>usingnamespacestd;doubledivision(inta,intb){if(b==0){throw"Division by zero!";// 抛出异常}return(a/b);}intmain(){intx=50;inty=0;doublez=0;try{z=division(x,y);// 可能抛出异常的代码cout<<z<<endl;}catch(constchar*msg){// 捕获并处理异常cerr<<msg<<endl;}return0;}在这个例子中,如果y为零,division函数将抛出一个字符串异常,该异常在main函数的catch块中被捕获并处理。
一个try块可以跟随多个catch块,来捕获多种不同类型的异常:
代码块
#include<iostream>#include<stdexcept>// 包含标准异常类usingnamespacestd;// 为了演示方便,项目中不建议这样使用voidtestFunction(){throwruntime_error("Runtime error occurred!");}intmain(){try{testFunction();// 可能抛出异常的函数}catch(constlogic_error&e){cout<<"Caught a logic_error: "<<e.what()<<endl;}catch(construntime_error&e){cout<<"Caught a runtime_error: "<<e.what()<<endl;}catch(...){// 捕获所有其他类型的异常cout<<"Caught an unknown exception"<<endl;}return0;}testFunction抛出了一个runtime_error异常,该异常在第二个catch块中被捕获并处理。最后一个catch块使用省略号…来捕获所有其他类型的异常。
C++标准异常类
C++标准库提供了一系列预定义的异常类,这些类都继承自std::exception基类。使用标准异常类可以使代码更加清晰。
层次结构
如图
标准异常类层次结构
- std::exception:所有标准异常的基类。
- std::bad_alloc:内存分配失败时抛出。
- std::bad_cast:动态类型转换失败时抛出。
- std::bad_typeid:使用 typeid 运算符失败时抛出。
- std::bad_exception:在函数声明中使用了异常规格,但抛出了未列出的异常时抛出 (C++11已弃用)。
- std::logic_error:逻辑错误异常基类,包括:
- std::domain_error:数学域错误,如sqrt(-1)。
- std::invalid_argument:无效参数错误。
- std::length_error:超出允许长度的错误。
- std::out_of_range:范围错误,如访问vector的非法索引。
- std::runtime_error:运行时错误异常基类,包括:
- std::overflow_error:上溢错误。
- std::range_error:范围错误(与 std::out_of_range 不同,用于其他情况)。
- std::underflow_error:下溢错误。
示例
#include<iostream>#include<stdexcept>usingnamespacestd;voidtestLogicError(){throwinvalid_argument("Invalid argument error!");}voidtestRuntimeError(){throwrange_error("range error!");}intmain(){try{testLogicError();// 抛出逻辑错误异常}catch(constlogic_error&e){cout<<"Caught a logic_error: "<<e.what()<<endl;}try{testRuntimeError();// 抛出运行时错误异常}catch(construntime_error&e){cout<<"Caught a runtime_error: "<<e.what()<<endl;}return0;}testLogicError 函数抛出了一个 invalid_argument 异常,而 testRuntimeError 函数抛出了一个 range_error 异常。这两个异常分别在对应的 catch 块中被捕获并处理。
自定义异常类
虽然C++标准库提供了丰富的异常类,但在某些情况下,开发者可能需要定义自己的异常类。我们可以通过继承 std::exception 基类并重载 what 方法来实现。
示例
#include<iostream>#include<exception>#include<string>usingnamespacestd;classMyException:publicexception{public:MyException(conststring&message):message_(message){}virtualconstchar*what()constnoexceptoverride{returnmessage_.c_str();}private:string message_;};voidtestCustomException(){throwMyException("Custom exception occurred!");}intmain(){try{testCustomException();// 抛出自定义异常}catch(constMyException&e){cout<<"Caught a MyException: "<<e.what()<<endl;}catch(constexception&e){cout<<"Caught an unknown exception: "<<e.what()<<endl;}return0;}MyException 类继承自 std::exception 并重载了 what 方法。testCustomException 函数抛出了一个 MyException 异常,该异常在 main 函数的 catch 块中被捕获并处理。
注意,这里还添加了一个捕获所有其他 std::exception 子类的 catch 块,确保能够捕获所有未知异常。
noexcept
从C++11开始,推荐使用noexcept关键字来声明函数不抛出任何异常:
voidfunc()noexcept;// 声明函数不抛出任何异常如果func函数在执行过程中抛出了异常,程序会直接终止。noexcept关键字还可以用于提高性能,因为编译器可以优化不抛出异常的函数调用。
如果你看过 gcc 源码,你会发现,基本上通篇都是noexcept。
noexcept会告诉编译器,它修饰的函数不会产生异常(exception),这有利于编译器做更多的优化。
C++ 的异常处理是在运行时检测的,而不是在编译时检测,为了运行时检测,编译器应该会做些额外的操作,如果能够通过noexcept明确的告诉编译器这个函数不会抛出异常,编译器应该会做一些优化。
验证函数是否 noexcept?
noexcept还可以当作运算符,它可以传入参数,来验证某个函数是否是noexcept:
voidmay_throw();voidno_throw()noexcept;intmain(){noexcept(may_throw());// falsenoexcept(no_throw());// true}什么时候使用 noexcept?
- 移动构造函数,移动赋值函数,建议使用
noexcept修饰,因为搭配标准库使用时,noexcept作用巨大,它可以优先移动而非拷贝,推荐阅读 🔗move_if_noexcept。 - 而析构函数默认就是
noexcept的,不需要显式指定noexcept。 - 在明确确认某个函数不会产生 exception 时,可以使用
noexcept。
推荐看看这几个noexcept相关的文档:
- https://www.cnblogs.com/sword03/p/10020344.html
- https://en.cppreference.com/w/cpp/language/noexcept_spec
- https://en.cppreference.com/w/cpp/language/noexcept
- https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#Re-noexcept
- https://stackoverflow.com/questions/10787766/when-should-i-really-use-noexcept
- https://www.heise.de/blog/C-Core-Guidelines-Der-noexcept-Spezifier-und-Operator-4121657.html
异常安全性
异常安全性是指程序在遇到异常时仍然能够保持正确的状态,不会出现资源泄露或者数据不一致等问题。
因为C++允许程序在执行过程中抛出异常,这可能导致程序的控制流发生变化,如果资源管理不当,就可能出现资源泄露或程序崩溃等问题。
异常跨函数传播示例
有些场景下,通过异常处理错误会更方便,特别是跨函数传播的时候。
场景描述:图像处理流水线,假设我们有一个图像处理系统,分为三个独立编译的模块(可能是不同团队开发的库):
- 底层库:
ImageDecoder(第三方库,负责解码图像文件)。 - 中间层:
ImageProcessor(内部库,负责图像增强)。 - 应用层:
MainApp(主程序,处理用户请求)。
当底层库解码失败时,异常会穿过中间层,直达应用层处理。
简易代码:
// 底层库, 解码classImageDecoder{staticvoiddecode(){throwImageException("decode error");}};// 中间层, 图像处理voidImageProcess(){ImageDecoder::decode();}// 应用层intmain(){try{ImageProcess();}catch(ImageException e){xxx}catch(...){}return0;}Copy-and-Swap 惯用法与异常安全
核心思想:以拷贝构造 + 非抛出交换实现赋值操作,利用 RAII 自动管理资源,确保赋值要么完全成功,要么原对象不变(强异常安全保证)。
基本模式
classBuffer{int*data_;size_t size_;// 私有辅助:非抛出交换friendvoidswap(Buffer&a,Buffer&b)noexcept{usingstd::swap;swap(a.data_,b.data_);swap(a.size_,b.size_);}public:Buffer(size_t n):data_(newint[n]),size_(n){}// 拷贝构造:可能抛异常(new),但这是新对象,没问题Buffer(constBuffer&other):data_(newint[other.size_]),size_(other.size_){std::copy(other.data_,other.data_+other.size_,data_);}// 移动构造Buffer(Buffer&&other)noexcept:data_(other.data_),size_(other.size_){other.data_=nullptr;other.size_=0;}// 析构函数~Buffer(){delete[]data_;}// 拷贝赋值:copy-and-swap 的核心Buffer&operator=(constBuffer&other){// 1. 拷贝构造一个临时对象(可能抛异常)// 如果抛异常,this 原封不动 → 强异常安全Buffertmp(other);// 2. 非抛出交换swap(*this,tmp);// 3. tmp 析构释放旧资源return*this;}// 移动赋值(也可以复用 copy-and-swap)Buffer&operator=(Buffer&&other)noexcept{swap(*this,other);return*this;}};异常安全流程拆解
| 步骤 | 操作 | 若抛异常 | 为什么安全 |
|---|---|---|---|
| 1 | Buffer tmp(other)拷贝构造 | 抛出 →tmp未构造完毕,this不变 | 拷贝在独立对象上进行 |
| 2 | swap(*this, tmp)交换指针 | noexcept,不会抛 | 只用noexcept操作交换资源所有权 |
| 3 | tmp析构 | 析构不抛 | tmp现在持有旧资源,自动释放 |
关键要点
swap必须noexcept— 这是整个惯用法的前提。如果swap可能抛,就失去了强异常保证。参数传值 vs 传引用— 可以用传值代替传 const 引用 + 临时构造,编译器可以优化:
// 简写:传值方式(copy-and-swap 的另一种写法)Buffer&operator=(Buffer other)noexcept{swap(*this,other);return*this;}这种写法同时支持拷贝赋值和移动赋值(传右值时调用移动构造)。
自赋值安全— copy-and-swap 天然处理自赋值,因为拷贝构造了独立副本,交换后再释放旧资源。
性能权衡:每次赋值都分配新内存,对于频繁赋值的场景可能不划算。如果性能敏感,可以用copy-on-write或先判断资源是否足够复用。