从 malloc/free 到 new/delete:真正理解 C++ 中的内存管理与对象生命周期

文章目录

    • [1. 先分清两件事:内存放在哪里,对象是否已经存在](#1. 先分清两件事:内存放在哪里,对象是否已经存在)
    • [2. C 的动态分配:拿到的是内存,不是 C++ 对象的构造过程](#2. C 的动态分配:拿到的是内存,不是 C++ 对象的构造过程)
    • [3. `new` 与 `delete`:先从内置类型看语法和初始化](#3. new 与 delete:先从内置类型看语法和初始化)
    • [4. 对自定义类型,分配和构造必须连起来看](#4. 对自定义类型,分配和构造必须连起来看)
    • [5. `new` 表达式和 `operator new` 函数,不是同一个东西](#5. new 表达式和 operator new 函数,不是同一个东西)
    • [6. 拓展:placement new(定位 new)](#6. 拓展:placement new(定位 new))
    • [7. 为何要严格匹配内存开辟与释放规则](#7. 为何要严格匹配内存开辟与释放规则)
    • [8. 小结](#8. 小结)

1. 先分清两件事:内存放在哪里,对象是否已经存在


在 C 中,我们常用"栈、堆、数据段、代码段"来描述一个典型程序的内存布局。下面用一段 C++ 代码来观察变量与它具体指向的内容:

cpp 复制代码
#include <iostream>

//定义全局(静态)变量
int global_value = 1;
static int file_value = 2;

int main() 
{
    //静态变量
    static int local_static = 3;
    //普通变量
    int local_value = 4;
    int local_array[2] = { 5, 6 };
    //指针变量
    const char* text = "C++";
    //动态开辟内存
    int* dynamic_value = new int(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;

    delete dynamic_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。:

c 复制代码
#include <stdlib.h>
#include <stdio.h>

int main()
{
    int* values = (int*)calloc(3, sizeof(int));
    if (values == NULL)
    {
        perror("calloc fail!");
        return 1;
    }

    int* grown = (int*)realloc(values, 5 * sizeof(int));
    if (grown == NULL)
    {
        perror("realloc fail!");
        free(values);
        return 1;
    }

    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);
    return 0;
}

运行示例:

前两次打印时,由于未给新分配好的内存空间赋值,所以打印随机值,正常分配后则输出 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[]。

cpp 复制代码
// 完整示例:内置类型的不同初始化方式
#include <iostream>

int main() 
{
    int* a = new int;        // 值未初始化,先赋值再读取
    int* b = new int(10);    // 初始化为 10
    int* c = new int();      // 值初始化,得到 0
    int* d = new int[3];     // 三个元素的值未初始化(随机值)
    int* e = new int[3]();   // 三个元素均值初始化为 0
    int* f = new int[3] {1, 2, 3}; //三个元素按照给定元素初始化
    int* g = new int[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] << ' ';
    }

    delete a;
    delete b;
    delete c;
    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 类在构造时申请一个整数内存,在析构时释放它。

cpp 复制代码
// 完整示例:单个对象与对象数组的生命周期
#include <iostream>

class Cell 
{
public:
    explicit Cell(int value = 0) 
    : _value(new int(value)) 
    {
        std::cout << "构造 " << *_value << std::endl;
    }

    ~Cell() 
    {
        std::cout << "析构 " << *_value << std::endl;
        delete _value;
    }

private:
    int* _value;
};

int main() 
{
    Cell* one = new Cell(7);
    delete one;

    Cell* three = new Cell[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) 释放函数:只释放原始存储 不调用析构函数

在通常的路径中,可以把过程想成:

text 复制代码
new Cell(7) : 取得足够且对齐的存储 -> 在其中构造 Cell -> 得到 Cell*
delete p    : 对 Cell 执行析构    ->  释放其占用的存储

operator new ≠ new表达式

全局 operator new 常由运行库在底层借助 malloc 或其他分配器实现,但标准并不要求它必须调用 malloc 。

  1. 不要混淆:调用operator new不会产生对象 ,只是一块未初始化内存,直接当成 Cell * 去使用,属于未定义行为。
  2. operator new/operator delete可以被类重写(类内重载);new表达式语法本身不能重载。

6. 拓展:placement new(定位 new)


如果你已经用operator new拿到裸内存,想要手动执行构造函数,就用定位 new:

cpp 复制代码
void* raw = ::operator new(sizeof(Cell)); // 只分配内存,没有对象
Cell* obj = new(raw) Cell(7); // placement new:在raw这块内存上调用构造函数

释放的时候也要手动两步:

cpp 复制代码
obj->~Cell();        // 手动调用析构
::operator delete(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指针严禁交叉释放。
  • 对象完整生命周期:分配内存 -> 构造 -> 使用 -> 析构 -> 释放内存。
相关推荐
额额额对了1 小时前
SPI通信
linux·c语言·汇编·单片机·嵌入式硬件·arm
m0_380743872 小时前
Qt侧边栏布局的实现示例
开发语言·c++
汉克老师2 小时前
GESP2026年9月认证C++五级( 第三部分编程题(1、哥德巴赫猜想))精讲
c++·gesp·小学生·学c++编程
周玉奎先生2 小时前
CGM系列水泥基灌浆料型号参数解析及安庆重点工程应用匹配
经验分享·笔记·零售
Logic1012 小时前
C语言/数据结构位运算题解:异或XOR找出书柜中编号只出现一次的那一层——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
小灰灰搞电子2 小时前
C++ final 关键字详解:从虚函数到类继承的完整指南
c++·final
weixin_307779133 小时前
C++代码实现MATLAB中的pade函数功能
开发语言·c++·算法·matlab
神仙别闹3 小时前
基于C++实现的(控制台)学校开课查询系统
开发语言·c++
恒锐丰科技林技术员3 小时前
EG2106 半桥 MOS/IGBT 驱动芯片解析与工程应用
经验分享·嵌入式硬件·硬件工程