文章目录
- 1. 先分清两件事:内存放在哪里,对象是否已经存在
- 2. C 的动态分配:拿到的是内存,不是 C++ 对象的构造过程
- 3. `new` 与 `delete`:先从内置类型看语法和初始化
- 4. 对自定义类型,分配和构造必须连起来看
- 5. `new` 表达式和 `operator new` 函数,不是同一个东西
- 6. 拓展:placement new(定位 new)
- 7. 为何要严格匹配内存开辟与释放规则
- 8. 小结
1. 先分清两件事:内存放在哪里,对象是否已经存在
在 C 中,我们常用“栈、堆、数据段、代码段”来描述一个典型程序的内存布局。下面用一段 C++ 代码来观察变量与它具体指向的内容:
#include<iostream>//定义全局(静态)变量intglobal_value=1;staticintfile_value=2;intmain(){//静态变量staticintlocal_static=3;//普通变量intlocal_value=4;intlocal_array[2]={5,6};//指针变量constchar*text="C++";//动态开辟内存int*dynamic_value=newint(7);std::cout<<"global_value: "<<global_value<<std::endl;std::cout<<"file_value: "<<file_value<<std::endl;std::cout<<"local_static: "<<local_static<<std::endl;std::cout<<"local_value: "<<local_value<<std::endl;std::cout<<"local_array[0]: "<<local_array[0]<<std::endl;std::cout<<"text: "<<text<<std::endl;std::cout<<"*dynamic_value: "<<*dynamic_value<<std::endl;deletedynamic_value;}运行示例:
global_value、file_value和local_static具有静态存储期,通常放在静态区(数据段);local_value、local_array、text和dynamic_value这些局部变量通常位于栈帧中。- 指针变量和它指向的内容不是同一块东西:
dynamic_value自身是局部变量,*dynamic_value是动态创建的整数对象,空间占用在堆上; text自身是局部指针,而字符串字面量具有静态存储期,通常位于只读区域。
2. C 的动态分配:拿到的是内存,不是 C++ 对象的构造过程
先把熟悉的四个函数放在一张表里进行对比:
| C 函数 | 主要作用 | 须记住的边界 |
|---|---|---|
malloc(bytes) | 分配指定字节数 | 内容未初始化;失败返回空指针 |
calloc(count, bytes) | 分配并将字节置零 | 字节置零但不等于调用 C++ 构造函数 |
realloc(ptr, new_bytes) | 调整既有分配块 | 成功后旧指针失效,地址可能改变;失败时原块仍有效 |
free(ptr) | 释放上述函数得到的分配块 | 不调用 C++ 析构函数;free(nullptr)安全 |
下面是一个 C 语言的内存管理示例,只对整数数组使用calloc/realloc。:
#include<stdlib.h>#include<stdio.h>intmain(){int*values=(int*)calloc(3,sizeof(int));if(values==NULL){perror("calloc fail!");return1;}int*grown=(int*)realloc(values,5*sizeof(int));if(grown==NULL){perror("realloc fail!");free(values);return1;}values=grown;// 成功后只能继续使用新指针printf("%d %d\n",values[3],values[4]);values[3]=30;// 扩出来的字节不能假定已清零values[4]=40;printf("%d %d\n",values[3],values[4]);free(values);return0;}运行示例:
前两次打印时,由于未给新分配好的内存空间赋值,所以打印随机值,正常分配后则输出30 40。如果直接写values = realloc(values, ...),失败返回的空指针会覆盖原地址,从而导致原块无法释放(内存泄漏)。
在 C++ 中调用这些函数时,若是用来开辟自定义类型的对象时,它们不会调用类的构造函数。
对已经由new创建的对象,也不能用realloc扩容。
3.new与delete:先从内置类型看语法和初始化
new T创建一个T对象;new T[n]创建一个含n个对象的数组。
相较于手写malloc(n * sizeof(T)),new根据类型和元素个数决定所需存储,并直接返回相应类型的指针;对应地,单个对象用delete,数组用delete[]。
// 完整示例:内置类型的不同初始化方式#include<iostream>intmain(){int*a=newint;// 值未初始化,先赋值再读取int*b=newint(10);// 初始化为 10int*c=newint();// 值初始化,得到 0int*d=newint[3];// 三个元素的值未初始化(随机值)int*e=newint[3]();// 三个元素均值初始化为 0int*f=newint[3]{1,2,3};//三个元素按照给定元素初始化int*g=newint[3]{1};//第一个元素按给定元素初始化,其余为0*a=5;d[0]=1;d[1]=2;d[2]=3;std::cout<<"*a: "<<*a<<std::endl;std::cout<<"*b: "<<*b<<std::endl;std::cout<<"*c: "<<*c<<std::endl;for(size_t i=0;i<3;i++){std::cout<<"d["<<i<<"]:"<<d[i]<<' ';}std::cout<<std::endl;std::cout<<std::endl;for(size_t i=0;i<3;i++){std::cout<<"f["<<i<<"]:"<<f[i]<<' ';}std::cout<<std::endl;std::cout<<std::endl;for(size_t i=0;i<3;i++){std::cout<<"g["<<i<<"]:"<<g[i]<<' ';}deletea;deleteb;deletec;delete[]d;delete[]e;delete[]f;delete[]g;}运行示例:
普通new分配失败时通常抛出std::bad_alloc而中断程序,而不是像malloc那样返回空指针;所以写if (p == nullptr)并不能处理普通new的分配失败。
后续可以使用异常处理中的
try...catch来进行异常的捕捉。
4. 对自定义类型,分配和构造必须连起来看
在C++中,动态开辟类对象时比整数时多了一段关键过程:new自动调用构造函数让对象可用,delete自动调用析构函数在对象结束前清理资源。这也是与 C 的内存管理有差别的地方,malloc是函数,而new是操作符。
下面的Cell类在构造时申请一个整数内存,在析构时释放它。
// 完整示例:单个对象与对象数组的生命周期#include<iostream>classCell{public:explicitCell(intvalue=0):_value(newint(value)){std::cout<<"构造 "<<*_value<<std::endl;}~Cell(){std::cout<<"析构 "<<*_value<<std::endl;delete_value;}private:int*_value;};intmain(){Cell*one=newCell(7);deleteone;Cell*three=newCell[3];delete[]three;}运行示例:
正常执行时,先输出一次“构造 7、析构 7”,再输出三次“构造 0”和三次“析构 0”。
new Cell[3]要能逐个构造,因此这里的Cell需要有可不传参数的构造函数(默认构造函数);delete[]要结束全部数组元素的生命,再释放数组占用的存储。
如果把delete one改成free(one),析构函数不会执行,_value指向的资源无法经由析构函数释放;所以就可能导致崩溃、破坏分配器状态。
单个元素也可能暂时看不出异常。但还是建议尽量配对使用,并且尤其注意释放开辟的数组时使用方括号
delete[]。
5.new表达式和operator new函数,不是同一个东西
名字接近,职责却不同:
| 写法 | 做什么 | 是否构造或析构对象 |
|---|---|---|
new Cell(7) | new表达式:取得存储并初始化一个对象 | 调用构造函数 |
delete p | delete表达式:结束对象生命并释放存储 | 调用析构函数 |
::operator new(sizeof(Cell)) | 分配函数:只取得原始存储 | 不调用构造函数 |
::operator delete(raw) | 释放函数:只释放原始存储 | 不调用析构函数 |
在通常的路径中,可以把过程想成:
new Cell(7) : 取得足够且对齐的存储 -> 在其中构造 Cell -> 得到 Cell* delete p : 对 Cell 执行析构 -> 释放其占用的存储operator new≠new表达式
全局operator new常由运行库在底层借助malloc或其他分配器实现,但标准并不要求它必须调用malloc。
- 不要混淆:调用
operator new不会产生对象,只是一块未初始化内存,直接当成Cell *去使用,属于未定义行为。 operator new/operator delete可以被类重写(类内重载);new表达式语法本身不能重载。
6. 拓展:placement new(定位 new)
如果你已经用operator new拿到裸内存,想要手动执行构造函数,就用定位 new:
void*raw=::operatornew(sizeof(Cell));// 只分配内存,没有对象Cell*obj=new(raw)Cell(7);// placement new:在raw这块内存上调用构造函数释放的时候也要手动两步:
obj->~Cell();// 手动调用析构::operatordelete(raw);// 再释放原始内存7. 为何要严格匹配内存开辟与释放规则
先看以下规则,不只看一次运行的表象:
| 取得方式 | 正确释放 | 错误配对 |
|---|---|---|
malloc/calloc/realloc | free | delete、delete[] |
new T | delete | free、delete[] |
new T[n] | delete[] | free、delete |
这些错误配对都是未定义行为,包括“内置类型数组用delete并没发生崩溃”。对于有析构函数的对象数组,后果往往更明显(对象析构不完全导致内存泄漏):delete并不能按数组规则逐个结束所有元素的生命,释放时还可能把不合适的地址交给分配器。
那么,delete[]又怎样知道该析构多少个对象?有的实现会在分配块附近保存元素数目或相关元数据,常称为array cookie;某些类型、实现或优化下也可能不需要这样的记录。
cookie 是编译器实现细节,C++ 标准没有强制要求必须存在、放哪里、多大。
但记录的位置、大小、是否存在,都不能拿来随意编写可移植代码,要根据平台与编译器的选择具体决定。
但只要严格遵守配对使用,就能很大程度避免一些问题的发生,尤其对于一些底层实现很复杂的情况。
8. 小结
new/delete是表达式,分配释放内存同时自动调用构造、析构;new[]必须配delete[],错配属于未定义行为。operator new/operator delete只操作裸内存,不执行构造析构,是new表达式底层调用的函数。malloc/free只管理原始内存,不处理对象;和new/delete指针严禁交叉释放。- 对象完整生命周期:分配内存 -> 构造 -> 使用 -> 析构 -> 释放内存。