文章目录


第一章:C/C++ 内存分布详解

在 C/C++ 程序设计中,我们讨论的“内存”通常指进程的虚拟地址空间(Virtual Address Space)。操作系统通常为每个进程提供相互隔离的虚拟地址空间;这并不意味着其中每个地址都已经映射,也不代表对应的物理内存是连续的。

以 32 位系统为例,理论寻址范围为 2 32 2^{32} 232,即 4GB。下面展示的是一种常见的教学模型;用户空间与内核空间的比例、各区域的具体地址和排列方式会随操作系统、体系结构、ABI、链接方式以及安全随机化机制而变化。

1. 内存分布全景图

在这里插入图片描述

请添加图片描述

2. 各区域详细剖析

2.1 内核空间

  • 位置:常见于虚拟地址空间的高地址部分;在某些 32 位系统的典型 3GB/1GB 划分中,顶部 1GB 留给内核,但该比例并非固定。
  • 作用:操作系统内核代码和内核数据运行、存放的区域,可能包含系统调用相关结构、页表和内核栈等。
  • 权限用户态代码不能直接读写该区域。非法访问通常会触发操作系统异常或信号;空指针通常表现为零地址附近的无效映射, 但空指针的语言语义并不等同于“固定指向某个物理地址”。

2.2 栈区 (Stack)

  • 位置:通常位于用户空间的较高地址处,并在很多平台上向低地址方向增长;具体方向属于平台和 ABI 的实现细节。
  • 存储内容
    • 具有自动存储期的局部对象:函数内部定义的普通局部变量通常在这里,但编译器也可能将其放入寄存器或直接优化掉。
    • 函数参数:部分参数可能位于栈中,也可能通过寄存器传递。
    • 函数调用信息:栈帧(Stack Frame)可能保存返回地址、被保存的寄存器和局部状态等。
  • 管理方式自动管理。函数或作用域进入、退出时,由编译器生成代码调整栈帧并管理对象生命周期。
  • 特点
    • 效率通常很高:分配栈空间通常只需调整栈指针,并不一定逐个使用 push/pop 指令。
    • 空间有限:栈大小由操作系统、线程设置、链接选项和运行环境共同决定。某些 Linux 环境的主线程默认栈常见为 8MB,某些 Windows 工具链默认保留大小常见为 1MB,但都可以配置。 递归过深或创建过大的局部数组可能导致 Stack Overflow(栈溢出)。

2.3 共享区 / 文件映射区

  • 位置:在许多常见进程布局中位于堆与栈之间,但具体位置并不由 C/C++ 标准规定。
  • 作用
    • 加载动态链接库(.dll / .so)。
    • 用于 mmap 等系统接口实现文件映射、匿名映射或共享内存。
    • 支持高效文件访问和进程间通信。

2.4 堆区 (Heap)

  • 位置:传统教学模型中位于静态数据区之上,并常被描述为向高地址增长;现代分配器还可能通过 mmap 等方式从其他地址获得内存,因此实际布局不保证连续,也不保证始终单向增长。
  • 存储内容:程序运行期间动态分配的存储。
    • C 语言:malloccallocrealloc
    • C++:new 表达式以及底层分配函数
  • 管理方式:使用原始指针时通常需要显式释放(free/delete);现代 C++ 更推荐使用 RAII 容器和智能指针自动管理资源,否则可能导致内存泄漏(Memory Leak)
  • 特点
    • 可用空间通常较大:受虚拟地址空间、进程限制和系统可用资源约束。
    • 分配成本通常高于栈:分配器需要管理空闲块、对齐和并发访问,有时还会触发系统调用,并可能产生内存碎片。

2.5 数据段 (Data Segment)

这一区域通常用于存放具有静态存储期的对象,例如全局变量、命名空间作用域变量以及 static 变量。它们的生命周期通常贯穿整个程序运行过程,但具体落入哪个二进制段由实现、链接器和初始化方式决定。

A. 已初始化数据段 (.data)
  • 存储内容:通常存放需要占用可执行文件数据内容的、可写的已初始化全局变量和静态变量。初值不为 0 的对象常见于 .data;某些对象也可能因实现和链接优化被放入其他节。
  • 示例int g_val = 10;static int s_val = 5;
B. 未初始化数据段 (.bss)
  • 全称:Block Started by Symbol(历史遗留术语)。
  • 存储内容:通常存放未显式初始化或零初始化的全局变量和静态变量。
  • 特点.bss 通常只在可执行文件中记录所需大小,而不逐字节保存大量的零;程序装载时,系统会把相应存储清零。
  • 示例int g_val;(具有静态存储期,因此会被零初始化)

2.6 代码段 (Code Segment / Text Segment)

  • 位置:常见布局中位于用户空间较低地址处,但具体位置受平台、链接器和地址空间随机化影响。
  • 存储内容
    1. 代码段 .text:编译器生成的机器指令,通常具有只读和可执行权限。
    2. 只读数据段 .rodata:字符串字面量和部分只读常量通常放在这里。教学图中常把它与代码段合并称为“代码区/常量区”,但二者在实际目标文件中通常是不同的节。
  • 权限:修改字符串字面量属于未定义行为;很多实现把它放在只读页中,因此强行写入时常见结果是程序异常或崩溃。

3. 深度对比:栈 (Stack) vs 堆 (Heap)

这是面试和实际开发中最核心的区别:

特性 栈 (Stack) 堆 (Heap)
分配方式 通常由编译器自动管理作用域和栈帧 原始动态内存需要通过 new/deletemalloc/free 等管理;现代 C++ 常由 RAII 封装
生长方向 许多平台向低地址增长,但不由语言标准规定 传统模型常画为向高地址增长,现代实现不保证连续或单向
空间大小 通常较小,具体由线程和运行环境配置 通常较大,受虚拟地址空间、系统资源和进程限制约束
分配效率 通常很高,常通过调整栈指针完成 通常较低,需要分配器管理、对齐和同步
碎片问题 一般没有通用堆分配器那样的外部碎片问题 频繁分配和释放可能产生内部或外部碎片
内容 自动存储期对象、部分参数和函数调用上下文 动态对象和动态申请的原始存储

4. 实战代码解析

下面这段代码清晰地展示了变量在内存中的实际分布位置:

#include <cstdio>
#include <cstdlib>

// 1. 全局区(Global/Static)
int g_val = 100;          // 通常位于 .data
int g_uninit_val;         // 通常位于 .bss,并被零初始化
static int s_val = 200;   // 静态存储期,通常位于 .data

void func()
{
    // 2. 自动存储期对象:通常位于栈上,也可能被放入寄存器或优化掉
    int a = 10;

    // 3. 动态分配
    // C++ 中 malloc 返回 void*,转换为其他对象指针类型时需要显式转换;
    // 实际 C++ 项目通常优先使用容器、智能指针或 new 表达式。
    int* ptr = static_cast<int*>(std::malloc(sizeof(int)));
    if (ptr != nullptr)
    {
        *ptr = 30;
    }

    // 4. 只读数据
    // str_ptr 是局部指针对象;字符串字面量 "Hello" 通常位于只读数据区。
    const char* str_ptr = "Hello";

    // str_arr 是局部数组,元素由字符串字面量初始化,数组内容可以修改。
    char str_arr[] = "World";

    // %p 要求传入 void*。以下只打印对象地址。
    std::printf("=== 常见内存地址分布示例 ===\n");
    std::printf("[只读数据] 字符串字面量: %p\n",
                static_cast<const void*>(str_ptr));
    std::printf("[静态数据] 全局变量 g_val: %p\n",
                static_cast<void*>(&g_val));
    std::printf("[静态数据] 静态变量 s_val: %p\n",
                static_cast<void*>(&s_val));
    std::printf("[BSS示例 ] 未初始化全局:   %p\n",
                static_cast<void*>(&g_uninit_val));
    std::printf("[动态存储] malloc 分配:    %p\n",
                static_cast<void*>(ptr));
    std::printf("[自动存储] 局部变量 a:     %p\n",
                static_cast<void*>(&a));
    std::printf("[自动存储] 字符数组:       %p\n",
                static_cast<void*>(str_arr));
    std::printf("[自动存储] 指针变量 ptr:   %p\n",
                static_cast<void*>(&ptr));

    // 标准 C++ 不保证能够把函数指针可移植地转换为 void* 后使用 %p 输出;
    // 函数代码地址可借助调试器或平台专用接口观察。

    std::free(ptr);
}

int main()
{
    func();
    return 0;
}

预期输出分析(地址大小趋势)

在某些典型平台和某次具体运行中,可能观察到类似“代码/只读数据、静态数据、动态存储、栈”这样的分区趋势。但地址空间布局会受到操作系统、ASLR、PIE、链接器和分配器影响,不能把某一种地址大小顺序当成 C/C++ 语言保证

关键点解析

  1. 指针与所指对象的区别int* ptr = static_cast<int*>(std::malloc(sizeof(int)));
    • ptr 是一个具有自动存储期的局部指针对象,通常位于栈上,也可能被放入寄存器或优化掉。
    • std::malloc(sizeof(int)) 返回的动态存储块通常来自堆或其他动态映射区域。
    • ptr 保存的是那块动态存储的地址。
  2. const char*const char* str = "abc";
    • 字符串字面量 "abc" 具有静态存储期,通常位于只读数据区。
    • char str[] = "abc"; 会创建一个独立数组,并用字面量内容初始化它;如果该数组是普通局部数组,它通常位于栈上且元素可以修改。

补充:const 声明的变量位置

const 不能单独决定存储位置。它首先是类型限定符,用于限制通过相应表达式修改对象;对象最终位于栈、静态数据区、只读数据区、寄存器,还是被编译器完全消除,主要取决于存储期、作用域、是否取地址、初始化方式、链接方式和编译优化。

这个问题没有唯一答案。const 表示对象不能通过普通的非 const 途径修改,但它并不等同于“必然存放在物理只读内存中”。

决定变量存储位置的重要因素仍然包括它的存储期和编译器实现。

1. 局部 const 变量(函数内部、未加 static

📍 常见位置:栈、寄存器,或被优化掉

void func()
{
    const int a = 10;
}
  • 本质a 具有自动存储期。未优化时通常位于栈上;优化后可能只存在于寄存器、立即数中,甚至完全不占独立存储。
  • 区别const 会让编译器拒绝通过普通表达式执行 a = 20
  • 注意:如果对象最初就被定义为 const,通过 const_cast、C 风格强制转换等方式尝试修改它,在 C++ 中属于未定义行为;即使某次运行“看起来修改成功”,也不能依赖。

2. 全局 const 变量

📍 常见位置:只读数据区,也可能因实现而不同

const int g_a = 100;

int main()
{
}
  • 本质g_a 具有静态存储期。在许多实现中,常量初始化且无需可写存储的对象会被放入 .rodata,但 C++ 标准并不规定具体段名。
  • 链接属性补充在 C++ 中,命名空间作用域的非 extern、非 inline const 变量默认具有内部链接;这与它存放在哪个内存段是两个不同问题。
  • 注意:强行修改一个原本定义为 const 的对象仍然是未定义行为;是否立即崩溃取决于实现和优化。

3. static const 变量

📍 常见位置:静态存储区或只读数据区

void func()
{
    static const int b = 20;
}
  • 本质static 使局部变量具有静态存储期,const 限制通过普通表达式修改它。它通常可能被放入 .rodata,但也可能因动态初始化、取地址、链接器布局或调试配置而位于其他区域。
  • 注意:静态存储期只决定生命周期和初始化时机,不保证某个固定的物理段。

4. 字符串字面量(特殊)

📍 常见位置:只读数据区

const char* str = "Hello";
  • 这里有两个对象需要区分:
    1. str 指针变量本身:如果是普通局部变量,通常位于栈上,也可能在寄存器中。
    2. "Hello" 字符串字面量:具有静态存储期,通常位于只读数据区;修改它属于未定义行为。

5. 编译器优化(常量传播/常量折叠)

📍 可能没有独立存储

const int WIDTH = 1920;
int x = WIDTH + 10;
  • 如果程序不要求 WIDTH 具有可观察的独立地址,编译器可能不为它保留实际存储。
  • 生成的机器码可能直接使用立即数 1920 或结果 1930
  • 即使源代码中可以写 &WIDTH,编译器也只需保证程序可观察行为正确,不必按照教学图固定放入某个段。

总结一张表

代码写法 常见实现 语言层面应记住的结论 备注
void f() { const int a = 10; } 栈、寄存器或被优化掉 不能通过普通表达式修改;强改原生 const 对象是未定义行为 位置由实现和优化决定
const int g_a = 10;(全局) 常见于 .rodata 具有静态存储期;段名不由标准规定 C++ 中默认通常是内部链接
void f() { static const int x = 1; } 常见于静态区或 .rodata 具有静态存储期且受 const 限制 不保证一定是硬件只读页
"Hello World" 常见于 .rodata 修改字符串字面量是未定义行为 具有静态存储期
未取地址的简单常量 立即数、寄存器或被消除 可能没有独立存储 属于优化结果

补充:C 语言动态内存管理——malloccallocreallocfree

  • malloc(size):申请至少 size 字节的存储,内容未初始化;失败时返回空指针。
  • calloc(count, size):申请 count * size 字节,并把所有位初始化为零。对整数和无符号字符通常表现为数值 0;对所有类型都不要简单等同于“逐个执行 C++ 值初始化”。
  • realloc(ptr, new_size):调整原动态存储块的大小。它可能原地扩展,也可能申请新块、复制旧内容并释放旧块。
  • free(ptr):释放由 malloccallocrealloc 成功返回的存储;free(nullptr)/free(NULL) 不执行任何操作。

realloc 的关键规则:

int* p = malloc(4 * sizeof *p);

int* new_p = realloc(p, 10 * sizeof *p);
if (new_p != NULL)
{
    // realloc 成功后,旧指针 p 不能再使用;
    // 新地址可能与 p 相同,也可能不同。
    p = new_p;
}
else
{
    // realloc 失败时,原来的 p 仍然有效,仍需在之后释放。
}

free(p);

不要直接写 p = realloc(p, new_size); 后再判断 p,否则一旦失败返回空指针,就会丢失原内存块的地址并造成内存泄漏。

第二章:C++ 内存管理详解

在上一章中,我们探讨了 C++ 内存分布的基础(栈、堆、数据段等)。在实际开发中,堆(Heap)内存的管理是最为复杂且容易出错的环节。

C++ 的 newdelete 操作符不仅仅是 C 语言中 mallocfree 的语法糖,它们承载了对象生命周期管理的核心职责。本章我们将把目光聚焦在“类型”上,对比内置类型与自定义类型在动态内存分配中的不同表现,特别是针对单个变量与数组的创建细节。

注意:

  1. new 表达式的结果是一个指针值。 通常应立即把它交给合适的所有者(例如智能指针、容器或裸指针)管理,但语法上并不要求必须先赋给某个指针变量,也可以直接作为函数实参等使用。
  2. 对简单声明可以用“new + 去掉变量名后的类型/数组形式”帮助记忆,例如 int a[10] 对应 new int[10];但复杂声明应以正式语法为准。
  3. 基本的 newdelete 是 C++ 关键字,普通使用不需要额外头文件;直接使用 std::bad_allocstd::nothrowstd::new_handler 或标准 Placement New 时需要包含 <new>
  4. C++ 默认情况下,new 分配内存失败不会返回 nullptr,而是抛出 std::bad_alloc 异常。

1. 内置类型的内存管理

内置类型主要指 int, char, double, float 以及指针等基础数据类型。对于这些类型,内存管理相对“纯粹”,主要关注的是内存的分配与释放,不涉及复杂的构造与析构逻辑。

1.1 单个变量的申请与释放

对于单个内置类型变量,我们需要关注的是初始化问题。

void TestBuiltInSingle() {
    // 1. 申请内存但不初始化
    // *p1 具有不确定值,在赋值前读取可能导致未定义行为
    int* p1 = new int;

    // 2. 直接初始化
    // *p2 的值为 10
    int* p2 = new int(10);

    // 3. 值初始化
    // *p3 的值为 0
    int* p3 = new int();

    // 释放
    delete p1;
    delete p2;
    delete p3;
}
  • 关键点new intnew int() 有区别。前者默认初始化标量对象,不赋予确定数值;后者进行值初始化,结果为 0

1.2 数组(多个变量)的申请与释放

当我们需要动态创建一个内置类型的数组时:

void TestBuiltInArray() {
    // 1. 申请 10 个 int 的数组,不初始化
    // 在逐个赋值前读取这些元素可能导致未定义行为
    int* arr1 = new int[10];

    // 2. 申请 10 个 int 的数组,并全部值初始化为 0
    int* arr2 = new int[10]();

    // 3. C++11 列表初始化
    // 前三个为 1、2、3,其余元素值初始化为 0
    int* arr3 = new int[10]{1, 2, 3};

    // 释放
    // 严格遵循匹配原则,使用 delete[]
    delete[] arr1;
    delete[] arr2;
    delete[] arr3;
}

1.3 核心思考:内置类型混用 deletedelete[] 会怎样?

int* p = new int[10];
delete p; // ❌ 未定义行为
  • 结论:无论元素是内置类型还是自定义类型,new 必须与 delete 匹配,new[] 必须与 delete[] 匹配。
  • 原因:分配和释放形式不匹配会违反 C++ 对 delete 表达式的要求。实现可能使用不同的分配函数、对齐方式、数组开销或分配器元数据;不能因为 int 没有析构函数,就认为 delete p 一定能够正确释放 new int[10]
  • 表现:未定义行为可能暂时看不出问题,也可能导致堆损坏、崩溃、内存泄漏或被优化器产生不可预测结果。
  • 规则:始终坚持 new Tdeletenew T[N]delete[]

2. 自定义类型的内存管理

一旦涉及到类(Class),情况就发生了本质变化。new 不仅仅分配内存,还负责构造;delete 不仅仅释放内存,还负责析构。

假设我们要使用以下类:

class A {
public:
    int _a;
    A(int a = 0) : _a(a) {
        std::cout << "A() Constructed: " << this << std::endl;
    }
    ~A() {
        std::cout << "~A() Destructed: " << this << std::endl;
    }
};

2.1 单个对象的申请与释放

void TestCustomSingle() {
    1. 分配内存 -> 2. 调用构造函数
    A* p = new A(10); 
    
    1. 调用析构函数 -> 2. 释放内存
    delete p; 
}
  • 概念上的底层动作
    • new A(10) 先调用匹配的分配函数(通常是 operator new)取得至少 sizeof(A) 的原始存储,再在该存储中初始化一个 A 对象。
    • delete p 先结束对象生命周期并调用析构函数,然后调用匹配的释放函数(通常是 operator delete)归还存储。

2.2 new初始化具体流程:

1. 直接初始化: new A(10)(或 new A{10}

  • 流程:申请原始存储 → 选择匹配的构造函数 A(int) → 在该存储中直接构造对象。

  • 结论:这个 new 表达式本身不会先构造一个完整的临时 A 再拷贝到动态存储中;它会直接初始化最终对象。构造函数参数的求值或构造函数内部仍可能涉及其他对象的拷贝/移动。

2. 易混淆点辨析: A a = 10;(自动存储期对象)

  • 注意: new A = 10 是语法错误;new-initializer 应使用圆括号或花括号,例如 new A(10)new A{10}

  • A a = 10; 属于拷贝初始化:

    • C++17 之前:语言模型可能涉及临时对象,但允许编译器省略拷贝/移动,实际编译器通常会直接构造。
    • C++17 及以后:prvalue 规则和强制复制消除使许多此类场景直接构造最终对象。

2.3 初始化方式

在使用 new 申请动态存储时,括号形式会决定采用默认初始化、值初始化、直接初始化还是列表初始化。对于标量和聚合类型,这经常决定结果是未初始化还是零初始化;对于类类型,最终结果主要取决于所选择的构造函数是否正确初始化成员,不能简单概括为“加括号就一定把所有成员清零”。

主要分为以下几种方式,核心区别在于默认初始化(Default Initialization)值初始化(Value Initialization)

1. 默认初始化:new T(无括号)

这是最简单的写法,但对于标量类型或没有成员初始化的简单聚合类型,读取未初始化值会产生风险。

  • 语法T* p = new T;
  • 行为
    • 类类型尝试调用可访问的默认构造函数; 如果不存在可用的默认构造函数,则编译失败。
    • 标量类型以及未被构造函数或成员初始化器初始化的成员:不会自动获得确定数值。
struct A { int x; };
class B
{
public:
    B() : y(10) {}
    int y;
};

A* p1 = new A;     // p1->x 未初始化,赋值前不要读取
B* p2 = new B;     // 调用 B(),p2->y 为 10
int* p3 = new int; // *p3 未初始化,赋值前不要读取
2. 值初始化:new T()(空括号)

空括号表示对对象进行值初始化,但“值初始化”并不等于对所有类对象都无条件清零。

  • 语法T* p = new T();
  • 行为
    • 标量类型零初始化,例如 new int() 得到 0
    • 类类型按照值初始化规则选择默认构造函数。 若最终调用的是用户提供的默认构造函数,成员是否被初始化取决于该构造函数;若使用非用户提供的默认构造函数,规则可能先进行零初始化再进行默认初始化。
struct A { int x; };
class B
{
public:
    B() : y(10) {}
    int y;
};

A* p1 = new A();     // p1->x 为 0
B* p2 = new B();     // 调用 B(),p2->y 为 10
int* p3 = new int(); // *p3 为 0

总结:对标量或简单聚合类型,希望得到确定的零值时,可以使用 (){};对类类型则应确保构造函数或成员默认初始化器确实初始化了每个需要使用的成员。

3. 直接初始化:new T(args) (圆括号传参)

这是最常见的带参构造方式。

  • 语法T* p = new T(a, b);
  • 行为:直接寻找匹配参数列表 (a, b) 的构造函数进行调用。
class C {
public:
    C(int a) { val = a; }
    int val;
};

C* p = new C(100); // 调用 C(int),val 为 100
4. 列表初始化:new T{args} (花括号,C++11)

这是 C++11 引入的列表初始化语法,具有禁止窄化转换等优点。

  • 语法T* p = new T{a, b};
  • 行为
    • 对类类型按列表初始化规则选择构造函数,通常会优先考虑 std::initializer_list 构造函数。如果没有适用的 std::initializer_list 构造函数,再考虑其他构造函数。
    • 对于聚合类型:可以按成员顺序进行聚合初始化。
    • 安全性:禁止窄化转换(Narrowing Conversion),例如不能把不能安全表示的 double 隐式初始化为 int
    • 空花括号 new T{} 对标量会得到零值;对类类型按空列表初始化规则处理,通常与 new T() 结果相同,但仍应以具体构造函数集合为准。
struct Point { int x, y; };

Point* p1 = new Point{1, 2}; // 聚合初始化,x=1,y=2

// C++20 之前,如果 Point 没有相应构造函数,下面写法不成立;
// C++20 起支持部分聚合类型的圆括号直接初始化。
// Point* p2 = new Point(1, 2);

int* p3 = new int{5};       // OK
// int* p4 = new int{5.5};  // 编译错误:列表初始化禁止窄化
5. 数组的初始化

对于数组,各元素会按照相应规则逐个初始化。

  • new T[N]:默认初始化每个元素。若 Tint,元素没有确定初值。
  • new T[N]()new T[N]{}:值初始化每个元素。若 Tint,元素全部为 0;若 T 是类,则结果取决于它的构造函数。
  • new T[N]{a, b, c}(C++11):前几个元素由给定初始化器初始化,剩余元素进行值初始化。
int* arr1 = new int[5];      // 元素未初始化,赋值前不要读取
int* arr2 = new int[5]();    // [0, 0, 0, 0, 0]
int* arr3 = new int[5]{1, 2}; // [1, 2, 0, 0, 0]

注意:new A[3] = {1, 2, 3}; 是语法错误,new-initializer 前不能写 =;应写成 new A[3]{1, 2, 3}

快速一览表
写法 对类类型 对标量/简单聚合类型
new T 调用默认构造函数;不存在时编译失败 默认初始化,标量值可能不确定
new T() 值初始化;成员结果取决于构造规则 标量清零,简单聚合通常零初始化
new T(x) 调用匹配的构造函数;C++20 也可能用于部分聚合初始化 直接初始化为 x
new T{x} 列表初始化,可能选择 initializer_list 构造函数 初始化为 x,并禁止窄化

建议: 对标量和简单聚合类型,希望得到确定初值时,优先使用 (){};对类类型则应通过构造函数或成员默认初始化器建立完整不变式。

2.4 对象数组的申请与释放

这是本章的重难点。

class A
{
public:
    A() { /* 默认构造 */ }
    A(int x) { /* 带参构造 */ }
};

void TestCustomArray()
{
    // 1. 栈上数组(更准确地说:自动存储期局部数组)
    A arr1[10]; // 10 个元素全部调用 A()

    // 完全列表初始化(C++11+)
    A arr2[3] = {A(1), A(2), A(3)};

    // 如果构造函数没有被 explicit 修饰,还可以发生隐式转换:
    A arr3[3] = {1, 2, 3};

    // 部分列表初始化:
    A arr4[5] = {A(1), A(2)};
    // 前 2 个元素调用 A(int),后 3 个元素调用 A()

    // 2. 堆上数组(动态分配)
    A* pArr1 = new A[10]; // 10 个元素全部调用 A()

    // 列表初始化(C++11+)
    A* pArr2 = new A[3]{A(1), A(2), A(3)};
    A* pArr3 = new A[5]{A(1), A(2)};
    // 前 2 个调用 A(int),后 3 个调用 A()

    delete[] pArr1;
    delete[] pArr2;
    delete[] pArr3;
}
2.4.1 new A[10] 为什么要求默认构造函数?

执行 new A[10] 时,没有为每个元素提供单独的初始化器,因此编译器需要默认初始化 10 个对象,也就是为每个元素调用可访问的默认构造函数(包括所有参数都有默认值的构造函数)。如果 A 没有可用的默认构造函数,代码将无法通过编译。

如果使用列表初始化并为全部元素提供了合适的初始化器,则不一定要求默认构造函数;但只要仍有未显式初始化的剩余元素,它们就需要能够进行值初始化或默认构造。

2.4.2 delete[] 如何知道需要析构多少个元素?

对于自定义类型,严禁混用 deletedelete[]

A* arr = new A[10];
delete arr; // ❌ 未定义行为

C++ 语言只规定 new[] 必须与 delete[] 匹配,并保证需要析构的数组元素会被正确析构;标准没有规定实现必须使用某种固定的 Cookie 布局

许多 ABI 和编译器会在数组对象前面或分配块的其他位置保存额外信息,例如元素数量。这类额外信息通常被称为 Array Cookie。 一种常见的概念布局如下:

[ 可能的 Cookie / 对齐填充 ] [ A[0] ] [ A[1] ] ... [ A[9] ]
^                            ^
|                            |
分配函数得到的存储起点         new[] 表达式返回给用户的指针

delete[] arr 的概念过程是:

  1. 根据实现保存的元数据确定需要析构的元素;
  2. 按语言规定的顺序调用各元素的析构函数;
  3. 调用匹配的数组释放函数归还整块存储。

如果错误地写成 delete arr,结果是未定义行为。某些实现中可能只析构一个元素、向释放函数传入错误地址或破坏堆元数据;也可能表现为暂时“正常”。任何一种表现都不能依赖。

补充:Cookie 机制只有在显式写析构函数时才存在吗?

不能这样绝对判断。

实现是否需要数组额外开销,通常与元素类型是否需要逐个析构、是否使用 sized deallocation、对齐要求和 ABI 约定有关,而不是单纯检查“程序员有没有亲手写 ~T()”。

平凡析构(trivial destructor)指的是:一个类的析构过程不需要执行任何额外操作,编译器可以直接销毁对象,不需要调用用户自定义的析构函数。

1. 类型是否具有非平凡析构

情况 A:内置类型或平凡可析构类型
  • 编译器通常不需要为了逐个析构而保存元素数量。
  • 但这不等于标准保证“绝对没有任何额外开销”;分配器和 ABI 仍可保存自己的元数据。
情况 B:非平凡可析构类型
  • delete[] 必须能够对所有已构造元素执行析构。
  • 用户显式提供的析构函数通常会使类型成为非平凡可析构类型,即使函数体为空。
  • 即使没有显式写析构函数,只要基类或成员具有非平凡析构函数,编译器隐式生成的析构函数也可能是非平凡的。

2. Cookie 的大小和位置

Cookie 的大小、位置以及是否存在都属于实现细节:

  • 常见实现可能使用一个 size_t 保存元素个数;
  • 还可能因为内存对齐加入额外填充;
  • 某些实现可能从分配器元数据、sized delete 或其他 ABI 机制获得所需信息;
  • 因此不能假设 Cookie 一定恰好位于用户指针前 sizeof(size_t) 字节处。

3. 如何安全观察数组分配开销?

不要通过把指针向前移动再解引用来“读取 Cookie”。那会访问数组对象之外的存储,属于不可移植的行为,并可能触发未定义行为。

可以通过类专属的 operator new[] 观察编译器传给分配函数的字节数:

#include <cstddef>
#include <iostream>
#include <new>

class B
{
public:
    int value{};

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

    static void* operator new[](std::size_t bytes)
    {
        std::cout << "operator new[] receives: " << bytes << " bytes\n";
        std::cout << "10 * sizeof(B):          " << 10 * sizeof(B) << " bytes\n";
        return ::operator new[](bytes);
    }

    static void operator delete[](void* p) noexcept
    {
        ::operator delete[](p);
    }
};

int main()
{
    B* p = new B[10];
    delete[] p;
}

如果传入的字节数大于 10 * sizeof(B),多出的部分可能来自数组开销或对齐;如果两者相等,也不能据此证明“标准规定不存在 Cookie”,因为实现可能用其他方式保存信息。

4. 总结图解

下面只是一种常见实现的示意,不是标准规定的固定布局。

  1. T 是平凡可析构类型:

    [ T ][ T ][ T ]
    ^
    p(new[] 返回的地址)
    
  2. T 是非平凡可析构类型:

    [ 可能的元数据/填充 ][ T ][ T ][ T ]
    ^                    ^
    |                    p(new[] 返回的地址)
    |
    可能的分配起点
    

这也是必须匹配 new[]delete[] 的原因之一,但更根本的规则是:分配形式与释放形式不匹配本身就是未定义行为,不能依赖某一种 Cookie 实现来解释所有平台。

3. 总结与对比表

为了方便记忆,我们将本章内容总结如下:

特性 内置类型(intdouble 等) 自定义类型(Class/Struct)
new T 默认初始化标量,值可能不确定;new T()/new T{} 可得到零值 分配存储并初始化 1 个对象,通常调用构造函数
new T[N] 分配数组存储并逐个默认初始化元素 分配数组存储并逐个初始化 N 个对象
delete ptr 释放单个 new T 对应的存储 调用 1 个对象的析构函数,再释放匹配存储
delete[] ptr 释放 new T[N] 对应的数组存储 析构数组中已构造的元素,再释放匹配存储
数组额外开销 可能没有用于析构计数的 Cookie,但实现仍可有分配器开销 常见实现可能保存元素数或其他元数据,具体由 ABI/编译器决定
混用后果 new[]delete 是未定义行为 new[]delete 同样是未定义行为,常见后果包括析构不完整、堆损坏或崩溃

补充:空指针被delete[]或者delete不会报错?为什么

这是 C++ 标准明确规定的行为:对空指针(nullptrNULL)进行 deletedelete[] 操作是安全的,它不会执行任何操作

为什么要这样设计?主要有两个原因:为了便利性安全性

1. 极大地简化代码(便利性)

如果 C++ 标准不规定“delete 空指针安全”,那么我们在每一个析构函数或者清理资源的地方,都必须写成这样:

// 如果标准没有规定安全,我们就得这样写(很丑,很麻烦):
if (_str != nullptr) {
    delete[] _str;
}

if (_ptr != nullptr) {
    delete _ptr;
}

这会让代码变得非常臃肿。因为标准规定了它是安全的,所以编译器帮我们承担了这份工作。我们可以直接写:

// 现在的写法(清爽):
delete[] _str; // 哪怕 _str 是 nullptr,这行代码也什么都不做,直接跳过
delete _ptr;

2. 它是怎么实现的?(语言保证与可能实现)

delete p;delete[] p;delete 表达式。当操作数是空指针值时,语言保证它不析构任何对象,也不会产生释放非空对象的效果。

实现可以在编译器生成的代码中先判空,也可以让匹配的释放函数处理空指针。下面只是一种可能的概念实现:

if (p != nullptr)
{
    p->~T();
    ::operator delete(p);
}

对于数组,编译器还需要遵循数组析构和匹配释放规则。不要把“判空一定写在 operator delete 内部”当成标准规定;标准保证的是 delete nullptr 的整体行为安全。

3. 这对你的 String 模拟实现有什么用?

这点对实现 String 类非常重要,特别是在移动构造和移动赋值的时候。

考虑这个场景:

// 移动构造函数
String(String&& s) {
    // 1. 窃取别人的资源
    _str = s._str; 
    
    // 2. 把别人置为空,防止它析构时把资源释放了
    s._str = nullptr; 
}

// ... 之后 s 销毁,调用 ~String()
~String() {
    delete[] _str; 
    // 此时 s._str 已经是 nullptr 了。
    // 如果 delete[] nullptr 不安全,程序就崩了。
    // 但正因为它是安全的,你不需要在析构函数里加 if 判断。
}

4. 唯一的危险:Double Free(重复释放)

虽然 delete 空指针安全,但对同一个已经释放、仍保存旧地址的悬空指针再次执行 deletedelete[] 属于未定义行为。它可能崩溃,也可能破坏堆,甚至暂时看不出问题。

char* p = new char[10];

delete[] p; 第一次释放正确
 delete[] p;  ❌ p 仍保存旧地址,再次释放是未定义行为

p = nullptr;
delete[] p;delete[] nullptr,不执行任何操作

总结:

可以直接 delete[] _str,无须先写 if (_str != nullptr);但必须保证指针要么为空,要么仍指向与当前 delete 形式匹配、尚未释放的动态对象或数组。

补充:malloc/freenew/delete 的区别

它们都可以用于获得和归还动态存储,但语义层次不同:

  1. 形式不同malloccallocreallocfree 是函数;newdelete 是语言表达式/操作符,内部会调用相应的分配与释放函数。
  2. 类型不同malloc 返回 void*new T 直接返回 T*
  3. 大小计算不同malloc 需要显式传入字节数;new T 根据类型计算大小,new T[N] 根据类型和元素个数计算数组所需存储。
  4. 初始化与生命周期不同malloc 只提供原始字节,不开始普通类对象的完整构造过程;new 表达式会在获得存储后初始化对象并调用构造函数。
  5. 释放语义不同free 只归还存储;delete 会先结束对象生命周期并调用析构函数,再调用匹配的释放函数。
  6. 失败方式不同malloc 失败时返回空指针;普通抛出式 new 失败时通常抛出 std::bad_allocnew (std::nothrow) 失败时返回空指针。
  7. 匹配规则
    • malloc / calloc / reallocfree
    • new Tdelete
    • new T[N]delete[]

第三章:内存分配底层——operator newoperator delete

直接调用 operator new 通常只得到尚未构造对象的原始存储。如果要在其中创建对象,可以使用 Placement New;对象生命周期结束后,应先按需要调用析构函数,再使用与原始存储来源匹配的方式释放存储。是否需要 operator delete,取决于这块存储最初是否由匹配的 operator new 获得。

new / delete 底层通常会调用 operator new / operator delete,而它们在很多实现中最终会调用 malloc / free

3.1 核心概念区分:Expression vs Function

首先必须厘清两个极其容易混淆的概念:

  1. new 表达式(new-expression)
    例如 A* p = new A(10);new 是关键字,整个 new-expression 的语言行为不能由用户直接重载。
  2. operator new 函数
    这是可以替换或在类中重载的分配函数,典型声明为 void* operator new(std::size_t size)。它主要负责取得原始存储,不负责执行普通对象的构造函数。

它们的关系

当你写下 new A() 时,编译器实际上执行了以下逻辑(伪代码):

// 1. 调用 operator new 申请生肉(内存)
void* raw_mem = operator new(sizeof(A));

// 2. 在生肉上调用构造函数(烹饪)
call_constructor(raw_mem); 

// 3. 返回指针
A* p = static_cast<A*>(raw_mem);

这一章我们重点讨论的是第 1 步:那个负责“找系统要内存”的函数。

3.2 全局 operator newoperator delete

C++ 标准库提供全局分配和释放函数的默认实现。普通 new/delete 表达式可以直接使用,不需要包含头文件。

如果要在源代码中直接使用 std::bad_allocstd::nothrowstd::new_handler、标准 Placement New,或显式引用相关声明,应包含:

#include <new>

全局替换分配函数在每个翻译单元中都有语言所需的隐式声明,因此 int* p = new int; 本身不依赖显式包含 <new>

1. 全局 operator new

许多标准库实现最终会借助 malloc 或底层系统分配器,但这是实现细节。下面是简化的示意代码:

#include <cstdlib>
#include <new>

void* operator new(std::size_t size)
{
    if (size == 0)
    {
        size = 1;
    }

    while (true)
    {
        if (void* ptr = std::malloc(size))
        {
            return ptr;
        }

        std::new_handler handler = std::get_new_handler();
        if (handler != nullptr)
        {
            handler();
        }
        else
        {
            throw std::bad_alloc{};
        }
    }
}

2. 全局 operator delete

许多实现最终会把存储交还给 free 或其他底层分配器。下面同样只是简化示意:

void operator delete(void* ptr) noexcept
{
    std::free(ptr);
}

标准的释放函数必须能够接受空指针;而对空指针执行 delete 表达式本身也具有安全语义。

3.3 类专属重载

这是这一章的重头戏。你可以为特定类提供类专属的分配和释放函数。

为什么要做这个?

  1. 性能优化:针对固定大小对象使用内存池,减少通用分配器开销。
  2. 调试监控:统计对象分配次数、大小和潜在泄漏。
  3. 特殊存储:把对象放入共享内存、特定设备内存或自定义区域;实际可用性还取决于对象类型和平台约束。

语法规则

类作用域中的分配和释放函数属于静态成员函数,即使省略 static 也没有 this 指针。为了清晰可以显式写 static。释放函数通常应声明为 noexcept,并可根据需要提供 sized delete、对齐版本等匹配重载。

完整示例:编写一个具有内存监控功能的类

#include <iostream>
#include <new>

class Data {
public:
    int m_val;

    Data(int v) : m_val(v) { 
        std::cout << "  [Construct] Data constructed with " << v << std::endl; 
    }
    ~Data() { 
        std::cout << "  [Destruct] Data destroyed" << std::endl; 
    }

    // 重载 operator new
    // 编译器会自动计算类的大小并传给 size
    static void* operator new(size_t size) {
        std::cout << "1. [operator new] Allocating " << size << " bytes" << std::endl;
        // 这里我们可以自定义分配策略,简单起见我们调用全局的 new
        return ::operator new(size); 
    }

    // 重载 operator delete
    static void operator delete(void* p) noexcept
    {
        std::cout << "3. [operator delete] Freeing memory" << std::endl;
        ::operator delete(p);
    }
};

int main() {
    std::cout << "=== Start ===" << std::endl;
    
    // 触发 operator new -> 构造函数
    Data* p = new Data(100); 

    std::cout << "=== Using Object ===" << std::endl;
    
    // 触发 析构函数 -> operator delete
    delete p; 

    std::cout << "=== End ===" << std::endl;
    return 0;
}

运行结果:

=== Start ===
1. [operator new] Allocating 4 bytes
  [Construct] Data constructed with 100
=== Using Object ===
  [Destruct] Data destroyed
3. [operator delete] Freeing memory
=== End ===

3.4 数组版本:operator new[]operator delete[]

使用 new Data[10] 时,会选择数组分配函数 operator new[];使用 delete[] 时,会选择匹配的数组释放函数 operator delete[]

  • operator new[]:接收实现计算出的总字节数,其中可能包含元素存储、对齐和数组额外开销。
  • operator delete[]:归还对应的数组存储。逐个析构元素是 delete[] 表达式的职责,不是分配函数本身的职责。

如果类只重载 operator new 而没有重载 operator new[],创建数组时通常会使用全局 operator new[],因此类专属的内存池或监控不会自动覆盖数组分配。

static void* operator new[](std::size_t size)
{
    std::cout << "[Array new] Allocating " << size << " bytes" << std::endl;
    return ::operator new[](size);
}

static void operator delete[](void* p) noexcept
{
    std::cout << "[Array delete] Freeing array" << std::endl;
    ::operator delete[](p);
}

3.5 Placement New(定位 new)

通常的 new-expression 同时负责取得存储和初始化对象。标准的非分配 Placement New 允许把这两步分离:在调用者已经准备好的、大小与对齐均合适的存储中初始化对象。

1. 核心定义与头文件

普通 new-expression 会调用分配函数取得存储;标准 Placement New 使用调用者传入的地址,不额外申请存储。

  • 头文件:应包含 <new>

    #include <new>
    
  • 概念上的标准形式

    void* operator new(std::size_t, void* ptr) noexcept
    {
        return ptr;
    }
    

这里说的是标准的非分配 Placement New;用户还可以定义带其他额外参数的 placement allocation function,那些自定义形式不一定“不分配内存”。

2. 具体语法规则

基本语法格式如下:

new (address) Type(arguments);
  • address:指向一块大小足够、对齐正确且允许在此开始对象生命周期的存储。
  • Type:要构造的类型。
  • arguments:传给构造函数或初始化过程的参数。

3. 标准使用流程 (SOP)

使用 Placement New 必须严格遵守 “五步走” 流程,任何一步出错都可能导致内存腐烂或崩溃。

第一步:准备“生肉” (Raw Memory)

详细解释: 普通的 new 会自动向操作系统申请内存,但使用 Placement New 的前提是,你必须提前自己准备好一块“空地”(内存)。这块内存此时只是纯粹的字节,里面没有任何有效的数据或对象,所以叫“生肉”。为了保证这块地能装下你的对象,它的大小至少要是 sizeof(你的类)

这块存储可以来自不同区域,常见示例如下:

#include <cstddef>

// 场景 A:在栈上准备对齐正确的原始字节存储
alignas(Complex) std::byte stack_memory_pool[sizeof(Complex)];

// 场景 B:直接调用全局分配函数取得原始动态存储
void* heap_memory_pool = ::operator new(sizeof(Complex));

如果类型具有扩展对齐要求(over-aligned type),需要使用相应的对齐分配形式,不能只假设任意 char* 地址都满足要求。

第二步:调用 Placement New

详细解释: 拿着你准备好的类的图纸(构造函数),去你刚刚找好的空地上(memory_pool)把对象构建出来。注意看语法:多了一个括号 (memory_pool)

// 语法:new (提前分配好的内存地址) 类名(构造参数);
Complex* pc = new (stack_memory_pool) Complex(1.5, 2.5);

此时编译器究竟做了什么?(拆解动作)

  1. 不申请内存: 编译器看到 new 后面有括号 (stack_memory_pool),就知道“哦,老板已经自己找好地了,我不需要去系统堆里申请新内存了”。
  2. 原地构造: 编译器直接跑到 stack_memory_pool 这个内存地址上,执行 Complex(1.5, 2.5) 构造函数,把这块“生肉”变成了真正的、有意义的 Complex 对象。
  3. 返回指针: 把这个内存地址转换成 Complex* 类型,交到指针 pc 手里。
第三步:使用对象

详细解释: 到了这一步,房子已经盖好了。指针 pc 指向的是一个完全合法、可以直接使用的 C++ 对象。你可以像对待普通对象一样调用它的各种方法。

std::cout << "实部: " << pc->real() << std::endl;
第四步:手动析构 (关键!)

详细解释: 这是最容易出错的地方。Placement New 只负责在既有存储中开始对象生命周期;结束对象生命周期和归还底层存储是两个独立步骤。

  • 对于需要析构的对象,先显式调用析构函数。
  • 不要简单地对 Placement New 返回的指针使用 delete,除非你能够证明该指针和分配方式满足 delete 表达式的全部要求。通常应按照原始存储的来源分别处理。
pc->~Complex();
// 对象生命周期结束,但承载它的原始存储仍然存在。
第五步:释放原始存储(如果有必要)
  • 栈上的 std::byte 数组:离开作用域后自动结束其自身生命周期,不需要手动释放。
  • ::operator new 取得的原始动态存储:对象析构后使用匹配的 ::operator delete 归还。
::operator delete(heap_memory_pool);

4. 完整代码示例

#include <cstddef>
#include <iostream>
#include <new>

class User
{
public:
    int id;

    explicit User(int i) : id(i)
    {
        std::cout << "Construct User " << id << '\n';
    }

    ~User()
    {
        std::cout << "Destruct User " << id << '\n';
    }
};

int main()
{
    // 1. 取得足够容纳两个 User 的原始动态存储
    void* raw = ::operator new(sizeof(User) * 2);
    auto* bytes = static_cast<std::byte*>(raw);

    // 2. 在不同偏移位置构造对象
    User* u1 = new (bytes) User(1);
    User* u2 = new (bytes + sizeof(User)) User(2);

    // 3. 使用对象
    std::cout << "u1 id: " << u1->id << '\n';
    std::cout << "u2 id: " << u2->id << '\n';

    // 4. 按构造的逆序显式析构
    u2->~User();
    u1->~User();

    // 5. 使用与原始分配方式匹配的函数释放存储
    ::operator delete(raw);

    return 0;
}

5. 常见陷阱与注意事项

  1. 内存对齐(Alignment)
    提供的地址必须满足 alignof(T)。可以使用 alignas(T) std::byte buffer[sizeof(T)],或使用支持相应对齐的动态分配函数。std::aligned_storage 在 C++23 中已弃用,不宜作为新代码的首选。
  2. 析构与释放要分开匹配
    Placement New 创建的对象需要按类型规则结束生命周期;底层存储则必须按照它最初的来源释放,不能机械地对对象指针执行 delete
  3. 重复构造与覆盖风险
    在同一地址开始新对象生命周期前,应确认旧对象生命周期已经正确结束。对非平凡析构或持有资源的对象,跳过必要的析构可能造成资源泄漏或其他生命周期错误。

6. 为什么要这么麻烦?(应用场景)

既然这么危险,为什么还要用?

  1. 构建内存池(Memory Pool):一次性申请较大的存储,再在其中按对齐要求构造对象,减少通用分配器调用次数。
  2. 共享内存(Shared Memory):可以在共享区域中构造满足约束的对象;含进程私有指针、虚函数表或同步状态的普通对象不能不加设计地跨进程共享。
  3. 标准库容器底层:容器通常先分配未构造的存储,再通过 allocator、std::construct_at 或等价的原地构造机制创建元素。

3.6 常见面试题与坑

1. deletefree 的区别?

  • free 是 C 标准库函数,只归还匹配的动态存储,不调用 C++ 析构函数。
  • delete 是 C++ 的 delete 表达式,会先按类型规则调用析构函数,再调用匹配的 deallocation function。

2. 构造函数抛出异常会发生什么?

new 创建对象时,如果内存申请成功但构造函数失败,C++ 会自动调用匹配的 operator delete 释放对象内存;由于对象没有构造完成,所以不会调用析构函数,原异常继续向外传播。

第三章总结

  1. 分工明确operator new 负责取得原始存储,初始化过程负责创建对象,new-expression 组织这些步骤。
  2. 可定制:通过类专属或全局分配/释放函数,可以实现内存池、统计和特殊分配策略。
  3. 底层实现:许多全局 operator new 实现会借助 malloc 或系统分配器,并在失败时遵循 new_handler/异常规则。
  4. Placement New:标准的非分配 Placement New 可以在指定地址原地构造对象,但析构和底层存储释放必须分别正确处理。

补充:单个/数组的 Placement New 使用

标准写法 new (ptr) Data() 每次在 ptr 指向的位置构造一个对象。 这是内存池和对象池中最常见、最容易控制的用法。

C++ 语法也允许 Placement Array New,但它的数组分配开销并不具有可移植的固定大小。

1. 单个对象的 Placement New

alignas(Data) std::byte buffer[sizeof(Data) * 3];

Data* p1 = new (buffer) Data();
Data* p2 = new (buffer + sizeof(Data)) Data();
Data* p3 = new (buffer + sizeof(Data) * 2) Data();

对每个偏移地址,都必须保证大小和对齐正确;构造完成后,应按逆序显式析构需要析构的对象。

2. 数组的 Placement Array New

语法:

Data* pArray = new(buffer) Data[3];

表示在已有内存 buffer 上连续构造 3 个 Data 对象。

但是数组 new 可能需要额外空间保存数组信息(如元素数量、对齐等),例如:

+---------+---------+---------+---------+
| 额外信息 | Data[0] | Data[1] | Data[2] |
+---------+---------+---------+---------+

因此:

3 * sizeof(Data)

不一定足够,标准也没有提供计算额外开销的方法,可能导致越界。

推荐做法:

不要使用数组 Placement New:

new(buffer) Data[3];

而使用单对象 Placement New 逐个构造:

new(&p[0]) Data();
new(&p[1]) Data();
new(&p[2]) Data();

优点:

  • 内存大小可控
  • 无额外数组开销
  • 可以记录已构造对象数量,异常时按逆序析构

总结:

数组 Placement New 存在隐藏开销,无法保证只需要 n*sizeof(T) 空间;工程中更推荐使用单对象 Placement New 手动构造数组元素。

第四章:newdelete 的底层实现原理

在第三章中,我们知道了 operator newoperator delete 是负责取得和归还原始存储的分配/释放函数。

本章继续从概念模型出发,说明普通 new-expression 和 delete-expression 如何组织存储分配、对象构造、对象析构和存储释放。实际生成的机器代码由编译器、ABI 和优化级别决定。

4.1 new 表达式的完全拆解

当我们写出如下代码:

Complex* pc = new Complex(1, 2);

可以用下面的概念步骤理解:

编译器背后的步骤

  1. 取得原始存储
    调用匹配的 allocation function,例如:

    void* mem = ::operator new(sizeof(Complex));
    
  2. 在存储中初始化对象
    编译器在取得的存储中直接初始化 Complex 对象。可以把它类比为 Placement New,但普通 new-expression 的构造步骤是语言内建行为,并不要求编译器真的生成一条对标准 Placement New 函数的源代码级调用。

    Complex* pc = new (mem) Complex(1, 2); // 用于理解或手动模拟
    
  3. 返回结果指针
    初始化成功后,new-expression 产生指向新对象的 Complex*

总结:普通 new-expression 可以概念化为“取得原始存储 + 在其中初始化对象”;若初始化失败,还会执行匹配的清理逻辑。

4.2 delete 表达式的完全拆解

当我们执行:

delete pc;

可以概念化为以下步骤:

编译器背后的两步走

  1. 结束对象生命周期并析构
    如果 pc 非空,按照对象的实际析构规则调用析构函数,清理对象持有的资源。

    if (pc != nullptr)
    {
        pc->~Complex();
    }
    
  2. 归还原始存储
    调用与原分配方式匹配的 deallocation function。

    ::operator delete(static_cast<void*>(pc));
    

上面是帮助理解的伪代码。真实 delete-expression 还会处理虚析构、数组形式、对齐分配、sized delete 等规则,不能简单用两行手写代码替代所有情况。

代码示例:

#include <iostream>
#include <new> // 必须包含此头文件才能使用 Placement New (定位 new)

// 定义一个简单的 Complex 类,并在构造/析构中加入打印,方便观察
class Complex {
private:
    int real;
    int imag;

public:
    // 构造函数
    Complex(int r, int i) : real(r), imag(i) {
        std::cout << "  -> [构造函数执行] 内存地址: " << this 
                  << ", 值: " << real << "+" << imag << "i\n";
    }

    // 析构函数
    ~Complex() {
        std::cout << "  -> [析构函数执行] 内存地址: " << this << " 的对象被销毁\n";
    }

    void doSomething() const {
        std::cout << "  -> [业务逻辑] 这是一个复数对象。\n";
    }
};

int main() {
    // =================================================================
    // 第一部分:C++ 程序员眼中的日常代码
    // =================================================================
    std::cout << "========== 1. 标准 new 和 delete 表达式 ==========\n";
    
    std::cout << "执行代码: Complex* p1 = new Complex(1, 2);\n";
    Complex* p1 = new Complex(1, 2); // 编译器在这里默默做了三件事
    
    p1->doSomething();

    std::cout << "执行代码: delete p1;\n";
    delete p1;                       // 编译器在这里默默做了两件事

    std::cout << "\n--------------------------------------------------\n\n";

    // =================================================================
    // 第二部分:编译器眼中的底层代码(手动模拟)
    // =================================================================
    std::cout << "========== 2. 手动拆解编译器的底层动作 ==========\n";

    // ----------------- 模拟 new 表达式 -----------------
    std::cout << "【模拟第一步:分配原始内存】\n";
    std::cout << "执行代码: void* mem = ::operator new(sizeof(Complex));\n";
    void* mem = ::operator new(sizeof(Complex));
    std::cout << "  -> 拿到原始存储,地址: " << mem << '\n';

    std::cout << "【模拟第二步:在原始存储中构造对象】\n";
    std::cout << "执行代码: Complex* p2 = new (mem) Complex(3, 4);\n";
    Complex* p2 = new (mem) Complex(3, 4);
    std::cout << "  -> 对象构造完成,可以使用。\n\n";

    p2->doSomething();
    std::cout << '\n';

    // ----------------- 模拟 delete 表达式 -----------------
    std::cout << "【模拟第一步:显式调用析构函数】\n";
    p2->~Complex();
    std::cout << "  -> 对象生命周期结束,原始存储仍然存在。\n\n";

    std::cout << "【模拟第二步:释放原始存储】\n";
    ::operator delete(mem);
    std::cout << "  -> 存储释放完毕。\n";
    std::cout << "  -> 内存释放完毕。\n";

    return 0;
}

4.3 构造失败时的存储清理

如果普通 new-expression 已经成功取得存储,但对象初始化抛出异常,new-expression 会调用与本次分配匹配的 deallocation function,再继续传播异常。

下面是用于理解的等价思路:

void* mem = ::operator new(sizeof(Complex));

try
{
    new (mem) Complex(1, 2);
}
catch (...)
{
    ::operator delete(mem);
    throw;
}

这段代码只是手动模拟普通 new-expression 的清理思路。对于带额外 placement 参数的自定义 new-expression,构造失败时能否自动找到释放函数,取决于是否存在签名匹配的 placement delete。标准的 new (ptr) T(...) 使用调用者提供的存储,其匹配 placement delete 不会替调用者归还那块外部存储。

结论:普通 new-expression 对“本次分配得到但尚未成功构造成对象的存储”提供自动清理保证;这不等于构造函数已经产生的所有外部副作用都会被自动回滚,因此不应笼统称为整个操作都具有强异常安全保证。

第四章总结

  1. new-expression:取得匹配的原始存储,并在其中初始化对象。
  2. delete-expression:按类型规则析构对象,再调用匹配的释放函数。
  3. 数组元数据:实现可能为 new[] 添加数组数量、对齐或其他开销,但具体 Cookie 布局不是标准保证。
  4. 严格匹配new/deletenew[]/delete[] 不能混用,否则属于未定义行为。
  5. 构造失败清理:普通 new-expression 在初始化失败时会调用匹配的释放函数回收本次取得的存储。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐