newdelete是C++中最基础且重要的申请和释放堆内存的语法,以它为线索可以引出C++堆内存管理的相关内容,它也是智能指针所需要的基础知识,值得整理一下。

基本使用

内置类型

newdelete最基础的用法是被设计用来替代C语言中的mallocfree的,分配堆内存来构造指定类型的对象,返回对应类型的指针,例如

1
2
int *p = new int; // uninitialized
delete p;

这两个语句都是非常危险的:

  • 最简单的new语句对内置数据类型不会对内存进行初始化,我们得到值是随机的;(Debug模式下可能内存被清空,改成Release模式可以看到随机值)
  • delete语句不会在释放内存之后将指针置空,指针会变成空悬指针。

我们可以用下面的new语句进行初始化

1
2
3
4
5
double *p1 = new double;       // uninitialized
double *p2 = new double(); // 0
double *p3 = new double{}; // 0
double *p4 = new double(2.1); // 2.1
double *p5 = new double{3.2}; // 3.2

delete释放之后将指针置空是非常必要的习惯

1
2
3
int *p = new int{};
delete p;
p = nullptr;

自定义类型

new/delete当然也支持自定义类型的内存分配和释放:

  • new在分配内存之后,对自定义类型可能会调用对应的构造函数;
  • delete可能会对自定义类型会调用析构函数,然后释放内存。(如果是退出main函数由系统自动释放,则肯定不会调用析构函数)

以下面提供了构造函数的自定义类型Demo为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Demo {
public:
int data;

Demo() : data(0) { std::cout << "Demo Default construction" << std::endl; }

explicit Demo(int d) : data(d) {
std::cout << "Demo Explicit construction with data = " << data
<< std::endl;
}

~Demo() {
std::cout << "Demo Destruction with data = " << data << std::endl;
}
};

传递正确的参数即可调用构造函数进行初始化(这里有细微区别:()在初始化时会对其中的参数进行隐式类型转换,{}在初始化时不会进行任何类型转换)

1
2
3
4
Demo *p1 = new Demo(1);
Demo *p2 = new Demo(2.1);
Demo *p3 = new Demo{3};
Demo *p4 = new Demo{4.1}; // error

因为Demo提供了默认构造函数,下面的new语句会调用默认构造函数进行初始化

1
2
3
Demo *p0 = new Demo;
Demo *p1 = new Demo();
Demo *p2 = new Demo{};

如果没有默认构造函数但是有其它构造函数,这些语句会导致编译报错。

C++对于不含任何构造函数的类的处理逻辑是不同的,例如下面的自定义类型Demo2不含任何构造函数

1
2
3
4
5
class Demo2 {
public:
double s;
int x;
};

各个语句可能的初始化效果如下

1
2
3
4
5
Demo2 *p1 = new Demo2; // uninitialized
Demo2 *p2 = new Demo2(); // 0 0
Demo2 *p3 = new Demo2{}; // 0 0
Demo2 *p4 = new Demo2(1.0, 2.0); // 1.0 2
Demo2 *p5 = new Demo2{1.0, 2}; // 1.0 2

如果改成下面的Demo3,把所有数据成员改成私有的,C++的处理又是不一样的

1
2
3
4
5
class Demo3 {
private:
double s;
int x;
};

各个语句的效果如下,后两个语句会编译报错

1
2
3
4
5
Demo3 *p1 = new Demo3;
Demo3 *p2 = new Demo3();
Demo3 *p3 = new Demo3{};
// Demo3 *p4 = new Demo3(1.0, 2.0); // error
// Demo3 *p5 = new Demo3{1.0, 2}; // error

C++这部分的语法规则很复杂,涉及到不同的自定义类的处理逻辑,不用管这些细节,为了保证对内存进行初始化,不要使用new <type_name>;语句,改成new <type_name>{};是最稳妥的选择。

对于自定义类型的delete也不简单,是否会在释放之前调用析构函数是值得讨论的。

例如对于只提供了前置声明的自定义类型的指针调用delete实际上是未定义行为,下面的代码虽然不会编译报错,但是在不同的编译器可能有不一样的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct A;

struct B {
A *a;

B();

~B() { delete a; }
};

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

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

B::B() : a(new A{}) {}

int main() { B(); }

运行结果可能是

1
call A()

也可能是

1
2
call A()
call ~A()

也就是说,我们无法保证自定义类型的析构函数被正常调用!(如果使用智能指针避免出现显式的new/delete,可能会有不一样的情况)

解释一下:

  • 在提供A的完整定义之前,我们就尝试定义B类型;
  • 如果把new A{};放在A的完整定义之前,编译无法通过,因为不知道A的完整定义;
  • 但是把delete a;放在A的完整定义之前,编译是可以通过的,但是它属于未定义行为,对应的析构函数可能不会被调用。

正确的做法是把delete a;也放在A的完整定义之后。

数组版本

new/delete只能一次管理一个对象对应的内存,还有对应的数组版本:new[]/delete[], 使用例如

1
2
int* arr1 = new int[5];
int* arr2 = new int[5]{1, 2, 3, 4, 5};

同样存在是否进行初始化的区别,这里不做讨论。

对于自定义类型的使用也是类似的,例如下面的Demo类型(这里为了初始化数组,提供了从intDemo的自动类型转换)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Demo {
public:
int data;

Demo() : data(0) { std::cout << "Demo Default construction" << std::endl; }

Demo(int d) : data(d) {
std::cout << "Demo Explicit construction with data = " << data
<< std::endl;
}

~Demo() {
std::cout << "Demo Destruction with data = " << data << std::endl;
}
};

使用如下

1
2
3
4
5
auto *p1 = new Demo[5]; // 默认构造x5
delete[] p1; // 默认析构x5

auto *p2 = new Demo[5]{1, 2, 3, 4, 5}; // 默认构造x5
delete[] p2; // 默认析构x5

数组版本下,分别会自动调用五次构造函数和五次析构函数。

必须注意的是,new/deletenew[]/delete[]在使用中要正确配对,如果使用new[]申请的内存通过delete[]释放,很可能会出现错误和内存泄漏, 例如对于自定义类型数组,通过delete释放时只会主动析构数组的第一个对象,而忽略后续的对象。

1
2
auto *p = new Demo[5]; // 默认构造x5
delete p; // 默认析构x1

抛异常 vs 返回空指针

new/new[]在分配内存失败时,默认会抛出异常,但是为了延续C语言中malloc分配内存失败返回空指针的习惯,我们也可以加上选项进行修改

1
2
int *large_array = new int[1000000000000000]; // may throw std::bad_alloc
int *large_array = new (std::nothrow) int[1000000000000000];

两者的对比例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

int main() {
try {
int *large_array = new int[1000000000000000];
std::cout << "Memory allocation succeeded." << std::endl;
delete[] large_array;
}
catch (const std::bad_alloc &e) {
std::cerr << "Memory allocation failed: " << e.what() << std::endl;
}

int *large_array = new (std::nothrow) int[1000000000000000];
if (large_array == nullptr) {
std::cerr << "Memory allocation failed: return nullptr" << std::endl;
}
else {
std::cout << "Memory allocation succeeded." << std::endl;
delete[] large_array;
}
return 0;
}

运行结果如下

1
2
Memory allocation failed: std::bad_alloc
Memory allocation failed: return nullptr

进阶使用

定位 new 表达式

定位new表达式(placement new)是 C++ 提供的一种特殊的 new 表达式, 允许在特定的位置(可能是通常的堆内存,也可以是栈内存等)进行对象的构造初始化。 此时直接在预分配的内存块中构造对象,略去了向系统申请内存的环节

1
2
3
alignas(int) char buffer[sizeof(int)]; // alignas(int)涉及到内存对齐
int *p = new (buffer) int(42);
std::cout << "Value of *p: " << *p << std::endl; // 42

定位new表达式实际上涉及了下面的底层函数调用

1
void *operator new(size_t, void*);

这个函数是不允许用户进行重写的。

new/delete的底层原理

为了简化描述,我们只讨论new/delete的底层行为,new[]/delete[]有对应的版本。

我们探究一下new语句的底层原理,A* a = new A;这行语句实际执行了如下操作:

  1. 调用一个名为operator new的标准库函数,分配一块足够大的、原始的、未命名的内存空间以便存储A类型的对象
  2. 指针类型转换,使用定位new表达式,调用相应的构造函数完成初始化
  3. 返回一个指向该对象的指针

类似的,delete语句的底层原理如下,delete p;这行语句实际执行了如下操作:

  1. 调用对象的析构函数
  2. 调用一个名为operator delete的标准库函数,释放对应的内存空间

示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

class A {
public:
A() { std::cout << "Constructor called for A" << std::endl; }

~A() { std::cout << "Destructor called for A" << std::endl; }
};

int main() {
void *memory = operator new(sizeof(A)); // 分配内存
A *a = new (memory) A; // 指针类型转换,定位new,调用构造函数
a->~A(); // 调用析构函数
operator delete(memory); // 释放内存
return 0;
}

这里可能涉及如下的底层函数

1
2
void* operator new(size_t size);
void operator delete(void* ptr);

它们其实才是malloc/free在C++中真正对应的版本。通常使用的new/delete语句是以它们为基础进行的封装, 例如C++ primer提供了一种简单的实现

1
2
3
4
5
6
7
8
void *operator new(std::size_t size) {
if (void *mem = malloc(size)) {
return mem;
}
throw std::bad_alloc();
}

void operator delete(void *ptr) { free(ptr); }

它们只是C++提供的全局函数,与之相对的,通常使用的new/delete可以称为new/delete运算符和表达式。

前面提到的不抛出异常的new表达式,对应会调用下面的版本

1
2
void* operator new(size_t size, std::nothrow_t&) noexcept;
void operator delete(void* ptr, std::nothrow_t&) noexcept;

我们使用的std::nothrow是标准库提供的一个std::nothrow_t类型的实例,这个类型只是个占位的空类型。

对于delete实际上还有指定释放内存大小size的版本

1
void operator delete(void* ptr, std::size_t sz);

这个版本在用户监控内存时会发挥重要作用。

自定义类型重载 new/delete

C++针对自定义类型在new/delete时会调用全局版本的函数

1
2
::operator new(size_t size);
::operator delete(void* ptr);

但是如果自定义类型A定义了下面的成员函数

1
2
A::operator new(size_t size);
A::operator delete(void* ptr);

那么在涉及A类型的构造将用其替换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
#include <iostream>
#include <new>

class MyClass {
public:
int data;

MyClass(int d) : data(d) {
std::cout << "Constructor called for MyClass with data = " << data
<< std::endl;
}

~MyClass() {
std::cout << "Destructor called for MyClass with data = " << data
<< std::endl;
}

void *operator new(std::size_t size) {
std::cout << "Custom operator new called for size = " << size
<< std::endl;

void *ptr = ::operator new(size);
if (ptr == nullptr) { throw std::bad_alloc(); }
return ptr;
}

void operator delete(void *ptr) {
std::cout << "Custom operator delete called" << std::endl;

::operator delete(ptr);
}
};

int main() {
MyClass *obj = new MyClass(42);
delete obj;

return 0;
}

运行结果如下

1
2
3
4
Custom operator new called for size = 4
Constructor called for MyClass with data = 42
Destructor called for MyClass with data = 42
Custom operator delete called

注意:

  • 如果将这两个函数定义为某个类型的成员函数,那么无须将其显式声明为static,总是会将其视作静态成员函数,并且显然在这些函数中不能操作对象的任何数据成员,不允许作为虚函数。
  • 这里重载的是抛异常版本的,同理我们也可以重载不抛异常的版本。
  • 事实上,我们还可以直接在全局作用域下重写这两个函数,那么我们自定义的版本将会彻底接管所有的new表达式,而非仅仅针对某个类型的new表达式。

监控内存分配

通过重载全局的内存分配函数,我们就可以监控内存的使用是否存在泄露,例如

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
48
49
50
#include <iostream>
#include <memory>

struct AllocationMetrics {
inline static uint32_t TotalAllocated = 0; // 总分配内存
inline static uint32_t TotalFreed = 0; // 总释放内存

static void add(uint32_t amount) { TotalAllocated += amount; }

static void free(uint32_t amount) { TotalFreed += amount; }

static uint32_t current_usage() { return TotalAllocated - TotalFreed; }
};

void *operator new(size_t size) {
AllocationMetrics::add(size);
return malloc(size);
}

void operator delete(void *memory, size_t size) {
AllocationMetrics::free(size);
free(memory);
}

struct Object {
int x, y, z;
};

void PrintMemoryUsage() {
std::cout << "Memory Usage:" << AllocationMetrics::current_usage()
<< " bytes\n";
}

int main() {
PrintMemoryUsage();
{
std::unique_ptr<Object> obj = std::make_unique<Object>();
PrintMemoryUsage();
}

PrintMemoryUsage();
Object *obj = new Object;
PrintMemoryUsage();
delete obj;
PrintMemoryUsage();
std::string string = "Cherno";
PrintMemoryUsage();

return 0;
}

注意这里我们提供的是含大小的operator delete函数的全局重载

1
void operator delete(void *memory, size_t size);

含大小参数的operator delete函数会在用户提供定义时替代默认的operator delete

1
void operator delete(void *memory);

但是实际上在某些情况下,具体会使用哪一个是未定义的,取决于编译器的具体实现,尤其在用户同时提供两个版本的operator delete函数时。

补充

对于modern C++来说,我们其实不应该大量使用new/delete来显式地管理内存,当然更不应该使用malloc/free, 我们需要基于RAII的思想,尽量将其封装起来或者使用标准库提供的现成工具。

对于C语言的malloc,如果我们申请malloc(0),并不会返回空指针,而是会返回一个有效指针,但是这个做法很危险,容易导致内存写入越界,在释放时也容易产生错误。