Cpp new/delete 语句
new和delete是C++中最基础且重要的申请和释放堆内存的语法,以它为线索可以引出C++堆内存管理的相关内容,它也是智能指针所需要的基础知识,值得整理一下。
基本使用
内置类型
new和delete最基础的用法是被设计用来替代C语言中的malloc和free的,分配堆内存来构造指定类型的对象,返回对应类型的指针,例如
1 | int *p = new int; // uninitialized |
这两个语句都是非常危险的:
- 最简单的
new语句对内置数据类型不会对内存进行初始化,我们得到值是随机的;(Debug模式下可能内存被清空,改成Release模式可以看到随机值) delete语句不会在释放内存之后将指针置空,指针会变成空悬指针。
我们可以用下面的new语句进行初始化
1 | double *p1 = new double; // uninitialized |
在delete释放之后将指针置空是非常必要的习惯
1 | int *p = new int{}; |
自定义类型
new/delete当然也支持自定义类型的内存分配和释放:
new在分配内存之后,对自定义类型可能会调用对应的构造函数;delete可能会对自定义类型会调用析构函数,然后释放内存。(如果是退出main函数由系统自动释放,则肯定不会调用析构函数)
以下面提供了构造函数的自定义类型Demo为例
1 | class Demo { |
传递正确的参数即可调用构造函数进行初始化(这里有细微区别:()在初始化时会对其中的参数进行隐式类型转换,{}在初始化时不会进行任何类型转换)
1 | Demo *p1 = new Demo(1); |
因为Demo提供了默认构造函数,下面的new语句会调用默认构造函数进行初始化
1 | Demo *p0 = new Demo; |
如果没有默认构造函数但是有其它构造函数,这些语句会导致编译报错。
C++对于不含任何构造函数的类的处理逻辑是不同的,例如下面的自定义类型Demo2不含任何构造函数
1 | class Demo2 { |
各个语句可能的初始化效果如下
1 | Demo2 *p1 = new Demo2; // uninitialized |
如果改成下面的Demo3,把所有数据成员改成私有的,C++的处理又是不一样的
1 | class Demo3 { |
各个语句的效果如下,后两个语句会编译报错
1 | Demo3 *p1 = new Demo3; |
C++这部分的语法规则很复杂,涉及到不同的自定义类的处理逻辑,不用管这些细节,为了保证对内存进行初始化,不要使用
new <type_name>;语句,改成new <type_name>{};是最稳妥的选择。
对于自定义类型的delete也不简单,是否会在释放之前调用析构函数是值得讨论的。
例如对于只提供了前置声明的自定义类型的指针调用delete实际上是未定义行为,下面的代码虽然不会编译报错,但是在不同的编译器可能有不一样的结果
1 |
|
运行结果可能是
1 | call A() |
也可能是
1 | call A() |
也就是说,我们无法保证自定义类型的析构函数被正常调用!(如果使用智能指针避免出现显式的new/delete,可能会有不一样的情况)
解释一下:
- 在提供
A的完整定义之前,我们就尝试定义B类型; - 如果把
new A{};放在A的完整定义之前,编译无法通过,因为不知道A的完整定义; - 但是把
delete a;放在A的完整定义之前,编译是可以通过的,但是它属于未定义行为,对应的析构函数可能不会被调用。
正确的做法是把delete a;也放在A的完整定义之后。
数组版本
new/delete只能一次管理一个对象对应的内存,还有对应的数组版本:new[]/delete[],
使用例如
1 | int* arr1 = new int[5]; |
同样存在是否进行初始化的区别,这里不做讨论。
对于自定义类型的使用也是类似的,例如下面的Demo类型(这里为了初始化数组,提供了从int到Demo的自动类型转换)
1 | class Demo { |
使用如下
1 | auto *p1 = new Demo[5]; // 默认构造x5 |
数组版本下,分别会自动调用五次构造函数和五次析构函数。
必须注意的是,new/delete和new[]/delete[]在使用中要正确配对,如果使用new[]申请的内存通过delete[]释放,很可能会出现错误和内存泄漏,
例如对于自定义类型数组,通过delete释放时只会主动析构数组的第一个对象,而忽略后续的对象。
1 | auto *p = new Demo[5]; // 默认构造x5 |
抛异常 vs 返回空指针
new/new[]在分配内存失败时,默认会抛出异常,但是为了延续C语言中malloc分配内存失败返回空指针的习惯,我们也可以加上选项进行修改
1 | int *large_array = new int[1000000000000000]; // may throw std::bad_alloc |
两者的对比例如
1 |
|
运行结果如下
1 | Memory allocation failed: std::bad_alloc |
进阶使用
定位 new 表达式
定位new表达式(placement new)是 C++ 提供的一种特殊的 new 表达式,
允许在特定的位置(可能是通常的堆内存,也可以是栈内存等)进行对象的构造初始化。
此时直接在预分配的内存块中构造对象,略去了向系统申请内存的环节
1 | alignas(int) char buffer[sizeof(int)]; // alignas(int)涉及到内存对齐 |
定位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 |
|
这里可能涉及如下的底层函数
1 | void* operator new(size_t size); |
它们其实才是malloc/free在C++中真正对应的版本。通常使用的new/delete语句是以它们为基础进行的封装,
例如C++ primer提供了一种简单的实现
1 | void *operator new(std::size_t size) { |
它们只是C++提供的全局函数,与之相对的,通常使用的new/delete可以称为new/delete运算符和表达式。
前面提到的不抛出异常的new表达式,对应会调用下面的版本
1 | void* operator new(size_t size, 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 | ::operator new(size_t size); |
但是如果自定义类型A定义了下面的成员函数
1 | A::operator new(size_t size); |
那么在涉及A类型的构造将用其替换C++提供的全局版本函数,例如我们可以利用全局版本的函数进行封装,以达到监控内存分配的目的
1 |
|
运行结果如下
1 | Custom operator new called for size = 4 |
注意:
- 如果将这两个函数定义为某个类型的成员函数,那么无须将其显式声明为
static,总是会将其视作静态成员函数,并且显然在这些函数中不能操作对象的任何数据成员,不允许作为虚函数。 - 这里重载的是抛异常版本的,同理我们也可以重载不抛异常的版本。
- 事实上,我们还可以直接在全局作用域下重写这两个函数,那么我们自定义的版本将会彻底接管所有的
new表达式,而非仅仅针对某个类型的new表达式。
监控内存分配
通过重载全局的内存分配函数,我们就可以监控内存的使用是否存在泄露,例如
1 |
|
注意这里我们提供的是含大小的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),并不会返回空指针,而是会返回一个有效指针,但是这个做法很危险,容易导致内存写入越界,在释放时也容易产生错误。
