在C++里如何释放内存的时候不调用对象的析构函数?

今天,看到一个有趣的面试题,问题是:在C++里如何释放内存的时候不调用对象的析构函数?

之所以有趣,是因为这个问题违反了C++中资源管理的RAII(资源获取即初始化),它要求资源的释放应当和对象的生命周期紧密相关。在正常情况下,当对象离开其作用域时,它的析构函数被调用,以释放它所管理的资源,比如内存、文件句柄或网络连接等。

然而,这个问题提出了一种特殊情况,在出于性能优化、特殊的内存管理策略,或是为了与低级操作系统功能或硬件直接交互的需求。在这些情况下,我们可能需要释放对象占用的内存,但又不希望执行其析构函数。

在C++中,如果真的需要这么做,有什么方法呢?我们一起来梳理看看。

placement new方式

可以通过使用 placement new 来在预先分配的内存块上构造对象,然后不显式调用它的析构函数。

#include <new> // 需要包含头文件new

char buffer[sizeof(MyClass)]; // 分配足够的内存来存放MyClass对象
MyClass* obj = new(buffer) MyClass(); // 在buffer上构造对象

// ... 使用obj

// 显式调用析构函数是这样的:
// obj->~MyClass();

// 如果你不调用析构函数,对象的生命周期将结束,
// 但是它的析构函数不会被执行。但是由于对象用的是栈上的内存,内存会正常释放。

使用 placement new 需要你非常明确地知道自己在做什么,因为这样做会绕过正常的构造和析构过程。这可能导致资源泄露、内存未正确释放或其他未定义行为。

MyClass* obj = new MyClass(); // 常规地分配对象
// ... 在这里使用obj
operator delete(obj); // 释放内存但不调用析构函数

placement new的chromium的封装

chromium里面对placement new的设计模式提供了一套模板支持,如下:

template <typename T>
class NoDestructor {
 public:
  // Not constexpr; just write static constexpr T x = ...; if the value should
  // be a constexpr.
  template <typename... Args>
  explicit NoDestructor(Args&&... args) {
    new (storage_) T(std::forward<Args>(args)...);
  }

  // Allows copy and move construction of the contained type, to allow
  // construction from an initializer list, e.g. for std::vector.
  explicit NoDestructor(const T& x) { new (storage_) T(x); }
  explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); }

  NoDestructor(const NoDestructor&) = delete;
  NoDestructor& operator=(const NoDestructor&) = delete;

  ~NoDestructor() = default;

  const T& operator*() const { return *get(); }
  T& operator*() { return *get(); }

  const T* operator->() const { return get(); }
  T* operator->() { return get(); }

  const T* get() const { return reinterpret_cast<const T*>(storage_); }
  T* get() { return reinterpret_cast<T*>(storage_); }

 private:
  alignas(T) char storage_[sizeof(T)];
};


//使用方法:
void foo() {
  // std::string析构函数不会被调用,即便出了foo的scope
  NoDestructor<std::string> s("Hello world!");
}

上述代码的细节说明:

  • new (storage_) T(x) 使用了 placement new 操作符。这个操作符的语法是 new (address) Type(arguments),它允许你在一个已经分配好的内存地址 address 上直接构造一个 Type 类型的对象。这个操作不会分配新的内存,而是使用你提供的内存地址。在这个例子中,storage_ 是一个足够大的字符数组,能够存放 T 类型的对象,而 alignas(T) 确保了这个数组的对齐方式与 T 类型相同。

  • T(x) 是调用 T 类型对象的复制构造函数,以 x 为参数来构造一个新的 T 实例。

NoDestructor 类的 storage_ 成员中直接构造一个 T 类型的对象。因为它使用了 placement new,所以不会为这个 T 对象分配新的堆内存,而是利用 storage_ 这块已经预留的栈内存。这也意味着 T 对象的析构函数不会在 NoDestructor 对象被销毁时自动调用,这正是 NoDestructor 的设计目的。

union方式

union类型的析构函数在执行body之后不会调用variant member对象的析构函数

#include <iostream>
template<class T>
union NoDestructor{
    T value;
    ~Forget(){}
};

struct A{
   ~A(){
      std::cout<<"destroy A\n";
   }
};

int main(){
  auto f =  NoDestructor<A>{A{}}; // 不会执行A的析构
  // f.value.~A(); // 需要手动调用析构, 否则不会析构
}


union 是一种特殊的类类型,它允许你在同一个内存地址存储不同的数据类型,但是一次只能使用其中一个成员。这意味着 union 的所有成员都共享同一块内存空间,所以其大小等于其最大成员的大小。

union 有一些限制,其中之一就是所有的成员函数必须是非虚(non-virtual)的。理解这一点需要知道虚函数和虚函数表(vtable)的工作原理。在C++中,当类有一个或多个虚函数时,编译器会为该类创建一个虚函数表。这个虚函数表是一个函数指针数组,用于支持动态绑定,也就是在运行时决定调用哪个函数。每个有虚函数的对象都会含有一个指向虚函数表的指针,通常称为vptr。在 union 的情况下,由于所有成员共享同一块内存空间,如果 union 允许虚函数存在,那么vptr的存储位置就会和 union 的其他成员发生冲突,导致不确定的行为。此外,由于 union 的成员可以是不同的数据类型,编译器也无法确定应该使用哪个成员的虚函数表。

正因为这些原因,C++标准规定 union 不能包含虚函数。所有的成员函数,包括构造函数和析构函数,都必须是非虚的。这样就保证了 union 成员之间不会发生内存覆盖,同时也避免了动态绑定相关的复杂性。

在C++11及以后的版本中,union 可以包含非静态数据成员的构造函数和析构函数,但是仍然不能包含虚函数。如果 union 包含一个或多个非平凡的成员(比如包含自己的构造函数或析构函数的类类型成员),那么你需要负责正确地构造和析构这些成员,因为 union 不会自动为你做这些事情。

利用union的这个特性,就能轻松实现“释放内存的时候不调用对象的析构函数”。

但是,在使用union的时候,这个特性反而是一个坑,需要小心处理。一般来说,需要手动判断哪个成员是有效的,并显式地调用该成员的析构函数。类似这样:

union U {
    Type1 member1;
    Type2 member2;
    // ...
    
    ~U() {
        switch (active_member) {
            case Member1:
                member1.~Type1();  // 显式调用析构函数
                break;
            case Member2:
                member2.~Type2();  // 显式调用析构函数
                break;
            // ...
        }
    }
};

jmp 方式

直接通过longjmp,跳出作用域,避免析构函数调用:

#include <setjmp.h>
int main()
{
	jmp_buf buf {};
    if (setjmp(buf) == 0) {
	    string s(p); // 对象s不会析构
         longjmp(buf, 1);   
    }
}

不过通过longjmp没有很好的封装形式,语义上也过于隐晦,因此不常用于这个场景。

结语

这个面试题既有趣也有深度,它提供了一个探讨C++语言内存和资源管理机制的机会,同时考察面试者对C++底层细节的了解程度。然而,在实际的软件开发中,绝大多数情况下都应该遵循RAII原则,让析构函数自动管理资源的释放。

  • 10
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
C++中,如果一个类中有虚函数,我们通常都会将它的析构函数设为虚析构函数。虚析构函数是指在基类中将析构函数声明为虚函数,这样在删除指向派生类对象的基类指针时,会调用派生类的析构函数。 需要虚析构函数的主要原因是防止内存泄漏。当我们在使用多态时,通常会使用基类指针来指向派生类对象,这时如果析构函数不是虚函数,删除指向派生类对象的基类指针时,只会调用基类的析构函数,而不会调用派生类的析构函数。这就会导致派生类中申请的动态内存无法被释放,从而造成内存泄漏。 使用虚析构函数可以保证在删除指向派生类对象的基类指针时,会先调用派生类的析构函数,从而保证所有动态内存都能正确释放。 举个例子,假设我们有一个基类 Animal 和一个派生类 Cat。Animal 类中有一个指针类型的成员变量,指向一个动态分配的字符串。Cat 类继承自 Animal 类,并且重载了析构函数。如果 Animal 类的析构函数不是虚函数,那么在删除指向 Cat 对象的 Animal 指针时,只会调用 Animal 类的析构函数,从而导致 Cat 类中申请的动态内存无法被释放,造成内存泄漏。 ```c++ class Animal { public: Animal() { name = new char[20]; strcpy(name, "Animal"); } ~Animal() { delete[] name; cout << "Animal destructor" << endl; } protected: char* name; }; class Cat : public Animal { public: Cat() { name = new char[20]; strcpy(name, "Cat"); } ~Cat() { delete[] name; cout << "Cat destructor" << endl; } }; int main() { Animal* p = new Cat(); delete p; // Animal destructor,没有调用 Cat 的析构函数,造成内存泄漏 return 0; } ``` 如果将 Animal 类的析构函数声明为虚析构函数,那么在删除指向 Cat 对象的 Animal 指针时,就会先调用 Cat 类的析构函数,从而正确释放动态内存。 ```c++ class Animal { public: Animal() { name = new char[20]; strcpy(name, "Animal"); } virtual ~Animal() { // 声明为虚析构函数 delete[] name; cout << "Animal destructor" << endl; } protected: char* name; }; class Cat : public Animal { public: Cat() { name = new char[20]; strcpy(name, "Cat"); } ~Cat() { delete[] name; cout << "Cat destructor" << endl; } }; int main() { Animal* p = new Cat(); delete p; // Cat destructor,然后 Animal destructor return 0; } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值