条款27:要求或禁止在堆中产生对象(上)
本文含有图片,无法贴上,请下载WORD文档阅读。下载
有时你想这样管理某些对象,要让某种类型的对象能够自我销毁,也就是能够“delete this.” 很明显这种管理方式需要此类型对象要被分配在堆中。而其它一些时候你想获得一种保障:“不在堆中分配对象,从而保证某种类型的类不会发生内存泄漏。”如果你在嵌入式系统上工作,就有可能遇到这种情况,发生在嵌入式系统上的内存泄漏是极其严重的,其堆空间是非常珍贵的。有没有可能编写出代码来要求或禁止在堆中产生对象(heap-based object)呢?通常是可以的,不过这种代码也会把“on the heap”的概念搞得比你脑海中所想的要模糊。
要求在堆中建立对象
让我们先从必须在堆中建立对象开始说起。为了执行这种限制,你必须找到一种方法禁止以调用“new”以外的其它手段建立对象。这很容易做到。非堆对象(non-heap object)在定义它的地方被自动构造,在生存时间结束时自动被释放,所以只要禁止使用隐式的构造函数和析构函数,就可以实现这种限制。
把这些调用变得不合法的一种最直接的方法是把构造函数和析构函数声明为private。这样做副作用太大。没有理由让这两个函数都是private。最好让析构函数成为private,让构造函数成为public。处理过程与条款26相似,你可以引进一个专用的伪析构函数,用来访问真正的析构函数。客户端调用伪析构函数释放他们建立的对象。
例如,如果我们想仅仅在堆中建立代表unlimited precision numbers(无限精确度数字)的对象,可以这样做:
class UPNumber {
public:
UPNumber();
UPNumber(int initValue);
UPNumber(double initValue);
UPNumber(const UPNumber& rhs);
// 伪析构函数 (一个const 成员函数, 因为
// 即使是const对象也能被释放。)
void destroy() const { delete this; }
...
private:
~UPNumber();
};
然后客户端这样进行程序设计:
UPNumber n; // 错误! (在这里合法,但是
// 当它的析构函数被隐式地
// 调用时,就不合法了)
UPNumber *p = new UPNumber; //正确
...
delete p; // 错误! 试图调用
// private 析构函数
p->destroy(); // 正确
另一种方法是把全部的构造函数都声明为private。这种方法的缺点是一个类经常有许多构造函数,类的作者必须记住把它们都声明为private。否则如果这些函数就会由编译器生成,构造函数包括拷贝构造函数,也包括缺省构造函数;编译器生成的函数总是public(参见Effecitve C++ 条款45)。因此仅仅声明析构函数为private是很简单的,因为每个类只有一个析构函数。
通过限制访问一个类的析构函数或它的构造函数来阻止建立非堆对象,但是在条款26已经说过,这种方法也禁止了继承和包容(containment):
class UPNumber { ... }; // 声明析构函数或构造函数
// 为private
class NonNegativeUPNumber:
public UPNumber { ... }; // 错误! 析构函数或
//构造函数不能编译
class Asset {
private:
UPNumber value;
... // 错误! 析构函数或
//构造函数不能编译
};
这些困难不是不能克服的。通过把UPNumber的析构函数声明为protected(同时它的构造函数还保持public)就可以解决继承的问题,需要包含UPNumber对象的类可以修改为包含指向UPNumber的指针:
class UPNumber { ... }; // 声明析构函数为protected
class NonNegativeUPNumber:
public UPNumber { ... }; // 现在正确了; 派生类
// 能够访问
// protected 成员
class Asset {
public:
Asset(int initValue);
~Asset();
...
private:
UPNumber *value;
};
Asset::Asset(int initValue)
: value(new UPNumber(initValue)) // 正确
{ ... }
Asset::~Asset()
{ value->destroy(); } // 也正确
判断一个对象是否在堆中
如果我们采取这种方法,我们必须重新审视一下“在堆中”这句话的含义。上述粗略的类定义表明一个非堆的NonNegativeUPNumber对象是合法的:
NonNegativeUPNumber n; // 正确
那么现在NonNegativeUPNumber对象n中的UPNumber部分也不在堆中,这样说对么?答案要依据类的设计和实现的细节而定,但是让我们假设这样说是不对的,所有UPNumber对象 —即使是做为其它派生类的基类—也必须在堆中。我们如何能强制执行这种约束呢?
没有简单的办法。UPNumber的构造函数不可能判断出它是否做为堆对象的基类而被调用。也就是说对于UPNumber的构造函数来说没有办法侦测到下面两种环境的区别:
NonNegativeUPNumber *n1 =
new NonNegativeUPNumber; // 在堆中
NonNegativeUPNumber n2; //不再堆中
不过你可能不相信我。也许你想你能够在new操作符、operator new和new 操作符调用的构造函数的相互作用中玩些小把戏(参见条款8)。可能你认为你比他们都聪明,可以这样修改UPNumber,如下所示:
class UPNumber {
public:
// 如果建立一个非堆对象,抛出一个异常
class HeapConstraintViolation {};
static void * operator new(size_t size);
UPNumber();
...
private:
static bool onTheHeap; //在构造函数内,指示
// 对象是否被构造在
... // 堆上
};
// obligatory definition of class static
bool UPNumber::onTheHeap = false;
void *UPNumber::operator new(size_t size)
{
onTheHeap = true;
return ::operator new(size);
}
UPNumber::UPNumber()
{
if (!onTheHeap) {
throw HeapConstraintViolation();
}
proceed with normal construction here;
onTheHeap = false; // 为下一个对象清除标记
}
如果不再深入研究下去,就不会发现什么错误。这种方法利用了这样一个事实:“当在堆上分配对象时,会调用operator new来分配raw memory”,operator new设置onTheHeap为true,每个构造函数都会检测onTheHeap,看对象的raw memory是否被operator new所分配。如果没有,一个类型为HeapConstraintViolation的异常将被抛出。否则构造函数如通常那样继续运行,当构造函数结束时,onTheHeap被设置为false,然后为构造下一个对象而重置到缺省值。
这是一个非常好的方法,但是不能运行。请考虑一下这种可能的客户端代码:
UPNumber *numberArray = new UPNumber[100];
第一个问题是为数组分配内存的是operator new[],而不是operator new,不过(倘若你的编译器支持它)你能象编写operator new一样容易地编写operator new[]函数。更大的问题是numberArray有100个元素,所以会调用100次构造函数。但是只有一次分配内存的调用,所以100个构造函数中只有第一次调用构造函数前把onTheHeap设置为true。当调用第二个构造函数时,会抛出一个异常,你真倒霉。
即使不用数组,bit-setting操作也会失败。考虑这条语句:
UPNumber *pn = new UPNumber(*new UPNumber);
这里我们在堆中建立两个UPNumber,让pn指向其中一个对象;这个对象用另一个对象的值进行初始化。这个代码有一个内存泄漏,我们先忽略这个泄漏,这有利于下面对这条表达式的测试,执行它时会发生什么事情:
new UPNumber(*new UPNumber)
它包含new 操作符的两次调用,因此要调用两次operator new和调用两次UPNumber构造函数(参见条款8)。程序员一般期望这些函数以如下顺序执行:
调用第一个对象的operator new
调用第一个对象的构造函数
调用第二个对象的operator new
调用第二个对象的构造函数
但是C++语言没有保证这就是它调用的顺序。一些编译器以如下这种顺序生成函数调用:
调用第一个对象的operator new
调用第二个对象的operator new
调用第一个对象的构造函数
调用第二个对象的构造函数
编译器生成这种代码丝毫没有错,但是在operator new中set-a-bit的技巧无法与这种编译器一起使用。因为在第一步和第二步设置的bit,第三步中被清除,那么在第四步调用对象的构造函数时,就会认为对象不再堆中,即使它确实在。
这些困难没有否定让每个构造函数检测*this指针是否在堆中这个方法的核心思想,它们只是表明检测在operator new(或operator new[])里的bit set不是一个可靠的判断方法。我们需要更好的方法进行判断。
如果你陷入了极度绝望当中,你可能会沦落进不可移植的领域里。例如你决定利用一个在很多系统上存在的事实,程序的地址空间被做为线性地址管理,程序的栈从地址空间的顶部向下扩展,堆则从底部向上扩展:
在以这种方法管理程序内存的系统里(很多系统都是,但是也有很多不是这样),你可能会想能够使用下面这个函数来判断某个特定的地址是否在堆中:
// 不正确的尝试,来判断一个地址是否在堆中
bool onHeap(const void *address)
{
char onTheStack; // 局部栈变量
return address < &onTheStack;
}
这个函数背后的思想很有趣。在onHeap函数中onTheSatck是一个局部变量。因此它在堆栈上。当调用onHeap时,它的栈框架(stack frame)(也就是它的activation record)被放在程序栈的顶端,因为栈在结构上是向下扩展的(趋向低地址),onTheStack的地址肯定比任何栈中的变量或对象的地址小。如果参数address的地址小于onTheStack的地址,它就不会在栈上,而是肯定在堆上。