警惕含有指针的自定义类内存重新被分配

前言

我有一个存储信息的类CInfo,和CInfoArray类来存储一定数量的CInfo结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class CInfo
{
private:
IPtr* m_pBase;
};

//******************************************

class CInfoArray
{
private:
std::vector<CInfo> m_InfoArray;
}

由于逻辑上我需要将CInfo的成员指针指向IPtr的子类比如CPtr1,CPtr2...,并且我想实现只让CInfo类本身来管理自己指针m_pBase的创建和销毁

为了方便描述,其中IPtr,CPtr1,CPtr2...仅实现

1
2
3
4
virtual void print()
{
std::cout << "im IPtr/CPtr1/CPtr2" << std::endl;
}

那么我是这样实现的:

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
class CInfo
{
public:
CInfo(std::function<IPtr*()> vFunc)
: m_pBase(nullptr)
{
m_pBase = vFunc();
}
~CInfo()
{
if(m_pBase)
{
delete m_pBase;
m_pBase = nullptr;
}
}

IPtr* m_pBase;
};

//******************************************

class CInfoArray
{
public:
template <typename T>
void registerInfo()
{
m_InfoArray.emplace_back([] { return new T; });
}

void OnPrint()
{
for (auto& info : m_InfoArray)
{
info.m_pBase->print();
}
}
private:
std::vector<CInfo> m_InfoArray;
};

当我在main函数里这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
CInfoArray Array;
Array.registerInfo<IPtr>();
Array.OnPrint();
}
// 成功打印
// im IPtr

//***************************************

int main()
{
CInfoArray Array;
Array.registerInfo<IPtr>();
Array.registerInfo<IPtr>();
Array.OnPrint();
}

// 在 CInfoArray::OnPrint() 异常中断

这究竟是为什么,让我们来一探究竟

环境:

  • win11
  • vs2022 v143
  • Debug x64

单步调试

具体报错的那行是info.m_pBase->print();

开始单步调试,初步观察到:

  • 第二次registerInfo进入了CInfo析构函数
  • 使用计数器后发现CInfo的构造函数和析构函数只被分别调用了一次
  • registerInfo被调用两次后,m_InfoArray的大小为2
  • 引发异常的语句info.m_pBase->print();m_pBase的地址不是nullptr(CInfo的构造函数和析构函数都有将m_pBase赋值为nullptr)

那么可以发现,整个过程中一共出现了三个CInfo实例,其中有一个神奇的第三者还释放了另外二者之一所维持的内存空间,也就是说这个第三者是没有自己的内存空间的,没有执行构造函数在堆上分配内存的步骤

呼之欲出,出现了拷贝


vector重新分配内存的锅

查阅了一点资料,就大小而言,std::vectorsizecapacity两个值

.size() is the number of elements that are contained in the vector, whereas .capacity() is the number of elements that can be added to the vector, before memory will be re-allocated.

C++ vectors do not support in-place reallocation of memory

(警觉) RE-ALLOCATED,那么说得通了,感觉应该是分配了新的空间,然后调用默认拷贝函数直接把CInfo拷贝过去了

实践一下,我们把OnPrint()和析构函数的delete注释掉,只运行10次registerInfo<>

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
Initial Size:   0, Capacity:    1
-----------------------------------------------------
Constructor of CInfo, address: 0000019A89325B50
Size: 1, Capacity: 1
-----------------------------------------------------
Constructor of CInfo, address: 0000019A89325658
Destructor of CInfo, address: 0000019A89325B50
Size: 2, Capacity: 2
-----------------------------------------------------
Constructor of CInfo, address: 0000019A893248A0
Destructor of CInfo, address: 0000019A89325650
Destructor of CInfo, address: 0000019A89325658
Size: 3, Capacity: 3
-----------------------------------------------------
Constructor of CInfo, address: 0000019A893245A8
Destructor of CInfo, address: 0000019A89324890
Destructor of CInfo, address: 0000019A89324898
Destructor of CInfo, address: 0000019A893248A0
Size: 4, Capacity: 4
-----------------------------------------------------
Constructor of CInfo, address: 0000019A89320C90
Destructor of CInfo, address: 0000019A89324590
Destructor of CInfo, address: 0000019A89324598
Destructor of CInfo, address: 0000019A893245A0
Destructor of CInfo, address: 0000019A893245A8
Size: 5, Capacity: 6
-----------------------------------------------------
Constructor of CInfo, address: 0000019A89320C98
Size: 6, Capacity: 6
-----------------------------------------------------
Constructor of CInfo, address: 0000019A893190B0
Destructor of CInfo, address: 0000019A89320C70
Destructor of CInfo, address: 0000019A89320C78
Destructor of CInfo, address: 0000019A89320C80
Destructor of CInfo, address: 0000019A89320C88
Destructor of CInfo, address: 0000019A89320C90
Destructor of CInfo, address: 0000019A89320C98
Size: 7, Capacity: 9
-----------------------------------------------------
Constructor of CInfo, address: 0000019A893190B8
Size: 8, Capacity: 9
-----------------------------------------------------
Constructor of CInfo, address: 0000019A893190C0
Size: 9, Capacity: 9
-----------------------------------------------------
Constructor of CInfo, address: 0000019A8931B098
Destructor of CInfo, address: 0000019A89319080
Destructor of CInfo, address: 0000019A89319088
Destructor of CInfo, address: 0000019A89319090
Destructor of CInfo, address: 0000019A89319098
Destructor of CInfo, address: 0000019A893190A0
Destructor of CInfo, address: 0000019A893190A8
Destructor of CInfo, address: 0000019A893190B0
Destructor of CInfo, address: 0000019A893190B8
Destructor of CInfo, address: 0000019A893190C0
Size: 10, Capacity: 13
-----------------------------------------------------
Destructor of CInfo, address: 0000019A8931B050
Destructor of CInfo, address: 0000019A8931B058
Destructor of CInfo, address: 0000019A8931B060
Destructor of CInfo, address: 0000019A8931B068
Destructor of CInfo, address: 0000019A8931B070
Destructor of CInfo, address: 0000019A8931B078
Destructor of CInfo, address: 0000019A8931B080
Destructor of CInfo, address: 0000019A8931B088
Destructor of CInfo, address: 0000019A8931B090
Destructor of CInfo, address: 0000019A8931B098

行,可以看到,每次re-allocation发生,也就是capacity改变,原地址对象都会被析构

我们再实现拷贝构造函数看看(什么也不干,只实现打印信息)

The copy constructor is called whenever an object is initialized (by direct-initialization or copy-initialization) from another object of the same type

也就是说拷贝构造函数在创建新对象的过程中执行,它是在新对象的上下文中执行的。其目的是使用另一个已存在的对象(即被拷贝的对象)的状态来初始化新创建的对象

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
Initial Size:   0, Capacity:    1
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99F5BA0
Size: 1, Capacity: 1
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99F5798
Copy 00000208F99F5BA0 => 00000208F99F5790
Destructor of CInfo, address: 00000208F99F5BA0
Size: 2, Capacity: 2
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99F42A0
Copy 00000208F99F5790 => 00000208F99F4290
Copy 00000208F99F5798 => 00000208F99F4298
Destructor of CInfo, address: 00000208F99F5790
Destructor of CInfo, address: 00000208F99F5798
Size: 3, Capacity: 3
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99F4488
Copy 00000208F99F4290 => 00000208F99F4470
Copy 00000208F99F4298 => 00000208F99F4478
Copy 00000208F99F42A0 => 00000208F99F4480
Destructor of CInfo, address: 00000208F99F4290
Destructor of CInfo, address: 00000208F99F4298
Destructor of CInfo, address: 00000208F99F42A0
Size: 4, Capacity: 4
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99F08A0
Copy 00000208F99F4470 => 00000208F99F0880
Copy 00000208F99F4478 => 00000208F99F0888
Copy 00000208F99F4480 => 00000208F99F0890
Copy 00000208F99F4488 => 00000208F99F0898
Destructor of CInfo, address: 00000208F99F4470
Destructor of CInfo, address: 00000208F99F4478
Destructor of CInfo, address: 00000208F99F4480
Destructor of CInfo, address: 00000208F99F4488
Size: 5, Capacity: 6
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99F08A8
Size: 6, Capacity: 6
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99E90B0
Copy 00000208F99F0880 => 00000208F99E9080
Copy 00000208F99F0888 => 00000208F99E9088
Copy 00000208F99F0890 => 00000208F99E9090
Copy 00000208F99F0898 => 00000208F99E9098
Copy 00000208F99F08A0 => 00000208F99E90A0
Copy 00000208F99F08A8 => 00000208F99E90A8
Destructor of CInfo, address: 00000208F99F0880
Destructor of CInfo, address: 00000208F99F0888
Destructor of CInfo, address: 00000208F99F0890
Destructor of CInfo, address: 00000208F99F0898
Destructor of CInfo, address: 00000208F99F08A0
Destructor of CInfo, address: 00000208F99F08A8
Size: 7, Capacity: 9
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99E90B8
Size: 8, Capacity: 9
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99E90C0
Size: 9, Capacity: 9
-----------------------------------------------------
Constructor of CInfo, address: 00000208F99EB098
Copy 00000208F99E9080 => 00000208F99EB050
Copy 00000208F99E9088 => 00000208F99EB058
Copy 00000208F99E9090 => 00000208F99EB060
Copy 00000208F99E9098 => 00000208F99EB068
Copy 00000208F99E90A0 => 00000208F99EB070
Copy 00000208F99E90A8 => 00000208F99EB078
Copy 00000208F99E90B0 => 00000208F99EB080
Copy 00000208F99E90B8 => 00000208F99EB088
Copy 00000208F99E90C0 => 00000208F99EB090
Destructor of CInfo, address: 00000208F99E9080
Destructor of CInfo, address: 00000208F99E9088
Destructor of CInfo, address: 00000208F99E9090
Destructor of CInfo, address: 00000208F99E9098
Destructor of CInfo, address: 00000208F99E90A0
Destructor of CInfo, address: 00000208F99E90A8
Destructor of CInfo, address: 00000208F99E90B0
Destructor of CInfo, address: 00000208F99E90B8
Destructor of CInfo, address: 00000208F99E90C0
Size: 10, Capacity: 13
-----------------------------------------------------
Destructor of CInfo, address: 00000208F99EB050
Destructor of CInfo, address: 00000208F99EB058
Destructor of CInfo, address: 00000208F99EB060
Destructor of CInfo, address: 00000208F99EB068
Destructor of CInfo, address: 00000208F99EB070
Destructor of CInfo, address: 00000208F99EB078
Destructor of CInfo, address: 00000208F99EB080
Destructor of CInfo, address: 00000208F99EB088
Destructor of CInfo, address: 00000208F99EB090
Destructor of CInfo, address: 00000208F99EB098

环境为x64,所以指针大小为8字节,由于CInfo类只有一个指针成员,所以一个CInfo实例占8字节

从上面的打印信息可以看出

  • 向capacity已满的std::vector插入元素,顺序为构造元素=>分配新内存=>拷贝=>析构旧内存的元素

拷贝/移动

std::vector的定义为

1
2
3
4
template<
class T,
class Allocator = std::allocator<T>
> class vector;

T must meet the requirements of CopyAssignable and CopyConstructible.

也就是说std::vector所容纳的元素必须含有可用的拷贝/移动构造函数

默认拷贝/移动构造函数

C++为类自动生成默认的拷贝构造函数和移动构造函数,如果你没有显式地为类提供这些。它们的行为和区别基于类的成员如何被拷贝或移动。

默认拷贝构造函数

如果没有为类显式定义拷贝构造函数,编译器会生成一个默认拷贝构造函数。默认拷贝构造函数会逐个拷贝类的每个非静态成员。对于基本类型成员,这意味着简单的值复制。对于类类型成员,如果成员类定义了拷贝构造函数,这个函数将被调用;否则,会递归进行成员的成员的默认拷贝构造。

  • 对基本数据类型成员,执行位拷贝。
  • 对类类型成员,调用其拷贝构造函数。
  • 对数组类型成员,逐个拷贝数组中的元素。
  • 对指针成员,拷贝指针值(地址),不拷贝指针指向的数据(浅拷贝)。

默认移动构造函数

如果没有为类显式定义移动构造函数(以及拷贝构造函数、拷贝赋值操作符和析构函数),编译器会生成一个默认的移动构造函数。默认移动构造函数会逐个移动类的每个非静态成员。成员的移动是通过调用其移动构造函数完成的,如果成员没有移动构造函数,则会回退到使用拷贝构造函数

  • 对基本数据类型成员,执行位拷贝。
  • 对类类型成员,调用其移动构造函数。如果成员类没有提供移动构造函数,将使用拷贝构造函数。
  • 对数组类型成员,逐个移动数组中的元素。
  • 对指针成员,移动指针值(地址),并将源对象的指针值设置为nullptr或其他合适的“空”值。

区别

  • 生成条件:如果类包含用户声明的拷贝构造函数、拷贝赋值操作符、移动赋值操作符、析构函数或移动构造函数,编译器不会自动生成默认移动构造函数。类似地,如果类定义了移动操作,编译器不会自动生成默认的拷贝构造函数。
  • 性能:默认移动构造函数通常比默认拷贝构造函数更高效,因为它涉及到资源的转移而非复制。
  • 用途:默认拷贝构造函数用于创建对象的精确副本,而默认移动构造函数用于资源的转移,这在临时对象被销毁或从函数返回对象时特别有用。

理解默认构造函数的生成规则和它们如何影响类的行为是理解C++类设计的重要部分。在许多情况下,提供自定义的拷贝和移动构造函数可以更精确地控制对象的拷贝和移动,特别是在涉及资源管理的情况下。

拷贝构造函数的触发时机

  1. 当用类的一个对象初始化该类的另一个对象时
  2. 如果函数的形参是类的对象,形参和实参结合时
  3. 如果函数的返回值是类的对象,函数执行完成返回调用者时
  4. 需要产生一个临时类对象时

改进写法总结

这个故事告诉我们含有指针的自定义类需要注意深浅拷贝问题 STL容器真神奇

  • 在拷贝/移动构造函数实现深拷贝

  • 或者将CInfo类的指针成员设置为智能指针

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
class CInfo
{
public:
CInfo(std::function<std::shared_ptr<IPtr>()> vFunc)
: m_pBase(vFunc()) { }
~CInfo() = default;

std::shared_ptr<IPtr> m_pBase;
};

//******************************************

class CInfoArray
{
public:
template <typename T>
void registerInfo()
{
m_InfoArray.emplace_back(
[] { return std::make_shared<T>(); });
}

void OnPrint()
{
for (auto& info : m_InfoArray)
{
info.m_pBase->print();
}
}

std::vector<CInfo> m_InfoArray;
};

到这里,和指针相关的异常已经解决了,不过还有一些逻辑上的小细节可以改进

逻辑上我需要将CInfo的成员指针指向IPtr的子类比如CPtr1,CPtr2...,并且我想实现只让CInfo类本身来管理自己指针m_pBase的创建和销毁

这使得我使用模板来进行构造(将CInfo写成模板类就不能装在一个std::vector里了)

1
2
3
4
5
6
template <typename T>
void registerTest(const std::string& vName)
{
m_TestMenu.emplace_back(vName,
[] { return std::make_shared<T>(); });
}

那么改进点1就是如何给不同的IPtr的子类传参,使用参数包,如下:

1
2
3
4
5
6
7
8
template <typename T, typename... Args>
void registerTest(const std::string& vName, Args&&...args)
{
m_TestMenu.emplace_back(vName,
[=] {
return std::make_shared<T>(args...);
});
}

其他

  • 指向vector内部的引用、迭代器、临时指针变量也很容易被忽视,需要警惕内存重新分配

(不重新分配内存) vector<> 的插入操作影响引用、迭代器和指针

1
2
3
4
5
6
7
8
9
std::vector<int> IntVec = { 1, 2, 3, 4, 5, 6, 7, 8 };
IntVec.reserve(100);
int* a = &IntVec[3];
int& b = IntVec[4];
std::vector<int>::iterator c = IntVec.begin() + 5;
*a = 19;
b = 20;
*c = 21;
// 1 2 3 19 20 21 7 8

如果我向开头插入一个数呢

1
2
3
4
5
6
7
8
9
10
std::vector<int> IntVec = { 1, 2, 3, 4, 5, 6, 7, 8 };
IntVec.reserve(100);
int* a = &IntVec[3];
int& b = IntVec[4];
std::vector<int>::iterator c = IntVec.begin() + 5;
IntVec.insert(IntVec.begin(), 114514);
*a = 19;
b = 20;
// *c = 21; // can't dereference invalidated vector iterator
// 114514 1 2 19 20 5 6 7 8

可以看出指针和引用能正常运行,但是指向的已经不是我们想要的元素了;而迭代器那行直接被中断了

看看删除操作

1
2
3
4
5
6
7
8
9
10
11
std::vector<int> IntVec = { 1, 2, 3, 4, 5, 6, 7};
IntVec.reserve(20);
int* a = &IntVec[4];
int& b = IntVec[5];
std::vector<int>::iterator c = IntVec.begin() + 6;
IntVec.erase(IntVec.begin());
IntVec.erase(IntVec.begin());
IntVec.erase(IntVec.begin());
*a = 19;
b = 20;
// *c = 21; // can't dereference invalidated vector iterator

同样,可以看出指针和引用能正常运行,但是指向的已经不是我们想要的元素了;迭代器那行也是直接被中断

如果删除的元素过多,已经使得指针/引用超出了vector的范围会怎样?通过vs2022的内存观察窗口可以看出,vector整体的内存没有被释放,只是从被删除的索引开始,将i+1的元素值覆盖到第i位了;指针/引用悄咪咪地修改了指向内存的内容,但那块内存也只能通过指针/引用访问,似乎没有办法通过vector访问,调用vector<int>::resize(int)会初始化内存qwq

未完待续…


参考资料