1. Microsoft对于<new>的实现版本中的异常处理
上次,我讲述了标准运行库头文件<new>中申明的12个全局函数中的异常行为。这次我将开始讨论Microsoft对这些函数的实现版本。
在Visual C++ 5中,标准运行库头文件<new>提供了这些申明:
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的<new>头文件版本缺少:
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,<new>头文件有同样的缺陷,只是它申明了operator delete(void *, void *)。
1.1 数组
Visual C++在标准运行库的实行中没有定义operator new[]和operator delete[]形式的版本。幸好,你可以构建自己的版本:
#include <stdio.h>
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的<new>头文件正确地申明了非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 <new>
我们已经知道,它申明了一个和我们的程序同样的异常规格的函数。
奇怪啊,奇怪!编译器没有警告,即使在级别4!这是否意味着运行库的申明有些奇特属性而我们的没有?不,它事实上意味着Micorsoft的欺骗行为:
l <new>包含了标准运行库头文件<exception>。
l <exception>包含了非标头文件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 <exception>
#include <stdio.h>
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的讨论要等到下次。将继续讨论更多的通行策略来异常保护你的设计。