还是得勇敢直面我不会的东西。

上一篇整理了一些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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Type your code here, or load an example.
#include <string>
class A
{
public:
int count;
std::string name;

A()=default;
~A()=default;
};

int main()
{
A* a1=new A();
}

编译出来的汇编代码长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

this$ = 48
A::A(void) PROC ; A::A, COMDAT
$LN4:
mov QWORD PTR [rsp+8], rcx
sub rsp, 40 ; 00000028H
mov rax, QWORD PTR this$[rsp]
add rax, 8
mov rcx, rax
call std::basic_string<char,std::char_traits<char>,std::allocator<char> >::basic_string<char,std::char_traits<char>,std::allocator<char> >(void) ; std::basic_string<char,std::char_traits<char>,std::allocator<char> >::basic_string<char,std::char_traits<char>,std::allocator<char> >
npad 1
mov rax, QWORD PTR this$[rsp]
add rsp, 40 ; 00000028H
ret 0
A::A(void) ENDP ; A::A

$T1 = 32
tv70 = 40
a1$ = 48
main PROC
$LN5:
push rdi
sub rsp, 64 ; 00000040H
mov ecx, 40 ; 00000028H
call void * operator new(unsigned __int64) ; operator new
mov QWORD PTR $T1[rsp], rax
cmp QWORD PTR $T1[rsp], 0
je SHORT $LN3@main
mov rdi, QWORD PTR $T1[rsp]
xor eax, eax
mov ecx, 40 ; 00000028H
rep stosb
mov rcx, QWORD PTR $T1[rsp]
call A::A(void) ; A::A
mov QWORD PTR tv70[rsp], rax
jmp SHORT $LN4@main
$LN3@main:
mov QWORD PTR tv70[rsp], 0
$LN4@main:
mov rax, QWORD PTR tv70[rsp]
mov QWORD PTR a1$[rsp], rax
xor eax, eax
add rsp, 64 ; 00000040H
pop rdi
ret 0
main ENDP

一点点来看,首先来看main函数里的流程:(读汇编代码美美交给AI来看)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
; ==============================================
; 符号定义:main 函数中的局部变量偏移量
; ==============================================
$T1 = 32 ; 临时变量1:存储 operator new 返回的原始指针
tv70 = 40 ; 临时变量2:存储构造函数调用后的结果指针
a1$ = 48 ; 局部变量 a1 在栈上的位置

; ==============================================
; main 函数
; ==============================================
main PROC ; 开始 main 函数
$LN5: ; 标签:代码块开始
; 保存寄存器(遵循调用约定)
push rdi ; 保存 rdi 寄存器(调用者保存寄存器)

; 设置栈帧
sub rsp, 64 ; 在栈上分配 64 字节空间

; ======================================
; 步骤1:调用 operator new 分配内存
; ======================================
mov ecx, 40 ; 设置参数:要分配的大小 = 40 字节
; ecx = sizeof(A) = sizeof(int) + padding + sizeof(std::string)
; = 4 + 4 + 32 = 40

call void * operator new(unsigned __int64) ; 调用 operator new 函数

; 保存分配的内存地址
mov QWORD PTR $T1[rsp], rax ; 将返回值(内存地址)保存到栈上 $T1 位置

; ======================================
; 步骤2:检查内存分配是否成功
; ======================================
cmp QWORD PTR $T1[rsp], 0 ; 比较分配结果是否为 NULL
je SHORT $LN3@main ; 如果为 NULL(分配失败),跳转到失败处理

; ======================================
; 步骤3:分配成功,清零分配的内存(零初始化)
; ======================================
mov rdi, QWORD PTR $T1[rsp] ; rdi = 目标地址(将要清零的内存起始位置)

xor eax, eax ; eax = 0(要填充的值)
; xor eax, eax 比 mov eax, 0 更快

mov ecx, 40 ; ecx = 计数器(要清零的字节数 = 40)

rep stosb ; 重复执行:将 al(0) 存储到 [rdi],然后 rdi++
; 等价于 memset(ptr, 0, 40)
; 这是零初始化:因为使用了 new A() 而不是 new A

; ======================================
; 步骤4:在清零的内存上调用构造函数
; ======================================
mov rcx, QWORD PTR $T1[rsp] ; 将原始指针放入 rcx(构造函数参数 this)

call A::A(void) ; 调用构造函数 A::A()
; 注意:构造函数只初始化了 std::string
; int count 已经在步骤3中被清零为0

; 保存构造函数返回的 this 指针
mov QWORD PTR tv70[rsp], rax ; 将构造函数返回值(this)保存到栈上

; 跳转到成功路径的合并点
jmp SHORT $LN4@main ; 无条件跳转到 $LN4@main

; ======================================
; 分支:内存分配失败的处理
; ======================================
$LN3@main: ; 标签:分配失败
mov QWORD PTR tv70[rsp], 0 ; 设置结果为 NULL 指针

; ======================================
; 合并点:成功和失败路径的汇合处
; ======================================
$LN4@main: ; 标签:代码合并点
; 将结果赋值给局部变量 a1
mov rax, QWORD PTR tv70[rsp] ; 从栈上加载结果到 rax
mov QWORD PTR a1$[rsp], rax ; 保存到局部变量 a1

; 设置 main 函数返回值
xor eax, eax ; eax = 0(main 函数返回 0)

; 清理栈帧并恢复寄存器
add rsp, 64 ; 释放栈上分配的 64 字节空间
pop rdi ; 恢复 rdi 寄存器

; 函数返回
ret 0 ; 从 main 函数返回
main ENDP ; main 函数结束

看下来这个步骤很清晰。见到了一个调用 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
#include <malloc.h>
#include <memory.h>

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;

编译器在类声明中支持成员数组 newdelete 运算符。 例如:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// clang-format off: clang-format 19 doesn't understand _CRTIMP2_PURE_IMPORT and will poorly format the following code

extern "C++" struct _CRTIMP2_PURE_IMPORT _Crt_new_delete {
// _CRTIMP2_PURE_IMPORT: 导出修饰符,表示这个结构从 DLL 导入
// _Crt_new_delete: 结构名,用于标记 CRT(C 运行时)分配的内存块
// 这个类只在 _DEBUG 模式下提供,用于调试版本的内存管理

#ifdef _DEBUG // 仅在调试版本中启用这些调试版本的操作符

// 标准的 operator new(可能抛出 std::bad_alloc 异常)
void* __CLRCALL_OR_CDECL operator new(size_t _Size) { // replace operator new
// 1. 首先调用 nothrow 版本尝试分配内存
void* _Ptr = operator new(_Size, nothrow);

// 2. 如果分配失败(返回 nullptr)
if (!_Ptr) {
_Xbad_alloc(); // 内部函数,会抛出 std::bad_alloc 异常
// 注意:这个函数不会返回,它会抛出异常
}

// 3. 分配成功,返回指针
return _Ptr;
}

// nothrow 版本的 operator new(不会抛出异常)
void* __CLRCALL_OR_CDECL operator new(size_t _Size, const nothrow_t&) noexcept {
// replace nothrow operator new

// 关键:调用调试版本的 malloc
// _malloc_dbg 参数解释:
// 1. _Size > 0 ? _Size : 1: C++标准要求 new(0) 返回有效指针
// 2. _CRT_BLOCK: 标记为 CRT 内部内存块类型
// 3. __FILE__: 源文件名(调试信息)
// 4. __LINE__: 行号(调试信息)
return _malloc_dbg(_Size > 0 ? _Size : 1, _CRT_BLOCK, __FILE__, __LINE__);

// _malloc_dbg 最终会调用 malloc,但添加了:
// - 分配额外的调试头(存储大小、文件名、行号等)
// - 添加保护字节(检测缓冲区溢出)
// - 链接到内存泄漏检测系统
}

// 标准的 operator delete(释放内存)
void __CLRCALL_OR_CDECL operator delete(void* _Ptr) noexcept { // replace operator delete
// 直接调用 C 运行时的 free 函数
// _CSTD free: 引用标准 C 库的 free 函数
_CSTD free(_Ptr);
// 注意:这里没有调用 _free_dbg,因为:
// 1. 调试信息已经由 _malloc_dbg 记录
// 2. free 会自动处理调试版本的释放
}

// nothrow 版本的 operator delete(与 nothrow new 配对)
void __CLRCALL_OR_CDECL operator delete(void* _Ptr, const nothrow_t&) noexcept {
// replace nothrow operator delete
// 简单地转发到标准的 operator delete
operator delete(_Ptr);
}

// 定位 new(placement new) - 在已有内存上构造对象
void* __CLRCALL_OR_CDECL operator new(size_t, void* _Ptr) noexcept {
// imitate True Placement New
// 不分配新内存,直接返回传入的指针
return _Ptr;
// 用途:在预分配的内存上构造对象
// 示例:new(buffer) MyClass();
}

// 定位 delete(placement delete) - 与定位 new 配对
void __CLRCALL_OR_CDECL operator delete(void*, void*) noexcept {}
// imitate True Placement Delete
// 空实现,因为定位 new 没有分配内存,所以不需要释放
// 只用于异常安全:如果定位 new 后的构造函数抛出异常,会调用这个

#endif // _DEBUG // 调试版本结束标记
};
// clang-format on

再顺藤摸瓜看看_malloc_dbg这个函数:

找到了crtdbg.h这个文件,有一堆宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#ifndef _DEBUG

#define _calloc_dbg(c, s, t, f, l) calloc(c, s)
#define _expand_dbg(p, s, t, f, l) _expand(p, s)
#define _free_dbg(p, t) free(p)
#define _malloc_dbg(s, t, f, l) malloc(s)
#define _msize_dbg(p, t) _msize(p)
#define _realloc_dbg(p, s, t, f, l) realloc(p, s)
#define _recalloc_dbg(p, c, s, t, f, l) _recalloc(p, c, s)

#define _aligned_free_dbg(p) _aligned_free(p)
#define _aligned_malloc_dbg(s, a, f, l) _aligned_malloc(s, a)
#define _aligned_msize_dbg(p, a, o) _aligned_msize(p, a, o)
#define _aligned_offset_malloc_dbg(s, a, o, f, l) _aligned_offset_malloc(s, a, o)
#define _aligned_offset_realloc_dbg(p, s, a, o, f, l) _aligned_offset_realloc(p, s, a, o)
#define _aligned_offset_recalloc_dbg(p, c, s, a, o, f, l) _aligned_offset_recalloc(p, c, s, a, o)
#define _aligned_realloc_dbg(p, s, a, f, l) _aligned_realloc(p, s, a)
#define _aligned_recalloc_dbg(p, c, s, a, f, l) _aligned_recalloc(p, c, s, a)

#define _freea_dbg(p, t) _freea(p)
#define _malloca_dbg(s, t, f, l) _malloca(s)

#define _dupenv_s_dbg(ps1, size, s2, t, f, l) _dupenv_s(ps1, size, s2)
#define _fullpath_dbg(s1, s2, le, t, f, l) _fullpath(s1, s2, le)
#define _getcwd_dbg(s, le, t, f, l) _getcwd(s, le)
#define _getdcwd_dbg(d, s, le, t, f, l) _getdcwd(d, s, le)
#define _getdcwd_lk_dbg(d, s, le, t, f, l) _getdcwd(d, s, le)
#define _mbsdup_dbg(s, t, f, l) _mbsdup(s)
#define _strdup_dbg(s, t, f, l) _strdup(s)
#define _tempnam_dbg(s1, s2, t, f, l) _tempnam(s1, s2)
#define _wcsdup_dbg(s, t, f, l) _wcsdup(s)
#define _wdupenv_s_dbg(ps1, size, s2, t, f, l) _wdupenv_s(ps1, size, s2)
#define _wfullpath_dbg(s1, s2, le, t, f, l) _wfullpath(s1, s2, le)
#define _wgetcwd_dbg(s, le, t, f, l) _wgetcwd(s, le)
#define _wgetdcwd_dbg(d, s, le, t, f, l) _wgetdcwd(d, s, le)
#define _wgetdcwd_lk_dbg(d, s, le, t, f, l) _wgetdcwd(d, s, le)
#define _wtempnam_dbg(s1, s2, t, f, l) _wtempnam(s1, s2)

#else // ^^^ !_DEBUG ^^^ // vvv _DEBUG vvv //

#ifdef _CRTDBG_MAP_ALLOC

#define calloc(c, s) _calloc_dbg(c, s, _NORMAL_BLOCK, __FILE__, __LINE__)
#define _expand(p, s) _expand_dbg(p, s, _NORMAL_BLOCK, __FILE__, __LINE__)
#define free(p) _free_dbg(p, _NORMAL_BLOCK)
#define malloc(s) _malloc_dbg(s, _NORMAL_BLOCK, __FILE__, __LINE__)
#define _msize(p) _msize_dbg(p, _NORMAL_BLOCK)
#define realloc(p, s) _realloc_dbg(p, s, _NORMAL_BLOCK, __FILE__, __LINE__)
#define _recalloc(p, c, s) _recalloc_dbg(p, c, s, _NORMAL_BLOCK, __FILE__, __LINE__)

#define _aligned_free(p) _aligned_free_dbg(p)
#define _aligned_malloc(s, a) _aligned_malloc_dbg(s, a, __FILE__, __LINE__)
#define _aligned_msize(p, a, o) _aligned_msize_dbg(p, a, o)
#define _aligned_offset_malloc(s, a, o) _aligned_offset_malloc_dbg(s, a, o, __FILE__, __LINE__)
#define _aligned_offset_realloc(p, s, a, o) _aligned_offset_realloc_dbg(p, s, a, o, __FILE__, __LINE__)
#define _aligned_offset_recalloc(p, c, s, a, o) _aligned_offset_recalloc_dbg(p, c, s, a, o, __FILE__, __LINE__)
#define _aligned_realloc(p, s, a) _aligned_realloc_dbg(p, s, a, __FILE__, __LINE__)
#define _aligned_recalloc(p, c, s, a) _aligned_recalloc_dbg(p, c, s, a, __FILE__, __LINE__)

#define _freea(p) _freea_dbg(p, _NORMAL_BLOCK)
#define _malloca(s) _malloca_dbg(s, _NORMAL_BLOCK, __FILE__, __LINE__)

看起来眼花缭乱。但精简一下就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef _DEBUG  // 如果不是调试版本

// 所有 _dbg 函数直接映射到普通版本
#define _malloc_dbg(s, t, f, l) malloc(s)
// ... 其他类似

#else // 如果是调试版本

#ifdef _CRTDBG_MAP_ALLOC // 如果启用了完整的内存调试映射

// 这里重定向了所有标准内存函数到调试版本
#define malloc(s) _malloc_dbg(s, _NORMAL_BLOCK, __FILE__, __LINE__)
#define free(p) _free_dbg(p, _NORMAL_BLOCK)
// ... 其他类似

#endif // _CRTDBG_MAP_ALLOC
#endif // _DEBUG

如果忽略调试,我们就单纯看malloc(s)这个函数。终于见到了熟悉的malloc。malloc的函数特别纯粹:

1
2
3
4
5
void* malloc(size_t size) {
// 1. 向操作系统请求一块内存
// 2. 管理这块内存(记录大小等信息)
// 3. 返回给用户可用的指针
}

这里再仔细看看malloc的行为:

写一个简单的使用malloc的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>

int main() {
// 1. 分配一个整数的大小
int* ptr = (int*)malloc(sizeof(int));

if (ptr == NULL) {
printf("内存分配失败!\n");
return 1;
}

// 2. 使用分配的内存
*ptr = 42;
printf("分配的整数: %d\n", *ptr);

// 3. 释放内存
free(ptr);
ptr = NULL; // 避免悬垂指针

return 0;
}

汇编为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# License: MSVC Proprietary
# The use of this compiler is only permitted for internal evaluation purposes and is otherwise governed by the MSVC License Agreement.
# See https://visualstudio.microsoft.com/license-terms/vs2022-ga-community/

; ==============================================
; 数据段:存储字符串常量
; ==============================================

; 字符串1:中文编码的"内存分配失败!" + 换行符 + null终止符
$SG6371 DB 0e5H, 086H, 085H, 0e5H, 0adH, 098H, 0e5H, 088H, 086H, 0e9H
DB 085H, 08dH, 0e5H, 0a4H, 0b1H, 0e8H, 0b4H, 0a5H, 0efH, 0bcH, 081H
DB 0aH, 00H
; 对应 UTF-8 编码的"内存分配失败!\n\0"
; 这是调试版本的中文错误提示

ORG $+1 ; 地址对齐指令,确保下一条指令在正确地址

; 字符串2:中文"分配的整数: " + 格式字符串 "%d" + 换行符 + null
$SG6372 DB 0e5H, 088H, 086H, 0e9H, 085H, 08dH, 0e7H, 09aH, 084H, 0e6H
DB 095H, 0b4H, 0e6H, 095H, 0b0H, ': %d', 0aH, 00H
; 对应 UTF-8 编码的"分配的整数: %d\n\0"

; ==============================================
; 全局变量:stdio 选项存储
; ==============================================
unsigned __int64 `__local_stdio_printf_options'::`2'::_OptionsStorage DQ 01H DUP (?)
; 这是一个全局变量,用于存储 printf 的选项(如缓冲区大小等)
; DQ 01H DUP (?) 表示分配一个 8 字节(quad word)未初始化的空间

; ==============================================
; 栈帧布局定义
; ==============================================
ptr$ = 32 ; 局部变量 ptr 在栈上的偏移量是 32 字节

; ==============================================
; main 函数开始
; ==============================================
main PROC
$LN4: ; 标签:代码块开始
; 函数序言(prologue):设置栈帧
sub rsp, 56 ; 00000038H
; 在栈上分配 56 字节空间(局部变量 + 对齐)
; 为什么要 56 字节?
; - 32字节:预留空间(可能用于参数传递)
; - 8字节:ptr 变量
; - 16字节:对齐到16字节边界(Windows x64 调用约定要求)

; ======================================
; 步骤1:调用 malloc 分配内存
; ======================================
mov ecx, 4 ; 参数1:size = 4 字节
; Windows x64 调用约定:前4个参数在 RCX, RDX, R8, R9
; 这里 malloc 只需要一个参数,所以放在 RCX 的低32位 ECX

call QWORD PTR __imp_malloc ; 调用 malloc
; __imp_malloc 是导入函数地址(从 DLL 导入)
; QWORD PTR 表示这是一个64位指针

; ======================================
; 步骤2:保存返回的指针
; ======================================
mov QWORD PTR ptr$[rsp], rax ; 将返回值保存到栈上的 ptr 变量
; malloc 的返回值(分配的内存地址)在 RAX 中
; ptr$[rsp] = rsp + 32(上面定义的偏移量)

; ======================================
; 步骤3:检查分配是否成功
; ======================================
cmp QWORD PTR ptr$[rsp], 0 ; 比较 ptr 是否为 NULL
jne SHORT $LN2@main ; 如果不为 NULL,跳转到成功处理
; jne = Jump if Not Equal(如果不等于0)

; ======================================
; 分支:分配失败的处理
; ======================================
lea rcx, OFFSET FLAT:$SG6371 ; 加载字符串地址到 RCX
; LEA = Load Effective Address(加载有效地址)
; OFFSET FLAT: 表示相对于数据段基址的偏移
; 准备调用 printf 的第一个参数

call printf ; 调用 printf 输出错误信息
; 这里没有 __imp_ 前缀,可能是静态链接或内联

mov eax, 1 ; 设置返回值 1(表示错误)
jmp SHORT $LN1@main ; 跳转到函数结尾

; ======================================
; 标签:分配成功的处理
; ======================================
$LN2@main:
; ======================================
; 步骤4:使用分配的内存(写入值)
; ======================================
mov rax, QWORD PTR ptr$[rsp] ; 加载 ptr 到 RAX
mov DWORD PTR [rax], 42 ; 将 42 写入指针指向的位置
; DWORD PTR [rax] 表示将 RAX 作为指针,写入32位值
; 42 的十六进制是 2AH

; ======================================
; 步骤5:读取并打印这个值
; ======================================
mov rax, QWORD PTR ptr$[rsp] ; 再次加载 ptr(可能被优化掉,但这里没优化)
mov edx, DWORD PTR [rax] ; 读取刚写入的值到 EDX
; EDX 是 printf 的第二个参数寄存器

lea rcx, OFFSET FLAT:$SG6372 ; 加载格式字符串到 RCX(第一个参数)
call printf ; 调用 printf

; ======================================
; 步骤6:释放内存
; ======================================
mov rcx, QWORD PTR ptr$[rsp] ; 将 ptr 加载到 RCX(free 的参数)
call QWORD PTR __imp_free ; 调用 free

; ======================================
; 步骤7:置空指针(避免悬垂指针)
; ======================================
mov QWORD PTR ptr$[rsp], 0 ; 将 ptr 设为 NULL

; ======================================
; 步骤8:设置返回值 0(成功)
; ======================================
xor eax, eax ; EAX = 0(返回 0)
; XOR 自己是最快的清零方式

; ======================================
; 标签:函数返回点(两个分支的汇合处)
; ======================================
$LN1@main:
; 函数结尾(epilogue):恢复栈帧
add rsp, 56 ; 释放栈空间
ret 0 ; 返回
main ENDP

new中的构造函数

先考虑可能出现的情况。会变化的有以下这些:new的是内置类型对象还是自定义类型对象。如果是自定义类型对象,使用new A;还是new A()有零初始化的问题。以及有无用户显式定义了构造函数。

无显式初始化

写一个测试代码看一看,是否有构造函数的影响且初始化采用new X的不带各种括号的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct A {
int x;
A() { std::cout << "A constructed" << std::endl; } // 有用户定义构造函数
};

struct B {
int x; // 没有构造函数
};

struct C {
int x;
C() = default; // 显式默认构造函数
};

int main() {
std::cout << "Testing A (has ctor):" << std::endl;
A* a = new A; // 会输出

std::cout << "\nTesting B (no ctor):" << std::endl;
B* b = new B; // 不会输出

std::cout << "\nTesting C (defaulted ctor):" << std::endl;
C* c = new C; // 不会输出

delete a;
delete b;
delete c;

return 0;
}

运行结果:

1
2
3
4
5
6
Testing A (has ctor):
A constructed

Testing B (no ctor):

Testing C (defaulted ctor):

一个一个来看:

第一部分:A::A() 构造函数分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
A::A(void) PROC
$LN3:
mov QWORD PTR [rsp+8], rcx ; 保存 this 指针
sub rsp, 40 ; 分配栈空间

; 输出 "A constructed"
lea rdx, OFFSET FLAT:`string' ; RDX = "A constructed"
mov rcx, QWORD PTR __imp_std::cout ; RCX = std::cout
call std::operator<< ; 调用 operator<<

; 输出 std::endl
lea rdx, OFFSET FLAT:std::endl ; RDX = endl 函数地址
mov rcx, rax ; RCX = cout(上一条的返回值)
call QWORD PTR __imp_std::basic_ostream::operator<<

mov rax, QWORD PTR this$[rsp] ; 返回 this 指针
add rsp, 40 ; 恢复栈
ret 0
A::A(void) ENDP

A 的构造函数是真实存在的函数,包含实际的代码(输出语句)。

第二部分:main 函数分析

创建对象 A(有构造函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
; 输出 "Testing A (has ctor):"
lea rdx, OFFSET FLAT:$SG35287 ; RDX = 字符串地址
mov rcx, QWORD PTR __imp_std::cout ; RCX = cout
call std::operator<<
; ... 输出 endl

; 分配 A 对象
mov ecx, 4 ; 分配 4 字节
call void * operator new ; 调用 operator new
mov QWORD PTR $T1[rsp], rax ; 保存返回指针

cmp QWORD PTR $T1[rsp], 0 ; 检查分配是否成功
je SHORT $LN3@main ; 如果失败,跳转

; 关键:调用构造函数!
mov rcx, QWORD PTR $T1[rsp] ; RCX = this 指针
call A::A(void) ; 调用构造函数 A::A()

mov QWORD PTR tv80[rsp], rax ; 保存结果
jmp SHORT $LN4@main

; 分配失败处理
$LN3@main:
mov QWORD PTR tv80[rsp], 0 ; 存储 nullptr

; 保存到变量 a
$LN4@main:
mov rax, QWORD PTR tv80[rsp]
mov QWORD PTR a$[rsp], rax ; 保存到 a

对于类 A,有明显的 call A::A(void) 指令,这就是构造函数的调用。

创建对象 B(没有构造函数)

1
2
3
4
5
6
7
8
9
; 输出 "Testing B (no ctor):"
; ... 类似前面的输出代码

; 分配 B 对象
mov ecx, 4 ; 分配 4 字节
call void * operator new ; 调用 operator new
mov QWORD PTR $T3[rsp], rax ; 保存返回指针
mov rax, QWORD PTR $T3[rsp] ; 加载指针
mov QWORD PTR b$[rsp], rax ; 直接保存到 b

关键差异:

  1. 没有空指针检查分配后直接保存指针,没有 cmpje 指令
  2. 没有构造函数调用 没有 call B::B(void) 之类的指令
  3. 分配后直接使用,指针直接存入变量

创建对象 C(默认构造函数)

1
2
3
4
5
6
7
8
9
; 输出 "Testing C (defaulted ctor):"
; ... 输出代码

; 分配 C 对象
mov ecx, 4 ; 分配 4 字节
call void * operator new ; 调用 operator new
mov QWORD PTR $T4[rsp], rax ; 保存返回指针
mov rax, QWORD PTR $T4[rsp] ; 加载指针
mov QWORD PTR c$[rsp], rax ; 直接保存到 c

与 B 完全一样,C() = default; 和没有构造函数在汇编层面没有区别。

值初始化

刚刚的实验的new的方式是不带任何括号的。下面看看new X()这种带括号的值初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>

struct A {
int x;
A() { std::cout << "A constructed" << std::endl; }
};

struct B {
int x;
// 没有构造函数
};

struct C {
int x;
C() = default; // 显式默认
};

int main() {
std::cout << "Testing A (has ctor) with parentheses:" << std::endl;
A* a = new A(); // 值初始化 - 会调用构造函数

std::cout << "\nTesting B (no ctor) with parentheses:" << std::endl;
B* b = new B(); // 值初始化 - 会进行零初始化

std::cout << "\nTesting C (defaulted ctor) with parentheses:" << std::endl;
C* c = new C(); // 值初始化 - 会进行零初始化

// 为了演示差异,输出值
std::cout << "\nChecking values:" << std::endl;
std::cout << "a->x = " << a->x << std::endl; // 构造函数可能设定了值
std::cout << "b->x = " << b->x << std::endl; // 应该为0(零初始化)
std::cout << "c->x = " << c->x << std::endl; // 应该为0(零初始化)

delete a;
delete b;
delete c;

return 0;
}

测试结果:

1
2
3
4
5
6
7
8
9
10
11
Testing A (has ctor) with parentheses:
A constructed

Testing B (no ctor) with parentheses:

Testing C (defaulted ctor) with parentheses:

Checking values:
a->x = -842150451
b->x = 0
c->x = 0

情况1:new A() - 有用户定义构造函数

asm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
; 分配内存
mov ecx, 4 ; 分配4字节
call void * operator new ; 调用 operator new
mov QWORD PTR $T3[rsp], rax ; 保存指针

; 检查分配是否成功
cmp QWORD PTR $T3[rsp], 0
je SHORT $LN3@main ; 如果失败,跳转

; 调用构造函数
mov rcx, QWORD PTR $T3[rsp] ; RCX = this 指针
call A::A(void) ; 调用构造函数

mov QWORD PTR tv80[rsp], rax ; 保存结果
jmp SHORT $LN4@main

; 分配失败处理
$LN3@main:
mov QWORD PTR tv80[rsp], 0 ; 存储 nullptr

; 保存到变量 a
$LN4@main:
mov rax, QWORD PTR tv80[rsp]
mov QWORD PTR $T4[rsp], rax
mov rax, QWORD PTR $T4[rsp]
mov QWORD PTR a$[rsp], rax ; 保存到 a

关键点:对于有构造函数的类 A,new A() 的行为是:

  1. 分配内存
  2. 检查分配成功
  3. 调用构造函数
  4. 保存指针

特别注意:没有清零操作。因为类 A 有用户定义的构造函数,编译器依赖构造函数来初始化对象。

情况2:new B() - 没有构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
; 分配内存
mov ecx, 4 ; 分配4字节
call void * operator new ; 调用 operator new
mov QWORD PTR $T1[rsp], rax ; 保存指针

; 检查分配是否成功
cmp QWORD PTR $T1[rsp], 0
je SHORT $LN5@main ; 如果失败,跳转

; 清零内存
mov rdi, QWORD PTR $T1[rsp] ; RDI = 目标地址
xor eax, eax ; EAX = 0
mov ecx, 4 ; ECX = 4字节
rep stosb ; 清零4字节内存

mov rax, QWORD PTR $T1[rsp]
mov QWORD PTR tv91[rsp], rax ; 保存结果
jmp SHORT $LN6@main

; 分配失败处理
$LN5@main:
mov QWORD PTR tv91[rsp], 0 ; 存储 nullptr

; 保存到变量 b
$LN6@main:
mov rax, QWORD PTR tv91[rsp]
mov QWORD PTR b$[rsp], rax ; 保存到 b

关键点:对于没有构造函数的类 B,new B() 的行为是:

  1. 分配内存
  2. 检查分配成功
  3. 清零内存(rep stosb
  4. 保存指针

这就是值初始化的核心:new B() 触发了零初始化,即使类 B 没有构造函数。

情况3:new C() - 默认构造函数

asm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
; 分配内存
mov ecx, 4 ; 分配4字节
call void * operator new ; 调用 operator new
mov QWORD PTR $T2[rsp], rax ; 保存指针

; 检查分配是否成功
cmp QWORD PTR $T2[rsp], 0
je SHORT $LN7@main ; 如果失败,跳转

; 清零内存(与B完全相同)
mov rdi, QWORD PTR $T2[rsp] ; RDI = 目标地址
xor eax, eax ; EAX = 0
mov ecx, 4 ; ECX = 4字节
rep stosb ; 清零4字节内存

mov rax, QWORD PTR $T2[rsp]
mov QWORD PTR tv139[rsp], rax ; 保存结果
jmp SHORT $LN8@main

; 分配失败处理
$LN7@main:
mov QWORD PTR tv139[rsp], 0 ; 存储 nullptr

; 保存到变量 c
$LN8@main:
mov rax, QWORD PTR tv139[rsp]
mov QWORD PTR c$[rsp], rax ; 保存到 c

类 C 的代码与类 B 完全相同。C() = default; 和没有构造函数在 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <string>

class Demo {
std::string data;
int id;

public:
// 构造函数1:有成员初始化列表
Demo() : data("default"), id(0) {
std::cout << "Constructor 1: member init list\n";
}

// 构造函数2:没有成员初始化列表
Demo(int i) {
data = "assigned";
id = i;
std::cout << "Constructor 2: assignment in body\n";
}

// 构造函数3:混合
Demo(const std::string& s) : data(s) {
id = 42; // 内置类型,区别不大
std::cout << "Constructor 3: mixed\n";
}
};

int main() {
// 外部语法决定调用哪个构造函数
Demo* d1 = new Demo(); // 调用Demo() - 有初始化列表
Demo* d2 = new Demo{}; // 调用Demo() - 有初始化列表
Demo* d3 = new Demo(100); // 调用Demo(int) - 没有初始化列表
Demo* d4 = new Demo{100}; // 调用Demo(int) - 没有初始化列表
Demo* d5 = new Demo{"test"}; // 调用Demo(string) - 有部分初始化列表

delete d1; delete d2; delete d3; delete d4; delete d5;
return 0;
}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Demo* d1 = new Demo()
Constructor 1: member init list
========================
Demo* d2 = new Demo{}
Constructor 1: member init list
========================
Demo* d3 = new Demo(100);
Constructor 2: assignment in body
========================
Demo* d4 = new Demo{ 100 }
Constructor 2: assignment in body
========================
Demo* d5 = new Demo{"test" }
Constructor 3: mixed

详细分析:三个构造函数的汇编对比

构造函数1分析:Demo::Demo(void) - 有成员初始化列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Demo::Demo(void) PROC
$LN4:
mov QWORD PTR [rsp+8], rcx ; 保存 this 指针
sub rsp, 40 ; 分配栈空间

; === 成员初始化列表开始 ===
mov rax, QWORD PTR this$[rsp] ; RAX = this
lea rdx, OFFSET FLAT:`string' ; RDX = "default" 字符串地址
mov rcx, rax ; RCX = this(data成员的地址)
call std::basic_string::basic_string(char const*) ; 直接构造data!
; === 成员初始化列表结束 ===

; 初始化 id
mov rax, QWORD PTR this$[rsp] ; RAX = this
mov DWORD PTR [rax+32], 0 ; this->id = 0

; 输出信息
lea rdx, OFFSET FLAT:`string' ; RDX = 输出字符串
mov rcx, QWORD PTR __imp_std::cout
call std::operator<<

; 返回 this
mov rax, QWORD PTR this$[rsp]
add rsp, 40
ret 0
Demo::Demo(void) ENDP

关键点data 成员通过直接调用构造函数初始化,这是最高效的方式。

构造函数2分析:Demo::Demo(int) - 没有成员初始化列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Demo::Demo(int) PROC
$LN4:
mov DWORD PTR [rsp+16], edx ; 保存参数 i
mov QWORD PTR [rsp+8], rcx ; 保存 this
sub rsp, 40

; === 没有成员初始化列表!data默认构造 ===
mov rax, QWORD PTR this$[rsp] ; RAX = this
mov rcx, rax ; RCX = this(data地址)
call std::basic_string::basic_string(void) ; 默认构造函数!
; data现在是一个空字符串

; === 构造函数体开始:赋值操作 ===
mov rax, QWORD PTR this$[rsp] ; RAX = this
lea rdx, OFFSET FLAT:`string' ; RDX = "assigned"
mov rcx, rax ; RCX = &data
call std::basic_string::operator= ; 赋值操作!
; 这里调用了 operator=,不是构造函数

; 初始化 id
mov rax, QWORD PTR this$[rsp]
mov ecx, DWORD PTR i$[rsp] ; ECX = 参数 i
mov DWORD PTR [rax+32], ecx ; this->id = i

; 输出信息
lea rdx, OFFSET FLAT:`string'
mov rcx, QWORD PTR __imp_std::cout
call std::operator<<

; 返回 this
mov rax, QWORD PTR this$[rsp]
add rsp, 40
ret 0
Demo::Demo(int) ENDP

关键差异

  1. 多一次函数调用:先调用默认构造函数,再调用 operator=
  2. 效率更低std::string 被构造了两次(默认构造 + 赋值)

构造函数3分析:Demo::Demo(const string&) - 混合方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Demo::Demo(std::basic_string<char,...> const &) PROC
$LN4:
mov QWORD PTR [rsp+16], rdx ; 保存参数 s 的引用
mov QWORD PTR [rsp+8], rcx ; 保存 this
sub rsp, 40

; === 成员初始化列表:data使用拷贝构造 ===
mov rax, QWORD PTR this$[rsp] ; RAX = this
mov rdx, QWORD PTR s$[rsp] ; RDX = 参数 s(源字符串)
mov rcx, rax ; RCX = this(目标地址)
call std::basic_string::basic_string(string const&) ; 拷贝构造!

; === 构造函数体:初始化 id ===
mov rax, QWORD PTR this$[rsp]
mov DWORD PTR [rax+32], 42 ; this->id = 42

; 输出信息
lea rdx, OFFSET FLAT:`string'
mov rcx, QWORD PTR __imp_std::cout
call std::operator<<

; 返回 this
mov rax, QWORD PTR this$[rsp]
add rsp, 40
ret 0
Demo::Demo(std::basic_string<char,...> const &) ENDP

关键点data 通过拷贝构造函数直接初始化,这是高效的。

main 函数分析:五种创建方式

1. d1 = new Demo() - 默认构造函数

1
2
3
4
5
6
7
8
9
mov     ecx, 40          ; sizeof(Demo) = 40 (32+8,考虑对齐)
call void * operator new
mov QWORD PTR $T2[rsp], rax

cmp QWORD PTR $T2[rsp], 0
je SHORT $LN3@main ; 检查分配失败

mov rcx, QWORD PTR $T2[rsp]
call Demo::Demo(void) ; 调用构造函数1

2. d2 = new Demo{} - 列表初始化(空列表)

1
2
3
4
5
6
7
; 与 d1 完全相同!
mov ecx, 40
call void * operator new
cmp rax, 0
je ...
mov rcx, rax
call Demo::Demo(void) ; 同样调用构造函数1

3. d3 = new Demo(100) - 直接初始化

1
2
3
4
5
mov     ecx, 40
call void * operator new
mov edx, 100 ; 参数:i = 100
mov rcx, rax
call Demo::Demo(int) ; 调用构造函数2

4. d4 = new Demo{100} - 列表初始化

1
2
3
4
5
6
; 与 d3 完全相同!
mov ecx, 40
call void * operator new
mov edx, 100 ; 参数:i = 100
mov rcx, rax
call Demo::Demo(int) ; 同样调用构造函数2

5. d5 = new Demo{"test"} - 列表初始化(字符串)

这是最复杂的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
; 1. 分配内存
mov ecx, 40
call void * operator new
mov QWORD PTR $T6[rsp], rax

; 2. 创建临时字符串 "test"
lea rdx, OFFSET FLAT:$SG35705 ; RDX = "test"
lea rcx, QWORD PTR $T17[rsp] ; RCX = 临时字符串位置
call std::basic_string::basic_string(char const*) ; 构造临时字符串

; 3. 标记需要清理临时字符串
mov eax, DWORD PTR $T1[rsp]
or eax, 1
mov DWORD PTR $T1[rsp], eax

; 4. 调用构造函数3
lea rdx, QWORD PTR $T17[rsp] ; RDX = 临时字符串的引用
mov rcx, QWORD PTR $T6[rsp] ; RCX = 新分配的Demo对象
call Demo::Demo(string const&) ; 调用构造函数3

; 5. 清理临时字符串
mov eax, DWORD PTR $T1[rsp]
and eax, 1
test eax, eax
je SHORT $LN30@main
lea rcx, QWORD PTR $T17[rsp]
call std::basic_string::~basic_string ; 析构临时字符串

对比

  1. new Demo()new Demo{} 的汇编完全一样
    在这个例子中,两者都调用 Demo::Demo(void),生成相同的代码。

  2. new Demo(100)new Demo{100} 的汇编完全一样
    两者都调用 Demo::Demo(int),生成相同的代码。

  3. 构造函数2的效率问题清晰可见
    对比构造函数1和2:

  • 构造函数1:1次字符串构造函数调用
  • 构造函数2:2次函数调用(默认构造 + operator=
  1. 临时对象的处理
    new Demo{"test"} 需要创建临时字符串,然后通过引用传递给构造函数,最后清理临时对象。
  • 根据文章内容,您对C++中newdelete的探索非常深入!您已经发现了不同初始化方式的细微差别以及构造函数实现方式对性能的影响。基于您的分析,我重新整理了总结部分,并优化了对比表格,使其更清晰准确。

总结

通过实验分析,可以得出以下关键结论:

  1. new的本质new表达式分为两步:首先调用operator new分配内存,然后根据需要调用构造函数。对于没有用户定义构造函数的类型,编译器会优化掉构造函数调用。

  2. 初始化差异

    • new T(默认初始化):对有构造函数的类型调用构造函数;对无构造函数的类型不初始化。
    • new T()(值初始化):对有构造函数的类型调用构造函数并可能进行零初始化;对无构造函数的类型进行零初始化。
    • new T{}(列表初始化):行为与直接初始化类似,但禁止窄化转换。
  3. 构造函数效率:使用成员初始化列表直接在内存中构造对象,比在构造函数体内赋值更高效,避免了先默认构造再赋值的开销。

  4. 内存分配operator new最终会调用malloc,在调试版本中会添加额外的调试信息用于内存泄漏检测。

  5. 错误处理:默认情况下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 ❌ 否 先默认构造再赋值 效率较低,多一次赋值操作

说明:

  1. 值初始化对于有构造函数的类:根据编译器实现,new Class()可能在调用构造函数前先进行零初始化。MSVC在调试版本中会执行零初始化,但这不是C++标准要求的。
  2. 性能建议:对于类成员变量,优先使用成员初始化列表,避免在构造函数体内赋值,特别是对于std::stringstd::vector等非平凡类型。
  3. 内存管理new/delete必须配对使用,new[]/delete[]必须配对使用,混用会导致未定义行为。

通过对C++中new表达式和构造函数实现方式的深入分析,可以清楚地认识到:外部初始化语法与类内部构造函数的实现方式是两个独立且正交的概念。

当我们在代码中使用new Tnew T()new T{}等不同语法创建对象时,这些外部语法只负责选择调用哪个构造函数以及决定是否进行零初始化,它们无法改变类内部已经定义好的构造方式。是否在构造过程中先调用成员变量的默认构造函数,完全取决于类设计者在编写构造函数时是选择使用成员初始化列表还是在构造函数体内进行赋值操作。

使用成员初始化列表的构造函数会直接调用成员变量带参数的构造函数,一步到位地完成初始化,避免了不必要的默认构造步骤。而将初始化操作放在构造函数体内的方式,则会强制编译器先调用成员变量的默认构造函数创建一个临时对象,然后再通过赋值运算符将值赋给该对象,这种额外的开销与外部使用何种new语法毫无关系。

无论外部采用new MyClassnew 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又是谁?

具体使用请参考https://learn.microsoft.com/zh-cn/cpp/cpp/initializing-classes-and-structs-without-constructors-cpp?view=msvc-170微软的C++文档:

在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吧,这也太可怕了。

太可怕了。