• 【读书笔记】【More Effective C++】异常(Exceptions)


    条款 9:利用 destructors 避免泄露资源

    • 问题的提出:使用指针时,如果在 delete 指针之前产生异常,将会导致不能删除指针,从而产生资源泄漏。【无法释放 heap 中数据】
      class Animal {
        public:
          virtual void processAdoption() = 0;
      }
      class Cat : public Animal {
        public:
          virtual void processAdoption();
      }
      class Dog : public Animal {
        public:
          virtual void processAdoption();
      }
      
      void batchAdoption(istream& dataSource) {
        while (dataSource) {
          Animal *animal = readAnimal(dataSource); // 可能抛出异常
          animal->processAdoption();  // 可能抛出异常
          delete animal;
        }
      }
      // 在上面的batchAdoption()方法中
      // readAnimal()和 processAdoption() 都可能抛出异常
      // 程序中断,从而导致delete animal无法执行,内存泄漏发生。
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
    • 有两种解决方案:
      • 第一种:利用异常捕获,即 try、catch。【缺点就是代码比较冗余】
        void batchAdoption(istream& dataSource) {
          while (dataSource) {
            Animal *animal = readAnimal(dataSource); // 不能放入try中,否则animal对外部不可见
            try {
              animal->processAdoption();
            } catch (...) {
              delete animal;  // 代码冗余
              throw;
            }
            delete animal;  // 代码冗余
          }
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
      • 第二种:使用对象封装资源(把资源封装在对象内)。【用类似指针的对象来取代指针,即智能指针】
        void batchAdoption(istream& dataSource) {
          while (dataSource) {
            auto_ptr animal(readAnimal(dataSource));
            animal->processAdoption();
            // 无需调用语句delete animal,出了作用域即调用析构函数
          }
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
    • 总结:
      • 使用对象封装资源,如使用 auto_ptr,使得资源能够自动被释放。
      • 智能指针的核心思想:以一个对象存放必须自动释放的资源,并依赖该对象的析构函数释放资源。
      • 用了智能指针,即使函数内抛出异常,资源仍然会得到释放。

    条款 10:在 constructors 内阻止资源泄露(resource leak)

    • 条款 9 针对的问题是:假若在函数被调用的情况下发生异常,heap 中资源将无法被释放,导致内存泄漏问题发生。
    • 而本条款针对的问题是:当类中需要包含多个 heap 对象,但是在构造函数中出现异常的情况下,如何释放掉已经创建的 heap 对象
      • 即在构造函数中,先 new A,再 new B,然而在 new B 的过程中出现异常,此时 new A 指向的内存就会出现内存泄漏。
      • 出现内存泄漏的原因是:C++ 只会析构已构造完成的对象,对象只有在其 constructor 执行完毕才算是完全构造妥当。【即在前面提及的场合下,class A 的析构函数不会被调用】
    • 在前面描述的问题中,可以如同上一条款提及的指导思路一样:先考虑异常捕获(即 try、catch)。
      • 但它也有问题:带来了重复的代码(因为需要对每个在 heap 中的内存资源进行记录,即 try catch 每一个资源)。
      • 可以提取出重复代码,将其包装到一个函数中去(即 try catch 创建所有资源的过程,然后在 catch 中调用共同的操作)。
    • try…catch 方法有一个致命的弱点:
      • 如果某一个变量声明为 const 类型,就不得不将其初始化动作放到初始化列表(member initialization list)中,我们就无法像前面说的那样使用 try…catch 来捕捉异常,依然会造成内存资源泄漏。
    • 如果这些 heap 中变量本身就是 const(如下面代码所示,image 和 audio 均是常量指针,只能放在 member initialization list 中初始化),则可以采用下面这个方案:【可以解决问题,但是代码不简洁】
      BookEntry::BookEntry(const string &name, const string& address,
                  const string& image, const string& audio) 
          : m_name(name),  // name 是非指针变量
          m_address(address),  // address 是非指针变量
          m_image(createImage(image)),  // image 和 audio 均是常量(const)指针
          m_audio(createAudio(audio)) {
      }
      Image* createImage(const std::string& image) { // image 在 audio 面前初始化,所以不需要捕捉异常
        if(image != "") return new Image(image);
        else return 0;
      }
      Audio* createAudio(const std::string& audio) { // audio 第二个初始化,所以捕捉异常到异常后需要释放 image 对象
        try {
          if(audio != "") return new Audio(audio);
          else return 0;
        } catch () {
          delete m_image;  // 捕捉到异常需要释放已经初始化的 image 对象
          throw;
        }
      }
      // 可以看到针对m_image变量定义了构建函数
      // 在heap中创建变量的工作放到这个构建函数中,并返回创建好的指针
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
    • 当然,最合适的解决方案仍是利用对象来封装资源的指导思想:
      • 就像上一个条款建议的,我们直接将指针包裹到 auto_ptr 中(将前面的 image 指针和 audio 指针改为被智能指针包裹即可),利用作用域生命周期来控制具体的行为。

    条款 11:禁止异常(exceptions)流出 desrtuctors 之外

    • 析构函数会在下面两种情况下被调用:
      • 对象在正常情况下被销毁,即离开对象所在作用域或者主动销毁,对象生命周期终结,析构函数被调用,对象被销毁;
      • 异常抛出引起了栈展开(stack-unwinding),析构函数会被调用,简单地来说,异常会造成析构函数被调用。【这种情况说明了:调用析构函数的时候可能正存在着异常,但析构函数无法区分是否有异常】
    • 栈展开的相关机制有兴趣可参考相关内容,但本条款的关键在于:在进行栈展开过程中(此时说明已有一个异常),析构函数若再次抛另一个异常,则会导致标准库函数 terminate 被调用,terminate 函数调用 abort 函数,程序异常退出。【简单来说,二次异常抛出,析构函数直接中止,不仅销毁过程未做完就结束了,整个程序都会中止】
    • 因此,析构函数应该从不抛出异常,其解决方法是:在析构函数中使用 try-catch 块屏蔽所有异常,直接拦截析构函数中抛出的异常,保证不会有更多的异常向上传递:【注意 catch 语句中什么都不可以处理,因为处理语句也有可能抛出异常,这就使得局面又回到原点】
      ~Destructor() {
        	try {
        		doSomething();
        	} catch (...) {
        		// doNothing, avoid more exception
        	}
        }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    • 总结如下,若析构函数抛出异常,有两种危害:
      1. 异常点之后的语句无法完成,析构工作没有完成。
      2. 有可能是栈展开调用析构函数,可能出现两次异常抛出导致程序终结的情况。

    条款 12:了解“抛出一个exception”与“传递一个参数”或“调用一个虚函数”之间的差异

    • 本条款旨在介绍异常处理的细节。
    • 首先要理解,抛出异常与函数调用有许多类似的地方:
      1. 某个类对象被接受;
      2. 被接受的类对象可以选择不同的接收端,从而实现多态;
      3. 可以通过 by-value、by-reference 和 by-pointer 三种方式其中一种来传递类对象。【即 catch 语句的参数可以是值、引用或指针】
    • 但无论是通过什么方式来传递对象,被抛出的对象总是一个副本,这样做保证了,catch捕获的对象总能存在:
      • 即每当抛出 exception 时候,exception 总会被复制,然后复制的副本会被传递给 catch 子句。【从另外一个角度来说,无论 catch 语句对接收到的对象进行了什么处理,均是对副本的处理,不会影响原来的对象】
      • 相关示例如下:
        // 示例1:抛出的异常为局部变量
        void passThrowWidget() {
          Widget widget;
          doSomething(widget);
          
          // 抛出的对象是widget的一个副本
          // 当前作用域的widget在离开本函数时已经被销毁
          throw widget; 
        }
        
        // 示例2:抛出的异常为静态局部变量
        void passThrowWidget() {
          static Widget widget;
          doSomething(widget);
          // 尽管本函数内的widget不会被销毁,但是抛出的widget依然是一个副本
          throw widget;
        }
        // 无论原对象以什么形式定义,抛出的对象总是一个副本。
        // 这样做保证了,catch捕获的对象总能存在,
        // 否则可能导致捕获的异常对象已经被销毁。
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
        • 15
        • 16
        • 17
        • 18
        • 19
        • 20
    • 还有另一个需要注意的:被抛出的异常对象会调用其复制构造函数,复制构造函数以静态类型为模板创建。【即以静态绑定的类型为准】
      • 实例代码如下:
        class Widget; // 基类
        class ChildWidget : public Widget { // 派生类
        }
        
        void passThrowWidget() {
          ChildWidget child; // ChildWidget 是 child 的类型
          Widget &widget = child;  // Widget 是 widget 的静态类型
          throw widget;  // 调用 Widget 的复制构造函数进行复制,而不是 ChildWidget
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
      • 此时考虑在 catch 语句块内传播异常
        catch (Widget& w) // 方案一:不复制异常,而是直接抛出当前的异常
        {
          // ...
          throw; // 重新抛出当前的异常,不管 w 的动态类型是什么,最后都可以得到保证
        }
        
        catch (Widget& w) // 方案二:复制后抛出
        {
          // ...
          throw w; // 抛出当前的异常的副本,相当于新的异常,且副本只保留了原对象静态类型
        }
        // 方案二带来的问题是:
          // 复制操作带来的开销
          // 复制行为是基于静态类型的拷贝,因此传递抛出的对象可能不是原来想要传递的对象
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
    • catch 效率:【在这部分讨论了异常通过值、引用来传递】
      • 前面说了,传入 catch 的异常有如下三种形式:【后面再讨论 by pointer 的方式捕捉异常】
        1. catch (Widget w)【by value 方式捕捉】
        2. catch (Widget &w)【by reference 方式捕捉】
        3. catch (const Widget &w)【by reference-to-const 方式捕捉】
      • 这里就有一个地方展现了函数调用与异常捕获的不一样:
        • 前面说过了被抛出的异常对象是一个拷贝后的临时对象,则说明 catch 语句可以通过 reference-to-const 方式捕捉一个临时对象;
        • 而函数调用是不允许的,不能对传入函数中的临时变量进行修改,即无法把临时对象传递给一个 non-const reference。【对于函数调用而言,接受临时变量的函数参数只能是上面的 by value 形式和 by reference-to-const 形式】
      • 回到异常抛出,如果采用第 1 种方式 catch 异常(值传递),则抛出异常将会被复制两次:第一次是在抛出时,第二次是在 catch 时。【所以高效做法是采用引用的方式(第 2、3 种方式)捕获异常】
    • 接下来要讨论的是 catch 以 by pointer 的形式捕捉异常:
      • 指针也可以当作异常被接受,与上面复制类道理相同,抛出异常时,指针将会被复制。
      • 由于离开作用域后,局部变量会被销毁,因此不能抛出一个局部变量的指针
    • 异常与类型吻合(type match)的关系:
      • 函数调用的参数是允许隐式转换的,如 int 转为 double;而异常捕获的参数是不允许前述的基本隐式转换,即对 int 异常的抛出不会被捕获 double 异常的 catch 语句捕获到。
    • 但是异常机制又允许另外两种转换:
      1. 继承体系中的异常转换:针对基类异常的 catch 子句,可以处理继承类的异常。【该规则使用于 by value、by reference 和 by pointer 三种形式】
      2. 允许从有型指针到无型指针的转换。【即 catch (const void*) 可以捕捉任意指针类型的异常】
    • 最后要提及的一点不同是:
      • try 语句后可能会跟着多个不同的 catch 语句,而 catch 语句的匹配总是按照顺序而进行的,即 catch 语句实行最先匹配策略。【与对象调用虚函数的动作比较,会实行最佳匹配策略,被调用的函数是与对象类型最佳匹配的函数】
      • 实例如下所示:
        try{}
        catch (base& ex){
          // ...
        }
        catch (derived& ex){ // 这个语句永远不会被执行,因为所有针对继承类的异常都被前面的语句捕获了
          // ...
        } // 要想该语句被执行,只能将该语句移到 catch(base& ex) 的前面去
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
    • 最后总结,函数调用和异常抛出的区别如下:
      1. 异常对象总是会被复制,如果以 by value 方式捕捉,甚至会被复制两次。
      2. 异常抛出的对象允许的类型转换动作不多,不支持基本的类型转换。
      3. catch 子句实行最先匹配策略,以出现的顺序来进行匹配操作,第一个匹配成功者便执行。

    条款 13:以 by reference 方式捕捉 exceptions

    • 本条款其实已经包含在前一条款内,但单独提出,介绍异常通过引用来捕获的好处。
    • 异常指针传递带来的麻烦:【讨论为什么不推荐指针传递异常】
      • 指针传递如下所示:
        void func() {
          Widget error; // 形式1
          static Widget errorStatic; // 形式2
          Widget *errorHeap = new Widget; // 形式3
          
          throw &error; // 形式1
          throw &errorStatic; // 形式2
          throw errorHeap; // 形式3
        }
        catch (Widget *widget) {
          // ...
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
      • 三种形式的缺点如下:
        • 形式1:一旦离开局部变量的作用域,局部变量就会被销毁,此种方式是错误的。
        • 形式2:无法时刻谨记,同时长期保存一个函数内的局部变量会带来很大空间开销。
        • 形式3:外界需要维护指针,当指针用完后,就需要外界调用delete来销毁,维护成本过高。
    • 异常对象值传递带来的麻烦:【讨论为什么不推荐值传递】
      • 值传递也存在两个问题:
        1. 复制两次异常对象,抛出异常对象一次,catch 对象时又被复制一次。
        2. 不能使用虚函数实现多态。【对象切割问题】
      • 实例如下所示:
        // 问题一的体现:
        void func() {
          Widget widget;
          throw widget; // 第一次复制
        }
        catch (Widget widget) { // 第二次复制
        }
        
        // 问题二的体现:
        class exception {
          public:
            virtual const char *what() throw(); // ”throw()“关键字声明该函数不会抛出任何异常
        }
        class DerivedException : public exception {
          public:
            virtual const char *what() throw(); // 虚函数实现多态
        }
        void func() {
          DerivedException widget;
          throw widget; // 的确是抛出了派生类的异常,但是捕获函数中会将其切割为基类,随后就调用了基类的 what 函数
        }
        catch (exception widget) { // 捕捉继承体系里的所有异常
          widget.what(); // 调用的是exception::what(),这种情况叫做slicing(切割),即子类信息被切割掉,只留下基类的信息
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
        • 15
        • 16
        • 17
        • 18
        • 19
        • 20
        • 21
        • 22
        • 23
        • 24
    • 异常引用传递带来的好处:
      • 虽然抛出异常时的复制无法避免,但是 catch 时采用引用方式,因此可以避免第二次复制异常,最后总共复制了一次异常对象。
      • 同时,引用使得我们可以顺利调用虚函数,实现多态,实例如下所示:
        void func() {
          DerivedException widget;
          throw widget;
        }
        catch (exception& widget) {
          widget.what(); // 调用的是DerivedException的what,实现了多态
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7

    条款 14:明智运用 exception specifications

    • 所谓的异常说明,指的是明确指出一个函数可以抛出什么样的异常。【一般就不要用,不用就表明函数可以产生任意的异常】
      • 标识符 throw 即为异常限定符,异常限定符标识了函数可以抛出的异常类型。
      • 当 throw 后面的括号内容为空,即 throw(),则表示该函数不抛出任何异常。
      • void f2() throw(int); 表示 f2 只抛出类型为 int 的异常。
    • 如果函数抛出一个未列于其 exception specification 的异常,这个错误将会在运行期被检测出来,于是特殊函数 unexpected 会被自动调用。
      • 紧接着的调用链为 unexpected() -> terminate() -> abort(),因此程序如果违反异常生命,缺省结果就是程序被中止。
    • 下面将会讨论如何避免 unexpected 函数被调用:【编译器不会阻止情况发生,程序员需要自己避免异常声明不被打破】
      • 方法一,避免将异常声明放在需要型别自变量的 templates 身上;
        template<class T>
        bool operator==(const T& left, const T& right) throw() { // 这样是一种不好的做法
          return &left == &right;
        }
        // 我们无法确定,取地址操作符“&”是否已经被重载,且可能抛出异常。
        // 此种情况的实质是,我们无法确定,所有类对象的同名函数都不会抛出异常。
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
      • 方法二,外层函数不使用 throw() 进行修饰:如果函数 A 内调用了函数 B,而函数 B 无 exception specification,那么 A 函数本身也不要设定 exception specification。【内部允许产生所有异常,外部自然也不要加以限制】
        • 容易被忽略的情况是注册回调函数:【如下面代码所示,makeCallBack 函数不应该异常声明,因为没有任何办法清楚注册来的 func 指针可能抛出什么异常】
          typedef void (*CallbackPtr)();
          class Callback {
            public:
              Callback(CallbackPtr func) : m_func(func) {}
              void makeCallBack() throw() { // 这里的异常声明很容易带来问题
                m_func(); // 可能抛出异常
              }
            private:
              CallbackPtr m_func;
          }
          // 如代码所示,如果注册的“回调函数”没有throw修饰,
          // 而调用“回调函数”的外层函数却有throw修饰,
          // “回调函数”抛出异常就会引起程序终止。
          
          • 1
          • 2
          • 3
          • 4
          • 5
          • 6
          • 7
          • 8
          • 9
          • 10
          • 11
          • 12
          • 13
      • 方法三,处理系统可能抛出的 exceptions;如果无法处理,可以自定义 unexpected 函数。【该节后续部分不展开了,看不懂】
        • C++ 提供了函数 set_unexpected(),可以向该函数中传递我们自定义的函数,来替换默认的 unexpected()

    条款 15:了解异常处理(exception handling)的成本

    • 为了能够在运行期处理 exceptions,程序必须做大量的簿记工作。
      • 在每一个执行点,它们必须能够确认如果发送 exception,哪些对象需要析构;
      • 它们必须在每一个 try 语句块的进入点和离开点做记号;
      • 针对每个 try 语句块它们必须记录对应的 catch 子句以及能够处理的 exceptions 型别。
  • 相关阅读:
    Python中的三种推导式及用法
    【React 源码】(五)React 应用的启动过程
    go 流程控制之switch 语句介绍
    案例 | 美创助力镇海区大数据发展管理中心构建“数改”安全防护力
    6 获取AOE网的关键路径--来源王英S同学
    6、Nacos服务多级存储模型
    [排序算法] 快速排序 (C++) (含三种写法)
    1_SpringMVC_概述,2_SpringMVC_项目搭建
    blender安装cats-blender-plugin-0-19-0插件,导入pmx三维模型
    9.ClickHouse系列之数据一致性保证
  • 原文地址:https://blog.csdn.net/weixin_44705592/article/details/126552809