让C++对象只能分配到堆/栈区的随想
对象分配到堆上的过程:三个形式的new
要把对象分配到栈上,需要使用到new operator,而new operator会调用operator new和placement new。
- operator new用于调用malloc申请堆空间,如果申请失败会抛出bad_alloc异常
- placement new用于“定向构造”,即在指定的内存上(这里就是指operator new申请的空间)调用构造函数以构造出新对象
根据侯捷先生的书所说,STL(书中的版本)对于某些对象做了统一的分配和统一的构造,而不把这两个步骤通过直接调用new operator合二为一。
实际上,placement new也有其他作用,例如可以将一个对象构造在已知的栈空间,或者全局静态区空间上。(当然那样就不能对那个对象调用delete啦,会core dump的),代码就像这样:
char buf[100]{};
new (buf) HeapOnly();
HeapOnly *h = static_cast<HeapOnly *>((void*)buf);
delete h; //!!! crash
可见C++给了程序员足够多的自由。
如何限制只能在堆上
一个很自然的想法是,既然只能在堆上,那么分配的方法就只有new了,那把构造函数private掉就可以了。事实上这是不对的,因为如上文所讲,new operator会隐式调用placement new,placement new会隐式调用构造函数,造成了在类外访问构造函数,所以不能把构造函数设置为private。如果这么干,只要在类外,堆和栈上都分配不了。
解决方案如下:
class HeapOnly
{
public:
HeapOnly() = default;
void Destroy(){delete this;} // 必须要提供
private: // none protected!
~HeapOnly() = default;
};
那么为什么要把析构函数声明为private呢?这里是两个问题,依次解决。
-
为什么要在析构函数上动手脚?根据前文,把构造函数private掉是不行了,但是如果对析构函数这么做,就会形成能在栈上分配,但是却无法在函数结束时释放的情况,编译器会在编译期发现这种错误,报出编译错误。
但是请注意,这样做的话,就无法显式的delete掉HeapOnly及其派生类了。和new operator类似,delete operator也会先调用对象的析构函数再释放内存,这样又造成了在类外访问private成员的情况,所以必须要提供一个销毁该对象的接口Destroy()。 -
为什么是声明为private而不是protected呢?众所周知,protected的意思是子类可见,private是只有自己可见。写这样一个HeapOnly的类的意义类似于boost::noncopyable,要让所有继承该类的子类都只能被分配到堆上。
- HeapOnly的析构函数protected时, 假设派生类叫Derive,HeapOnly的析构函数对于Derive是可见的,Derive如果没有再把析构函数对外隐藏,那么在堆上析构Derive就有了可能:因为Derive的析构函数会转而调用基类的析构函数,Derive也就可以在栈上分配了!所以声明为protected是不行的。
- HeapOnly的析构函数private时, 假设派生类叫Derive,HeapOnly的析构函数对于Derive是不可见的,如果要析构栈上对象Derive,就必须在Derive的析构函数中保证基类析构函数的可见性,HeapOnly的析构函数是private时,Derive的析构函数是无法调用基类析构函数的(因为private对子类不可见)。编译器会报这个这样的错误:
Call to implicitly-deleted default constructor of 'Derive'
可见,编译器认为既然Derive无法调用基类的析构函数,那就认为Derive的析构函数也是隐式的被删除了吧。所以Derive就无法在栈上分配了。
-
题外话:什么时候析构函数应该是virtual的?就算析构函数不是virtual的,显式的删除一个栈上的派生对象,也会根据继承层次一路从派生类析构到基类。但是如果是根据一个基类指针删除堆上的派生对象,如果析构函数不是virtual的,那么派生类的析构函数就不会被调用……一句话总结,就是:这样做是为了当用一个基类的指针删除一个派生类的对象时,派生类的析构函数会被调用。
如何限制只能在栈上(当然这里也可以是静态区)
这个就比只能在堆上分配要简单一些,只需要把类中operator new[]和operator new[]声明为protected就可以了。
class NoHeap
{
protected:
static void *operator new(std::size_t);
static void *operator new [] (std::size_t);
};
需要注意的有两点:
- 这两个函数都是static的,因为它们的生命周期比对象长(毕竟靠他们创建对象)
- 前文提到过,operator new是用来分配空间的,所以子类不仅需要调用自己的operator new,也需要调用基类的operator new,所以把operator new设置为protected就足以对外隐藏NoHeap的所有子类的operator new了,导致所有的子类都无法在堆上分配。
如何运行时判断在堆还是在栈上
首先需要明确的是,前面所讲的都是在编译期对于程序员使用一个类型的方法的约束#,当然在运行期是没办法做这个约束了(毕竟程序都跑起来了),不过有没有办法判断一个对象到底是在堆上还是在栈上呢?可以试试下面这个类:
class Detect {
public:
Detect()
{
int i;
Check(&i);
}
void Check(int *i) {
int j;
if ((i < &j) == ((void*)this < (void*)&j))
{
std::cout << "Stack" << std::endl;
return;
}
else
{
std::cout << "Heap" << std::endl;
return;
}
}
};
在Detect的构造函数中分配一个局部变量i,再在调用的Check函数中分配局部变量j,如果Detect在栈上分配,那么典型的情况应该像这样:
this ----> Detect() ---> int i ---> Check() ---> int j
高地址------------------------------------------>低地址
当然堆栈也有可能是从低地址向高地址生长的,视机器而定。Check里通过判断&i/&j/this三者的相对位置,使得这两种情况都可以应对。当然并不保证所有机器都适用,在我的x86_64电脑上测试通过。
番外
可以用SFINAE手法来判断一个类是否有Destroy成员函数,这么做有什么作用呢?
比如在使用单例模式来管理一个对象时,可以根据对象是否是HeapOnly的派生类,进而在编译期决定到底调用delete还是调用Destroy。参考代码如下:
template <typename T>
struct HasDestroy
{
template <typename C> static char test(decltype(&C::Destroy));
template <typename C> static long test(...);
const static bool value = sizeof(test<T>(0)) == 1;
};
实际使用中,只需要如下调用就可以了:
bool res = HasDestroy<T>::value;