C与C++中的异常处理

1.     指导方针

    根据读者们的建议,经过反思,我部分修正在Part14中申明的原则:

l         只要可能,使用那些构造函数不抛异常的基类和成员子对象。

l         不要从你的构造函数中抛出任何异常。

    这次,我将思考读者的意见,C++先知们的智慧,以及我自己的新的认识和提高。然后将它们转化为指导方针来阐明和引申那些最初的原则。

    (关键字说明:我用“子对象”或“被包容对象”来表示数组中元素、无名的基类、有名的数据成员;用“包容对象”来表示数组、派生类对象或有数据成员的对象。)

 

1.1     C++的精髓

    你可能认为构造函数在遇到错误时有职责抛异常以正确地阻止包容对象的构造行为。Herb Sutter在一份私人信件中写道:

一个对象的生命期始于构造完成。

推论:一个对象当它的构造没有完成时,它从来就没存在过。

推论:通报构造失败的唯一方法是用异常来退出构造函数。

    我估计你正在做这种概念上就错误的事(“错”是因为它不符合C++的精髓),而这也正是做起来困难的原因。

    C++的精髓”是主要靠口头传授的C++神话。它是我们最初的法则,从ISO标准和实际中得出的公理。如果没有存在过这样的C++精髓的圣经,混乱将统治世界。Given that no actual canon for the Spirit exists, confusion reigns over what is and is not within the Spirit, even among presumed experts.

    CC++的精髓之一是“trust the programmer”。如同我写给Herb的:

最终,我的“完美”观点是:在错误的传播过程中将异常映射为其它形式应该是系统设计人员选定的。这么做不总是最佳的,但应该这么做。C++最强同时也是最弱的地方是你可以偏离你实际上需要的首选方法。还有一些其它被语言许可的危险影行为,取决于你是否知道你正在做什么。In the end, my "perfect" objective was to map exceptions to some other form of error propagation should a designer choose to do so. Not that it was always best to do so, but that it could be done. One of the simultaneous strengths/weaknesses of C++ is that you can deviate from the preferred path if you really need to. There are other dangerous behaviors the language tolerates, under the assumption you know what you are doing.

    C++标准经常容忍甚至许可潜在的不安全行为,但不是在这个问题上。显然,认同程序员的判断力应该服从于一个更高层次的目的(Apparently, the desire to allow programmer discretion yields to a higher purpose)。HerbC++精髓的第二个表现形式上发现了这个更高层次的目的:一个对象不是一个真正的对象(因此也是不可用的),除非它被完全构造(意味着它的所有要素也都被完全构造了)。

    看一下这个例子:

struct X

   {

   A a;

   B b;

   C c;

   void f();

   };

 

try

   {

   X x;

   x.f();

   }

catch (…)

   {

   }

 

    这里,ABC是其它的类。假设x.ax.b的构造完成了,而x.c的构造过程中抛了异常。如我们在前面几部分中看到的,语言规则规定执行这样的序列:

l         x的构造函数抛了异常

l         x.b的析构函数被调用

l         x.a的析构函数被调用

l         控制权交给异常处理函数

    这个规则符合C++的精髓。因为x.c没有完成构造,它从未成为一个对象。于是,x也从未成为一个对象,因为它的一个内部成员(x.c)从没存在过。因为没有一个对象真的存在过,所以也没有哪个需要正式地析构。

    现在假设x的构造函数不知怎么控制住了最初的异常。在这种情况下,执行序列将是:

l         x.f()被调用

l         x.c的析构函数被调用

l         x.b的析构函数被调用

l         x.a的析构函数被调用

l         x的析构函数被调用

l         控制权跳过异常处理函数向下走

于是异常将会允许析构那些从没被完全构造的对象(x.cx)。这将造成自相矛盾:一个死亡的对象是从来都没有产生过的。通过强迫构造函数抛异常,语言构造避免了这种矛盾。

 

1.2     C++的幽灵

    前面表明一个对象当且仅当它的成员被完全构造时才真的存在。但真的一个对象存在等价于被完全构造?尤其x.c的构造失败“总是”如此恶劣到x必须在真的在被产生前就死亡?

    C++语言有异常前,x的定义过程必定成功,并且x.f()的调用将被执行。代替抛异常的方法,我们将调用一个状态检测函数:

X x;

if (x.is_OK())

   x.f();

或使用一个回传状态参数:

bool is_OK;

X x(is_OK);

if (is_OK)

   x.f();

    在那个时候,我们不知何故在如x.c这样的子对象的构造失败时没有强调:这样的对象从没真的存在过。那时的设计真的这么根本错误(而我们现在绝不允许的这样行为了)? C++的精髓真的在那时是不同的?或者我们生活在梦中,没有想到过x真的没有成形、没有存在过?

    公正地说,这个问题有点过份,因为C++语言现在和过去相比已不是同样的语言。将老的(异常支持以前)的C++当作现在的C++如同将C当作C++。虽然它们有相同的语法,但语意却是不相同的。看一下:

struct X

   {

   X()

      {

      p = new T; // assume ‘new’ fails

      }

   void f();

   };

 

X x;

x.f();

 

    假设new语句没有成功分配一个T对象。异常支持之前的编译器(或禁止异常的现代编译器)下,new返回NULL,x的构造函数和x.f()被调用。但在异常允许后,new抛异常,x构造失败,x.f()没有被调用。同样的代码,非常不同的含意。

    在过去,对象没有自毁的能力,它们必须构造,并且依赖我们来发现它的状态。它们不处理构造失败的子对象。并且,它们不调用标准运行库中抛异常的库函数。简而言之,过去的程序和现在的程序存在于不同的世界中。我们不能期望它们对同样的错误总有同样的反应。

 

1.3     这是你的最终答案吗?

    我现在相信C++标准的行为是正确的:构造函数抛异常将析构正在处理的对象及其包容对象。我不知道C++标准委员会制订这个行为的精确原因,但我猜想是:

l         部分构造的对象将导致一些微妙的错误,因为它的使用者对其的构造程度的假设超过了实际。同样的类的不同对象将会有出乎意料的和不可预测的不同行为。

l         编译器需要额外的纪录。当一个部分构造的对象消失时,编译器要避免对它及它的部分构造的子对象调用析构函数。

l         对象被构造和对象存在的等价关系将被打破,破坏了C++的精髓。

 

1.4     对对象的使用者的指导

    异常是对象的接口的一部分。如果能够,事先准备好接口可能抛的异常集。如果一个接口没有提供异常规格申明,而且又不能从其它地方得知其异常行为,那么假设它可能在任何时候抛任意的异常。

    换句话说,准备好捕获或至少要过滤所有可能的异常。不要让任何异常在没有被预料到的情况下进入或离开你的代码;即使你只是简单地传递或重新抛出异常,也必须是经过认真选择的。

 

1.5     构造函数抛异常

    准备好所有子对象的构造函数可能抛的异常的异常集,并在你的构造函数中捕获它们。如:

struct A

   {

   A() throw(char, int);

   };

 

struct B

   {

   B() throw(int);

   };

 

struct C

   {

   C() throw(long);

   };

 

struct X

   {

   A a;

   B b;

   C c;

   X();

   };

 

    子对象构造函数的异常集是{char,int,long}。它就是X的构造函数遭遇的可能异常。如果X的构造函数未经过滤就传递这些异常,它的异常规格申明将是

X() throw(char, int, long);

但使用function try块,构造函数可以将这些异常映射为其它类型:

X() throw(unsigned)

   try

      {

      // … X::X body

      }

   catch (…)

      {

      // map caught sub-object exceptions to another type

      throw 1U; // type unsigned

      }

    如同前面的部分所写,用户的构造函数不能阻止子对象的异常传播出去,但能控制传递出去的类型,通过将进入的异常映射为受控的传出类型(这儿是unsigned)。

 

1.6     构造函数不抛异常

    如果没有子对象的构造函数抛异常,其异常集是空,表明包容对象的构造函数不会遇到异常。唯一能确定你的构造函数不抛异常的办法是只包容不抛异常的子对象。

   如果必须包容一个可能抛异常的子对象,但仍然不想从你自己的构造函数中抛出异常,考虑使用被叫做Handle ClassPimpl的方法(“Pimpl”个双关语:pImpl或“pointer to implementation”)。长久以来被用作减短编译时间的技巧,它也提高异常安全性。

    回到前面的例子:

    class X

   {

public:

   X();

   // …other X members

private:

   A a;

   B b;

   C c;

   };

    根据这种方法,必须将X分割为两个独立的部分。第一部分是被X的用户引用的“公有”头文件:

struct X_implementation;

 

class X

   {

public:

   X() throw();

   // …other X members

private:

   struct X_implementation *implementation;

   };

 

    而第二部分是私有实现

struct X_implementation

   {

   A a;

   B b;

   C c;

   };

 

X::X() throw()

   {

   try

      {

      implementation = new X_implementation;

      }

   catch (…)

      {

      // … Exception handled, but not implicitly rethrown.

      }

   }

 

// …other X members

 

    X的构造函数捕获了构造*implementation过程(也就是构造abc的过程)中的所有异常。更进一层,如果数据成员变了,X的用户不需要重新编译,因为X的头文件没有变化。

    (反面问题:如果X::X捕获了一个异常,*implementation及至少子对象a/b/c中的一个没有完全构造。但是,包容类X的对象作为一个有效实体延续了生命期。这个X的部分构造的对象的存在违背C++精髓吗?)

    许多C++的指导手册讨论这个方法,所以我不在这儿详述了。一个极其详细的讨论出现在Herb Sutter的著作《Exceptional C++》的Items2630上。

 

1.7     对对象提供者的指导

    不要将异常体系等同于一种错误处理体系,认为它和返回错误码或设置全局变量处在同一层次上。异常根本性地改变了它周围的代码的结构和意义。它们临时地改变了程序的运行期语意,跳过了一些通常都运行的代码,并激活其它从没被运行的代码。它们强迫你的程序回应和处理可导致程序死亡的错误状态。

    因此,异常的特性和简单的错误处理大不相同。如果你不希望这些特性,或不理解这些特性,或不想将这些特性写入文档,那么不要抛异常,使用其它的错误处理体系。

    如果决定抛异常,必须明白全部的因果关系。明白你的决定对使用你的代码的人有巨大的潜在影响。你的异常是你的接口的一部分;你必须在文档中写入你的接口将抛什么异常,什么时候抛,以及为什么抛。并将这文档在异常规格申明出注释出来。

 

1.8     构造函数抛异常

    如果你的构造函数抛异常,或你(直接地或间接地)包容的某个子对象抛异常,包容你的对象的用户对象也将抛异常并因此构造失败。这就是重用你的代码的用户的代价。要确保这个代价值得。

    你没有被强迫要在构造函数里抛异常,老的方法仍然有效的。当你的构造函数遇到错误时,你必须判断这些错误是致命的还是稍有影响。抛出一个构造异常传递了一个强烈的信息:这个对象被破坏且无法修补。返回一个构造状态码表明一个不同信息:这个对象被破坏但还具有功能。

    不抛异常只是因为它是一个时髦的方法:在一个对象真的不能或不该生存时,推迟其自毁。

 

1.9     过职

    别让你的接口过职。如果知道你的接口的精确异常集,将它在异常规格申明中列举出来。否则,不提供异常规格申明。没有异常规格申明比撒谎的异常规格申明好,因为它不会欺骗用户。

    这条规则的可能例外是:模板异常。如前三部分所写,模板的编写者通常不知道可能抛出的异常。如果你的模板不提供异常规格申明,用户将降低安全感和信心。如果你的模板有异常规格申明你必须:

l         要么使用前面看过的异常安全的技巧来确保异常规格申明是精确的

l         要么在文档中写下你的模板只接受有确定特性的参数类型,并警告其它类型将导致失控(with the caveat that other types may induce interface-contract violations beyond your control)。

 

1.10     必要vs充分

    不要人为增加你的类的复杂度,只是为了适应所有可能的需求。不是所有对象都会被重用的。如pet Becker写给我的:

    现在的程序员花了太多的时间来应付可能发生的事情,而他们本应该简单地拒绝的。如果有一个抛异常的好理由的话,大胆地抛异常,并写入文档,不要创造一些精巧的方法来避免抛这些异常。增加的复杂度可能导致维护上的恶梦,超过了错误使用受限版本时遇到的痛苦。

    Pete的说法对析构函数也同样有用。看一下这条原则(从Part14引用过来的):

    不要在析构函数中抛异常。

    一般来说,符合这条原则比违背它好。但,有时不是这样的:

l         如果你准备让其他人包容你的对象,或至少不禁止别人包容你的对象,那么别在析构函数中抛异常。

l         如果你真的有理由抛异常,并且知道它违背了安全策略,那么大胆地抛异常,在文档中写入原因。

    就如同在设计的时候必须考虑异常处理,也必须考虑重用。在析构函数上申明throw()是成为一个好的子对象的必要条件,但远不充分。你必须前瞻性地考虑你的代码将遇到什么上下文,它将容忍什么、将反抗什么。如果增加了设计的复杂度,确保这些复杂度是策略的一部分,而不是脆弱的“以防万一”的保险单。

1.11     感谢

   (略)

    除了一些零星的东西,我已经完成了异常安全的主题!实际上我也几乎完成了异常的专题。下次时间暂停,在三月中将讨论很久前承诺的C++异常和Visual C++ SEH的混合使用。

Advertisements

2 Comments

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s