让C++对象只能分配到堆/栈区的随想

对象分配到堆上的过程:三个形式的new

要把对象分配到栈上,需要使用到new operator,而new operator会调用operator newplacement 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呢?这里是两个问题,依次解决。

  1. 为什么要在析构函数上动手脚?根据前文,把构造函数private掉是不行了,但是如果对析构函数这么做,就会形成能在栈上分配,但是却无法在函数结束时释放的情况,编译器会在编译期发现这种错误,报出编译错误。
    但是请注意,这样做的话,就无法显式的delete掉HeapOnly及其派生类了。和new operator类似,delete operator也会先调用对象的析构函数再释放内存,这样又造成了在类外访问private成员的情况,所以必须要提供一个销毁该对象的接口Destroy()。

  2. 为什么是声明为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就无法在栈上分配了。

  3. 题外话:什么时候析构函数应该是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);
    };

需要注意的有两点:

  1. 这两个函数都是static的,因为它们的生命周期比对象长(毕竟靠他们创建对象)
  2. 前文提到过,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;
posted @ 2020-03-24 13:25  joeyzzz  阅读(531)  评论(0编辑  收藏  举报