Cpp new/delete 语句
new
和delete
是C++中最基础且重要的申请和释放堆内存的语法,以它为线索可以引出C++堆内存管理的相关内容,它也是智能指针所需要的基础知识,值得整理一下。
基本使用
内置类型
new
和delete
最基础的用法是被设计用来替代C语言中的malloc
和free
的,分配堆内存来构造指定类型的对象,返回对应类型的指针,例如
1
2int *p = new int; // uninitialized
delete p;
这两个语句都是非常危险的:
- 最简单的
new
语句对内置数据类型不会对内存进行初始化,我们得到值是随机的;(Debug模式下可能内存被清空,改成Release模式可以看到随机值) delete
语句不会在释放内存之后将指针置空,指针会变成空悬指针。
我们可以用下面的new
语句进行初始化 1
2
3
4
5double *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
3int *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
15class 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
4Demo *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
3Demo *p0 = new Demo;
Demo *p1 = new Demo();
Demo *p2 = new Demo{};
如果没有默认构造函数但是有其它构造函数,这些语句会导致编译报错。
C++对于不含任何构造函数的类的处理逻辑是不同的,例如下面的自定义类型Demo2
不含任何构造函数
1
2
3
4
5class Demo2 {
public:
double s;
int x;
};
各个语句可能的初始化效果如下 1
2
3
4
5Demo2 *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
5class Demo3 {
private:
double s;
int x;
};
各个语句的效果如下,后两个语句会编译报错 1
2
3
4
5Demo3 *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
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
2call A()
call ~A()
也就是说,我们无法保证自定义类型的析构函数被正常调用!(如果使用智能指针避免出现显式的new/delete
,可能会有不一样的情况)
解释一下:
- 在提供
A
的完整定义之前,我们就尝试定义B
类型; - 如果把
new A{};
放在A
的完整定义之前,编译无法通过,因为不知道A
的完整定义; - 但是把
delete a;
放在A
的完整定义之前,编译是可以通过的,但是它属于未定义行为,对应的析构函数可能不会被调用。
正确的做法是把delete a;
也放在A
的完整定义之后。
数组版本
new/delete
只能一次管理一个对象对应的内存,还有对应的数组版本:new[]/delete[]
,
使用例如 1
2int* arr1 = new int[5];
int* arr2 = new int[5]{1, 2, 3, 4, 5};
同样存在是否进行初始化的区别,这里不做讨论。
对于自定义类型的使用也是类似的,例如下面的Demo
类型(这里为了初始化数组,提供了从int
到Demo
的自动类型转换)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class 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
5auto *p1 = new Demo[5]; // 默认构造x5
delete[] p1; // 默认析构x5
auto *p2 = new Demo[5]{1, 2, 3, 4, 5}; // 默认构造x5
delete[] p2; // 默认析构x5
数组版本下,分别会自动调用五次构造函数和五次析构函数。
必须注意的是,new/delete
和new[]/delete[]
在使用中要正确配对,如果使用new[]
申请的内存通过delete[]
释放,很可能会出现错误和内存泄漏,
例如对于自定义类型数组,通过delete
释放时只会主动析构数组的第一个对象,而忽略后续的对象。
1
2auto *p = new Demo[5]; // 默认构造x5
delete p; // 默认析构x1
抛异常 vs 返回空指针
new/new[]
在分配内存失败时,默认会抛出异常,但是为了延续C语言中malloc分配内存失败返回空指针的习惯,我们也可以加上选项进行修改
1
2int *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
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
2Memory allocation failed: std::bad_alloc
Memory allocation failed: return nullptr
进阶使用
定位 new 表达式
定位new
表达式(placement new)是 C++ 提供的一种特殊的
new
表达式,
允许在特定的位置(可能是通常的堆内存,也可以是栈内存等)进行对象的构造初始化。
此时直接在预分配的内存块中构造对象,略去了向系统申请内存的环节
1
2
3alignas(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;
这行语句实际执行了如下操作:
- 调用一个名为
operator new
的标准库函数,分配一块足够大的、原始的、未命名的内存空间以便存储A类型的对象 - 指针类型转换,使用定位
new
表达式,调用相应的构造函数完成初始化 - 返回一个指向该对象的指针
类似的,delete
语句的底层原理如下,delete p;
这行语句实际执行了如下操作:
- 调用对象的析构函数
- 调用一个名为
operator delete
的标准库函数,释放对应的内存空间
示例如下 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
2void* operator new(size_t size);
void operator delete(void* ptr);
它们其实才是malloc/free
在C++中真正对应的版本。通常使用的new/delete
语句是以它们为基础进行的封装,
例如C++ primer提供了一种简单的实现 1
2
3
4
5
6
7
8void *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
2void* 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
2A::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
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
4Custom 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
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)
,并不会返回空指针,而是会返回一个有效指针,但是这个做法很危险,容易导致内存写入越界,在释放时也容易产生错误。