1. 模板安全
上次,我开始讨论异常安全。这次,我将探究模板安全。
模板根据参数的类型进行实例化。因为通常事先不知道其具体类型,所以也无法确切知道将在哪儿产生异常。你大概最期望的就是去发现可能在哪儿抛异常。这样的行为很具挑战性。
看一下这个简单的模板类:
template <typename T>
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<int> i;
包容了一个int。i的定义过程导致编译器从模板实例化了一个定义为wrapper<int>的类:
template <>
class wrapper<int>
{
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>只接受int或其引用(一个内嵌类型或内嵌类型的引用),所以不会触及异常。wrapper<int>不抛异常,也没有直接或间接调用任何可能抛异常的函数。我不进行正规的分析了,但相信我:wrapper<int>是异常安全的。
1.1 class类型的参数
现在看:
wrapper<X> x;
这里X是一个类。在这个定义里,编译器实例化了类wrapper<X>:
template <>
class wrapper<X>
{
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的子对象。这个子对象需要构造,意味着调用了X的默认构造函数。这个构造函数可能抛异常。
l wrapper<X>::get()产生并返回了一个X的临时对象。为了构造这个临时对象,get()调用了X的拷贝构造函数。这个构造函数可能抛异常。
l wrapper<X>::set()执行了表达式value_ = value,它实际上调用了X的赋值运算。这个运算可能抛异常。
在wrapper<int>中针对不抛异常的内嵌类型的操作现在在wrapper<X>中变成调用可能抛异常的函数了,同样的模板,同样的语句,但极其不同的含义。
由于这样的不确定性,我们需要采用保守的策略:假设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 <typename T>
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的构造函数,并决定其虚构函数的角色(如果需要的话),条件是必须保持异常安全。
1.6 题外话
我本准备一次维持异常安全的。但现在是第二部分,并仍然有足够的素材写成第三部分(我发誓那是最后的部分)。下次,我将讨论get()和set()上的异常安全问题,和今天的内容同样精彩。