C++ 中的 new 与 delete

C艹 内存管理,从入门到入坟。

这篇文章我将简单讲讲如何重载 new 运算符函数,以此来控制堆内存申请相关的一些细节问题。


new 表达式

new表达式 就是我们在 C++ 中正常使用的那种堆内存申请方式,有以下两种形式:

  • new (args) type (initializers)

  • new (args) type [size] {initializers}

其中,argsinitializers 是可选的。当有 args 时,该表达式被称为 定位new。如果没有在这里写明 initializers,那么系统将对该块内存进行默认初始化,所以当type 为类类型时系统会调用该类的默认构造函数(例如 std::string* ptr = new std::string),而 type 为内置基础类型时该内存为未定义状态(例如 int* ptr = new int)。另外,在 int* ptr = new int() 这种形式中,由于写明了 initializers(有括号就算),其属于显式请求初始化,即使 type 是内置基础类型 int,该块内存也会合法地被初始化为 0,你可以用 valgrind 工具测试看看。

调用 new 表达式时,系统会依次做这几样事:

  1. 调用 new 运算符函数申请内存,此时使用上述的定位 new 语法可以将 args 传递给 new 运算符函数。

  2. 从 new 运算符函数返回的指针地址开始构造对象。

  3. 将该指针地址作为 new 表达式的返回值,转换为 type* 并返回。

综合上述行为,new 运算符函数做的事情只有申请内存,行为类似 std::allocator::allocate 函数;而 new 表达式则先调用其申请空间,然后构造对象并返回空间首地址,那么就相当于分别调用 std::allocator::allocate 函数、std::allocator::construct 函数并返回转换后的指针。事实上,在早期 C++ 中 std::allocator 没有出现之前就要如此处理堆内存的申请以及对象的构造问题 。

new 运算符函数

void* operator new(std::size_t, args...) 以及 void* operator new[](std::size_t, args...) 即为上文提到的 new 运算符函数,它在标准库中有三个重载版本:

  1. void* operator new(std::size_t)

  2. void* operator new(std::size_t, void*)

  3. void* operator new(std::size_t, const std::nothrow_t&)

所有 new 运算符函数 的第一个参数都必须为 size_t 类型,表明申请内存的大小。调用 new 表达式 时会由系统来计算类型所需内存的大小并传递给它。

重载版本1 是通用的、默认的、专门用于申请内存的版本。它在申请失败时抛出 std::bad_alloc 异常,而在申请成功时返回该内存的首地址。

重载版本2 只简单地将第二个参数返回,这样在调用 args 类型为指针的 new 表达式时,系统会选取这个版本的重载,效果是只构造对象而不申请内存。

重载版本3 的第二个参数仅仅是一个标记,调用 args 类型为 std::nothrow_t 类型的 new 表达式时,系统会选取这个版本的重载。效果是当内存申请失败时不会抛出 std::bad_alloc 异常,作为替代而返回空指针。

除了标准库的这三个重载版本,程序员还可以重载作为 type 的成员变量(强制隐性声明为静态成员函数)或是全局作用域中的 new 运算符函数。在调用 new 表达式时,如果重载了作为 type 的静态成员的 new 运算符函数,那么该表达式会优先选择自定义的版本。如果想调用标准库版本,必须在 new 表达式前补上全局作用域说明符的四筒符号::。全局的 new 运算符函数可用作 debug,但必须保证其正确性,因为几乎所有的 new 表达式都会受到影响。另外,与 重载版本2 参数相同的 new 运算符函数由于其特殊性而被系统保留,不可以被自定义重载。

delete 表达式与运算符函数

delete表达式 类似而相对于 new 表达式,有以下两种形式:

  • delete pointer

  • delete[] pointer

其中,pointer 是之前申请的内存首地址,如果为空指针(NULLnullptr)是什么事都不会发生的。

调用 delete 表达式时,系统会依次做这几样事:

  1. 调用内存中对象的析构函数销毁对象。

  2. 调用 delete 运算符函数释放内存。

delete运算符函数 形如 void operator delete(void*)void operator delete[](void*),当然也可以在成员函数或是全局作用域中被重载。delete 运算符函数只负责释放内存而不负责析构,相当于调用 std::allocator::deallocate,那么 delete 表达式就相当于分别调用 std::allocator::destroy 以及 std::allocator::deallocate

关于意义

这些都是比较高阶的内存管理知识,这些规则主要是用来优化内存、提高程序运行速度的。最重要的是,这些规则可以分离堆内存的申请和对象的构造,防止出现调用 new 表达式时构造一次,事后赋予新值时又手动调用构造函数,从而一共构造两次对象的情况。

有时我们甚至可能永远不会用到某一块申请的堆内存,比如 vector(动态数组)的实现中往往会在调用 vector::push_back 时才在屁股后面构造一个对象,那没构造的那部分内存就是用不到的。出于容器的特性,给 vector 申请多一点堆内存是必要的,但是我们又不想在根本用不到某块堆内存的情况下白白承受构造默认对象所造成的代价,这时最好的办法就是使用 std::allocator 或者重载 new 运算符函数来分离申请内存和构造对象这两个步骤。

另外,还有一个小技巧:可以将某个类型中的成员 new 运算符函数声明为删除的函数,让使用者无法直接使用 new 表达式,从而简单地禁止在堆内存上保存该类型的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <cstddef> // std::size_t

class T
{
public:
void* operator new(std::size_t) = delete;
void operator delete(void*) = delete;
};

int main()
{
T* t = new T(); // 编译错误,系统无法调用被删除的new运算符函数。
delete t; // 编译错误,系统无法调用被删除的delete运算符函数。
return 0;
}

当然这只是简单地禁用,将 T* t = new T(); 改成 T* t = ::new T();以及将 delete t; 改成 ::delete t; ,或者说使用标准 C 的库函数申请、释放内存就可以绕过去这层限制。不过那样的代码足以逼死一些强迫症,咱的目的也就达到了。

实例代码

光说不练假把戏,最后贴一段简单的代码来总结一下吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <cstddef> // std::size_t
#include <cstdlib> // std::malloc, std::free
#include <iostream>

class T
{
private:
int m_data;
public:
T(int data) : m_data(data) { std::cout << "T(int)" << std::endl; }
~T() { std::cout << "~T()" << std::endl; }
void print() { std::cout << m_data << std::endl; }
// 不能只重载new运算符函数或delete运算符函数其中之一,否则可能会出现意想不到的bug。
void* operator new(std::size_t size, const char* message) {
std::cout << "size = " << size << "\nmessage = \"" << message << '"'
<< std::endl;
// 其实就是用new运算符函数包装了标准C的库函数malloc(),还可以抛出异常。
// 当然,new表达式只会处理这个C++风格的new运算符函数,而不会调用标准C的库函数。
if (void* mem = std::malloc(size)) {
return mem;
} else {
throw std::bad_alloc();
}
}
void operator delete(void* mem) noexcept { std::free(mem); }
};

int main()
{
// 调用new运算符函数申请内存。
T* t = static_cast<T*>(T::operator new(sizeof(T), "hello, world"));
// 这句可以调用全局的`void* operator new(std::size_t, void*)`,在该指针`t`处构造对象。
::new (t) T(8);
// 上面两句与下面这句等价。
// T* t = new ("hello, world") T(8);

// 打印看看这块内存是否确实构造成功了。
t->print();

// 调用析构函数销毁对象。
t->~T();
// 调用delete运算符函数释放内存。
T::operator delete(t);
// 上面两句与下面这句等价。
// delete t;
return 0;
}

输出结果:

1
2
3
4
5
6
$ g++ main.cpp && ./a.out
size = 4
message = "hello, world"
T(int)
8
~T()

蝉在叫,人坏掉。这是第六片星星。