
Jo Stichbury再次开放代码诊所,以提供关于Symbian C++的更多建议。
在本月的诊所文章中,Jo Stichbury研究了为什么在构造函数中发生异常退出是有害的,以及为什么首选使用二阶段构造。
本星期我将详细解答四月末在Symbian开发者网站讨论区论坛提出的问题。用户Simo Salminen对于一些文档中不建议在构造函数中包含可能发生异常退出代码的做法表示疑问[R1]。
首先,我们简单解释一下为什么在构造函数中发生异常退出是有害的。请看如下的代码,它在堆上为CWidget类型的对象分配空间:
CWidget* widget = new (ELeave) CWidget();
在代码执行时,如果有足够的内存,那么CWidget对象首先分配在堆上(如果没有足够内存,发生异常退出,不进行构造)。假设CWidget成功地分配在堆上,CWidget的构造函数将被调用,以初始化该对象。如果构造函数发生异常退出,那么构造函数可能已经成功分配的其它内存都会变成孤立内存,这是因为在构造阶段如果发生异常,析构函数不会被调用。这将导致内存泄漏。
在Symbian OS中,通常使用二阶段构造来允许可能发生异常退出的初始化代码能够安全调用[R2]。使用二阶段构造时,CWidget类的定义如下:
class CWidget : public CBase
{
public:
static CWidget* NewL();
static CWidget* NewLC();
~CWidget();// 必须处理部分已构造的对象
private:
CWidget(); // 保证不会发生异常退出
void ConstructL(); // 第二阶段构造代码,可能发生异常退出
...
};
这对于大多数Symbian C++开发者来说是很熟悉的,他们认为C类的二阶段构造是很自然的事情。然而,Simo Salminen发现这篇文档对此做了进一步的讨论。除了添加静态工厂函数,一个私有ConstructL()和与二阶段构造相关的样板代码,它还提出,只在CWidget构造函数中使用清理栈会如何呢?
CWidget::CWidget()
{
CleanupStack::PushL(this);
// 在这里调用可能发生异常退出的代码
CleanupStack::Pop(this);
}
现在,如果发生异常退出,那么分配给CWidget()的任意内存都会受到清理栈的保护。它肯定比静态NewL()和NewLC()工厂函数,以及私有ConstructL()函数更容易实现。当时该文档并不鼓励这种用法,它写道,“这在一些情况下可以工作,但是在另一些情况下就不行了,因为构造函数阶段的生命期问题,以及基类构造顺序”。这句话相当含糊,但是我也不得不承认当我在2004年编写Symbian OS高效编程时,也“胡说八道”地对这种做法不好的原因做了一番解释。我说到“初看起来,这可能很诱人,因为...[它]...可以使得安全退出,只要在构造函数中任何可能发生异常退出的操作都是在该对象被压入清理栈之后执行的。然而,如果该类作为基类使用,任何从该类继承而来的类的构造函数都会在继承体系的各个层次上调用PushL()(以及相应的Pop()),而不是NewL()函数中的一次清理栈操作。此外,从形式上来看,C++构造函数无法添加后缀L来表示其可能发生异常退出,除非该类自己的名字后缀就是这样。” 通过回顾,发现这是一个合理的解释,但是避免使用多个PushL()/Pop()对获得的性能增益并不大,除非继承体系层次很深,同时当ROM受限时,在每一个C类中编写二进制构造产生的附加代码量也是让人头痛的问题。
那么不走这条捷径而选择二阶段构造的真正原因是什么呢?在Simo贴出这个问题之后,诺基亚论坛开发者社区讨论了如果CWidget()按照我上面说明的那样进行实现,即使用清理栈代替二阶段构造[R3],会出现什么情况。有一点很清楚,在Symbian OS设计之时,我们无法发现任何大问题,现在由于Symbian OS按照标准C++异常处理机制,就会出现一些严重的问题。留意如下代码:
CWidget::CWidget()
{
CleanupStack::PushL(this);
InitializeL();
CleanupStack::Pop(this);
}
如果InitializeL() 异常退出,下列事件会发生(更多细节参见 [R4] )。
class CGadget : public CBase
{
public:
CGadget();
~CGadget() {delete iBuf};
private:
HBufC* iBuf;
};
class CWidget : public CGadget
{
public:
CWidget();
~CWidget() {};
private:
void InitializeL();
};
由于Symbian OS采用C++异常处理机制来进行异常处理,我们详细解释了为什么优先选择二阶段构造,而不是简单地在可能发生异常退出的构造函数中使用清理栈来防止内存泄漏。本次讨论没有涉及的原因是二阶段构造可以防止潜在的两次删除。然而,在异常退出是以setjmp和longjmp的形式出现时,这并不是问题。由于现在采用的是C++异常处理机制,该问题还是存在的。这是因为C++在发生异常时保证调用构造完毕的超类的析构函数。如果一个类是从另一个具有显式且已实现的析构函数的类继承而来,那么你必须在标准C++构造函数完成之后才能调用可能发生异常退出的代码。或者,简单一点,使用二阶段构造确保在构造函数完全执行之后再调用可能发生异常退出的代码。
另一个优先选择二阶段构造的原因是使用静态工厂函数返回一个完全实例化的对象,可以保证C类构造函数为私有函数。这防止了C类对象意外在栈上进行实例化,同时也允许在不破坏二进制兼容性的前提下修改类的大小(你可以在Symbian OS高效编程的第18章找到更多信息)。从个人的观点来看,我也认为二阶段构造更加直观。与在构造函数中完成全部工作相比,理解在对象实例化和初始化阶段发生异常退出造成的后果要更加容易。
原帖中包括一篇 优秀摘要 ,作者在其中介绍了二阶段构造的原始用法,从而使得Symbian OS可以应对未来异常处理机制的引入带来的考验。
以C++异常处理的方式来实现异常退出处理会造成额外的后果,这在[R4] 中有详细讨论。下个月我将介绍允许代码在析构函数中发生异常退出的缺陷。
[R1] “ 需要ConstructL() 的真正原因?”位于Symbian开发者网站论坛,2008年4月。
[R2] 二阶段构造 位于Symbian开发者资料库的Symbian OS向导部分。
[R3] “ 需要ConstructL() 的真正原因?”位于诺基亚论坛开发者讨论版,2008年4月/5月。
[R4] “ 退出和异常” ,Jason Morley,Symbian开发者网站,2007年。
感谢Lauri Aalto,Will Bamberg,Tanzim Husain和Hamish Willee,他们为本文的许多方面提供反馈意见,同时感谢用户Simo Saminen在developer.symbian.com上的讨论论坛里发起这次讨论。
代码诊所会于每月第一个星期五在Symbian开发者网站上定期更新。
你有Symbian C++方面的问题?请将问题发送至位于 sdn@symbian.com的代码医生。如果问题被采用,你会获得Symbian出版社出版的“ Symbian OS软件开发(第2版)”。