Table of Contents

异常保证

异常安全函数必须提供以下三个保证之一

  1. 基本承诺

    如果异常被抛出,程序内的任何事物仍然保持在有效状态下,即所有对象处于一种内部前后一致的状态。 然而程序的现实状态不可预料。 如:我们可以使得changeBackground一旦有异常被抛出,PrettyMenu对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况(除非调用某个成员函数)。 如果调用一个只提供基本承诺的函数,而真的出现异常,程序有可能处于任何状态——只要那个状态合法。

  2. 强烈保证

    如果异常被抛出,程序状态不改变。调用这样的函数需要有这样的认知:如果函数成功,就完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。

  3. 不抛掷 (nothrow) 保证

    承诺不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型身上的所有操作都提供该保证。

异常安全必须提供上述三个保证之一。如果他不这样做,它就不具备异常安全性。

一般而言你应该会想提供可实施之最强保证。但是nothrow在实际中往往做不到,所以对大部分函数而言,抉择往往落在基本保证和强烈保证之间。

有个一般化的设计策略很典型地会导致强烈保证,很值得熟悉它。这个策略被称为 copy and swap。 原则很简单: 为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改抛出异常,原对象仍保持未改变状态。待所有改变成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换。

实现上通常是将所有“录属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(即副本)。这种手法被称为pimpl idiom。

“copy and swap”策略还有效率的问题,因为需要创建副本。所以强烈保证不一定时时刻刻都是实际的,如果不行,你应该提供“基本保证”,况且对许多函数而言,”基本保证“绝对是一个通情达理的选择。

注意

  1. 如果调用的函数没有提供任何异常安全性保证,则该函数本身也不可能提供任何保证。
  2. 如果系统内有一个函数不具备异常安全性,整个系统就不具备异常安全性。

让你自己的代码具备异常安全性:

  1. 以对象管理资源;
  2. 挑选三个”异常安全性“中的某一个实施于你所写的每一个函数身上。 你应该挑选”显示可操作“条件的最强烈等级。只有当你的函数调用了传统代码,才别无选择地将他设为”无任何保证“。 函数的”异常安全性保证“是其可见接口的一部分,所以你应该慎重选择,就像选择函数接口的其他任何部分一样。

总结

  1. 异常安全函数即使发生异常也不会泄露资源或允许任何数据败坏。这样的函数区分为三种可能的保证:基本型、强烈性、不抛出异常型;
  2. ”强烈保证“往往能够以 copy and swap 实现出来,但”强烈保证“并非所有函数都和实现或具备现实意义;
  3. 函数提供的”异常安全保证“通常最高只等于其所调用之各个函数的”异常安全性“中的最弱者。

绝对不要将异常抛出析构函数

这一条在《Effective C++》 《More Effective C++》中均被作为独立章节讲解,可见其重要性。

有一点不要误解: 析构函数的代码当然可以 throw 异常,只是这个异常不要被抛出析构函数之外。 意思就是如果在析构函数中 catch 住异常,并且不再抛出,这就不会带来问题。

至于原因,有两点。我们先看第一点。

  • 异常被抛出析构函数之外,往往意味着析构函数的工作没有做完。 如果析构函数需要释放一些资源,异常可能导致资源泄露,使得程序处于一个不安全的状态
  • 如果两个异常同时存在: 第一个异常还没有被 catch,第二个异常又被抛出,这会导致 C++会调用 terminate 函数,把程序结束掉

第一个原因示例:

class A {
public:
~A() {
    throw exception;
    free(p); // not executed, resource leaks
};

第二个原因示例:

class A {
public:
    ~A() {
        free(p);
        throw exception; // no memory leak
   }
};

void f() {
    A a;
    throw exception;
}

f() 抛出异常后,会进行 stack-unwinding。 在这个过程中,会析构所有的 active local object。 所谓 active local object,就是已经构造完成的局部对象,例如上面的对象 a。 调用 a 的析构函数时,(第一个) 异常还没有被 catch。可是 a 的析构函数也抛出了 (第二个) 异常。这时,两个异常同时存在了。程序会立即结束。

异常与构造函数

构造函数本来就是一件难以琢磨的东东,背后做了很多事情: 成员对象的构造、基类成分的构造、虚表指针的设置等。 这些事情本来就很纠结了,再让构造函数抛出异常,会出现怎样的悲剧呢?

有一点比较安慰: 异常即使被抛出构造函数之外,也不会造成程序结束。那么,是否存在资源泄漏的问题呢?不可一概而论,我们分情况分析。

对象自身的内存如何释放

对象有可能在栈上,也可能在堆上,我们分两种情况讨论。

// 对象在栈上
f() {
    A a;
}
// 对象在堆上
f() {
    A * a = new A();
}

如果对象是在栈上,那么函数退栈自然会释放a占用的空间,无需多虑。

如果对象是在堆上,我们还得两种情况讨论:

  1. 如果是new运算符抛出的异常,那么堆空间还没有分配成功,也就无需释放
  2. 如果是构造函数抛出的异常,堆空间已经分配成功,那么编译器会负责释放堆空间 (Inside The C++ Object Model, p301)

可见,对象本身的内存,是不会泄露的。

成员对象和基类成分怎么办

成员对象和基类成分的内存,会随着对象自身内存的释放而被一起释放,没什么问题。

但是,有一点需要谨记: 如果一个对象的构造函数抛出异常,那么该对象的析构函数不会被调用。

原因很简单: 如果对象没有被构造完整,析构函数中的某些代码可能会有风险。为了避免这类意外问题,编译器拒绝生成调用析构函数的代码。

那么,成员对象的基类成员对象的析构函数,会被调用吗?如果不会调用,则可能出现资源泄漏。答案是,会被调用。见下面的代码。

class B : class C {
    A a;
    A * pa;
public:
    B() {
        pa = new A();
    }
    ~B() {
        delete pa;
    }
};

如果B的构造函数抛出异常,编译器保证: 成员对象 a 的析构函数、基类 C 的析构函数会被调用 (Inside The C++ Object Model, p301)。

成员指针怎么办

注意上述代码中的 pa,它指向一块堆空间,由于 B 的析构函数不会被调用了,内存就会出现泄漏。

这还真是一个问题,编译器也不能帮我们做更多事情,只能由程序员自己负责释放内存。

我们可能要这样写代码:

class B : class C {
    A a;
    A * pa;
public:
    B() {
        pa = new A();
        try {
            throw exception;
        } catch() {
            delete pa; //确保释放pa
            throw;
        }
    }
    ~B() {
        delete pa;
    }
};

这样的代码难看很多,有一种建议的做法就是:用智能指针包装 pa。智能指针作为 B 的成员对象,其析构函数是可以被自动调用的,进而释放pa。

析构函数如何被自动调用

上面提到:

  1. 普通函数抛出异常时,所有 active local object 的析构函数都会被调用
  2. 构造函数抛出异常时,所有成员对象以及基类成分的析构函数都会被调用

那么,这是怎么实现的呢?

我们以第一种情况为例,分析实现细节。看下面的代码:

1
2
3
4
5
6
7
8
f() {
    A a1;
    if () {  // 某些条件下,抛出异常
        throw exception;
    }
    A a2;
    throw exception; // 总会抛出异常
}

如果 L4 抛出异常,那么对象 a1 会被析构。如果 L7 抛出异常,那么对象 a1 a2 都要被析构。编译器是怎么知道,什么时候该析构哪些对象的呢?

支持异常机制的编译器,会做一些”簿记“工作,将需要被析构的对象登记在特定的数据结构中。编译器将上述代码分成不同的区段,每个区段中需要被析构的对象,都不相同。

例如,上述代码中,L2 L3~L5 L7 就是三个不同的区段:

  1. 如果 L2 抛出异常,那么没有对象需要析构
  2. 如果 L4~L7 抛出异常,那么 a1 需要被析构
  3. 如果 L8 抛出异常,那么 a1 和 a2 都要析构

编译器通过分析代码,簿记这些区段以及需要析构的 object list。 运行时,根据异常抛出时所在的区段,查找上述的数据结构,就可以知道哪些对象需要被析构。

构造函数抛出异常时,成员对象及基类成分被析构的原理,是类似的。在 C++ 运行时看来,构造函数只是普通的函数而已。

总结

C++ 的异常机制,给编译器和运行时均带来了一定的复杂度和代价。 上述的”簿记“工作,只是冰上一角。

异常处理

关于异常的使用,涉及很多细节。怎么throw 怎么catch,都是有讲究的。留作另外一篇文章。以下文字来自网络:

这是上一次看完Herb Sutter的《Exceptional C++》 后形成的看法,因为懒于更新Blog,一直没有写下来。

  一般讲到三个境界,很多人会联想到……#1见山是山,见水是水#2见山不是山,见水不是水#3见山还是山,见水还是水。嗯没错,区区这里说的也是这东西,只不过是有关编程,有关C++,有关异常而已。

  事情源起于今天下班时间过后,老大随着他的手机铃声《上海滩》潇洒地下班了。留下区区和梁兄在办公室里,当时他好像在对一个RDI程序进行逆向工程,而区区只是在摆弄oberon。

  “你觉得用C++进行异常安全性编程时最重要的范式是什么?try,catch,finally要怎么使用才得当?”梁忽然地就问了。

  老实说,牛X得不得了的梁很少跟在下请教问题的(虽然这个问句有强烈的讨论意味,但是区区就权当是在被“请教”了),所以觉得应该尽可能答得好一些。

  “编写异常安全的C++程序,最好就是,不要使用try,catch,finally。”——区区这样回答的。

  “哦,很怪喔,那怎么能怎么处理呢?”————就知道会被追问~~

  于是,那区区就说:

  是这样的,有关异常的C++编程,有三个境界:

  #第一个境界就是:程序中看到不try,catch,finally。

  这是新手的水平,他不知道有的模块/函数是会有异常抛出的,不处理的话,程序会当掉,很多资源会不能及时正确回收。或者他写程序时反复应用errno或者检查返回值的方式来处理异常情况,排错代码和正常流程代码搅在一起,混乱不堪。

  #第二个境界就是:程序中看到好多好多try,catch,finally。

  这是入门级的水平,他懂得利用抛异常的方式来处理错误情况,所以在程序中,正常的流程会统一在try里,各种错误处理,都安排在catch当中,小心翼翼地做好的善后工作。有时候狠起来还使用catch(…)来强行把所有的异常都压下来。这样没有什么混乱?才怪,各种善后处理虽然都做了,但是他不知道要写多少个try,多少个catch,而且经常要把思路放到catch当中去。

  #第三个境界就是:程序中还是看到不try,catch,finally。

  然而有不同,这一回他是手中无剑心有剑的高手境界了。

  他知道异常安全的三个保证,并且懂得在什么时候分别提供#资源回收保证#数据一致性保证#无异常保证。

  ---他会使用C++超强的RAII(资源获取即初始化)来使得资源在产生异常时会自动回收(写一个类就可以管理一种资源,一劳永逸,不用天天catch来catch去)。

  ---他会使用pimpl技法(我比较喜欢叫它疙瘩技法)帮助实现RAII,并把逻辑操作分派到各个成员内部当中,使之在发生异常时保持一致性。

  ---他另外还会常常使用一个no throw的swap操作一次性把所有的操作完成。这样的话,对象就不会成为烂尾楼。

  于是,这个高手写的类自己不用异常来打扰你,如果真的在内部其它类发生了异常,这个异常也安全地透过这个高手的类传到更上一层去,不破坏类本身的数据完整性。虽然异常还会有,但是,安全了~

  所以综合来说,要写好一个异常安全的模块,最好有几个东东要牢记于心~::

  异常(它会出现),RAII,pimpl,数据一致性,swap。

  如果忘了,可以翻出boost::shared_ptr的源程序好好看一遍。(这句话其实没敢跟师兄说)

  其实少用try catch,是对于一个相对的说法。当知道一个模块要通过抛异常来报告错误,并且这个错误的处理责任是模块调用者时,就应当使用try,别把异常给漏了。比如说boost::lexical_cast的用户~

  再比如梁的模块一般最终要以c函数接口形式发布,那在模块的最上层,加一个try,也是合适的。

  而且梁自己也说了,现在正在逆向的这个程序,使用了很好的SEH(这是windows的异常处理功能),所以很容易把握开发者的意图————你看,用异常处理来写程序就是好,连反汇编的可读性都比返回错误码的范式要强得多(当然,有的人不希望被逆向的)。