27.要求或禁止在堆中产生对象

前言

 
有时候我们希望可以自由的构造与析构对象,这要求它们存在于heap中。又有时我们希望对象不会产生资源泄漏的问题,那我们必须保证某个类不会在heap中构造对象。


仅允许在heap中建立对象

 
显然,为了达成这一目的,我们必须找到一种方法,禁止所有new之外的能够构造对象的手段。
non-heap object在定义它的地方被自动构造,生存时间结束后自动释放,所以只需要禁止隐式的构造和析构函数就可以实现这种限制。
最直接的手法就是把构造函数和析构函数声明为private,但这样的副作用太大,我们只需要令其中一个声明为private即可。

  1. 构造函数为public,析构函数为private
    这会导致对象依旧可以构造在stack中,只是在析构时会报错。
  2. 构造函数为private,析构函数为public
    一般来说,一个class会存在多个构造函数,因此必须将所有构造函数都声明为private。

通过限制访问一个类的析构或构造函数来阻止建立非堆对象固然很好,但这种方法也同时禁止了继承与containment.但我们有两种技术来克服这些缺陷:

  1. 对于继承而言,可以把构造或析构函数声明为protected。
  2. 对于包含来说,可以把包含对象改为包含指向对象的指针。(pimpl)

以下给出具体实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//derived
class UPNumber { ... };//protected:~UPNumber
class NonNegativeUPNumber:public UPNumber { ... };
//containment
class Asset {
public:
Asset(int initValue);
~Asset();
...
private:
UPNumber *value;// RAII is better
};
Asset::Asset(int initValue):value(new UPNumber(initValue)){ ... }
Asset::~Asset(){value->destroy();}


判断一个对象是否在堆中

 
如果采用上述方法,当我们写下如下表达式时:

1
NonNegativeUPNumber n;//n处于stack中

n的base部分是否处于stack中却是根据具体实现来确定的,如果现在我们需要作出强制性的保证:一个对象,哪怕是作为派生类的基类部分,也必须出现在堆中。该如何执行这种约束?

没有特别简单的办法来完成这个功能,因为构造函数在调用不可能检测当前环境。简而言之,UPNumber的构造函数无法区分如下两种构造环境:

1
2
NonNegativeUPNumber *n1 = new NonNegativeUPNumber;//in heap
NonNegativeUPNumber n2;//in stack

操作符修改

也许你可能会试图在new操作符上玩儿一些小花样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class UPNumber {
public:
class HeapConstraintViolation {};//如果建立一个非堆对象,抛出一个异常
static void* operator new(size_t size);
UPNumber();
...
private:
static bool onTheHeap; //指示当前是否在堆中
};
bool UPNumber::onTheHeap = false;
void *UPNumber::operator new(size_t size){
onTheHeap = true;
return ::operator new(size);
}
UPNumber::UPNumber(){
if (!onTheHeap) {
throw HeapConstraintViolation();
}
...//构造
onTheHeap = false; // 为下一个对象清除标记
}

当我们在heap中调用时势必会使用new,因此建立判断位,确保每一次构建时都完美运行。
这个方法很好,但不能应用于实际中。举例而言:
1
UPNumber *numberArray = new UPNumber[100];

new[]只申请了一次内存,然后调用了100次构造函数,z这和我们预期的根本不一致。
就算不考虑operator[],我们也未必能够取得成功:
1
UPNumber *pn = new UPNumber(*new UPNumber);

我们在堆中建立两个UPNumber,让pn先指向一个对象,这个对象利用另一个对象的值进行初始化。就算不用顾忌内存泄漏,编译器的行为也未必会按照预期执行:
1
2
3
4
5
6
7
8
9
10
11
预期行为:
调用第一个对象的 operator new
调用第一个对象的构造函数
调用第二个对象的 operator new
调用第二个对象的构造函数

可能发生的实际行为:
调用第一个对象的 operator new
调用第二个对象的 operator new
调用第一个对象的构造函数
调用第二个对象的构造函数


通过地址判断对象处于何处

在很多系统中,程序的地址空间被作为线性地址管理,程序的栈自顶而下,堆则自底而上,如下图所示:
image_1cc5qj8cd7eqsckjee1oe2q9g9.png-66kB
因此我们试图使用这么一个函数来判断某个特定的地址是否在堆中:

1
2
3
4
bool onHeap(const void *address){
char onTheStack;
return address < &onTheStack;
}

该函数的原理很简单:新建的局部变量必然是栈的最底层,如果某个地址比他还低,那必然是存在于heap中。但是,谁告诉你内存空间只有栈和堆了?static变量也会有特定的位置,一般情况下它们会出现在heap的下面,如下所示:image_1cc5qp2o01jht12ih1jcbue71tge16.png-88.7kB
那么该函数不能工作的原因就很清楚了:无法判断堆对象与静态对象。


安全delete

令人悲伤的是我们无法找到一个通用方法来判断对象到底在不在堆上,但我们迫切地需要了解某个对象在不在heap中的初衷很简单:我们需要判断该对象能不能被安全delete。
“能否安全删除一个指针”与“一个指针是否指向堆中的事物”并不相同,因为不是所有heap中的事物都能被安全delete:

1
2
3
4
5
6
7
class Asset {
private:
UPNumber value;
...
};
Asset *pa = new Asset;
delete pa->value;//error

报错的原因在于value并非是一个由new返回的指针,而是一个由new返回的指针初始化的指针。
但判断“能否删除一个指针”比判断“一个指针指向的对象是否在堆上”容易,因为对于前者我们只需要每一次new的时候记录一下返回的地址(以一个vector或list保存之),每一次delete之前判断一下地址在不在记录,在的话就把地址从记录中移除。这一系列操作只需要重载一下operator new与delete就好了。
但是,在实际使用中,这种方法也不是特别受欢迎,因为:

  1. 重载后的operator new与operator delete必须在全局作用域,这会引发一些不兼容
  2. 不是所有客户都需要这项功能,记录地址未必需要
  3. 记录地址未必不出错,比如说多继承或者继承自虚基类的类往往有多个地址。

mixin class

我们希望存在某些函数提供以上功能,但不污染全局命名空间,没有额外开销,这时mixin class出场了(“mix in” Effective C++ 7中的uncopyable也是一个mixin class)。具体来说,mixin是一个抽象类,其功能可以与其派生类的功能相融合,以上述要求为例:

1
2
3
4
5
6
7
8
9
10
11
class HeapTracked {
public:
class MissingAddress{};
virtual ~HeapTracked() = 0;
static void *operator new(size_t size);
static void operator delete(void *ptr);
bool isOnHeap() const;
private:
typedef const void* RawAddress;
static list<RawAddress> addresses;
};

其具体实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
list<RawAddress> HeapTracked::addresses;
HeapTracked::~HeapTracked() {}//析构函数必须被定义 即使是纯虚函数 详见Effective C++ 8
void* HeapTracked::operator new(size_t size){
void *memPtr = ::operator new(size);
addresses.push_front(memPtr);//把地址放到list前端
return memPtr;
}
void HeapTracked::operator delete(void *ptr){
auto it = find(addresses.begin(), addresses.end(), ptr);
if (it != addresses.end()) {
addresses.erase(it);
::operator delete(ptr);
}
else {
throw MissingAddress();
}
}
bool HeapTracked::isOnHeap() const{
const void *rawAddress = dynamic_cast<const void*>(this);//动态转换
auto it = find(addresses.begin(), addresses.end(), rawAddress);
return it != addresses.end();
}

在判定函数中使用了动态转换,这是为了解决我们之前提出的第三个问题,将指针永远指向当前对象起始地址。
mixin class使用范式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Asset: public HeapTracked {
private:
UPNumber value;
...
};
void inventoryAsset(const Asset *ap){
if (ap->isOnHeap()) {
...
}
else {
...
}
}

遗憾的是这种mixin不能用于内置类型,因为内置类型无法继承自某类。但我们的本意就是为了安全地delete某对象,内置类型无法delete。


禁止建立堆对象

 
对象的建立无非三种情况:

  1. 直接实例化
  2. 对象作为派生类的基类被实例化
  3. 对象被嵌入到其他对象内

直接实例化

禁止直接实例化很简单,把operator new和delete设为private,如果你需要同时禁止堆对象数组,也可以把operator new[]与operator delete[]设为private:

1
2
3
4
5
6
7
class UPNumber {
private:
static void *operator new(size_t size);
static void operator delete(void *ptr);
...
};
UPNumber *p = new UPNumber;//error

派生类基类

把operator new声明为private会导致派生类对象的基类无法被实例化。因为operator new与operator delete是自动继承的,除非你手动声明派生类中的它们为public,否则它们默认为基类中的private版本:

1
2
3
class UPNumber { ... };
class NonNegativeUPNumber:public UPNumber {...};//默认operator new为private
NonNegativeUPNumber *p = new NonNegativeUPNumber;//error

包含

operator new是private这一特性,不会对包含产生任何影响:

1
2
3
4
5
6
7
8
class Asset {
public:
Asset(int initValue);
...
private:
UPNumber value;
};
Asset *pa = new Asset(100);//调用Asset::operator new或::operator new