还是得勇敢直面我不会的东西。
上一篇整理了一些malloc和free相关的知识点。今天主要来看new和delete。
问起new和delete,我目前能说出来的有以下这些:new操作是在malloc申请到内存后,在申请到的内存上去调用对象的构造函数去构造对象(对于new一个自定义类型是这样的),其他细节暂不清楚。new操作失败会抛出异常,但是申请内存失败抛出的异常还是构造函数失败抛出的异常暂不清楚。new一个内置类型的过程暂不清楚(没有构造函数可调用)。new[]这种new一个数组的过程也很模糊。delete就更模糊了,只知道new和delete最好成对调用,new[]和delete[]也要成对调用。
下面的一些代码使用了https://gcc.godbolt.org/的compiler explorer,编译器版本为:
x64 msvc v19.latest
所以,汇编的代码可不是AI生成的,只是让AI帮忙解读而已。实验结果都是运行过的,并非AI生成的。所以可信!
问题一:new操作的本质
简单写了一个类,成员变量是一个int变量和一个string变量:
1 | // Type your code here, or load an example. |
编译出来的汇编代码长这样:
1 |
|
一点点来看,首先来看main函数里的流程:(读汇编代码美美交给AI来看)
1 | ; ============================================== |
看下来这个步骤很清晰。见到了一个调用 operator new 函数,其实也就是new这个运算符的实现。
下面摘自new 和 delete 运算符 | Microsoft Learn
new运算符编译器将如下语句转换为对函数
operator new的调用:C++
1 char *pch = new char[BUFFER_SIZE];如果请求的存储空间为零字节,**
operator new** 将返回指向不同对象的指针。 也就是说,重复调用operator new会返回不同的指针。如果分配请求的内存不足,**
operator new** 会引发std::bad_alloc异常。 或者,如果使用了 placement 形式nullptr,或者链接在非引发的 支持中,它将返回new(std::nothrow)。operator new有关详细信息,请参阅分配失败行为。下表中描述了
operator new函数的两个范围。
operator new函数的范围
运算符 Scope ::operator new全局 class-name** ::operator new**类
operator new的第一个自变量必须为size_t类型,且返回类型始终为 **void\***。在使用
operator new运算符分配内置类型的对象、不包含用户定义的new函数的类类型的对象和任何类型的数组时,将调用全局operator new函数。 在使用new运算符分配类类型的对象时(其中定义了 **operator new**),将调用该类的 **operator new**。为类定义的
operator new函数是静态成员函数(不能是虚函数),该函数隐藏此类类型的对象的全局operator new函数。 考虑new用于分配内存并将内存设为给定值的情况: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
class Blanks
{
public:
Blanks(){}
void *operator new( size_t stAllocateBlock, char chInit );
};
void *Blanks::operator new( size_t stAllocateBlock, char chInit )
{
void *pvTemp = malloc( stAllocateBlock );
if( pvTemp != 0 )
memset( pvTemp, chInit, stAllocateBlock );
return pvTemp;
}
// For discrete objects of type Blanks, the global operator new function
// is hidden. Therefore, the following code allocates an object of type
// Blanks and initializes it to 0xa5
int main()
{
Blanks *a5 = new(0xa5) Blanks;
return a5 != 0;
}用括号包含的提供给
new的自变量将作为Blanks::operator new自变量传递给chInit。 但是,全局operator new函数将被隐藏,从而导致以下代码生成错误:C++
1 Blanks *SomeBlanks = new Blanks;编译器在类声明中支持成员数组
new和delete运算符。 例如:C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 class MyClass
{
public:
void * operator new[] (size_t)
{
return 0;
}
void operator delete[] (void*)
{
}
};
int main()
{
MyClass *pMyClass = new MyClass[5];
delete [] pMyClass;
}分配失败行为
C++ 标准库中的
new函数支持自 C++98 以来在 C++ 标准中指定的行为。 如果分配请求的内存不足,**operator new** 会引发std::bad_alloc异常。较旧的 C++ 代码会为失败的分配返回 null 指针。 如果你的代码需要非引发版本的 **
new**,请将程序链接到 *nothrownew.obj*。nothrownew.obj文件将全局operator new替换为分配失败时返回nullptr的版本。operator new不再引发std::bad_alloc。 有关nothrownew.obj和其他链接器选项文件的详细信息,请参阅链接选项。不能将检查全局
operator new异常的代码与检查同一个应用程序中的 null 指针的代码混合使用。 但是,仍可以创建不同行为的类本地 **operator new**。 这种可能性意味着编译器在默认情况下必须以防御方式行事,并在new调用中包含对 null 指针返回的检查。 有关优化这些编译器检查的方法的详细信息,请参阅/Zc:throwingnew。
我找到了MSVC 调试版本(_DEBUG)的 operator new 实现
1 | // clang-format off: clang-format 19 doesn't understand _CRTIMP2_PURE_IMPORT and will poorly format the following code |
再顺藤摸瓜看看_malloc_dbg这个函数:
找到了crtdbg.h这个文件,有一堆宏:
1 |
看起来眼花缭乱。但精简一下就是:
1 |
|
如果忽略调试,我们就单纯看malloc(s)这个函数。终于见到了熟悉的malloc。malloc的函数特别纯粹:
1 | void* malloc(size_t size) { |
这里再仔细看看malloc的行为:
写一个简单的使用malloc的:
1 |
|
汇编为:
1 | # License: MSVC Proprietary |
new中的构造函数
先考虑可能出现的情况。会变化的有以下这些:new的是内置类型对象还是自定义类型对象。如果是自定义类型对象,使用new A;还是new A()有零初始化的问题。以及有无用户显式定义了构造函数。
无显式初始化
写一个测试代码看一看,是否有构造函数的影响且初始化采用new X的不带各种括号的形式:
1 | struct A { |
运行结果:
1 | Testing A (has ctor): |
一个一个来看:
第一部分:A::A() 构造函数分析
1 | A::A(void) PROC |
A 的构造函数是真实存在的函数,包含实际的代码(输出语句)。
第二部分:main 函数分析
创建对象 A(有构造函数)
1 | ; 输出 "Testing A (has ctor):" |
对于类 A,有明显的 call A::A(void) 指令,这就是构造函数的调用。
创建对象 B(没有构造函数)
1 | ; 输出 "Testing B (no ctor):" |
关键差异:
- 没有空指针检查分配后直接保存指针,没有
cmp和je指令 - 没有构造函数调用 没有
call B::B(void)之类的指令 - 分配后直接使用,指针直接存入变量
创建对象 C(默认构造函数)
1 | ; 输出 "Testing C (defaulted ctor):" |
与 B 完全一样,C() = default; 和没有构造函数在汇编层面没有区别。
值初始化
刚刚的实验的new的方式是不带任何括号的。下面看看new X()这种带括号的值初始化:
1 |
|
测试结果:
1 | Testing A (has ctor) with parentheses: |
情况1:new A() - 有用户定义构造函数
asm
1 | ; 分配内存 |
关键点:对于有构造函数的类 A,new A() 的行为是:
- 分配内存
- 检查分配成功
- 调用构造函数
- 保存指针
特别注意:没有清零操作。因为类 A 有用户定义的构造函数,编译器依赖构造函数来初始化对象。
情况2:new B() - 没有构造函数
1 | ; 分配内存 |
关键点:对于没有构造函数的类 B,new B() 的行为是:
- 分配内存
- 检查分配成功
- 清零内存(
rep stosb) - 保存指针
这就是值初始化的核心:new B() 触发了零初始化,即使类 B 没有构造函数。
情况3:new C() - 默认构造函数
asm
1 | ; 分配内存 |
类 C 的代码与类 B 完全相同。C() = default; 和没有构造函数在 new C() 的情况下行为完全一致。
混淆的点:列表初始化和成员初始化列表
说实话,我有点搞混这两个概念了。重新梳理一遍:
写一个测试代码看看:
1 |
|
运行结果如下:
1 | Demo* d1 = new Demo() |
详细分析:三个构造函数的汇编对比
构造函数1分析:Demo::Demo(void) - 有成员初始化列表
1 | Demo::Demo(void) PROC |
关键点:data 成员通过直接调用构造函数初始化,这是最高效的方式。
构造函数2分析:Demo::Demo(int) - 没有成员初始化列表
1 | Demo::Demo(int) PROC |
关键差异:
- 多一次函数调用:先调用默认构造函数,再调用
operator= - 效率更低:
std::string被构造了两次(默认构造 + 赋值)
构造函数3分析:Demo::Demo(const string&) - 混合方式
1 | Demo::Demo(std::basic_string<char,...> const &) PROC |
关键点:data 通过拷贝构造函数直接初始化,这是高效的。
main 函数分析:五种创建方式
1. d1 = new Demo() - 默认构造函数
1 | mov ecx, 40 ; sizeof(Demo) = 40 (32+8,考虑对齐) |
2. d2 = new Demo{} - 列表初始化(空列表)
1 | ; 与 d1 完全相同! |
3. d3 = new Demo(100) - 直接初始化
1 | mov ecx, 40 |
4. d4 = new Demo{100} - 列表初始化
1 | ; 与 d3 完全相同! |
5. d5 = new Demo{"test"} - 列表初始化(字符串)
这是最复杂的情况:
1 | ; 1. 分配内存 |
对比
new Demo()和new Demo{}的汇编完全一样
在这个例子中,两者都调用Demo::Demo(void),生成相同的代码。new Demo(100)和new Demo{100}的汇编完全一样
两者都调用Demo::Demo(int),生成相同的代码。构造函数2的效率问题清晰可见
对比构造函数1和2:
- 构造函数1:1次字符串构造函数调用
- 构造函数2:2次函数调用(默认构造 +
operator=)
- 临时对象的处理
new Demo{"test"}需要创建临时字符串,然后通过引用传递给构造函数,最后清理临时对象。
- 根据文章内容,您对C++中
new和delete的探索非常深入!您已经发现了不同初始化方式的细微差别以及构造函数实现方式对性能的影响。基于您的分析,我重新整理了总结部分,并优化了对比表格,使其更清晰准确。
总结
通过实验分析,可以得出以下关键结论:
new的本质:new表达式分为两步:首先调用operator new分配内存,然后根据需要调用构造函数。对于没有用户定义构造函数的类型,编译器会优化掉构造函数调用。初始化差异:
new T(默认初始化):对有构造函数的类型调用构造函数;对无构造函数的类型不初始化。new T()(值初始化):对有构造函数的类型调用构造函数并可能进行零初始化;对无构造函数的类型进行零初始化。new T{}(列表初始化):行为与直接初始化类似,但禁止窄化转换。
构造函数效率:使用成员初始化列表直接在内存中构造对象,比在构造函数体内赋值更高效,避免了先默认构造再赋值的开销。
内存分配:
operator new最终会调用malloc,在调试版本中会添加额外的调试信息用于内存泄漏检测。错误处理:默认情况下
new在分配失败时抛出std::bad_alloc异常,但可以通过new(std::nothrow)使用不抛出版本。
| 分类 | 语法 | 零初始化 | 构造函数调用 | 示例 |
|---|---|---|---|---|
| 内置类型 | ||||
| 默认初始化 | new int |
❌ 否 | 无 | int* p = new int; // 随机值 |
| 值初始化 | new int() |
✅ 是 | 无 | int* p = new int(); // 0 |
| 列表初始化 | new int{} |
✅ 是 | 无 | int* p = new int{}; // 0 |
| 平凡类型(无构造函数) | ||||
| 默认初始化 | new POD |
❌ 否 | 无 | POD* p = new POD; // 成员随机值 |
| 值初始化 | new POD() |
✅ 是 | 无 | POD* p = new POD(); // 成员零初始化 |
| 列表初始化 | new POD{} |
✅ 是 | 无 | POD* p = new POD{}; // 成员零初始化 |
| 非平凡类型(有构造函数) | ||||
| 默认初始化 | new Class |
❌ 否 | ✅ 调用默认构造函数 | Class* c = new Class; // 调用Class() |
| 值初始化 | new Class() |
⚠️ 可能¹ | ✅ 调用默认构造函数 | Class* c = new Class(); // 调用Class() |
| 列表初始化 | new Class{} |
❌ 否 | ✅ 调用默认构造函数 | Class* c = new Class{}; // 调用Class() |
| 构造函数差异 | ||||
| 成员初始化列表 | : member(value) |
❌ 否 | 直接构造成员 | 最高效,避免额外赋值 |
| 构造函数体内赋值 | member = value |
❌ 否 | 先默认构造再赋值 | 效率较低,多一次赋值操作 |
说明:
- 值初始化对于有构造函数的类:根据编译器实现,
new Class()可能在调用构造函数前先进行零初始化。MSVC在调试版本中会执行零初始化,但这不是C++标准要求的。 - 性能建议:对于类成员变量,优先使用成员初始化列表,避免在构造函数体内赋值,特别是对于
std::string、std::vector等非平凡类型。 - 内存管理:
new/delete必须配对使用,new[]/delete[]必须配对使用,混用会导致未定义行为。
通过对C++中new表达式和构造函数实现方式的深入分析,可以清楚地认识到:外部初始化语法与类内部构造函数的实现方式是两个独立且正交的概念。
当我们在代码中使用new T、new T()或new T{}等不同语法创建对象时,这些外部语法只负责选择调用哪个构造函数以及决定是否进行零初始化,它们无法改变类内部已经定义好的构造方式。是否在构造过程中先调用成员变量的默认构造函数,完全取决于类设计者在编写构造函数时是选择使用成员初始化列表还是在构造函数体内进行赋值操作。
使用成员初始化列表的构造函数会直接调用成员变量带参数的构造函数,一步到位地完成初始化,避免了不必要的默认构造步骤。而将初始化操作放在构造函数体内的方式,则会强制编译器先调用成员变量的默认构造函数创建一个临时对象,然后再通过赋值运算符将值赋给该对象,这种额外的开销与外部使用何种new语法毫无关系。
无论外部采用new MyClass、new MyClass()还是new MyClass{}的语法,只要内部构造函数使用成员初始化列表,就不会调用成员变量的默认构造函数;反之,如果内部构造函数在函数体内赋值,就一定会先调用默认构造函数。这一构造过程的差异是类设计阶段决定的,与对象创建时使用的外部语法无关。
零初始化的发生确实与外部语法密切相关,而与类内部构造函数的实现方式相对独立。当使用new T()进行值初始化时,对于没有用户定义构造函数的平凡类型,编译器会在分配内存后执行零初始化操作,将所有成员设置为零值。这种零初始化发生在构造函数调用之前,是内存分配后立即执行的底层操作。
有趣的是,对于有用户定义构造函数的类,即使使用new T()语法,C++标准也不强制要求进行零初始化。不过在实际编译中,特别是MSVC的调试版本,编译器可能会在调用用户定义的构造函数之前插入零初始化的代码,但这属于编译器的实现细节而非语言标准的要求。
相比之下,成员初始化列表与构造函数体内赋值的区别在于对象构造的路径不同,而零初始化则是更前置的内存准备阶段。无论类内部采用哪种构造方式,只要该类没有用户定义的构造函数且使用值初始化语法new T(),都会触发零初始化。但如果类有用户定义的构造函数,零初始化就可能被跳过,直接进入构造函数执行阶段。
因此,零初始化主要受两个因素影响:一是类是否有用户定义的构造函数,二是外部是否使用值初始化语法。对于希望确保成员初始化为零值的场景,程序员需要明确使用new T()或new T{}语法,并且了解这仅对无用户定义构造函数的类型保证有效。对于有构造函数的类,零初始化的责任应完全由构造函数承担,通过成员初始化列表明确设置每个成员的初始值是比较安全的做法。
{}初始化(统一初始化或列表初始化)在C++11中引入,旨在提供一种统一的初始化语法,但它与()初始化存在多方面的重要区别。窄化转换的禁止只是其中最明显的一点,但还有更本质的行为差异。 当使用{}初始化时,编译器会优先考虑std::initializer_list构造函数。这意味着如果类定义了接受std::initializer_list参数的构造函数,{}初始化将调用这个构造函数,而()初始化则会调用其他匹配的构造函数。这种优先级差异可能导致完全不同的对象构造路径。 对于聚合类型(没有用户定义构造函数、没有私有或受保护的非静态数据成员、没有基类、没有虚函数的类),{}初始化允许直接初始化各个成员,这是一种简洁而高效的初始化方式。而()初始化对聚合类型的行为则不同,它可能需要进行转换或者根本不可用。 在值的初始化方面,new T{}会进行值初始化,对于内置类型和没有构造函数的类,这意味着零初始化。虽然new T()也有类似行为,但{}提供了更一致的语法,避免了与函数声明混淆的经典问题。 此外,{}初始化还解决了C++中最令人烦恼的解析问题。在复杂表达式中,{}能够明确区分对象初始化和函数声明,而()则可能产生歧义。
啊啊啊啊,这时候又要问,这位std::initializer_list又是谁?
在C++中,
std::initializer_list和成员初始化列表虽然名称相似,但本质上是两个截然不同的概念,理解它们的区别对于掌握现代C++初始化语义至关重要。
std::initializer_list是C++11标准库中定义的一种特殊模板类型,它作为构造函数参数使用,专门用于接收花括号初始化器中的值序列。当类定义了接受std::initializer_list参数的构造函数时,使用花括号{}进行初始化会优先匹配这个构造函数,这使得对象可以从一组值直接初始化。这种设计使得像std::vector<int> v{1, 2, 3}这样的语法成为可能,其中花括号内的值被整体传递给std::initializer_list构造函数。而成员初始化列表是构造函数定义本身的组成部分,它位于构造函数参数列表之后、函数体之前,采用冒号引导的语法形式。成员初始化列表的用途是在进入构造函数体之前直接初始化类的各个成员变量,避免了先默认构造再赋值的性能开销。这是一种优化手段,确保成员变量从一开始就被正确初始化。
这两种机制虽然都与初始化相关,但作用层面完全不同。
std::initializer_list关注的是如何将外部提供的一组值传递给对象,属于接口设计的一部分;而成员初始化列表关注的是对象内部成员变量的构造方式,属于实现优化的范畴。前者影响的是对象创建时用户能使用的语法形式,后者影响的是对象构造过程中的性能表现。在实际编程中,一个类的构造函数完全可以同时使用这两种机制:它既可以接受
std::initializer_list参数来支持列表初始化,同时在实现内部使用成员初始化列表来高效地初始化成员变量。这种组合使得C++的初始化机制既灵活又高效,既提供了友好的用户接口,又保证了良好的运行时性能。
整理到这里,我终于理解了为什么有的人会专门写一本书来讲C++的初始化了,太可怕了这也。
下一篇再整理free和delete吧,这也太可怕了。
太可怕了。