1. Microsoft对异常处理方法的扩展
前次,我概述了异常的分类和C标准库支持的处理方法。这次讨论Microsoft对这些方法的扩展:结构化异常处理(SEH)和Microsoft Foundation Class (MFC)异常处理。SEH对C和C++都有效,MFC异常体系只对C++有效。
1.1 机构化异常处理
机构化异常处理是Windows提供的服务功能并对所有语言写的程序有效。在Visual C++中,Microsoft封装和简化了这些服务(通过非标准的关键字和库程序)。Windows平台的其它编译器可能选择不同的方式来到达相似的结果。在这个专栏中,名词“Structured Exception Handling”和“SEH”专指Visual C++对Windows异常服务的封装。
1.2 关键字
为了支持SEH,Micorsoft用四个新关键字扩展了C和C++语言:
l __except
l __finally
l __leave
l __try
因为这是非标关键字,必须打开扩展选项后再编译(关掉/Fa)。
为什么这些关键字带下划线?C++标准(条款17.4.3.1.2,“Global names”)规定:
下列名字和函数总是保留给编译器:
l 所有带双下划线(__)或以一个下划线加一个大写字母开始的名字保留给编译器随意使用。
l 所有以一个下划线开始的名字保留给编译器作全局名称用。
C标准有类似的申明。
既然SEH的关键字符合上面的规则,Microsoft就有权这样使用它们。这也表明,你不被允许在自己的程序中使用保留的名字。你必须避免定义名字类似__MYHEADER_H__或_FatalError的标识符。
有趣而又不幸地,Visual C++的application wizards产生的源代码使用了保留的标识符。例如,如果你用ATL COM App Wizard生成一个新的service,结果框架代码定义了如_Handler和_twinMain的名字--标准所说的你的程序不能使用的保留名称。
要减少这个不合规定行为,你当然可以手工更改这些名称。还好,这些有疑问的名字都是类的私有变量,在类的定义外面是不可见的,在.h和.cpp中进行全局替换是可行的。不幸的是,有一个函数(_twinMain)和一个对象(_Module)被申明了extern,也就是说程序的其它部分会假定你使用了这些名字。(事实上,Visual C++库libc.lib在连接时需要名字_twinMain可用。)
我建议你保留Wizard生成的名字,不要在你自己的代码中定义这样的名字就可以了。另外,你应该将所有不合标准的定义写入文档并留给程序的维护人员;记住,Visual C++以后的版本(和现有的其它C++编译器)可能以另外的方式使用这些名字,从而破坏了你的代码。
1.3 标识符
Microsoft也在非标头文件excpt.h中定义了几个SEH的标识符,并且包含入windows.h中。在其内部,定义了:
l 供__except的过滤表达式使用的过滤结果宏。
l Win32对象和函数的别名宏,用于查询异常信息和状态。
l 伪关键字宏,和前面谈到的四个关键字有着相同名字和含义,但没有下划线。(例如,宏leave对应SEH关键字__leave。)
Microsoft用这些宏令我抓狂。他们对同一个函数了定义多个别名。例如,excpt.h有如下申明和定义:
unsigned long __cdecl _exception_code(void);
#define GetExceptionCode _exception_code
#define exception_code _exception_code
也就是说,你可以用三种方法调用同一函数。你用哪个?并且,这些别名会如你所期望地被维护吗?
在Microsoft的文档中,它看起来偏爱GetExceptionCode,它的名字和其它全局Windows API函数风格一致。我在MSDN中搜索到33处GetExceptionCode,两个_exception_code,而exception_code个数为0。根据Microsoft的引导,推荐使用GetExceptionCode及类似名称的其它函数。
因为_exception_code的两个别名是宏,所以你不能再使用同样的名字了。我曾经犯过这个错,当我在为这个专栏写例程的时候。我定义了一个局部对象叫exception_code(大概是吧)。实际上我就是定义了一个局部对象叫_exception_code,这是我无意中使用的宏exception_code展开的结果。当我一想到是这个问题,解决方案就是简单地将我的对象名字从exception_code改为code。
最后,excpt.h定义了一个特别的宏--“try”--已经成为C++真正的关键字的东西。这意味着你不能在包含了excpt.h的编译单元中简单地混合SEH和标准C++的异常块,除非你愿意#undef这个try宏。当这样undef而露出真正的try关键字时,要冒搞乱SEH的维护人员大脑的危险。另一方面,精通标准C++的程序员会将try理解为一个关键字而不是宏。
我认为,包含一个头文件(即使是象excpt.h这样的非标头文件)不应该改变符合语言标准的代码的行为。我更坚持掩盖或重定义掉语言标准定义的关键字是个坏习惯。我建议:#undef try,同样不使用其它的伪关键字宏,直接使用真正的关键字(如__try)。
1.4 语法
最基本的SEH语法是try块。如下形式:
__try compound-statement handler
处理体:
__except ( filter-expression ) compound-statement
或:
__finally compound-statement
完整一点看,try块如下:
__try
{
...
}
__except(filter-expression)
{
...
}
或:
__try
{
...
}
__finally
{
...
}
在__try里面你必须使用一个leave语句:
__try
{
...
__leave;
...
}
在更大的程序块中,一个try块被认为是个单条语句:
if (x)
{
__try
{
...
}
__finally
{
...
}
}
等价于:
if (x)
__try
{
...
}
__finally
{
...
}
其它注意点:
l 在给定的try块中你必须有一个正确的异常处理函数。
l 所有的语句必须合并。即使只有一条语句跟在__try、__except或__finally后面也必须将它放入{}中。
l 在异常处理函数中,相应的过滤表达式必须有一个或能转换为一个int型的值。
1.5 基本语意
上次我列举了异常生命期的5个阶段。在SEH体系下,这些阶段实现如下:
l 操作系统上报了一个硬件错误或检测到了一个软件错误,或用户代码检测到一个错误(阶段1)。
l (通常是由用户调用Win32函数RasieException启动,)操作系统产生并触发一个异常对象(阶段2)。这个对象是一个结构,其属性对异常处理函数可见。
l 异常处理函数“看到”异常,并且有机会捕获它(阶段3和4)。取决于处理函数的意愿,异常将或者恢复或者终止。(阶段5)。
一个简单的例子:
int filter(void)
{
/* Stage 4 */
}
int main(void)
{
__try
{
if (some_error) /* Stage 1 */
RaiseException(...); /* Stage 2 */
/* Stage 5 of resuming exception */
}
__except(filter()) /* Stage 3 */
{
/* Stage 5 of terminating exception */
}
return 0;
}
Microsoft调用定义在__except中的异常处理函数,和定义在__finally中的终止函数。
一旦异常被触发,由__except开始的异常处理函数被异常发生点顺函数调用链向外面询问。每个被发现的异常处理函数,其过滤表达式都被求值。每次求值后发生什么取决于其返回结果。
excpt.h定义了3个过滤结果的宏,都是int型的:
l EXCEPTION_CONTINUE_EXECUTION = -1
l EXCEPTION_CONTINUE_SEARCH = 0
l EXCEPTION_EXECUTE_HANDLER = 1
前面我说过,过滤表达式必须兼容int型,所以它们和这3个宏的值匹配。这个说法太保守了:我的经验显示Visual C++接受的过滤表达式可以具有所有的整型、指针型、结构、数组甚至是void型!(但我在尝试浮点指针时遇到了编译错误。)
更进一步,所有求出的值看来都有效(至少对整型如此)。所有非零且符号位为0的值效果相当于EXCEPTION_EXECUTE_HANDLER,而符号位为1的相当于EXCEPTION_CONTINUE_EXECUTION。这大概是按位取模的结果。
如果一个异常处理函数的过滤求值结果是EXCEPTION_CONTINUE_SEARCH,这个处理函数拒绝捕获异常,将继续搜索下一个异常处理函数。
通过由过滤表达式产生一个非EXCEPTION_CONTINUE_SEARCH来捕获异常,一旦捕获,程序就恢复。怎么恢复仍然由过滤表达式的值决定:
l EXCEPTION_CONTINUE_EXECUTION:表现为恢复异常。从发生异常处下面开始执行。异常处理函数本身的代码不执行。
l EXCEPTION_EXECUTE_HANDLER:表现为终止异常。从异常发生处开始退栈,一路上所遇到终止函数都被执行。栈退到捕获异常的处理函数所在的一级为止。进入处理函数体并执行。
如名所示,终止处理函数(以__finally开始的代码)在终止异常时被调用。里面是clean up代码,它们就象C标准库中的atexit()函数和C++的析构函数。终止处理函数在正常执行流程也会进入,就象不是捕获型代码。相反,异常处理函数总表现为捕获型:它们只在其过滤表达式求值为EXCEPTION_EXECUTE_HANDLER时才进入。
终止处理函数并不明确知道自己是从正常流程进入的还是在一个try块异常终止时进入的。要判断这点,可以调用AbnormalTermination函数。此函数返回一个int,0表明是从正常流程进入的,其它值表明在异常终止时进入的。
AbnormalTermination实际上是个指向_abnormal_termination()的宏。Visual C++将_abnormal_termination()设计为环境敏感的函数,就象一个关键字。你不能随便调用这个函数,只能在终止处理函数中调用。这意味着你不能在终止处理函数中调用一个中间函数,再在此中间函数中调用_abnormal_termination(),这样做会得到一个编译期错误。
1.6 例程
下面的C例子显示了不同的过滤表达式值和处理函数本身类型的相互作用。第一个版本是个小的完整程序,以后的版本都在它前面一个上有小小的改动。所有的版本都自解释的,你能看清流程和行为。
程序通过RaiseException()触发一个异常对象。RaiseException()函数的第一个参数是异常的代码,类型是32位无符号整型(DWORD);Microsoft为用户自定义的错误保留了[0xE0000000,0xEFFFFFFF]的范围。其它参数一般填0。
这里使用的异常过滤器很简单。实际使用中,大概要调用GetExceptionCode()和GetExceptionInformation()来查询异常对象的属性。
1.7 Version #1: Terminating Exception
用Visual C++生成一个空的Win32控制台程序,命名为SEH_test,选项为默认。将下列C源码加入工程文件:
#include
#include "windows.h"
#define filter(level, status) \
( \
printf("%s:%*sfilter => %s\n", \
#level, (int) (2 * (level)), "", #status), \
(status) \
)
#define termination_trace(level) \
printf("%s:%*shandling %snormal termination\n", \
#level, (int) (2 * (level)), "", \
AbnormalTermination() ? "ab" : "")
static void trace(int level, char const *message)
{
printf("%d:%*s%s\n", level, 2 * level, "", message);
}
extern int main(void)
{
DWORD const code = 0xE0000001;
trace(0, "before first try");
__try
{
trace(1, "try");
__try
{
trace(2, "try");
__try
{
trace(3, "try");
__try
{
trace(4, "try");
trace(4, "raising exception");
RaiseException(code, 0, 0, 0);
trace(4, "after exception");
}
__finally
{
termination_trace(4);
}
end_4:
trace(3, "continuation");
}
__except(filter(3, EXCEPTION_CONTINUE_SEARCH))
{
trace(3, "handling exception");
}
trace(2, "continuation");
}
__finally
{
termination_trace(2);
}
trace(1, "continuation");
}
__except(filter(1, EXCEPTION_EXECUTE_HANDLER))
{
trace(1, "handling exception");
}
trace(0, "continuation");
return 0;
}
现在编译代码。(可能会得到label end_4未用的警告;先忽略。)
注意:
l 程序有四个嵌套try块,两个有异常处理函数,两个有终止处理函数。为了更好地显示嵌套和控制流程,我把它们全部放入同一个函数中。实际编程中可能是放在多个函数或多个编译单元中的。
l 追踪运行情况,输出结果显示当前块的嵌套层次。
l 异常过滤器被实现为宏。第一个参数是嵌套层次,第二个才是实际要处理的值。
l 终止处理函数通过termination_trace宏跟踪其执行情况,显示出调用它们的原因。(记住,终止处理函数即使没有发生异常也会进入的。)
运行此程序,将看到如下输出:
0:before first try
1: try
2: try
3: try
4: try
4: raising exception
3: filter => EXCEPTION_CONTINUE_SEARCH
1: filter => EXCEPTION_EXECUTE_HANDLER
4: handling abnormal termination2: handling abnormal termination
1: handling exception
0:continuation
事件链:
l 第四层try块触发了一个异常。这导致顺嵌套链向上搜索,查找愿意捕获这个异常的异常过滤器。
l 碰到的第一个异常过滤器(在第三层)得出了EXCEPTION_CONTINUE_SEARCH,所以拒绝捕获这个异常。继续搜索下一个异常处理函数。
l 碰到的下一个异常过滤器(在第一层)得出了EXCEPTION_EXECUTE_HANDLER。这次,这个过滤器捕获这个异常。因为它求得的值,异常将被终止。
l 控制权回到异常发生点,开始退栈。沿路所有的终止处理函数被运行,并且所有的处理函数都知道异常终止发生了。一直退栈到控制权回到捕获异常的异常处理函数(在第一层)。在退栈时,只有终止处理函数被执行,中间的其它代码被忽略。
l 控制权一回到捕获异常的异常处理函数(在第一层),将以正常状态继续执行。
注意,控制权在同一嵌套层传递了两次:第一次异常过滤表达式求值,第二次在退栈和执行终止处理函数时。这造成了一种危害可能:如果一个异常过滤表达式以某种终止处理函数不期望的方式修改了的什么。一个基本原则就是,你的异常过滤器不能有副作用;如果有,则必须为你的终止处理函数保存它们。
1.8 版本2:未捕获异常
将例程中的这行:
__except(filter(1, EXCEPTION_EXECUTE_HANDLER))
改为
__except(filter(1, EXCEPTION_CONTINUE_SEARCH))
于是没有异常过滤器捕获这个异常。执行修改后的程序,你将看到:
0:before first try
1: try
2: try
3: try
4: try
4: raising exception
3: filter => EXCEPTION_CONTINUE_SEARCH
1: filter => EXCEPTION_CONTINUE_SEARCH
接着出现这个对话框:
1. 用户异常对话框
点“Details”将其展开
2. 用户异常对话框的详细信息
在出错信息中可看到:出错程序是SEH_TEST,通过RaiseException抛出的原始异常码是e0000001H。
这个异常漏出了程序,最后被操作系统捕获和处理。有些象你的程序是这么写的:
__try
{
int main(void)
{
...
}
}
__except(exception_dialog(), EXCEPTION_EXECUTE_HANDLER)
{
}
按对话框上的“Close”,所有的终止处理函数被执行,并退栈,直到控制权回到捕获异常的处理函数。你可以明显看到这些信息:
4: handling abnormal termination
2: handling abnormal termination
它们出现在关闭对话框之后。注意,你没有看到:
0:continuation
因为它的实现代码在终止处理函数之外,而退栈时只有终止处理函数被执行。
对我们的试验程序而言,捕获异常的处理函数在main之外,这意味着传递异常的行为到了程序范围外仍然在继续。其结果是,程序被终止了。
1.1 版本3:恢复异常
接下来,改:
__except(except_filter(3, EXCEPTION_CONTINUE_SEARCH))
为:
__except(except_filter(3, EXCEPTION_CONTINUE_EXECUTION))
重新编译并运行。可以看到这样的输出:
0:before first try
1: try
2: try
3: try
4: try
4: raising exception
3: filter => EXCEPTION_CONTINUE_EXECUTION
4: after exception
4: handling normal termination
3: continuation
2: continuation
2: handling normal termination
1: continuation
0:continuation
因为第三层的异常过滤器已经捕获了异常,第一层的过滤器不会被求值。捕获异常的过滤器求值为EXCEPTION_CONTINUE_EXECUTION,因此异常被恢复。异常处理函数不会被进入,将从异常发生点正常执行下去。
1.2 版本4:异常终止
这样的结构:
__try
{
/* ... */
return;
}
或:
__try
{
/* ... */
goto label;
}
__finally
{
/* ... */
}
/* ... */
label:
被认为是try块异常终止。以后调用AbnormalTermination()函数的话将返回非0值,就象异常仍然存在。
要看其效果,改这两行:
trace(4, "raising exception");
RaiseException(exception_code, 0, 0, 0);
为:
trace(4, "exiting try block");
goto end_4;
第4层的try块不是被一个异常结束的,现在是被goto语句结束的。运行结果:
0:before first try
1: try
2: try
3: try
4: try
4: exiting try block
4: handling abnormal termination
3: continuation
2: continuation
2: handling normal termination
1: continuation
0:continuation
第4层的终止处理函数认为它正在处理异常终止,虽然并没有发生过异常。(如果发生过异常的话,我们至少能从一个异常过滤器的输出信息上看出来的。)
结论:你不能只依赖AbnormalTermination()函数来判断异常是否仍存在。
1.3 版本5:正常终止
如果想正常终止一个try块,也就是想要AbnormalTermination() 函数返回FALSE,应该使用Microsoft特有的关键字__leave。想验证的话,改:
goto end_4;
为:
__leave;
重新编译并运行,结果是:
0:before first try
1: try
2: try
3: try
4: try
4: exiting try block
4: handling normal termination
3: continuation
2: continuation
2: handling normal termination
1: continuation
0:continuation
和版本4的输出非常接近,除了一点:第4层的终止处理函数现在认为它是在处理正常结束。
1.4 版本6:隐式异常
前面的程序版本处理的都是用户产生的异常。SEH也可以处理Windows自己抛出的异常。
改这行:
trace(4, "exiting try block");
__leave;
为:
trace(4, "implicitly raising exception");
*((char *) 0) = 'x';
这导致Windows的内存操作异常(引用空指针)。接着改:
__except(except_filter(3, EXCEPTION_CONTINUE_EXECUTION))
为:
__except(except_filter(3, EXCEPTION_EXECUTE_HANDLER))
以使程序捕获并处理异常。
执行结果为:
0:before first try
1: try
2: try
3: try
4: try
4: implicitly raising exception
3: filter => EXCEPTION_EXECUTE_HANDLER
4: handling abnormal termination
3: handling exception
2: continuation
2: handling normal termination
1: continuation
0:continuation
如我们所预料,Windows在嵌套层次4中触发了一个异常,并被层次3的异常处理函数捕获。
如果你想知道捕获的精确异常码,可以让异常传到main外面去,就象版本2中做的。为此,改:
__except(except_filter(3, EXCEPTION_EXECUTE_HANDLER))
为:
__except(except_filter(3, EXCEPTION_CONTINUE_SEARCH))
结果对话框在按了“Details”后,显示的信息非常象用户异常。
图3 内存异常对话框
和版本2的对话框不同是,上次显示了特别的异常码,这次说了“invalid page fault”--更用户友好些吧。
1.5 C++考虑事项
在所有C兼容异常处理体系中,SEH无疑是最完善和最灵活的(至少在Windows环境下)。具有讽刺意味的,它也是Windows体系以外的环境中最不灵活的,它将你和特殊的运行平台及Visaul C++源码兼容的编译器牢牢绑在了一起。
如果只使用C语言,并且不考虑移植到Windows平台以外,SEH很好。但如果使用C++并考虑可移植性,我强烈建议你使用标准C++异常处理而不用SEH。你可以在同一个程序中同时使用SEH和标准C++异常处理,只有一个限制:如果在有SEH try块的函数中定义了一个对象,而这个对象又没有non-trivial(无行为的)析构函数,编译器会报错。在同一函数中同时使用这样的对象和SEH的__try,你必须禁掉标准C++异常处理。
(Visual C++默认关掉标准C++异常处理。你可以使用命令行参数/GX或Visual Studio的Project Settings对话框打开它。)
在以后的文章中,我会在讨论标准C++异常处理时回顾SEH。我想将SEH整合入C++的主流中,通过将结构化异常及Windows运行库支持映射为C++异常和标准C++运行库支持。
1.6 MFC异常处理
说明:这一节我需要预先引用一点点标准C++异常处理的知识,但要到下次才正式介绍它们。这个提前引用是不可避免的,也是没什么可惊讶的,因为Microsoft将它们的MFC异常的语法和语义构建在标准C++异常的语法和语义的基础上。
我到现在为止所讲的异常处理方法对C和C++都有效。在此之外,Microsoft对C++程序还有一个解决方案:MFC异常处理类和宏。Microsoft现在认为MFC异常处理体系过时了,并鼓励你尽可能使用标准C++异常处理。然而Visual C++仍然支持MFC异常类和及宏,所以我将给它个简单介绍。
Microsoft用标准C++异常实现了MFC3.0及以后版本。所以你必须激活标准C++异常才能使用MFC,即使你不打算显式地使用这些异常。前面说过,你必须禁掉标准C++异常来使用SEH,这也意味着你不能同时使用MFC宏和SEH。Microsoft明文规定这两个异常体系是互斥的,不能在同一程序中混合使用。
SEH是扩展了编译器关键字集,MFC则定义了一组宏:
l TRY
l CATCH, AND_CATCH, 和END_CATCH
l THROW 和 THROW_LAST
这些宏非常象C++的异常关键字try、catch和throw。
另外,MFC提供了异常类体系。所有名字为CXXXException形式的类都是从抽象类CException派生的。这类似于标准C++运行库在中申明的从std::exception开始的派生体系。但,标准C++的关键字可以处理绝大部分类型的异常对象,而MFC宏只能处理CException的派生类型对象。
对于每个MFC异常类CXXXException,都有一个全局的辅助函数AfxThrowXXXException() ,它构造、初始化和抛出这个类的对象。你可以用这些辅助函数处理预定义的异常类型,用THROW处理自定义的对象(当然,它们必须是从CException派生的)。
基本的设计原则是:
l 用TRY块包含可能产生异常的代码。
l 用CATCH检测并处理异常。异常处理函数并不是真的捕获对象,它们其实是捕获了指向异常的指针。MFC靠动态类型来辨别异常对象。比较一下,SEH靠运行时查询异常码来辨别异常。
l 可以在一个TRY块上捆绑多个异常处理函数,每个捕获一个C++静态类型不同的对象。第一个处理函数使用宏CATCH,以后的使用AND_CATCH,用END_CATCH结束处理函数队列。
l MFC自己可能触发异常,你也可以显式触发异常(通过THROW或MFC辅助函数)。在异常处理函数内部,可以用THROW_LAST再次抛出最近一次捕获的异常。
l 异常一被触发,异常处理函数就将被从里到外进行搜索,和SEH时一样。搜索停止于找到一个类型匹配的异常处理函数。所有异常都是终止。和SEH不一样,MFC没有终止处理函数,你必须依赖于局部对象的析构函数。
一个小MFC例子,将大部分题目都包括了:
#include
#include "afxwin.h"
void f()
{
TRY
{
printf("raising memory exception\n");
AfxThrowMemoryException();
printf("this line should never appear\n");
}
CATCH(CException, e)
{
printf("caught generic exception; rethrowing\n");
THROW_LAST();
printf("this line should never appear\n");
}
END_CATCH
printf("this line should never appear\n");
}
int main()
{
TRY
{
f();
printf("this line should never appear\n");
}
CATCH(CFileException, e)
{
printf("caught file exception\n");
}
AND_CATCH(CMemoryException, e)
{
printf("caught memory exception\n");
}
/* ... handlers for other CException-derived types ... */
AND_CATCH(CException, e)
{
printf("caught generic exception\n");
}
END_CATCH
return 0;
}
/*
When run yields
raising memory exception
caught generic exception; rethrowing
caught memory exception
*/
记住,异常处理函数捕获指向对象的指针,而不是实际的对象。所以,处理函数:
CATCH(CException, e)
{
// ...
}
定义了一个局部指针CException *e指向了被抛出的异常对象。基于C++的多态,这个指针可以引用任何从CException派生的对象。
如果同一try块有多个处理函数,它们按从上到下的顺序进行匹配搜索的。所以,你应该将处理最派生类的对象的处理函数放在前面,不然的话,更派生类的处理函数不会接收任何异常的(再次拜多态所赐)。
因为你典型地想捕获CException,MFC定义了几个CException特有宏:
l CATCH_ALL(e)和AND_CATCH_ALL(e),等价于CATCH(CException, e)和AND_CATCH(CException, e)。
l END_CATCH_ALL ,结束CATCH_ALL... AND_CATCH_ALL队列。
l END_TRY等价于CATCH_ALL(e);END_CATCH_ALL。这让TRY... END_TRY中没有处理函数或说是接收所有抛出的异常。
这个被指的异常对象由MFC隐式析构和归还内存。这一点和标准C++异常处理函数不一样,MFC异常处理不会让任何人取得被捕获的指针的所有权。因此,你不能用MFC和标准C++体系同时处理相同的异常对象;不然的话,将导致内存泄漏:引用已被析构的对象,并重复析构和归还同一对象。
1.7 小结
MSDN在线还有另外几篇探索结构化异常处理和MFC异常宏的文章。
下次我将介绍标准C++异常,概述它们的特点及基本原理。我还会将它们和到现在已经看到的方法进行比较。
1. 标准C++异常处理的基本语法和语义
这次,我来概述标准C++异常处理的基本语法和语义。顺便,我会将它和前两次提到的技术进行比较。(在本文及以后,我将标准C++异常处理简称为EH,将微软的方法称为SEH。)
1.1 基本语法和语义
EH引入了3个新的C++语言关键字:
l catch
l throw
l try
异常通过如下语句触发
throw [expression]
函数通过“异常规格申明”定义它将抛出什么异常:
throw([type-ID-list])
可选项type-ID-list包含一个或多个类型的名字,以逗号分隔。这些异常靠try块中的异常处理函数进行捕获。
try compound-statement handler-sequence
处理函数队列包含一个或多个处理函数,形式如下:
catch ( exception-declaration ) compound-statement
处理函数的“异常申明”指明了这个函数将捕获什么类型的异常。
和SEH一样,跟在try和catch后面的语句必须刮在{}内,而整个try块组成一条完整的大语句。
例子:
void f() throw(int, some_class_type)
{
int i;
// ... generate an 'int' exception
throw i;
// ...
}
int main()
{
try
{
f();
}
catch(int e)
{
// ... handle 'int' exception ...
}
catch(some_class_type e)
{
// ... handle 'some_class_type' exception ...
}
// ... possibly other handlers ...
return 0;
}
异常规格申明是EH特有的,SEH和MFC都没有类似的东西。一个空的异常规格申明表明函数不抛出任何异常:
void f() throw()
{
// ... function throws no exceptions ...
}
如果函数没有异常规格申明,它可以抛出任何类型的异常:
void f()
{
// ... function can throw anything or nothing ...
}
当函数抛异常时,关键字throw通常后面带一个被抛出的对象:
throw i;
然而,throw也可以不带对象:
catch(int e)
{
// ... handle 'int' exception ...
throw;
}
它的效果是再次抛出当前正被捕获的对象(int e)。因为空throw的作用是再次抛出已存在的异常对象,所以它必须位于catch语句块中。MFC也有再次抛出异常的功能,SEH则没有,它没有将异常对象交给过处理函数,所以没什么可再次抛出的。
就象函数原型中的参数申明一样,异常申明也可以是无名的:
catch(char *)
{
// ... handle 'char *' exception ...
}
当这个处理函数捕获一个char *型的异常对象时,它不能操作这个对象,因为这个对象没有名字。
异常申明还可以是这样的特殊形式:
catch(...)
{
// ... handle any type of exception ...
}
就象不定参数中的“...”一样,异常申明中的“...”可以匹配任何异常的类型。
1.2 标准异常对象的类型
标准库函数可能报告错误。在C标准库中的报错方式在前面说过了。在C++标准库中,有些函数抛出特定的异常,而另外一些根本不抛任何异常。
因为C++标准中没有明确规定,所以C++的库函数可以抛出任何对象或不抛。但C++标准推荐运行库的实现通过抛出定义在中的异常类型或其派生类型来报告错误:
namespace std
{
class logic_error; // : public exception
class domain_error; // : public logic_error
class invalid_argument; // : public logic_error
class length_error; // : public logic_error
class out_of_range; // : public logic_error
class runtime_error; // : public exception
class range_error; // : public runtime_error
class overflow_error; // : public runtime_error
class underflow_error; // : public runtime_error
}
这些(异常)类只对C++标准库有约束力。在你自己的代码中,你可以抛出(和捕获)任何你所象要的类型。
1.3 标准中的其它申明
标准库头文件申明了几个EH类型和函数
namespace std
{
//
// types
//
class bad_exception;
class exception;
typedef void (*terminate_handler)();
typedef void (*unexpected_handler)();
//
// functions
//
terminate_handler set_terminate(terminate_handler) throw();
unexpected_handler set_unexpected(unexpected_handler) throw();
void terminate();
void unexpected();
bool uncaught_exception();
}
提要:
l exception是所有标准库抛出的异常的基类。
l uncaught_exception()函数在有异常被抛出却没有被捕获时返回true,其它情况返回false。它类似于SEH的函数AbnormalTermination()。
l terminate()是EH的应急处理。它在异常处理体系陷入了不可恢复状态时被调用,经常是因为试图重入(在前一个异常正处理过程中又抛了一个异常)。
l unexpected()在函数抛出一个它没有在“异常规格申明”中申明的异常时被调用。这个预料外的异常可能在退栈过程中被替换为一个bad_excetion对象。
l 运行库提供了缺省terminate_handler()和unexpected_handler() 函数处理对应的情况。你可以通过set_terminate()和set_unexpected()函数替换库的默认版本。
1.4 异常生命期
EH运行于异常生命期的五个阶段:
l 程序或运行库遇到一个错误状况(阶段1)并且抛出一个异常(阶段2)。
l 程序的运行停止于异常点,开始搜索异常处理函数。搜索沿调用栈向上搜索(很象SEH终止异常时的行为)。
l 搜索结束于找到了一个异常申明与异常对象的静态类型相匹配(阶段3)。于是进入相应的异常处理函数。
l 异常处理函数结束后,跳到此异常处理函数所在的try块下面最近的一条语句开始执行(阶段5)。这个行为意味着C++标准中异常总是终止。
这些步骤演示于这个简单的例子中:
#include
static void f(int n)
{
if (n != 0) // Stage 1
throw 123; // Stage 2
}
extern int main()
{
try
{
f(1);
printf("resuming, should never appear\n");
}
catch(int) // Stage 3
{
// Stage 4
printf("caught 'int' exception\n");
}
catch(char *) // Stage 3
{
// Stage 4
printf("caught 'char *' exception\n");
}
catch(...) // Stage 3
{
// Stage 4
printf("caught typeless exception\n");
}
// Stage 5
printf("terminating, after 'try' block\n");
return 0;
}
/*
When run yields
caught 'int' exception
terminating, after 'try' block
*/
1.5 基本原理
C标准库的异常体系处理C++语言时有如下难题:
l 析构函数被忽略。既然C标准库异常体系是为C语言设计的,它们不知道C++的析构函数。尤其,abort()、exit()和longjmp()在退栈或程序终止时不调用局部对象的析构函数。
l 繁琐的。查询全局对象或函数返回值导致了代码混乱-你必须在所有可能发生异常的地方进行明确的异常情况检测,即使是异常情况可能实际上从不发生。因为这种方法是如此繁琐,程序员们可能会故意“忘了”检测异常情况。
l 无弹性的。Longjmp()“抛出”的只能是简单的int型。errno和signal()/raise()只使用了很小的一个值域集合,分辨率很低。Abort()和exit()总是终止程序。Assert()只工作在debug版本中。
l 非固有的。所有的C标准库异常体系都需要运行库的支持,它不是语言内核支持的。
微软特有的异常处理体系也不是没有限制的:
l SEH异常处理函数不是直接捕获一个异常对象,而是通过查询一个(概念性的)类似errno的全局值来判断什么异常发生了。
l SEH异常处理函数不能组合,给定try块的唯有的一个处理函数必须在运行期识别和处理所有的异常事件。
l MFC异常处理函数只能捕获CException及派生类型的指针。
l 通过包含定义了MFC异常处理函数的宏的头文件,程序包含了数百个无关的宏和申明。
l MFC和SEH都是专属于与Microsoft兼容的开发环境和Windows运行平台的。
标准C++异常处理避免了这些短处:
l 析构安全。在抛异常而进行退栈时,局部对象的析构函数被按正确的顺序调用。
l 不引人注目的。异常的捕获是暗地里的和自动的。程序员无需因错误检测而搞乱设计。
l 精确的。因为几乎任何对象都可以被抛出和捕获,程序员可以控制异常的内容和含义。
l 可伸缩的。每个函数可以有多个try块。每个try块可以有单个或一组处理函数。每个处理函数可以捕获单个类型,一组类型或所有类型的异常。
l 可预测的。函数可以指定它们将抛的异常类型,异常处理函数可以指定它们捕获什么类型的异常。如果程序违反了其申明,标准库将按可预测的、用户定义的方式运行。
l 固有的。EH是C++语言的一部分。你可以定义、throw和catch异常而不需要包含任何库。
l 标准的。EH在所有的标准C++的实现中都可用。
基于更完备的想法,C++标准委员会考虑过两个EH的设计,在D&E的16章。(For a more complete rationale, including alternative EH designs considered by the C++ Standard's committee, check out Chapter 16 of the D&E.)
1.6 小结
下次,我将更深入挖掘EH的语言核心特性和EH的标准库支持。我也将展示Microsoft Visual C++实现EH的内幕。我将开始标志出EH的那些Visual C++只部分支持或完全不支持的特性,并且寻找绕过这些限制的方法。
在我相信设计EH的基本原理是健全的的同时,我也认为EH无意中包含了一些严重的后果。不用责备C++标准的制订者的短视,我理解设计和实现有效的异常处理是多么的难。当我们遭遇到这些无意中的后果时,我将展示它们对你代码的微妙影响,并且推荐一些技巧来减轻其影响。
1. 实例剖析EH
到现在为止,我仍然逗留在C和C++的范围内,但这次要稍微涉及一下汇编语言。目标:初步揭示Visual C++对EH的throw和catch的实现。本文不是巨细无遗的,毕竟我的原则是只关注(C/C++)语言本身。然而,简单的揭示EH的实现对理解和信任EH大有帮助。
1.1 我们所害怕的唯一一件事
在throw过程中退栈时,EH追踪哪个局部对象需要析构,预先安排必须的析构函数的调用,并且将控制权交给正确的异常处理函数。为了完成EH所需的记录和管理工作,编译器暗中在生成的代码中注入了数据、指令和库引用。
不幸的是,很多程序员(以及他们的经理)讨厌这种注入行为导致过分的代码膨胀。他们感到恐慌,认为EH会削弱程序的使用价值。所以,我认为EH触及了人们对未知的恐惧:因为源码中没有明确地表露出EH的工作,他们将作最坏的估算。
为了战胜这种恐惧,让我们通过短小的Visual C++代码剖析EH。
1.2 例1:基线版本
生成一个新的C++源文件EH.cpp如下:
class C
{
public:
C()
{
}
~C()
{
}
};
void f1()
{
C x1;
}
int main()
{
f1();
return 0;
}
然后,创建一个新的Visual C++控制台项目,并包含EH.CPP为唯一的源文件。使用默认项目属性,但打开“生成源码/汇编混合的.asm文件”选项。编译出Debug版本。在我机器上,得到的EH.exe是23,040字节。
打开EH.asm文件,你将发现f1()函数非常接近预料:设置栈框架,调用xl的构造和析构函数,然后重设栈框架。特别地,你将注意到没有任何EH产物或记录――并不奇怪,因为程序没有抛出或捕获任何异常。
1.3 例2:单异常处理函数
现在将f1改为如下形式:
void f1()
{
C x1;
try
{
}
catch(char)
{
}
}
重新编译EH.exe,然后注意文件大小。在我机器上,大小从23,040字节增到29,696字节。有些心跳吧,EH导致了29%的文件大小的增加。但看一下绝对增加,才6,656字节,并且绝大部分是来自于固定大小的库开销。剩下的少量才是额外注入到EH.obj中的代码和数据。
在EH.asm中,可以找到符号__$EHRec$定义了一个常量值,它表示对于栈框架的偏移量。每个函数都在其生成的代码中引用了__$EHRec$,编译器暗中定义了一个局部的“EH记录”记录对象。
EH记录是暂时的:和需要在代码中有个永久的静态记录相比,它们存在于栈中,在函数被进入时产生,在函数退出是消失。在且仅在函数需要提早析构局部对象时,编译器增加了EH记录(并且由局部代码维护它)。
隐含意思是,有些函数不需要EH记录。看这个,增加的第二个函数:
void f2()
{
}
没有涉及对象和异常。重新编译程序。EH.asm显示f1()的栈中和以前一样包括一个EH记录,但f2()的栈中没有。然而,如果将代码改成这样:
void f2()
{
C x2;
f1();
}
f2()现在定义了一个局部的EH记录,即使f2()自己没有try块。为什么?因为f2()调用了f1(),而f1()可能抛出异常而终止f2(),因此需要提早析构x2。
结论:如果一个包含局部对象的函数没有明确处理异常,但可能传递一个别人抛的异常,那么函数仍然需要一个EH记录和相应的维护代码。
这使你苦恼了吗?只要短路异常链就可以了。在我们的例子中,将f1()的定义改成:
void f1() throw()
{
C x1;
try
{
}
catch(char)
{
}
}
现在f1()承诺不抛异常。结果,f2()不需要传递f1()的异常,也就不需要EH记录了。你可以重新编译程序来核实,查看EH.asm并发现f2()的代码不再提到__$EHRec$。
1.4 例3:多个异常处理函数
EH记录及其支撑代码不是编译所引入的唯有的记录。对给定try块的每个处理函数,编译器也都创建了入口表。想看得清楚些,将现在的EH.asm改名另存,并将f1()扩展为:
void f1() throw()
{
C x1;
try
{
}
catch(char)
{
}
catch(int)
{
}
catch(long)
{
}
catch(unsigned)
{
}
}
重新编译,然后比较两次的EH.asm。
(提醒:下面列出的EH.asm,我没有忽略不相关的东西,也没有用省略号代替什么。精确的标号名在你的系统上可能不一样。并且不要以汇编语言分析器的眼光看这些代码。)
在我的EH.asm中,相关的名字、描述符和注释如下:
PUBLIC ; char `RTTI Type Descriptor'
PUBLIC ; int `RTTI Type Descriptor'
PUBLIC ; long `RTTI Type Descriptor'
PUBLIC ; unsigned int `RTTI Type Descriptor'
_DATA SEGMENT
DD FLAT:??_7type_info@@6B@ ; char `RTTI Type Descriptor'
DD ...
DB '.D', ...
_DATA ENDS
_DATA SEGMENT
DD FLAT:??_7type_info@@6B@ ; int `RTTI Type Descriptor'
DD ...
DB '.H', ...
_DATA ENDS
_DATA SEGMENT
DD FLAT:??_7type_info@@6B@ ; long `RTTI Type Descriptor'
DD ...
DB '.J', ...
_DATA ENDS
_DATA SEGMENT
DD FLAT:??_7type_info@@6B@ ; unsigned int `RTTI Type Descriptor'
DD ...
DB '.I', ...
_DATA ENDS
(对于“RTTI Type Descriptor”和“type_info”的注释提示我,Visual C++在EH和RTTI时使用了同样的类型名描述符。)
编译器同样生成了对在段中定义的类型描述符的引用。每个类型对应一个捕获这种类型的异常处理函数的地址。这种描述符/处理函数对构成了EH库代码分发异常时的分发表。这些也是从我的EH.asm下摘抄的,加上了注释和图表:
xdata$x SEGMENT
$T214 DD ...
DD ...
DD FLAT:$T217 ;---+
DD ... ; |
DD FLAT:$T218 ;---|---+
DD 2 DUP(...) ; | |
ORG $+4 ; | |
; | |
$T217 DD ... ;<--+ |
DD ... ; |
DD ... ; |
DD ... ; |
; |
$T218 DD ... ;<------+
DD ...
DD ...
DD 04H ; # of handlers
DD FLAT:$T219 ;---+
ORG $+4 ; |
; |
$T219 DD ... ;<--+
DD FLAT:??_R0D@8 ; char RTTI Type Descriptor
DD ...
DD FLAT:$L206 ; catch(char) address
DD ...
DD FLAT:??_R0H@8 ; int RTTI Type Descriptor
DD ...
DD FLAT:$L207 ; catch(int) address
DD ...
DD FLAT:??_R0J@8 ; long RTTI Type Descriptor
DD ...
DD FLAT:$L208 ; catch(long) address
DD ...
DD FLAT:??_R0I@8 ; unsigned int RTTI Type Descriptor
DD ...
DD FLAT:$L209 ; catch(unsigned int) address
xdata$x ENDS
分发表表头(标号$T214、 $T217和 $T218处的代码)是f1()专属的,并为f1()的所有异常处理函数共享。$T219出的分发表的每一个入口项都特属于f1()的一个特定的异常处理函数。
更一般地,编译器为每一带try块的函数生成一个分发表表头,为每一个异常处理函数增加一个入口项。类型描述符为程序的所有分发表共享。(例如,程序中所有catch(long)的处理函数引用同样的类型描述符。)
提要:要减小EH的空间开销,应该将程序中捕获异常的函数数目减到最小,将函数中异常处理函数的数目减到最小,将异常处理函数所捕获的异常类型减到最小。
1.5 例四:抛异常
用“抛一个异常”来将所有东西融会起来。将f1()的try语句改成这样:
try
{
throw 123; // type 'int' exception
}
重新编译程序,打开EH.asm,注意新出现的东西(我同样加了的注释和图表)。
; in these exported names, 'H' is the RTTI Type Descriptor
; code for 'int' -- which matches the data type of
; the thrown exception value 123
PUBLIC __TI1H
PUBLIC __CTA1H
PUBLIC
; EH library routine that actually throws exceptions
EXTRN
; new static data blocks used by library
; when throwing 'int' exception
xdata$x SEGMENT
DD ... ;<------+
DD FLAT:??_R0H@8 ; | is RTTI 'int'
; | Type Descriptor
DD ... ; |
DD ... ; |
ORG $+4 ; |
DD ... ; |
DD ... ; |
; |
__CTA1H DD ... ;<--+ |
DD FLAT:__CT??_R0H@84 ;---|---+
; |
__TI1H DD ... ; | __TI1H is argument passed to
DD ... ; |
DD ... ; |
DD FLAT:__CTA1H ;---+
xdata$x ENDS
和类型描述符一样,这些新的数据块为全部程序共享,例如,所有抛int异常代码引用__TI1H. 。同样要注意:相同的类型描述符被异常处理函数和throw语句引用。
翻到f1()处,相关部分如下:
;void f1() throw()
; {
; try
; {
...
push $L224 ; Address of code to adjust stack frame via handler
; dispatch table. Invoked by .
...
; throw 123;
push OFFSET FLAT:__TI1H ; Address of data area diagramed
; above
mov DWORD PTR $T213[ebp], 123 ; 123 is the exception's value
lea eax, DWORD PTR $T213[ebp]
push eax
call ; Call into EH library, which in
; turn eventually calls $L224
; and $L216 a.k.a. 'catch(int)'
; }
; // ...
; catch(int)
$L216:
; {
mov eax, $L182 ; Return to EH library, which jumps to $L182
ret 0
; }
; // ...
$L182:
; // Call local-object destructors, clean up stack, return
; }
$L224: ; This label referenced by 'try' code.
mov eax, OFFSET FLAT:$T223 ; $T223 is handler dispatch table, what
; had previously been label $T214
; before we added 'throw 123'
jmp ___CxxFrameHandler ; internal library routine
当程序运行时,(EH的库函数)调用了$L216,catch(int)处理函数的地址。当处理函数一结束,程序就继续顺EH库中的代码向下运行,跳到$L224,继续向下并最终跳到$L182。这个标号是f1()的终止和cleanup代码的地址,在其中调用了x1的析构函数。你可以在调试器下用单步进行验证。
1.6 小结
所有的异常处理体系都导致开销。除非你愿意在没有任何异常安全体系的情况下执行代码,你必须同意付出速度和空间的代价。EH作为语言的特性有优点的:编译器明确知道EH的实现并可以据此优化它。
除了编译器的优化,你自己还有很多方法来优化。在以后的文章中,我将揭示特定的方法来将EH的代价减到最小。有些方法是基于标准C++的,其它则依赖于Visual C++的具体实现。
1. C++的new和delete操作时的异常处理
今天,我们开始学习C++的new和delete操作时的异常处理。首先,我将介绍标准C++运行库对new和delete操作的支持。然后,介绍伴随着这些支持的异常。
1.1 New和Delete表达式
当写
B *p = new D;
这里,B和D是class类型,并且有构造和析构函数,编译器实际产生的代码大约是这样的:
B *p = operator new(sizeof(D));
D::D(p);
过程是:
l new操作接受D对象的大小(字节为单位)作为参数。
l new操作返回一块大小足以容纳一个D对象的内存的地址。
l D的缺省构造函数被调用。这个构造函数传入的this指针就是刚刚返回的内存地址。
l 最终结果:*p是个完整构造了的对象,静态类型是B,动态类型是D。
相似的,语句
delete p;
差不多被编译为
D::~D(p);
operator delete(p);
D的析构函数被调用,被传入的this指针是p;然后delete操作释放被分配的内存。
new操作和delete操作其实是函数。如果你没有提供自己的版本,编译器会使用标准C++运行库头文件中申明的版本:
void *operator new(std::size_t);
void operator delete(void *);
和其它标准运行库函数不同,它们不在命名空间std内。
因为编译器隐含地调用这些函数,所以它必须知道如何寻找它们。如果编译器将它们放在特别的空间内(如命名空间std),你就无法申明自己的替代版本了。因此,编译器按绝对名字从里向外进行搜索。如果你没有申明自己的版本,编译器最终将找到在中申明的全局版本。
这个头文件包含了8个new/delete函数:
//
// new and delete
//
void *operator new(std::size_t);
void delete(void *);
//
// array new and delete
//
void *operator new[](std::size_t);
void delete[](void *);
//
// placement new and delete
//
void *operator new(std::size_t, void *);
void operator delete[](void *, void *);
//
// placement array new and delete
//
void *operator new[](std::size_t, void *);
void operator delete[](void *, void *);
前两个我已经介绍了。接下来两个分配和释放数组对象,而最后四个根本不分配和释放任何东西!
1.2 数组new和数组delete
new[]操作被这样的表达式隐含调用:
B *p = new D[N];
编译器对此的实现是:
B *p = operator new[](sizeof(D) * N + _v);
for (std::size_t _i(0); _i < N; ++_i)
D::D(&p[_i]);
前一个例子分配和构造单个D对象,这个例子分配和构造一个有N个D对象的数组。注意,传给new[]操作的字节大小是sizeof(D)*N + _v,所有对象的总大小加_v。在这里, _v是数组分配时的额外开销。
如你所想,
delete[] p;
实现为:
for (std::size_t _i(_N_of(p)); _i > 0; --_i)
D::~D(&p[i-1]);
operator delete[](p);
这里,_N_of(p)是个假想词,它依赖于你的编译器在检测*p中的元素个数时的实现体系。
和p = new D[N]不同(它明确说明了*p包含N个元素),delete[] p没有在编译期明确说明*p元素个数。你的程序必须在运行期推算元素个数。C++标准没有强制规定推算的实现体系,而我所见过的编译器共有两种实现方法:
l 在*p前面的字节中保存元素个数。其存储空间来自于new[]操作时_v字节的额外开销。
l 由标准运行库维护一个私有的N对p的映射表。
1.3 Placement New 和 Placement Delete
关键字new可以接受参数:
p = new(arg1, arg2, arg3) D;
(C++标准称这样的表达式为 “new with placement”或“placement new”,我马上会简单地解释原因。)这些参数会被隐含地传给new操作函数:
p = operator new(sizeof(D), arg1, arg2, arg3);
注意,第一个参数仍然是要生成对象的字节数,其它参数总是跟在它后面。
标准运行库定义了一个new操作的特别重载版本,它接受一个额外参数:
void *operator new(std::size_t, void *);
这种形式的new操作被如下的语句隐含调用:
p = new(addr) D;
这里,addr是某些数据区的地址,并且类型兼容于void *。
addr传给这个特别的new操作,这个特别的new操作和其它new操作一样返回将被构造的内存的地址,但不需要在自由内存区中再申请内存,它直接将addr返回:
void *operator new(std::size_t, void *addr)
{
return addr;
}
这个返回值然后被传给D::D作构造函数的this指针。
就这样,表达式
p = new(addr) D;
在addr所指的内存上构造了一个D对象,并将p赋为addr的值。这个方法让你有效地指定新生成对象的位置,所以被叫作“placement new”。
这个new的额外参数形式最初被设计为控制对象的位置的,但是C++标准委员会认识到这样的传参体系可以被用于任意用途而不仅是控制对象的位置。不幸的是,术语“placement”已经被根据最初目的而制订,并适用于所有new操作的额外参数的形式,即使它们根本不试图控制对象的位置。
所以,下面每个表达式都是placement new的一个例子:
new(addr) D; // calls operator new(std::size_t, void *)
new(addr, 3) D; // calls operator new(std::size_t, void *, int)
new(3) D; // calls operator new(std::size_t, int)
即使只有第一个形式是一般被用作控制对象位置的。
1.4 placement Delete
现在,只要认为 placement delete 是有用处的就行了。我肯定会讲述理由的,可能就在接下来的两篇内。
Placement new操作和placement delete操作必须成对出现。一般来说,每一个
void *operator new(std::size_t, p1, p2, p3, ..., pN);
都对应一个
void operator delete(void *, p1, p2, p3, ..., pN);
根据这条原则,标准运行库定义了
void operator delete(void *, void *);
以对应我刚讲的placement new操作。
1.5 数组New和数组Delete
基于对称,标准运行库也申明了placement new[]操作和placement delete[]操作:
void *operator new[](std::size_t, void *);
void operator delete[](void *, void *);
如你所料:placement new[]操作返回传入的地址,而placement delete[]操作的行为和我没有细述的placement delete操作行为几乎一样。
1.6 异常
现在,我们把这些new/delete和异常结合起来。再次考虑这条语句:
B *p = new D;
当其调用new操作而没有分配到足够内存时将发生什么?
在C++的黑暗年代(1994年及以前),对大部分编译器而言,new操作将返回NULL。这曾经是对C的malloc函数的合理扩展。幸运的是,我们现在生活在光明的年代,编译器强大了,类被设计得很漂亮,而编译运行库的new操作会抛异常了。
前面,我展示了在中出现的8个函数的申明。那时,我做了些小手脚;这里是它们的完整形式:
namespace std
{
class bad_alloc
{
// ...
};
}
//
// new and delete
//
void *operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();
//
// array new and delete
//
void *operator new[](std::size_t) throw(std::bad_alloc);
void operator delete[](void *) throw();
//
// placement new and delete
//
void *operator new(std::size_t, void *) throw();
void operator delete(void *, void *) throw();
//
// placement array new and delete
//
void *operator new[](std::size_t, void *) throw();
void operator delete[](void *, void *) throw();
在这些new操作族中,只有非placement形式的会抛异常(std::bad_alloc)。这个异常意味着内存耗尽状态,或其它内存分配失败。你可能奇怪为什么placement形式不抛异常;但记住,这些函数实际上根本不分配任何内存,所以它们没有分配问题可报告。
没有delete操作抛异常。这不奇怪,因为delete不分配新内存,只是将旧内存还回去。
1.7 异常消除
相对于会抛异常的new操作形式,中也申明了不抛异常的重载版本:
namespace std
{
struct nothrow_t
{
// ...
};
extern const nothrow_t nothrow;
}
//
// new and delete
//
void *operator new(std::size_t, std::nothrow_t const &) throw();
void operator delete(void *, std::nothrow_t const &) throw();
//
// array new and delete
//
void *operator new[](std::size_t, std::nothrow_t const &) throw();
void operator delete[](void *, std::nothrow_t const &) throw();
这几个函数也被认为是new操作和delete操作的placement形式,因为它们也接收额外参数。和前面的控制对象分配位置的版本不同,这几个只是让你分辨出抛异常的new和不抛异常的new。
#include
#include
using namespace std;
int main()
{
int *p;
//
// 'new' that can throw
//
try
{
p = new int;
}
catch(bad_alloc &)
{
cout << "'new' threw an exception";
}
//
// 'new' that can't throw
//
try
{
p = new(nothrow) int;
}
catch(bad_alloc &)
{
cout << "this line should never appear";
}
//
return 0;
}
注意两个new表达式的重要不同之处:
p = new int;
在分配失败时抛std::bad_alloc,而
p = new(nothrow) int;
在分配失败时不抛异常,它返回NULL(就象malloc和C++黑暗年代的new)。
如果你不喜欢nothrow的语法,或你的编译器不支持,你可以这样达到同样效果:
#include
//
// function template emulating 'new(std::nothrow)'
//
template
T *new_nothrow() throw()
{
T *p;
try
{
p = new T;
}
catch(std::bad_alloc &)
{
p = NULL;
}
return p;
}
//
// example usage
//
int main()
{
int *p = new_nothrow(); // equivalent to 'new(nothrow) int'
return 0;
}
这个模板函数与它效仿的new(nothrow)表达式同有一个潜在的异常安全漏洞。现在,我将它作为习题留给你去找出来。(恐怕没什么用的提示:和placement delete有关。)
1.8 小结
new和delete是怪兽。和typeid一起,它们是C++中仅有的会调用标准运行库中函数的关键字。即使程序除了main外不明确调用或定义任何函数,new和delete语句的出现就会使程序调用运行库。如我在这儿所示范的,调用运行库将经常可能抛异常或处理异常。
本篇的例程中的代码和注释是用于我对C++标准的解释的。不幸的是,如我以前所说,Microsoft的Visual C++经常不遵守C++标准。在下一篇中,我将揭示Visual C++的运行库对new和delete的支持在什么地方背离了C++标准。我将特别注意在对异常的支持上的背离,并且将展示怎么绕过它们。
1. Microsoft对于的实现版本中的异常处理
上次,我讲述了标准运行库头文件中申明的12个全局函数中的异常行为。这次我将开始讨论Microsoft对这些函数的实现版本。
在Visual C++ 5中,标准运行库头文件提供了这些申明:
namespace std
{
class bad_alloc;
struct nothrow_t;
extern nothrow_t const nothrow;
};
void *operator new(size_t) throw(std::bad_alloc);
void operator delete(void *) throw();
void *operator new(size_t, void *);
void *operator new(size_t, std::nothrow_t const &) throw();
和在第五部分中讲述的标准所要求的相比,Microsoft的头文件版本缺少:
l 所有(三种)形式的operator new[]
l 所有(三种)形式的operator delete[]
l Placement operator delete(void *, void *)
l Placement operator delete(void *, std::nothrow_t const &)
并且,虽然运行库申明了operator new抛出std::bad_alloc,但函数的行为并不符合标准。
如果你使用Visaul C++ 6,头文件有同样的缺陷,只是它申明了operator delete(void *, void *)。
1.1 数组
Visual C++在标准运行库的实行中没有定义operator new[]和operator delete[]形式的版本。幸好,你可以构建自己的版本:
#include
void *operator new(size_t)
{
printf("operator new\n");
return 0;
}
void operator delete(void *)
{
printf("operator delete\n");
}
void *operator new[](size_t)
{
printf("operator new[]\n");
return 0;
}
void operator delete[](void *)
{
printf("operator delete[]\n");
}
int main()
{
int *p;
p = new int;
delete p;
p = new int[10];
delete[] p;
}
/* When run should yield
operator new
operator delete
operator new[]
operator delete[]
*/
为什么Visual C++的标准运行库缺少这些函数?我不能肯定,猜想是“向后兼容”吧。
operator new[]和operator delete[]加入C++标准比较晚,并且许多年来编译器们还不支持它,所有支持分配用户自定义对象的编译器都定义了operator new和operator delete,并且即使是分配数组对象也将调用它们。
如果一个以前不支持operator new[]和operator delete[]的编译器开始支持它们时,用户自定义的全局operator new和operator delete函数将不再在分配数组对象时被调用。程序仍然能编译和运行,但行为却变了。程序员甚至没法知道变了什么,因为编译器没有报任何错。
1.2 无声的变化
这些无声的变化给写编译器的人(如Microsoft)出了个难题。要知道,C++标准发展了近10年。在此期间,编译器的卖主跟踪标准的变化以确保和最终版本的最大程度兼容。同时,用户依赖于当前可用的语言特性,即使不能确保它们在标准化的过程中得以幸存。
如果标准的一个明显变化造成了符合前标准的程序的行为的悄然变化,编译器的卖主有三种选择:
1. 坚持旧行为,不理符合新标准的代码
2. 改到新行为,不理符合旧标准的代码
3. 让用户指定他们想要的行为
在此处的标准运行库提供operator new[]和operator delete[]的问题上,Micrsoft选择了1。我自己希望他们选择3,对这个问题和其它所有Visual C++不符合标准之处。他们可以通过#pragmas、编译选项或环境变量来判断用户的决定的。
Visual C++长期以来通过形如/Za的编译开关来实行选择3,但这个开关有一个未公开的行为:它关掉了一些标准兼容的特性,然后打开了另外一些。我期望的(想来也是大部分人期望的)是一个完美的调节方法来打开和关闭标准兼容的特性!
(在这个operator new[]和operator delete[]的特例中,我建议你开始使用容器类(如vector)来代替数组,但这是另外一个专栏的事情了。)
1.3 异常规格申明
Microsoft的头文件正确地申明了非placement的operator new:
void *operator new(std::size_t) throw(std::bad_alloc);
你可以定义自己的operator new版本来覆盖运行库的版本,你可能写成:
void *operator new(std::size_t size) throw(std::bad_alloc)
{
void *p = NULL;
// ... try to allocate '*p' ...
if (p == NULL)
throw std::bad_alloc();
return p;
}
如果你保存上面的函数,并用默认选项编译,Visual C++不会报错。但,如果你将警告级别设为4,然后编译,你将遇到这个信息:
warning C4290: C++ Exception Specification ignored
那么好,如果你自己的异常规格申明不能工作,肯定,运行库的版本也不能。保持警告级别为4,然后编译:
#include
我们已经知道,它申明了一个和我们的程序同样的异常规格的函数。
奇怪啊,奇怪!编译器没有警告,即使在级别4!这是否意味着运行库的申明有些奇特属性而我们的没有?不,它事实上意味着Micorsoft的欺骗行为:
l 包含了标准运行库头文件。
l 包含了非标头文件xstddef。
l xstddef包含了另一个非标头文件yvals.h。
l yvals.h包含了指令#pragma warning(disable:4290)。
l #pragma关闭了特定的级别4的警告,我们在自己的代码中看到的那条。
结论:Visual C++在编译期检查异常规格申明,但在运行期忽略它们。你可以给函数加上异常申明(如throw(std::bad_alloc)),编译器会正确地分析它们,但在运行期这个申明没有效果,就象根本没有写过。
1.4 怎么会这样
在这个专栏的第三部分,我讲述了异常规格申明的形式,却没有解释其行为和效果。Visual C++对异常规格申明的不完全支持给了我一个极好的机会来解释它们。
异常规格申明是函数及其调用者间契约的一部分。它完整列举了函数可能抛出的所有异常。(用标准中的说法,被称为函数 “允许”特定的异常。)
换句话说就是,函数不允许(承诺不抛出)其它任何不在申明中的异常。如果申明有但为空,函数根本不允许任何异常;相反,如果没有异常规格申明,函数允许任何异常。
除非函数与调用者间的契约是强制性的,否则它根本就不值得写出来。于是你可能会想,编译器应该在编译时确保函数没有撒谎:
void f() throw() // 'f' promises to throw no exceptions...
{
throw 1; // ... yet it throws one anyway!
}
惊讶的是,它在Visual C++中编译通过了。
不要认为Visual c++有病,这个例子可以用任何兼容C++的编译器编译通过。我从标准(sub clause 15.4p10)中引下来的:
C++的实现版本不该拒绝一个表达式,仅仅是因为它抛出或可能抛出一个其相关函数所不允许的异常。例如:
extern void f() throw(X, Y);
void g() throw(X)
{
f(); //OK
}
调用f()的语句被正常编译,即使当调用时f()可能抛出g()不允许的异常Y。
是不是有些特别?那么好,如果编译器不强制这个契约,将发生什么?
1.5 运行期系统
如果函数抛出了一个它承诺不抛的异常,运行期系统调用标准运行库函数unexpected()。运行库的缺省unexpected()的实现是调用terminate()来结束函数。你可以调用set_unexpected()函数安装新的unexpected()处理函数而覆盖其缺省行为。
这只是理论。但如前面的Visual C++警告所暗示,它忽略了异常规格申明。因此,Visual C++运行期系统不会调用unexpected()函数,当一个函数违背其承诺时。
要试一下你所喜爱的编译器的行为,编译并运行下面这个小程序:
#include
#include
using namespace std;
void my_unexpected_handler()
{
throw bad_exception();
}
void promise_breaker() throws()
{
throw 1;
}
int main()
{
set_unexpected(my_unexpected_handler);
try
{
promise_breaker();
}
catch(bad_exception &)
{
printf("Busted!");
}
catch(...)
{
printf("Escaped!");
}
return 0;
}
如果程序输出是:
Busted!
则,运行期系统完全捕获了违背异常规格申明的行为。反之,如果输出是:
Escaped!
则运行期系统没有捕获违背异常规格申明的行为。
在这个程序里,我安装了my_unexepected_handler()来覆盖运行库的缺省unexpected()处理函数。这个自定义的处理函数抛出一个std::bad_exception类型的异常。此类型有特别的属性:如果unexpected()异常处理函数抛出此类型,此异常能够被(外面)捕获,程序将继续运行而不被终止。在效果上,这个bad_exception对象代替了原始的抛出对象,并向外传播。
这是假定了编译器正确地检测了unexpected异常,在Visual C++中,my_unexpected_handler() 没有并调用,原始的int型异常抛到了外面,违背了承诺。
1.6 模拟异常规格申明
如果你愿意你的设计有些不雅,就可以在Visual C++下模拟异常规格申明。考虑一下这个函数的行为:
void f() throw(char, int, long)
{
// ... whatever
}
假设一下会发生什么?
l 如果f()没有发生异常,它正常返回。
l 如果f()发生了一个允许的异常,异常传到f()外面。
l 如果f()发生了其它(不被允许)的异常,运行期系统调用unexpected()函数。
要在Visual C++下实现这个行为,要将函数改为:
void f() throw(char, int, long)
{
try
{
// ... whatever
}
catch(char)
{
throw;
}
catch(int)
{
throw;
}
catch(long)
{
throw;
}
catch(...)
{
unexpected();
}
}
Visual C++一旦开始正确支持异常规格申明,它的内部代码必然象我在这儿演示的。这意味着异常规格申明将和try/catch块一样导致一些代价,就象我在第四部分中演示的。
因此,你应该明智地使用异常规格申明,就象你使用其它异常部件。任何时候你看到一个异常规格申明,你应该在脑子里将它们转化为try/catch队列以正确地理解其相关的代价。
1.7 预告
placement delete的讨论要等到下次。将继续讨论更多的通行策略来异常保护你的设计。
1. 部分构造及placement delete
讨论在一般情况下的部分构造、动态生成对象时的部分构造,以及用 placement delete来解决部分构造问题。
C++标准要求标准运行库头文件提供几个operator delete的重载形式。在这些重载形式中,Visual C++ 6缺少:
l void operator delete(void *, void *)
而Visual C++ 5缺少:
l void operator delete(void *, void *)
l void operator delete(void *, std::nothrow_t const &)
这些重载形式支持placement delete表达式,并解决了一个特殊问题:释放部分构造的对象。在这次和接下来一次,我将给出一般情况下的部分构造、动态生成对象时的部分构造,以及用 placement delete来解决部分构造问题的例子。
1.1 部分构造
看这个例子:
// Example 1
#include
class A
{
public:
A()
{
throw 0;
}
};
int main()
{
try
{
A a;
}
catch(...)
{
std::cout <<"caught exception" << std::endl;
}
return 0;
}
因为A的构造函数抛出了一个异常,a对象没有完全构造。在这个例子中,没有构造函数有可见作用:因为A没有子对象,构造函数实际上没有任何操作。但,考虑这样的变化:
// Example 2
#include
class B
{
public:
B()
{
throw 0;
}
};
class A
{
private:
B const b;
};
// ... main same as before ...
现在,A的构造函数不是无行为的,因为它构造了一个B成员对象,而它里面会抛异常。程序对这个异常作出什么反应?
从C++标准中摘下了四条(稍作了简化)原则:
l 一个对象被完全构造,当且仅当它的构造函数已经完全执行,而它的析构函数还没开始执行。
l 如果一个对象包含子对象,包容对象的构造函数只有在所有子对象被完全构造后才开始执行。
l 一个对象被析构,当且仅当它被完全构造。
l 对象按它们被构造的反序进行析构。
因为抛出了一个异常,B::B没有被完全执行。因此,B的对象A::b既没有被完全构造也没有被析构。
要证明这点,跟踪相应的类成员:
// Example 3
#include
class B
{
public:
B()
{
std::cout << "B::B enter" << std::endl;
throw 0;
std::cout << "B::B exit" << std::endl;
}
~B()
{
std::cout << "B::~B" << std::endl;
}
};
class A
{
public:
A()
{
std::cout << "A::A" << std::endl;
}
~A()
{
std::cout << "A::~A"<< std::endl;
}
private:
B const b;
};
// ... main same as before ...
当运行时,程序将只输出
B::B enter
caught exception
从而显示出对象a和b既没有完全构造也没有析构。
1.2 多对象
使例子变得更有趣和更有说明力,把它改得允许部分(不是全部)对象被完全构造:
// Example 4
#include
class B
{
public:
B(int const ID) : ID_(ID)
{
std::cout << ID_ << " B::B enter" < if (ID_ > 2)
throw 0;
std::cout << ID_ << " B::B exit" < }
~B()
{
std::cout << ID_ << " B::~B" < }
private:
int const ID_;
};
class A
{
public:
A() : b1(1), b2(2), b3(3)
{
std::cout <<"A::A" << std::endl;
}
~A()
{
std::cout <<"A::~A" << std::endl;
}
private:
B const b1;
B const b2;
B const b3;
};
// ... main same asbefore ...
注意B的构造函数现在接受一个对象ID值的参数。用它作B的对象的唯一标记并决定对象是否完全构造。大部分跟踪信息以这些ID开头,显示为:
1 B::B enter
1 B::B exit
2 B::B enter
2 B::B exit
3 B::B enter
2 B::~B
1 B::~B
caught exception
b1和b2完全构造而b3没有。所以,b1和b2被析构而b3没有。此外,b1和b2的析构按其构造的反序进行。最后,因为一个子对象(b3)没有完全构造,包容对象a也没有完全构造和析构。
1.3 动态分配对象
将类A改为其成员变量是动态生成的:
// Example 5
#include
// ... class B same as before ...
class A
{
public:
A() : b1(new B(1)), b2(new B(2)), b3(new B(3))
{
std::cout <<"A::A" << std::endl;
}
~A()
{
delete b1;
delete b2;
delete b3;
std::cout <<"A::~A" << std::endl;
}
private:
B * const b1;
B * const b2;
B * const b3;
};
// ... main same as before ...
这个形式符合C++习惯用法:在包容对象的构造函数里分配成员变量,并对其填充数据,然后在包容对象的析构函数里释放它们。
编译并运行例5。输出是:
1 B::B enter
1 B::B exit
2 B::B enter
2 B::B exit
3 B::B enter
caught exception
其结果与例4相似,但有一个巨大的不同:因为~A没有被执行,其中的delete语句也就没有执行,被成功分配的*b1和*b2的析构函数也没有调用。例四中的不妙状况(三个对象析构了两个)现在更差了(三个对象一个都没有析构)。
实际上,没有比这更坏的了。记住,delete b1语句有两个作用:
l 调用*b1的析构函数~b。
l 调用operator delete释放*b1所占有的内存。
所以我们不光是遇到~B没有被调用所导致的问题,还有每个B对象造成的内存泄漏问题。这不是件好事。
B对象是A私有的,它们是实现细节,对程序的其它部分是不可见的。用动态生成B的子对象来代替自动生成B的子对象不该改变程序的外在行为,这表明了我们的例子在设计上的缺陷。
1.4 析构动态生成的对象
为了最接近例4的行为,我们需要在任何情况强迫delete语句的执行。将它们放入~A明显不起作用。我们需要找个能起作用的地方,我们知道它能被执行的地方。跳入脑海的解决方法中,最优雅的方法来自于C++标准运行库:
// Example 6
#include
#include
// ... class B same as before ...
class A
{
public:
A() : b1(new B(1)), b2(new B(2)), b3(new B(3))
{
std::cout << "A::A" << std::endl;
}
~A()
{
std::cout << "A::~A" << std::endl;
}
private:
std::auto_ptr const b1;
std::auto_ptr const b2;
std::auto_ptr const b3;
};
// ... main same as before ...
auot_ptr读作“auto-pointer”。如名所示,auoto-pointer表现为通常的指针和自动对象的混合体。
std::auto_ptr是在中申明的类模板。一个std::auto_ptr类型的对象的表现非常象一个通常的B*类型对象,关键的不同是:auto_ptr是一个实实在在的类对象,它有析构函数,而这个析构函数将在B*所指对象上调用delete。最终结果是:动态生成的B对象如同是个自动B对象一样被析构。
可以把一个auto_ptr对象当作对动态生成的B对象的简单包装。在包装消失(析构)时,它也将被包装对象带走了。要实际看这个魔术戏法,编译并运行例6。结果是:
1 B::B enter
1 B::B exit
2 B::B enter
2 B::B exit
3 B::B enter
2 B::~B
1 B::~B
caught exception
Bingo!输出和例4相同。
你可能会奇怪为什么没有为b3调用~B。这表明了auto_ptr包装上的失败?根本不是。我们所读过的规则还在起作用。对b3进行的构造函数的调用接受了new B(3)传过来的参数。于是发生了一个异常终止了b3的构造。因为b3没有完全构造,它同样不会析构。
藏在atuo-pointer后面的想法没有新的地方;string对象实际上就是char数组的auto-pointer型包装。虽然如此,我仍然期望有一天我能更详细的讨论auto_ptr及其家族,目前只要把auto_ptr当作一个保证发生异常时能析构动态生成的对象的简单方法。
1.5 预告
既然b3的析构函数没有被调用,也就没有为其内存调用delete。如前面所见,被包装的B对象受到两个影响:
l 析构函数~B没有被调用。这是意料中的甚至是期望中的,因为B对象在先前没有完全构造。
l 内存没有被通过operator delete释放。不管是不是意料中的,它绝不是期望中的,因为B对象所占用的内存被分配了,即使B对象没有在此内存中完全构造。
我需要operator delete被调用,即使~B没有被调用。要实现这点,编译器必须在脱离delete语句的情况下调用operator delete。因为我知道b3是我的例子中的讨厌对象,我可以显式地为b3的内存调用operator delete;但要知道这只是教学程序,通常情况下我们不能预知哪个构造函数将失败。
不,我们所需要的是编译器检测到动态生成对象时的构造函数失败时隐含调用operator delete来释放对象占用的内存。这有些效仿编译器在自动对象构造失败时的行为:对象的内存如同程序体中的无用单元一样,是可回收的。
幸好,它有个大喜结局。要看这个结局,需到下回。在下回结束时,我将揭示C++语言如何提供了这个完美特性,为什么标准运行库申明了placement operator delete,以及为什么你可能想在自己的库或类中做同样的事。
1. 自动删除,类属new和delete、placement new 和placement delete
在上次结束时,我期望道:当一个新产生的对象在没有完全构造时,它所占用的内存能自动释放。很幸运,C++标准委员会将这个功能加入到了语言中(而不幸的是,这个特性加得太晚了,许多编译器还不支持它)。Visual C++ 5和6都支持这个“自动删除”特性(但,如我们将要看到的,Visual C++ 5的支持是不完全的)。
1.1 自动删除
要实际验证它,在上次的例6中增加带跟踪信息的operator new和operator delete函数:
// Example 7
#include
#include
#include
#include
void *operator new(size_t const n)
{
printf(" ::operator new\n");
return malloc(n);
}
void operator delete(void *const p)
{
std::cout << " ::operator delete" << std::endl;
free(p);
}
class B
{
public:
B(int const ID) : ID_(ID)
{
std::cout << ID_ << " B::B enter" << std::endl;
if (ID_ > 2)
{
std::cout << std::endl;
std::cout << " THROW" << std::endl;
std::cout << std::endl;
throw 0;
}
std::cout << ID_ << " B::B exit" << std::endl;
}
~B()
{
std::cout << ID_ << " B::~B" << std::endl;
}
private:
int const ID_;
};
class A
{
public:
A() : b1(new B(1)), b2(new B(2)), b3(new B(3))
{
std::cout << " A::A" << std::endl;
}
~A()
{
std::cout << " A::~A" << std::endl;
}
private:
std::auto_ptr const b1;
std::auto_ptr const b2;
std::auto_ptr const b3;
};
int main()
{
try
{
A a;
}
catch(...)
{
std::cout << std::endl;
std::cout << " CATCH" << std::endl;
std::cout << std::endl;
}
return 0;
}
程序将用我们自己的operator new和operator delete代替标准运行库提供的版本。这样,我们将能跟踪所有的动态创建对象时的分配和释放内存操作。(我同时小小修改了其它的跟踪信息,以便输出信息更容易读。)
注意,我们的operator new调用了printf而不是std::cout。本来,我确实使用了std::cout,但程序在运行库中产生了一个无效页错误。调试器显示运行库在初始化std::cout前调用了operator new,而operator new又试图调用还没有初始化的std::cout,程序于是崩溃了。
我在Visual C++ 6中运行程序,得到了头大的输出:
::operator new
::operator new
::operator new
::operator new
::operator new
::operator new
::operator delete
::operator delete
::operator new
::operator new
::operator new
::operator new
::operator new
::operator new
::operator delete
::operator delete
1 B::B enter
1 B::B exit
::operator new
2 B::B enter
2 B::B exit
::operator new
3 B::B enter
THROW
::operator delete
2 B::~B
::operator delete
1 B::~B
::operator delete
CATCH
::operator delete
::operator delete
::operator delete
::operator delete
::operator delete
Blech.
我无法从中分辨出有用的信息。原因很简单:我们的代码,标准运行库的代码,以及编译器暗中生成的代码都调用了operator new和operator delete。我们需要一些方法来隔离出我们感兴趣的调用过程,并只输出它们的跟踪信息。
1.2 类属new和delete
C++又救了我们。不用跟踪全局的operator new和operator delete,我们可以跟踪其类属版本。既然我们感兴趣的是B对象的分配和释放过程,我们只需将operator new和operator delete移到类B中去:
// Example 8
#include
#include
class B
{
public:
void *operator new(size_t const n)
{
std::cout << " B::operator new" << std::endl;
return ::operator new(n);
}
void operator delete(void *const p)
{
std::cout << " B::operator delete" << std::endl;
operator delete(p);
}
// ... rest of class B unchanged
};
// ... class A and main unchanged
编译器将为B的对象调用这些函数,而为其它对象的分配和释放调用标准运行库中的函数版本。
通过在你自己的类这增加这样的局部操作函数,你可以更好的管理动态创建的此类型对象。例如,嵌入式系统的程序员经常在特殊映射的设备或快速内存中分配某些对象,通过其类型特有的operator new和operator delete,可以控制如何及在哪儿分配这些对象。
对我们的例子,特殊的堆管理是没必要的。因此,我在类属operator new 和operator delete中调用了其全局版本而不再是malloc和free,并去除了对头文件的包含。这样,所有对象的分配和释放的实际语义保持了一致。
同时,因为我们的operator new不在在全局范围内,它不会被运行库在构造std::cout前调用,于是我可以在其中安全地调用std::cout了。因为不再调用printf,我也去掉了。
编译并运行例8。将发现输出信息有用多了:
B::operator new
1 B::B enter
1 B::B exit
B::operator new
2 B::B enter
2 B::B exit
B::operator new
3 B::B enter
THROW
B::operator delete
2 B::~B
B::operator delete
1 B::~B
B::operator delete
CATCH
三个B::operator new的跟踪信息对应于a.b1、a.b2和a.b3的构造。其中,a.b1和a.b2被完全构造(它们的构造函数都进入并退出了),而a.b3没有(它的构造函数只是进入了而没有退出)。注意这个:
3 B::B enter
THROW
B::operator delete
它表明,调用a.b3的构造函数,在其中抛出了异常,然后编译器自动释放了a.b3占用的内存。接下来的跟踪信息:
2 B::~B
B::operator delete
1 B::~B
B::operator delete
表明被完全构造的对象a.b2和a.b1在释放其内存前先被析构了。
结论:所有完全构造的对象的析构函数被调用,所有对象的内存被释放。
1.3 Placement new
例8使用了“普通的”非Placement new语句来构造三个B对象。现在考虑这个变化:
// Example 9
// ... preamble unchanged
class B
{
public:
void *operator new(size_t const n, int)
{
std::cout << " B::operator new(int)" << std::endl;
return ::operator new(n);
}
// ... rest of class B unchanged
};
class A
{
public:
A() : b1(new(0) B(1)), b2(new(0) B(2)), b3(new(0) B(3)) {
std::cout << " A::A" << std::endl;
}
// ... rest of class A unchanged
};
// ... main unchanged
这个new语句
new(0) B(1)
有一个placement参数0。因为参数的类型是int,编译器需要operator new的一个接受额外int参数的重载版本。我已经增加了一个满足要求的B::operator new函数。这个函数实际上并不使用这个额外参数,此参数只是个占位符,用来区分 placement new还是非placement new 的。
因为Visual C++ 5不完全支持 placement new和 placement delete,例9不能在其下编译。程序在Visual C++ 6下能编译,但在下面这行上生成了三个Level 4的警告:
A() : b1(new(0) B(1)), b2(new(0) B(2)), b3(new(0) B(3))
内容都是:
'void *B::operator new(unsigned int, int)':
no matching operator delete found;
memory will not be freed if initialization
throws an exception
想知道编译器为什么警告,运行程序,然后和例8比较输出:
B::operator new(int)
1 B::B enter
1 B::B exit
B::operator new(int)
2 B::B enter
2 B::B exit
B::operator new(int)
3 B::B enter
THROW
2 B::~B
B::operator delete
1 B::~B
B::operator delete
CATCH
输出是相同的,只一个关键不同:
3 B::B enter
THROW
和例8一样的是,a.b3的构造函数进入了并在其中抛出了异常;但和例8不同的是,a.b3的内存没有自动删除。我们应该留意编译器的警告的!
1.4 最后,Placement delete!
想要“自动删除”能工作,一个匹配抛异常的对象的operator new的operator delete的重载版本必须可用。摘自 C++标准 (subclause 5.3.4p19, "New"):
如果参数的数目相同并且除了第一个参数外其类型一致(在作了参数的自动类型转换后),一个placement的释放函数与一个placement的分配函数相匹配。所有的非palcement的释放函数匹配于一个非placement的分配函数。如果找且只找到一个匹配的释放函数,这个函数将被调用;否则,没有释放函数被调用。
因此,对每个placement分配函数
void operator new(size_t, P2, P3, ..., Pn);
都有一个对应的placement释放函数
void *operator delete(void *, P2, P3, ..., Pn);
这里
P2, P3, ..., Pn
一般是相同的参数队列。我说“一般”是因为,根据标准的说法,可以对参数进行一些转换。再引于标准(subclause 8.3.5p3, "Functions"),基于可读性稍作了修改:
在提供了参数类型列表后,将对这些类型作一些转换以决定函数的类型:
l 所有参数类型的const/volatile描述符修饰将被删除。这些cv描述符修饰只影响形参在函数体中的定义,不影响函数本身的类型。
例如:类型
void (*)(const int)
变为
void (*)(int)
l 如果一个存储类型描述符修饰了一个参数类型,此描述符被删除。这存储类型描述符修饰只影响形参在函数体中的定义,不影响函数本身的类型。
例如:
register char *
变成
char *
转换后的参数类型列表才是函数的参数类型列表。
顺便提一下,这个规则同样影响函数的重载判断,signatures和name mangling。基本上,函数参数上的cv描述符和存储类型描述符的出现不影响函数的身份。例如,这意味着下列所有申明引用的是同一个函数的定义。
l void f(int)
l void f(const int)
l void f(register int)
l void f(auto const volatile int)
增加匹配于我们的placement operator new的placement operator delete函数:
// Example 10
// ... preamble unchanged
class B
{
public:
void operator delete(void *const p, int)
{
std::cout << " B::operator delete(int)" << std::endl;
::operator delete(p);
}
// ... rest of class B unchanged
};
// ... class A and main unchanged
然后重新编译并运行。输出是:
B::operator new(int)
1 B::B enter
1 B::B exit
B::operator new(int)
2 B::B enter
2 B::B exit
B::operator new(int)
3 B::B enter
THROW
B::operator delete(int)
2 B::~B
B::operator delete
1 B::~B
B::operator delete
CATCH
和例8非常相似,每个operator new匹配一个operator delete。
一个可能奇怪的地方:所有B对象通过placement operator new分配,但不是全部通过placement operator delete释放。记住,placement operator delete只(在plcaement operator new失败时)被调用于自动摧毁部分构造的对象。完全构造的对象将通过delete语句手工摧毁,而delete语句调用非placement operator delete。(WQ注:没有办法调用placement delete语句,只能调用plcaement operator delete函数,见9.2。)
1.5 光阴似箭
在第九部分,我将展示placement delete是多么地灵巧(远超过现在展示的),但有小小的隐瞒和简化。并示范一个新的机制来在构造函数(如A::A)中更好地容忍异常。
1. placement new 和placement delete,及处理构造函数抛出的异常
当被调用了来清理部分构造时,operator delete的第一个void *参数带的是对象的地址(刚刚由对应的operator new返回的)。operator delete的所有额外placement参数都和传给operator new的相应参数的值相匹配。
在代码里,语句
p = new(n1, n2, n3) T(c1, c2, c3);
的效果是
p = operator new(sizeof(T), n1, n2, n3);
T(p, c1, c2, c3);
如果T(p, c1, c2, c3)构造函数抛出了一个异常,程序暗中调用
operator delete(p, n1, n2, n3);
原则:当释放一个部分构造的对象时,operator delete从原始的new语句知道上下文。
1.1 Placement operator delete的参数
要证明这点,增强我们的例子来跟踪相应的参数值:
// Example 11
#include
#include
class B
{
public:
B(int const ID) : ID_(ID)
{
std::cout << ID_ << " B::B enter" << std::endl;
if (ID_ > 2)
{
std::cout << std::endl;
std::cout << " THROW" << std::endl;
std::cout << std::endl;
throw 0;
}
std::cout << ID_ << " B::B exit" << std::endl;
}
~B()
{
std::cout << ID_ << " B::~B" << std::endl;
}
//
// non-placement
//
void *operator new(size_t const n)
{
void *const p = ::operator new(n);
std::cout << " B::operator new(" << n <<
") => " << p << std::endl;
return p;
}
void operator delete(void *const p)
{
std::cout << " B::operator delete(" << p <<
")" << std::endl;
::operator delete(p);
}
//
// placement
//
void *operator new(size_t const n, int const i)
{
void *const p = ::operator new(n);
std::cout << " B::operator new(" << n <<
", " << i << ") => " << p << std::endl;
return p;
}
void operator delete(void *const p, int const i)
{
std::cout << " B::operator delete(" << p <<
", " << i << ")" << std::endl;
::operator delete(p);
}
private:
int const ID_;
};
class A
{
public:
A() : b1(new(11) B(1)), b2(new(22) B(2)), b3(new(33) B(3))
{
std::cout << " A::A" << std::endl;
}
~A()
{
std::cout << " A::~A" << std::endl;
}
private:
std::auto_ptr const b1;
std::auto_ptr const b2;
std::auto_ptr const b3;
};
int main()
{
try
{
A a;
}
catch(...)
{
std::cout << std::endl;
std::cout << " CATCH" << std::endl;
std::cout << std::endl;
}
return 0;
}
用Visual C++ 6编译并运行。在我的机器上的输出是:
B::operator new(4, 11) => 007E0490
1 B::B enter
1 B::B exit
B::operator new(4, 22) => 007E0030
2 B::B enter
2 B::B exit
B::operator new(4, 33) => 007E0220
3 B::B enter
THROW
B::operator delete(007E0220, 33)
2 B::~B
B::operator delete(007E0030)
1 B::~B
B::operator delete(007E0490)
CATCH
注意这些数字:
l 4是每个被分配的B对象的大小的字节数。这个值在不同的C++实现下差异很大。
l 如007E0490这样的值是operator new返回的对象的地址,作为this指针传给T的成员函数的,并作为void *型指针传给operator delete。你看到的值几乎肯定和我的不一样。
l 11,22和33是最初传给operator new的额外placement参数,并在部分构造时传给相应的placement operator delete。
1.2 手工调用operator delete
所有这些operator new和operator delete的自动匹配是很方便的,但它只在部分构造时发生。对通常的完全构造,operator delete不是被自动调用的,而是通过明确的delete语句间接调用的:
p = new(1) B(2); // calls operator new(size_t, int)
// ...
delete p; // calls operator delete(void *)
这样的顺序其结果是调用placement operator new和非placement operator delete,即使你有对应的(placement)operator delete可用。
虽然你很期望,但你不能用这个方法强迫编译器调用placement operator delete:
delete(1) p; // error
而必须手工写下delete语句将要做的事:
p->~B(); // call *p's destructor
B::operator delete(p, 1); // call placement
// operator delete(void *, int)
要和自动调用operator delete时的行为保持完全一致,你必须保存通过new语句传给operator new的参数,并将它们手工传给operator delete。
p = new(n1, n2, n3) B;
// ...
p->~B();
B::operator delete(p, n1, n2, n3);
1.3 其它非placement delete
贯穿整个这个专题,我说了operator new和operator delete分类如下:
函数对
l void *operator new(size_t)
l void operator delete(void *)
是非placement分配和释放函数。
所有如下形式的函数对
l void *operator new(size_t, P1, ..., Pn)
l void operator delete(void *, P1, ..., Pn)
是placement分配和释放函数。
我这样说是因为简洁,但我现在必须承认撒了个小谎:
void operator delete(void *, size_t)
也可以是一个非placement释放函数而匹配于
void *operator new(size_t)
虽然它有一个额外参数。如你所猜想,operator delete的size_t参数带的是传给operator new的size_t的值。和其它额外参数不同,它是提供完全构造的对象用的。
在我们的例子中,将这个size_t参数加到非placement operator delete上:
// Example 12
// ... preamble unchanged
class B
{
void operator delete(void * const p, size_t const n)
{
std::cout << " B::operator delete(" << p <<
", " << n << ")" << std::endl;
::operator delete(p);
}
// ... rest of class B unchanged
};
// ... class A and main unchanged
The results:
B::operator new(4, 11) => 007E0490
1 B::B enter
1 B::B exit
B::operator new(4, 22) => 007E0030
2 B::B enter
2 B::B exit
B::operator new(4, 33) => 007E0220
3 B::B enter
THROW
B::operator delete(007E0220, 33)
2 B::~B
B::operator delete(007E0030, 4)
1 B::~B
B::operator delete(007E0490, 4)
CATCH
注意,为完全构造的对象,将额外的参数4提供给了operator delete。
1.4 显而易见的矛盾
你可能奇怪:C++标准允许非placement operator delete自动知道一个对象的大小,却否定了placement operator delete可具有相同的能力。要想使它们保持一致,一个placement分配函数
void *operator new(size_t, P1, P2, P3)
应该匹配于这样一个placement释放函数
void operator delete(void *, size_t, P1, P2, P3)
但事实不是这样,这两个函数不匹配。为什么语言被这样设计?我猜有两个原因:效率和清晰。
大部分情况下,operator delete不需要知道一个对象的大小;强迫函数任何时候都接受大小参数是低效的。并且,如果标准允许size_t参数可选,这样的含糊将造成:
void operator delete(void *, size_t, int)
在不同的环境下有不同的意义,决定它将匹配哪个:
void *operator new(size_t, int)
还是
void *operator new(size_t, size_t, int)
如果因下面的语句抛了个异常而被调用:
p = new(1) T; // calls operator new(size_t, int)
operator delete的size_t参数将是sizeof(T);但如果是被调用时是
p = new(1, 2) T; // calls operator new(size_t, size_t, int)
operator delete的size_t参数将是new语句的第一个参数值(这里是1)。于是,operator delete将不知道怎么解释它的size_t值。
我估计,你可能想知道是否非placement的函数
void operator delete(void *, size_t)
同时作为一个placement函数匹配于
void *operator new(size_t, size_t)
如果它被允许,operator delete将遇到前面讲的同样问题。而不被允许的话, C++标准将需要其规则的一个例外。
我没发现规则的这样一个例外。我试过几个编译器,— including EDG’s front end, my expert witness on such matters — 并认为:
void operator delete(void *, size_t)
实际上能同时作为一个placement释放函数和一个非placement释放函数。这是个重要的提醒。
如果你怀疑我,就将例12的placement operator delete移掉。
// Example 13
// ... preamble unchanged
class B
{
// void operator delete(void *const p, int const i)
// {
// std::cout << " B::operator delete(" << p <<
// ", " << i << ")" << std::endl;
// ::operator delete(p);>
// }
// ... rest of class B unchanged
};
// ... class A and main unchanged
现在,类里有一个operator delete匹配于两个operator new。其输出结果和例12仍然相同。(WQ注:结论是正确的,但不同的编译器下对例12到例14的反应相差很大,很是有趣!)
1.5 结束
两个最终要点:
l 贯穿我整个对::operator new和B::operator delete的讨论,我总是将函数申明为非static。通常这样的申明意味着有this指针存在,但这些函数的行为象它们没有this指针。实际上,在这些函数来试图引用this,你将发现代码不能编译。不象其它成员函数,operator new和operator delete始终是static的,即使你没有用static关键字。
l 无论我在哪儿提到operator new和operator delete,你都可以用operator new[] 和operator delete[]代替。相同的模式,相同的规则,和相同的观察结果。(虽然Visual C++标准运行库的中缺少operator new[]和operator delete[],编译器仍然允许你定义自己的数组版本。)
l
我想,这个结束了我对plcement new和delete及它们在处理构造函数抛出的异常时扮演的角色的解释。下次,我将介绍给你一个不同的技巧来容忍构造函数抛出的异常。
1. 从私有子对象中产生的异常
几部分来,我一直展示了一些技巧来捕获从对象的构造函数中抛出的异常。这些技巧是在异常从构造函数中漏出来后处理它们。有时,调用者需要知道这些异常,但通常(如我所采用的例程中)异常是从调用者并不关心的私有子对象中爆发的。使得用户要关心“不可见”的对象表明了设计的脆弱。
在历史上,(可能抛异常)的构造函数的实现者没有简单而健壮的解决方法。看这个简单的例子:
#include
class buffer
{
public:
explicit buffer(size_t);
~buffer();
private:
char *p;
};
buffer::buffer(size_t const count)
: p(new char[count])
{
}
buffer::~buffer()
{
delete[] p;
}
static void do_something_with(buffer &)
{
}
int main()
{
buffer b(100);
do_something_with(b);
return 0;
}
buffer的构造函数接受字符数目并从自由空间分配内存,然后初始化buffer::p指向它。如果分配失败,构造函数中的new语句产生一个异常,而buffer的用户(这里是main函数)必须捕获它。
1.1 try块
不幸的是,捕获这个异常不是件容易事。因为抛出来自buffer::buffer,所有buffer的构造函数的调用应该被包在try块中。没脑子的解决方法:
try
{
buffer b(count);
}
catch (...)
{
abort();
}
do_something_with(b); // ERROR. At this point,
// 'b' no longer exists
是不行的。do_something_with()的调用必须在try块中:
try
{
buffer b(100);
do_something_with(b);
}
catch (...)
{
abort();
}
//do_something_with(b);
(免得被说闲话:我知道调用abort()来处理这个异常有些过份。我只是用它做个示例,因为现在关心的是捕获异常而不是处理它。)
虽然有些笨拙,但这个方法是有效的。接着考虑这样的变化:
static buffer b(100);
int main()
{
// buffer b(100);
do_something_with(b);
return 0;
}
现在,b被定义为全局对象。试图将它包入try块
try // um, no, I don't think so
{
static buffer b;
}
catch (...)
{
abort();
}
int main()
{
do_something_with(b);
return 0;
}
将不能被编译。
1.2 暴露实现
每个例子都显示了buffer设计上的基本缺陷:buffer的接口以外的实现细节被暴露了。在这里,暴露的细节是buffer的构造函数中的new语句可能失败。这个语句用于初始化私有子对象buffer::p――一个main函数和其它用户不能操作甚至根本不知道的子对象。当然,这些用户更不应该被要求必须关注这样的子对象抛出的异常。
为了改善buffer的设计,我们必须在构造函数中捕获异常:
#include
class buffer
{
public:
explicit buffer(size_t);
~buffer();
private:
char *p;
};
buffer::buffer(size_t const count)
: p(NULL)
{
try
{
p = new char[count];
}
catch (...)
{
abort();
}
}
buffer::~buffer()
{
delete[] p;
}
static void do_something_with(buffer &)
{
}
int main()
{
buffer b(100);
do_something_with(b);
return 0;
}
异常被包含在构造函数中。用户,比如main()函数,从不知道异常存在过,世界又一次清静了。
1.3 常量成员
也这么做?注意,buffer::p一旦被设置过就不能再被改动。为避免指针被无意改动,谨慎的设计是将它申明为const:
class buffer
{
public:
explicit buffer(size_t);
~buffer();
private:
char * const p;
};
很好,但到了这步时:
buffer::buffer(size_t const count)
{
try
{
p = new char[count]; // ERROR
}
catch (...)
{
abort();
}
}
一旦被初始化,常量成员不能再被改变,即使是在包含它们的对象的构造函数体中。常量成员只能被构造函数的成员初始化列表设置一次。
buffer::buffer(size_t const count)
: p(new char[count]) // OK
这让我们回到了段落一中,又重新产生了我们最初想解决的问题。
OK,这么样如何:不用new语句初始化p,换成用内部使用new的辅助函数来初始化它:
char *new_chars(size_t const count)
{
try
{
return new char[count];
}
catch (...)
{
abort();
}
}
buffer::buffer(int const count)
: p(new_chars(count))
{
// try
// {
// p = new char[count]; // ERROR
// }
// catch (...)
// {
// abort();
// }
}
这个能工作,但代价是一个额外函数却仅仅用来保护一个几乎从不发生的事件。
1.4 函数try块
(WQ注:后面会讲到,function try块不能阻止构造函数的抛异常动作,它其实只起异常过滤的功能!!!见P14.3)
我在上面这些建议中没有发现哪个能确实令人满意。我所期望的是一个语言级的解决方案来处理部分构造子对象问题,而又不引起上面说到的问题。幸运的是,语言中恰好包含了这样一个解决方法。
在深思熟虑后,C++标准委员会增加了一个叫做“function try blocks”的东西到语言规范中。作为try块的堂兄弟,函数try块捕获整个函数定义中的异常,包括成员初始化列表。不用奇怪,因为语言最初没有被设计了支持函数try块,所以语法有些怪:
buffer::buffer(size_t const count)
try
: p(new char[count])
{
}
catch
{
abort();
}
看起来想是通常的try块后面的{}实际上是划分构造函数的函数体的。在效果上,{}有双重作用,不然,我们将面对更别扭的东西:
buffer::buffer(int const count)
try
: p(new char[count])
{
{
}
}
catch
{
abort();
}
(注意:虽然嵌套的{}是多余的,这个版本能够编译。实际上,你可以嵌套任意重{},直到遇到编译器的极限。)
如果在初始化列表中有多个初始化,我们必须将它们放入同一个函数try块中:
buffer::buffer()
try
: p(...), q(...), r(...)
{
// constructor body
}
catch (std::bad_alloc)
{
// ...
}
和普通的try块一样,可以有任意个异常处理函数:
buffer::buffer()
try
: p(...), q(...), r(...)
{
// constructor body
}
catch (std::bad_alloc)
{
// ...
}
catch (int)
{
// ...
}
catch (...)
{
// ...
}
古怪的语法之外,函数try块解决了我们最初的问题:所有从buffer子对象的构造函数抛出的异常留在了buffer的构造函数中。
因为我们现在期望buffer的构造函数不抛出任何异常,我们应该给它一个异常规格申明:
explicit buffer(size_t) throw();
接着一想,我们应该是个更好点的程序员,于是给我们所有函数加了异常规格申明:
class buffer
{
public:
explicit buffer(size_t) throw();
~buffer() throw();
// ...
};
// ...
static void do_something_with(buffer &) throw()
// ...
Rounding Third and Heading for Home
对我们的例子,最终版本是:
#include
class buffer
{
public:
explicit buffer(size_t) throw();
~buffer() throw();
private:
char *const p;
};
buffer::buffer(size_t const count)
try
: p(new char[count])
{
}
catch (...)
{
abort();
}
buffer::~buffer()
{
delete[] p;
}
static void do_something_with(buffer &) throw()
{
}
int main()
{
buffer b(100);
do_something_with(b);
return 0;
}
用Visual C++编译,自鸣得意地坐下来,看着IDE的提示输出。
syntax error : missing ';' before 'try'
syntax error : missing ';' before 'try'
'count' : undeclared identifier
'' : function-style initializer appears
to be a function definition
syntax error : missing ';' before 'catch'
syntax error : missing ';' before '{'
missing function header (old-style formal list?)
噢!
Visual C++还不支持函数try块。在我测试过的编译器中,只有Edison Design Group C++ Front End version 2.42认为这些代码合法。
(顺便提一下,我特别关心为什么编译将第一个错误重复了一下。可能它的计算你第一次会不相信。)
如果你坚持使用Visual C++,你可以使用在介绍函数try块前所说的解决方法。我喜欢使用额外的new封装函数。如果你认同,考虑将它做成模板:
template
T *new_array(size_t const count)
{
try
{
return new T[count];
}
catch (...)
{
abort();
}
}
// ...
buffer::buffer(size_t const count)
: p(new_array(count))
{
}
这个模板比原来的new_chars函数通用得多,对char以外的类型也有能工作。同时,它有一个隐蔽的异常相关问题,而我将在下次谈到。
1. 异常规格申明
现在是探索C++标准运行库和Visual C++在头文件中申明的异常支持的时候了。根据C++标准(subclause 18.6,“Exception handling” )上的描述,这个头文件申明了:
l 从运行库中抛出的异常对象的基类。
l 任何抛出的违背异常规格申明的对象的可能替代物。
l 在违背异常规格申明的异常被抛出是被调用的函数,以及在其行为上增加东西的钩子(“hook”)。
l 在异常处理过程被终止时被调用的函数,以及在其行为上增加东西的钩子。
我从分析异常规格申明及程序违背它时遭到什么可怕后果开始。分析将针对上面提到的主题,以及通常C++异常处理时的一些杂碎。
1.1 异常规格申明回顾
异常规格申明是C++函数申明的一部分,它们指定了函数可以抛出什么异常。例如,函数
void f1() throw(int)
可以抛出一个整型异常,而
void f2() throw(char *, E)
可以抛出一个char *或一个E(这里E是用户自定义类型)类型的异常。一个空的规格申明
void f3() throw()
表明函数不抛出异常,而没有规格申明
void f4()
表明函数可以抛出任何东西。注意语法
void f4() throw(...)
比前面的“抛任何东西”的函数更好,因为它类似“捕获任何东西”
catch(...)
然而,认可“抛任何东西” 的函数就允许了那些在异常规格申明存在前写下的函数。
1.2 违背异常规格申明
迄今为止,我写的都是:函数可能抛出在它的异常规格申明中描述的异常。“可能”有些单薄,“必须”则有力些。“可能”表示了函数可以忽略它们的异常规格。你也许认为编译器将禁止这种行为:
void f() throw() // Promises not to throw...
{
throw 1; // ...but does anyway - error?
}
但你错了。用Visual C++试一下,你将发现编译器保持沉默,它没有发现编译期错误。实际上,在我所用过的编译器中,没有一个报了编译期错误。
话虽这么说,但异常规格申明有它的规则的,函数违背它将遭受严重后果的。不幸的是,这些后果表现在运行期错误而不是编译期。想看的话,把上面的小段代码放到一个完整程序中:
void f() throw()
{
throw 1;
}
int main()
{
f();
return 0;
}
当程序运行时将发生什么?f()抛出一个int型异常,违背了它的契约。你可能认为这个异常将从main()中漏入运行期库。基于这个假设,你倾向于使用一个简单的try块:
#include
void f() throw()
{
throw 1;
}
int main()
{
try
{
f();
}
catch (int)
{
printf("caught int\n");
}
return 0;
}
来捕获这个异常,以防止它漏出去。
实际上,如果你用Visual C++ 6编译并运行,你将得到:
caught int
你再次奇怪throw()异常规格实际做了什么有用的事,除了增加了源代码的大小和看起来比较快感。你的奇怪感觉将变得迟钝,只要一回想到前面说了多少Visual C++违背C++标准的地方,只不过再多一个新问题:Visaul C++正确地处理了违背异常规格申明的情况了吗?
1.3 调查说明……
没有!
这个程序的行为符合标准吗?catch语句不该进入的。来自于标准(subclauses 15.5.2 and 18.6.2.2):
l 一个异常规格申明保证只有被列出的异常被抛出。
l 如果带异常规格申明的函数抛出了一个没有列出的异常,函数
l void unexpected()在退完栈后立即被调用。
l 函数unexpected()将不会返回……
当一个函数试图抛出没有列出的异常时,通过unexpected()函数调用了一个异常处理函数。这个异常处理函数的默认实现是调用terminate() 来结束程序。
在我给你一个简短的例程后,我将展示Visual C++的行为怎么样地和标准不同。
1.4 unexpected()函数指南
unexpected()函数是标准运行库在头文件中申明的函数。和其它大部分运行库函数一样,unexpected()函数存在于命名空间std中。它不接受参数,也不返回任何东西,实际上unexpected()函数从不返回,就象abort()和exit()一样。如果一个函数违背了它自己的异常规格申明,unexpected()函数在退完栈后被立即调用。
基于我对标准的理解,运行库的unexpected()函数的实现理论上是这样的:
void _default_unexpected_handler_()
{
std::terminate();
}
std::unexpected_handler _unexpected_handler =
_default_unexpected_handler;
void unexpected()
{
_unexpected_handler();
}
(_default_unexpected_handler和_unexpected_handler是我虚构的名字。你的运行库的实现可能使用其它名称,完全取决于其实现。)
std::unexpected()调用一个函数来真正处理unexpected的异常。它通过一个隐藏的指针(_unexpected_handler,类型是std::unexpected_handler)来引用这个处理函数的。运行库提供了一个默认处理函数(default_unexpected_handler()),它调用std::terminate()来结束程序。
因为是通过指针_unexpected_handler间接调用的,你可以将内置的调用_default_unexpected_handler改为调用你自己的处理函数,只要这个处理函数的类型兼容于std::unexpected_handler:
typedef void (*unexpected_handler)();
同样,处理函数必须不返回到它的调用者(std::unexpected())中。没人阻止你写一个会返回的处理函数,但这样的处理函数不是标准兼容的,其结果是程序的行为有些病态。
你可以通过标准运行库的函数std::set_unexpected()来挂接自己的处理函数。注意,运行库只维护一个处理函数来处理所有的unexpected异常;一旦你调用了set_unexpected()函数,运行库将不再记得前一次的处理函数。(和atexit()比较一下,atexit()至少可以挂32重exit处理函数。)要克服这个限制,你要么在不同的时间设置不同的处理函数,要么使你的处理函数在不同的上下文时有不同的行为。
1.5 Visual C++ vs unexpected
试一下这个简单的例子:
#include
#include
#include
using namespace std;
void my_unexpected_handler()
{
printf("in unexpected handler\n");
abort();
}
void throw_unexpected_exception() throw(int)
{
throw 1L; // violates specification
}
int main()
{
set_unexpected(my_unexpected_handler);
throw_unexpected_exception();
printf("this line should never appear\n");
return 0;
}
用一个标准兼容的编译器编译并运行,程序结果是:
in unexpected handler
可能接下来是个异常异常终止的特殊(因为有abort()的调用)。但用Visual C++编译并运行,程序会抛出“Unhandled exception”对话框。关闭对话框后,程序输出:
this line should never appear
必须承认,Visual C++没有正确实现unexpected()。这个函数被申明在中,运行期库中有其实现,只不过这个实现不做任何事。
实际上,Visual C++甚至没有正确地申明,用这个理论上等价的程序可以证明:
#include
#include
#include
//using namespace std;
void my_unexpected_handler()
{
printf("in unexpected handler\n");
abort();
}
void throw_unexpected_exception() throw(int)
{
throw 1L; // violates specification
}
int main()
{
std::set_unexpected(my_unexpected_handler);
throw_unexpected_exception();
printf("this line should never appear\n");
return 0;
}
Visual C++不能编译这个程序。查看表明:set_unexpected_handler()被申明为全局函数而不是在命名空间std中。实际上,所有的unexpected族函数都被申明为全局函数。
底线:Visual c++能编译使用unexpected()等函数的程序,但运行时的行为是不正确的。
我希望Microsoft能在下一版中改正这些问题。在未改正前,当讨论涉及到unexpected()时,我建议你使用标准兼容的C++编译器。
1.6 维持程序存活
在我所展示的简单例子中,程序在my_unexpected_handler()里停止了。有时,让程序停止是合理和正确的;但更多情况下,程序停止是太刺激了,尤其是当unexpected异常表明的是程序只轻微错误。
假定你想处理unexpected异常,并恢复程序,就象对大多数其它“正常”异常一样。因为unexpected()从不返回,程序恢复似乎不可能,除非你看了标准的subclause 15.5.2:
unexpected()不该返回,但它可以throw(或re-throw)一个异常。如果它抛出一个新异常,而这异常是异常规格申明允许的,搜索另外一个异常处理函数的行为在调用unexpected()的地方继续进行。
太好了!如果my_unexpected_handler()抛出一个允许的异常,程序就能从最初的违背异常规格申明的地方恢复了。在我们的例子里,最初的异常规格申明允许int型的异常。根据上面的说法,如果my_unexpected_handler抛出一个int异常,程序将能继续了。
基于这种猜测,试一下:
#include
#include
void my_unexpected_handler()
{
printf("in unexpected handler\n");
throw 2; // allowed by original specification
//abort();
}
用标准兼容的编译器编译运行,程序输出:
in unexpected handler
program resumed
和期望相符。
抛出的int异常和其它异常一样顺调用链传递,并被第一个相匹配的异常处理函数捕获。在我们的例子里,程序的控制权从my_unexpected_handler()向std::unexpected()再向main()回退,并在main()中捕获异常。用这种方法,my_unexpected_handler()变成了一个异常转换器,将一个最初的“坏”的long型异常转换为一个“好”的int型异常。
结论:通过转换一个unexpected异常为expected异常,你能恢复程序的运行。
1.7 预告
下次,我将结束std::unexpected()的讨论:揭示在my_unexpected_handler()中抛异常的限制,探索运行库对这些限制的补救,并给出处理unexpected异常的通行指导原则。我也将开始讨论运行库函数std::terminate()的相关内容。
void throw_unexpected_exception() throw(int)
{
throw 1L; // violates specification
}
int main()
{
std::set_unexpected(my_unexpected_handler);
try
{
throw_unexpected_exception();
printf("this line should never appear\n");
}
catch (int)
{
printf("program resumed\n");
}
return 0;
}
1. unexpected()的实现上固有的限制
上次,我介绍了C++标准运行库函数unexpected(),并展示了Visual C++的实现版本中的限制。这次,我想展示所有unexpected()的实现上固有的限制,以及绕开它们的办法。
1.1 异常处理函数是全局的、通用的
我在上次简要地提过这点,再推广一点:过滤unexpected异常的异常处理函数unexpected()是全局的,对每个程序是唯一的。
所有unexpected异常都被同样的一个unexpected()异常处理函数处理。标准运行库提供默认的处理函数来处理所有unexpected异常。你可以用自己的版本覆盖它,这时,运行库会调用你提供的处理函数来处理所有的unexpected异常。
和普通的异常处理函数,如:
catch (int)
{
}
不同,unexpected异常处理函数不“捕获”异常。一旦被进入,它就知道有unexpected异常被抛出,但不知道类型和起因,甚至没法得到运行库的帮助:运行库中没有程序或对象保存这些讨厌的异常。
在最好的情况下,unexpected异常处理函数可以把控制权交给程序的其它部分,也许它们有更好的办法。例如:
#include
using namespace std;
void my_unexpected_handler()
{
throw 1;
}
void f() throw(int)
{
throw 1L; // oops -- *bad* function
}
int main()
{
set_unexpected(my_unexpected_handler);
try
{
f();
}
catch (...)
{
}
return 0;
}
f()抛出了一个它承诺不抛的异常,于是my_unexpected_handler()被调用。这个处理函数没有任何办法来判断它被进入的原因。除了结束程序外,它唯一可能有些用的办法是抛出另外一个异常,希望新异常满足被老异常违背的异常规格申明,并且程序的其它部分将捕获这个新异常。
在这个例子里,my_unexpected_handler()抛出的int异常满足老异常违背的异常规格申明,并且main()成功地捕获了它。但稍作变化:
#include
using namespace std;
void my_unexpected_handler()
{
throw 1;
}
void f() throw(char)
{
throw 1L; // oops -- *bad* function
}
int main()
{
set_unexpected(my_unexepected_handler);
try
{
f();
}
catch (...)
{
}
return 0;
}
my_unexpected_handler()仍然在unexpected异常发生后被调用,并仍然抛出了一个int型异常。不幸的是,int型异常现在和老异常违背的异常规格申明相违背。因此,我们现在两次违背了同一异常规格申明:第一次是f(),第二次是f()的援助者my_unexpected_handler()。
1.2 Terminate
现在,程序放弃了,并调用运行库的程序terminate()自毁。terminate()函数是标准运行库在异常处理上的最后一道防线。当程序的异常处理体系感到无望时,C++标准要求程序调用terminate()函数。C++标准的Subclause 15.5.1列出了调用terminate()的情况:
和unexpected()处理函数一样,terminate()处理函数也可以用户定义。但和unexpected()处理函数不同的是,terminate()处理函数必须结束程序。记住:当你的terminate()处理函数被进入时,异常处理体系已经无效了,此是程序所需要的最后一件事是找一个terminate()处理函数来丢弃异常。
在能避免时就不要让你的程序调用terminate()。terminate()其实是个叫得好听点的exit()。如果terminate()被调用了,你的程序就会以一种不愉快的方式死亡。
就如同不能完全支持unexpected()一样,Visual c++也不能完全支持terminate()。要在实际运行中验证的话,运行:
#include
#include
#include
using namespace std;
void my_terminate_handler()
{
printf("in my_terminate_handler\n");
abort();
}
int main()
{
set_terminate(my_terminate_handler);
throw 1; // nobody catches this
return 0;
}
根据C++标准,抛出了一个没人捕获的异常将导致调用terminate()(这是我前面提到的Subclause 15.5.1中列举的情况之一)。于是,上面的程序一个输出:
in my_terminate_handler
但,用Visual C++编译并运行,程序没有输出任何东西。
1.3 避免terminate
在我们的unexpected()例子中,terminate()最终被调用是因为f()抛出了unexpected异常。我们的unexpected_handler()试图阻住这个不愉快的事,通过抛出一个新异常,但没成功;这个抛出行为因再度产生它试图解决的那个问题而结束。我们需要找到一个方法以使得unexpected()处理函数将控制权传给程序的其它部分(假定那部分程序是足够聪明的,能够成功处掉异常)而不导致程序终止。
很高兴,C++标准正好提供了这样一个方法。如我们所看过的,从unexpected()处理函数中抛出的异常对象必须符合(老异常违背的)异常规格申明。这个规则有一个例外:如果如果被违背的异常规格申明中包含类型bad_exception,一个bad_exception对象将替代unexpected()处理函数抛出的对象。例如:
#include
#include
using namespace std;
void my_unexpected_handler()
{
throw 1;
}
void f() throw(char, bad_exception)
{
throw 1L; // oops -- *bad* function
}
int main()
{
set_unexpected(my_unexpected_handler);
try
{
f();
}
catch (bad_exception const &)
{
printf("caught bad_exception\n");
// ... even though such an exception was never thrown
}
return 0;
}
当用C++标准兼容的编译器编译并运行,程序输出:
caught bad_exception
当用Visual C++编译并运行,程序没输出任何东西。因为Visual c++并没有在第一次抛异常的地方捕获unexpected异常,它没有机会进行bad_exception的替换。
和前面的例子相同的是,f()仍然违背它的异常规格申明,而my_unexpected_handler()仍然抛出一个int。不同之处是:f()的异常规格申明包含bad_exception。结果,程序悄悄地将my_unexpected_handler()原来抛出的int对象替换为bad_exception对象。因为bad_exception异常是允许的,terminate()没有被调用,并且这个bad_exception异常能被程序的其它部分捕获。
最终结果:最初从f()抛出的long异常先被映射为int,再被映射为bad_exception。这样的映射不但避免了前面导致terminate的再次异常问题,还给程序的其它部分一个修正的机会。bad_exception异常对象的存在表明了某处最初抛出了一个unexpected异常。通过在问题点附近捕获这样的对象,程序可以得体地恢复。
我也注意到一个奇怪的地方。在代码里,你看到f()抛出了一个long,my_unexpected_handler()抛出了一个int,而没人抛出bad_exception,但main()确实捕获到一个bad_exception。是的,程序捕获了一个它从没抛出的对象。就我所知,唯一被允许发生这种行为的地方就是unexpected异常处理函数和bad_exception异常间的相互作用。
1.4 一个更特别的函数
C++标准定义了3个“特别”函数来捕获异常。其中,你已经看到了terminate()和unexpected()。最后,也是最简单的一个是uncaght_exception()。摘自C++标准(15.5.3):
函数bool uncaught_exception()在被抛出的异常对象完成赋值到匹配的异常处理函数的异常申明完成初始化之间返回true。包括其中的退栈过程。如果异常被再次抛出,uncaught_exception() 从再抛点到再抛对象被再次捕获间返回true。
uncaught_exception() 让你查看是否程序抛出了一个异常而还没有被捕获。这个函数对析构函数有特别意义:
#include
#include
using namespace std;
class X
{
public:
~X();
};
X::~X()
{
if (uncaught_exception())
printf("X::~X called during stack unwind\n");
else
printf("X::~X called normally\n");
}
int main()
{
X x1;
try
{
X x2;
throw 1;
}
catch (...)
{
}
return 0;
}
在C++标准兼容的环境下,程序输出:
X::~X called during stack unwind
X::~X called normally
x1和x2在main()抛出异常前构造。退栈时调用x2的析构函数。因为一个未被捕获的异常在析构函数调用期间处于活动状态,uncaught_exception()返回true。然后,x1的析构函数被调用(在main()退出时),异常已经恢复,uncaught_exception()返回false。
和以前一样,Visual C++在这里也不支持C++标准。在其下编译,程序输出:
X::~X called normally
X::~X called normally
如果你了解Microsoft的SEH(我在第二部分讲过的),就知道uncaught_exception()类似于SEH的AbnormalTermination()。在它们各自的应用范围内,两个函数都是检测是否一个被抛出的异常处于活动状态而仍然没有被捕获。
1.5 小结
大多数函数不直接抛异常,但将其它函数抛的异常传递出来。决定哪些异常被传递是非常困难的,尤其是来自于没有异常规格申明的函数的。bad_exception ()是一个安全的阀门,提供了一个方法来保护那些你不能进行完全解析的异常。
这些保护能工作,但,和普通的异常处理函数一样,需要你明确地设计它。对每个可能违背其异常规格申明的函数,你都必须记得在其异常规格申明中加一个bad_exception并在某处捕获它。bad_exception和其它异常没有什么不同:如果你不想捕获它,不去产生它就行了。一个没有并捕获的bad_exception将导致程序终止,就象在最初的地方你没有使用bad_exception进行替换一样。
异常规格申明使你意图明确。它说“这是我允许这个函数抛出的异常的集合;如果函数抛出了其它东西,不是我的设计错了就是程序有神经病(the program is buggy)”。一个unexpected异常,不管它怎么出现的,都表明了一个逻辑错误。我建议你最好让错误以一种可预见的方式有限度地发生。
所有这些表明你可以描绘你的代码在最开始时的异常的行为。不幸的是,这样的描绘接近于巫术。下次,我将给出一些指导方针来分析你的代码中的异常。
1. 异常安全
接下来两次,我将讨论“异常安全”,C++标准中使用了(在auto_ptr中)却没有定义的术语。在C++范围内,不同的作者使用这个术语却表达不同的含义。在我的专题中,我从两个方面来定义“异常安全”:
l 如果一个实体捕获或抛出一个异常,但仍然维持它公开保证的语义,它就是“接口安全”的。依赖于它保证的力度,实体可能不允许将任何异常漏给其用户。
l 如果异常没有导致资源泄漏或产生未定义的行为,实体就是“行为安全”的。“行为安全”一般是强迫的。幸运的是,如果做到了“行为安全”,通常也间接提供了“接口安全”。
异常安全有点象const:好的设计必须在一开始就考虑它,它不能够事后补救。但是我们开始使用异常还没有多少年,所以还没有“异常安全问题集”这样的东西来指导我们。实际上,我期望大家通过一条艰辛的道路来掌握异常安全:通过经历异常故障在编码时绕过它们;或关闭异常特性,认为它们“太难”被正确掌握。
我不想撒谎:分析设计上的异常安全性太难了。但是,艰辛的工作也有丰厚的回报。不过,这个主题太难了,想面面俱到的话将花我几个月的时间。我最小的目标是:通过缺乏异常安全的例子来展示怎么使它们变得安全,并激励你在此专题之外去看和学更多的东西。
1.1 构造函数
如果一个普通的成员函数
x.f()
抛出一个异常,你可以容忍此异常并试图再次调用它:
X x;
bool done;
do
{
try
{
done = true;
x.f();
}
catch (...)
{
// do something to recover, then retry
done = false;
}
}
while (!done);
但,如果你试图再次调用一个构造函数,你实际上是调用了一个完全不同的对象:
bool done(false);
while (!done)
{
try
{
done = true;
X x; // calls X::X()
}
// from this point forward, `x` does not exist
catch (...)
{
// do something to recover, then retry
done = false;
}
}
你不能挽救一个构造函数抛异常的对象;异常的存在表明那个对象已经死了。
当一个构造函数抛异常时,它杀死了其宿主对象而没有调用析构函数。这样的抛异常行为危害了“行为安全”:如果这个抛异常的构造函数分配了资源,你无法依赖析构函数释放它们。一般构造和析构是成对的,并期待后者清理前者。如果析构函数没有被调用,这个期望是不满足的。
最后,如果你从构造函数中抛了一个异常,并且你的类是用户类的一个基类或子对象,那么用户类的构造函数必须处理你抛出的异常。或者它将异常抛给另外一个用户类的构造函数,如此递推下去,直到程序调用terminate()。实际上用户必须做你没有做的工作(维持构造函数的安全性)。
1.2 关于取舍的问题
构造函数抛异常同时降低了接口安全和行为安全。除非有迫不得以的理由,不要让构造函数抛异常。
也有不同的意见认为:异常应该被本来就做这事的专门代码捕获的。那些只是静静地接收异常而没有处理它们的异常处理函数违背了这些异常的初衷。如果一个函数没有准备好正确地处理一个异常,它应该将这个异常传递下去。
最低事实是:必须有人处理异常;如果所有人都放过它,程序将终止。还必须同时捕获触发异常的条件;如果没人标记它,程序可能以任何方式终止,并且恐怕不怎么文雅。
一个异常对象警示我们存在一个不该忽略的错误状况。不幸的是,这个对象的存在可能导致一个全新的不同的错误状况。在设计异常安全的时候,你必须在两个有时冲突的设计原则间进行取舍。
1.在错误发生时进行通报
2.防止这个通报行为导致其它错误。
因为构造函数抛异常可能有有害的副作用,你必须小心权衡这两个原则。我不允许我写的构造函数中抛异常,这样设计倾向于原则2;但我不想将它推荐为普遍原则,在其它情况下这两个原则是等重的。自己好自判断吧。
1.3 析构函数
析构函数抛异常可能使程序有奇怪的反应。它可能彻底地杀死程序。根据C++标准(subclause 15.1.1,“the terminate() function” ),简述如下:
在某些情况下,异常处理必须被抛弃以减少一些微妙的错误。这些情况中包括:当因为异常而退栈过程中将要被析构的对象的析构函数。在这些情况下,函数void terminate()被调用。退栈不会完成。
简而言之,析构函数不该提示是否发生了异常。但,如我上次所说,新的C++标准运行库程序uncaught_exception()可以让析构函数确定其所处的异常环境。不幸的是,我上次也说了,Visual C++未能正确地支持这个函数。
问题比我提示的还要糟。我上次写到,Microsoft的uncaught_exception()函数版本一定返回false,所以Visaul C++总告诉你的析构函数当前没有发生异常,在其中抛异常是可以的。如果你从一个支持uncaught_exception的环境转到Visual C++,以前正常工作的代码可能开始调用terminate()了。
要尝试一下的话,试下面的例子:
#include
#include
#include
using namespace std;
static void my_terminate_handler(void)
{
printf("Library lied; I'm in the terminate handler.\n");
abort();
}
class X
{
public:
~X()
{
if (uncaught_exception())
printf("Library says not to throw.\n");
else
{
printf("Library says I'm OK to throw.\n");
throw 0;
}
}
};
int main()
{
set_terminate(my_terminate_handler);
try
{
X x;
throw 0;
}
catch (...)
{
}
printf("Exiting normally.\n");
return 0;
}
在C++标准兼容的环境下,你得到:
Library says not to throw.
Exiting normally.
但Visual C++下,你得到:
Library says I'm OK to throw.
Library lied; I'm in the terminate handler.
并跟随一个程序异常终止。
And with six you get egg roll.
建议:除非你确切知道你现在及以后所用的平台都正确支持uncaught_exception(),不要调用它。
1.4 部分删除
即使你知道当前不在处理异常,你仍然不应该在析构函数中抛异常。考虑如下的例子:
class X
{
public:
~X()
{
throw 0;
}
};
int main()
{
X *x = new X;
delete x;
return 0;
}
当main()执行到delete x,如下两步将依次发生:
x的析构函数被调用。
operator delete被调用了来释放x的内存空间。
但因为x的析构函数抛了异常,operator delete没有被调用。这危及了行为安全。如果还不信,试一下这个更完整的例子:
#include
#include
class X
{
public:
~X()
{
printf("destructor\n");
throw 0;
}
void *operator new(size_t n) throw()
{
printf("new\n");
return malloc(n);
}
void operator delete(void *p) throw()
{
printf("delete\n");
if (p != NULL)
free(p);
}
};
int main()
{
X *x = new X;
try
{
delete x;
}
catch (...)
{
printf("catch\n");
}
return 0;
}
如果析构函数没有抛异常,程序输出:
new
destructor
delete
实际上程序输出:
new
destructor
catch
operator delete没有进入,x的内存空间没有被释放,程序有资源泄漏,the press hammers your product for eating memory, and you go back to flipping burgers for a living。
原则:异常安全要求你不能在析构函数中抛异常。和在构造函数抛异常上有不同意见不一样,这条是绝对的。为了明确表明意图,应该在申明析构函数时加上异常规格申明throw()。
1.5 预告
我本准备覆盖模板安全的,但没地方了。我将留到下次介绍,并开出推荐读物表。
1. 模板安全
上次,我开始讨论异常安全。这次,我将探究模板安全。
模板根据参数的类型进行实例化。因为通常事先不知道其具体类型,所以也无法确切知道将在哪儿产生异常。你大概最期望的就是去发现可能在哪儿抛异常。这样的行为很具挑战性。
看一下这个简单的模板类:
template
class wrapper
{
public:
wrapper()
{
}
T get()
{
return value_;
}
void set(T const &value)
{
value_ = value;
}
private:
T value_;
wrapper(wrapper const &);
wrapper &operator=(wrapper const &);
};
如名所示,wrapper包容了一个T类型的对象。方法get()和set()得到和改变私有的包容对象value_。两个常用方法--拷贝构造函数和赋值运算符没有使用,所以没有定义,而第三个--析构函数由编译器隐含定义。
实例化的过程很简单,例如:
wrapper i;
包容了一个int。i的定义过程导致编译器从模板实例化了一个定义为wrapper的类:
template <>
class wrapper
{
public:
wrapper()
{
}
int get()
{
return value_;
}
void set(int const &value)
{
value_ = value;
}
private:
int value_;
wrapper(wrapper const &);
wrapper &operator=(wrapper const &);
};
因为wrapper只接受int或其引用(一个内嵌类型或内嵌类型的引用),所以不会触及异常。wrapper不抛异常,也没有直接或间接调用任何可能抛异常的函数。我不进行正规的分析了,但相信我:wrapper是异常安全的。
1.1 class类型的参数
现在看:
wrapper x;
这里X是一个类。在这个定义里,编译器实例化了类wrapper:
template <>
class wrapper
{
public:
wrapper()
{
}
X get()
{
return value_;
}
void set(X const &value)
{
value_ = value;
}
private:
X value_;
wrapper(wrapper const &);
wrapper &operator=(wrapper const &);
};
粗一看,这个定义没什么问题,没有触及异常。但思考一下:
l wrapper包容了一个X的子对象。这个子对象需要构造,意味着调用了X的默认构造函数。这个构造函数可能抛异常。
l wrapper::get()产生并返回了一个X的临时对象。为了构造这个临时对象,get()调用了X的拷贝构造函数。这个构造函数可能抛异常。
l wrapper::set()执行了表达式value_ = value,它实际上调用了X的赋值运算。这个运算可能抛异常。
在wrapper中针对不抛异常的内嵌类型的操作现在在wrapper中变成调用可能抛异常的函数了,同样的模板,同样的语句,但极其不同的含义。
由于这样的不确定性,我们需要采用保守的策略:假设wrapper会根据类来实例化,而这些类在其成员上没有异常规格申明,它们可能抛异常。
1.2 使得包容安全
再假设wrapper的异常规格申明承诺其成员不产生异常。至少,我们必须在其成员上加上异常规格申明throw()。我们需要修补掉这些可能导致异常的地方:
l 在wrapper::wrapper()中构造value_的过程。
l 在wrapper::get()中返回value_的过程。
l 在wrapper::set()中对value_赋值的过程。
另外,在违背throw()的异常规格申明时,我们还要处理std::unexpected。
1.3 Leak #1:默认构造函数
对wrapper的默认构造函数,解决方法看起来是采用function try块:
wrapper() throw()
try : T()
{
}
catch (...)
{
}
虽然很吸引人,但它不能工作。根据C++标准(paragraph 15.3/16,“Handling an exception”):
对构造或析构函数上的function-try-block,当控制权到达了异常处理函数的结束点时,被捕获的异常被再次抛出。对于一般的函数,此时是函数返回,等同于没有返回值的return语句,对于定义了返回类型的函数此时的行为为未定义。
换句话说,上面的程序相当于是:
X::X() throw()
try : T()
{
}
catch (...)
{
throw;
}
这不是我们想要的。
我想过这样做:
X::X() throw()
try
{
}
catch (...)
{
return;
}
但它违背了标准的paragraph 15:
如果在构造函数上的function-try-block的异常处理函数体中出现了return语句,程序是病态的。
我被标准卡死了,在用支持function try块的编译器试验后,我没有找到让它们以我所期望的方式运行的方法。不管我怎么尝试,所有被捕获的异常都仍然被再次抛出,违背了throw()的异常规格申明,并打败了我实现接口安全的目标。
原则:无法用function try块来实现构造函数的接口安全。
引申原则1:尽可能使用构造函数不抛异常的基类或成员子对象。
引申原则2:为了帮助别人实现引申原则1,不要从你的构造函数中抛出任何异常。(这和我在Part13中所提的看法是矛盾的。)
我发现C++标准的规则非常奇怪,因为它们减弱了function try的实际价值:在进入包容对象的构造函数(wrapper::wrapper())前捕获从子对象(T::T())构造函数中抛出的异常。实际上,function try块是你捕获这样的异常的唯一方法;但是你只能捕获它们却不能处理掉它们!
(WQ注:下面的文字原载于Part15上,我把提前了。
上次我讨论了function try块的局限性,并承诺要探究其原因的。我所联系的业内专家没人知道确切答案。现在唯一的共识是:
l 如我所猜测,标准委员会将function try块设计为过滤而不是捕获子对象构造函数中发生的异常的。
l 可能的动机是:确保没人误用没有构造成功的包容对象。
我写信给了Herb Sutter,《teh Exceptional C++》的作者。他从没碰过这个问题,但很感兴趣,以至于将其写入“Guru of the Week”专栏。如果你想加入这个讨论,到新闻组comp.lang.c++.moderated上去看“Guru of the Week #66: Constructor Failures”。
)
注意function try可以映射或转换异常:
X::X()
try
{
throw 1;
}
catch (int)
{
throw 1L; // map int exception to long exception
}
这样看,它们非常象unexpected异常的处理函数。事实上,我现在怀疑这才是它们的设计目的(至少是对构造函数而言):更象是个异常过滤器而不是异常处理函数。我将继续研究下去,以发现这些规则后面的原理。
现在,至少,我们被迫使用一个不怎么直接的解决方法:
template
class wrapper
{
public:
wrapper() throw()
: value_(NULL)
{
try
{
value_ = new T;
}
catch (...)
{
}
}
// ...
private:
T *value_;
// ...
};
被包容的对象,原来是在wrapper::wrapper()进入前构造的,现在是在其函数体内构造的了。这个变化可以让我们使用普通的方法来捕获异常而不用function try块了。
因为value_现在是个T *而不是T对象了,get()和set()必须使用指针的语法了:
T get()
{
return *value_;
}
void set(T const &value)
{
*value_ = value;
}
1.4 Leak #1A:operator new
在构造函数内的try块中,语句
value_ = new T;
隐含地调用了operator new来分配*value_的内存。而这个operator new函数可能抛异常。
幸好,我们的wrapper::wrapper()能同时捕获T的构造函数和operator new函数抛出的异常,因此维持了接口安全。但,记住这个关键性的差异:
l 如果T的构造函数抛了异常,operator delete被隐含调用了来释放分配的内存。(对于placement new,这取决于是否存在匹配的operator delete,我在part 8和9说过了的。)
l 如果operator new抛了异常,operator delete不会被隐含调用。
第二点本不该有什么问题:如果operator new抛了异常,通常是因为内存分配失败,operator delete没什么需要它去释放的。但,如果operator new成功分配了内存但因为其它原因而仍然抛了异常,它必须负责释放内存。换句话说,operator new自己必须是行为安全的。
(同样的问题也发生在通过operator nwe[]创建数组时。)
1.5 Leak #1B:Destructor
想要wrapper行为安全,我们需要它的析构函数释放new出来的内存:
~wrapper() throw()
{
delete value_;
}
这看起来很简单,但请等一下说大话!delete value_调用*value_的析构函数,而这个析构函数可能抛异常。要实现~wrapper()的接口异常,我们必须加上try块:
~wrapper() throw()
{
try
{
delete value_;
}
catch (...)
{
}
}
但这还不够。如果*value_的析构函数抛了异常,operator delete不会被调用了来释放*value_的内存。我们需要加上行为安全:
~wrapper() throw()
{
try
{
delete value_;
}
catch (...)
{
operator delete(value_);
}
}
仍然没结束。C++标准运行库申明的operator delete为
void operator delete(void *) throw();
它是不抛异常了,但自定义的operator delete可没说不抛。要想超级安全,我们应该写:
~wrapper() throw()
{
try
{
delete value_;
}
catch (...)
{
try
{
operator delete(value_);
}
catch (...)
{
}
}
}
但这还存在危险。语句
delete value_;
隐含调用了operator delete。如果它抛了异常,我们将进入catch块,一步步执行下去并再次调用同样的operator delete!我们将程序连续暴露在同样的异常下。这不会是个好程序的。
最后,记住:operator delete在被new出对象的构造函数抛异常时被隐含调用。如果这个被隐含调用的operator delete也抛了异常,程序将处于两次异常状态并调用terminate()。
原则:不要在一个可能在异常正被处理过程被调用的函数中抛异常。尤其是,不要从下列情况下抛异常:
l destructors
l operator delete
l operator delete[]
几个小习题:用auto_ptr代替value_,然后重写wrapper的构造函数,并决定其虚构函数的角色(如果需要的话),条件是必须保持异常安全。