【C++基础】第七十三课:[拷贝控制]动态内存管理类

动态内存管理类

Posted by x-jeff on May 30, 2023

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.动态内存管理类

某些类需要在运行时分配可变大小的内存空间。这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保存它们的数据。

但是,这一策略并不是对每个类都适用:某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。

例如,我们将实现标准库vector类的一个简化版本。我们所做的一个简化是不使用模板,我们的类只用于string。因此,它被命名为StrVec。

1.1.StrVec类的设计

回忆一下,vector类将其元素保存在连续内存中,为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素(参见:【C++基础】第五十三课:[顺序容器]vector对象是如何增长的)。vector的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有可用空间,vector就会重新分配空间;它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。

我们在StrVec类中使用类似的策略。我们将使用一个allocator来获得原始内存。由于allocator分配的内存是未构造的,我们将在需要添加新元素时用allocatorconstruct成员在原始内存中创建对象。类似的,当我们需要删除一个元素时,我们将使用destroy成员来销毁元素。

每个StrVec有三个指针成员指向其元素所使用的内存:

  • elements,指向分配的内存中的首元素
  • first_free,指向最后一个实际元素之后的位置
  • cap,指向分配的内存末尾之后的位置

图13.2说明了这些指针的含义。

除了这些指针之外,StrVec还有一个名为alloc的静态成员,其类型为allocator<string>。alloc成员会分配StrVec使用的内存。我们的类还有4个工具函数:

  • alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。
  • free会销毁构造的元素并释放内存。
  • chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。
  • reallocate在内存用完时为StrVec分配新内存。

虽然我们关注的是类的实现,但我们也将定义vector接口中的一些成员。

1.2.StrVec类定义

有了上述实现概要,我们现在可以定义StrVec类,如下所示:

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
//类vector类内存分配策略的简化实现
class StrVec {
public:
	StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) { } //allocator成员进行默认初始化
	StrVec(const StrVec&); //拷贝构造函数
	StrVec &operator=(const StrVec&); //拷贝赋值运算符
	~StrVec(); //析构函数
	void push_back(const std::string&); //拷贝元素
	size_t size() const { return first_free - elements; }
	size_t capacity() const { return cap - elements; }
	std::string *begin() const { return elements; }
	std::string *end() const { return first_free; }
	// ...
private:
	static std::allocator<std::string> alloc; //分配元素
	//被添加元素的函数所使用
	void chk_n_alloc()
		{ if (size() == capacity()) reallocate(); }
	//工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
	std::pair<std::string*, std::string*> alloc_n_copy (const std::string*, const std::string*);
	void free(); //销毁元素并释放内存
	void reallocate(); //获得更多内存并拷贝已有元素
	std::string *elements; //指向数组首元素的指针
	std::string *first_free; //指向数组第一个空闲元素的指针
	std::string *cap; //指向数组尾后位置的指针
};

1.3.使用construct

函数push_back调用chk_n_alloc确保有空间容纳新元素。如果需要,chk_n_alloc会调用reallocate。当chk_n_alloc返回时,push_back知道必有空间容纳新元素。它要求其allocator成员来construct新的尾元素:

1
2
3
4
5
6
void StrVec::push_back(const string& s)
{
	chk_n_alloc(); //确保有空间容纳新元素
	//在first_free指向的元素中构造s的副本
	alloc.construct(first_free++, s);
}

1.4.alloc_n_copy成员

alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后的位置:

1
2
3
4
5
6
7
pair<string*, string*> StrVec::alloc_n_copy(const string *b, const string *e)
{
	//分配空间保存给定范围中的元素
	auto data = alloc.allocate(e-b);
	//初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
	return {data, uninitialized_copy(b, e, data)};
}

返回的pair的first成员指向分配的内存的开始位置;second成员则是uninitialized_copy的返回值,此值是一个指针,指向最后一个构造元素之后的位置。

1.5.free成员

free成员有两个责任:首先destroy元素,然后释放StrVec自己分配的内存空间。for循环调用allocator的destroy成员,从构造的尾元素开始,到首元素为止,逆序销毁所有元素:

1
2
3
4
5
6
7
8
9
10
void StrVec::free()
{
	//不能传递给deallocate一个空指针,如果elements为0,函数什么也不做
	if (elements) {
		//逆序销毁旧元素
		for(auto p = first_free; p != elements; /* 空 */)
			alloc.destroy(--p);
		alloc.deallocate(elements, cap - elements);
	}
}

destroy函数会运行string的析构函数。string的析构函数会释放string自己分配的内存空间。

一旦元素被销毁,我们就调用deallocate来释放本StrVec对象分配的内存空间。我们传递给deallocate的指针必须是之前某次allocate调用所返回的指针。因此,在调用deallocate之前我们首先检查elements是否为空。

1.6.拷贝控制成员

实现了alloc_n_copy和free成员后,为我们的类实现拷贝控制成员就很简单了。拷贝构造函数调用alloc_n_copy:

1
2
3
4
5
6
7
StrVec::StrVec(const StrVec &s)
{
	//调用alloc_n_copy分配空间以容纳与s中一样多的元素
	auto newdata = alloc_n_copy(s.begin(), s.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
}

析构函数调用free:

1
StrVec::~StrVec() { free(); }

拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值了:

1
2
3
4
5
6
7
8
9
StrVec &StrVec::operator=(const StrVec &rhs)
{
	//调用alloc_n_copy分配内存,大小与rhs中元素占用空间一样多
	auto data = alloc_n_copy(rhs.begin(), rhs.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

1.7.在重新分配内存的过程中移动而不是拷贝元素

在编写reallocate成员函数之前,我们稍微思考一下此函数应该做什么。它应该:

  • 为一个新的、更大的string数组分配内存
  • 在内存空间的前一部分构造函数,保存现有元素
  • 销毁原内存空间中的元素,并释放这块内存

观察这个操作步骤,我们可以看出,为一个StrVec重新分配内存空间会引起从旧内存空间到新内存空间逐个拷贝string。虽然我们不知道string的实现细节,但我们知道string具有类值行为。当拷贝一个string时,新string和原string是相互独立的。改变原string不会影响到副本,反之亦然。

由于string的行为类似值,我们可以得出结论,每个string对构成它的所有字符都会保存自己的一份副本。拷贝一个string必须为这些字符分配内存空间,而销毁一个string必须释放所占用的内存。

拷贝一个string就必须真的拷贝数据,因为通常情况下,在我们拷贝了一个string之后,它就会有两个用户。但是,如果是reallocate拷贝StrVec中的string,则在拷贝之后,每个string只有唯一的用户。一旦将元素从旧空间拷贝到了新空间,我们就会立即销毁原string。

因此,拷贝这些string中的数据是多余的。在重新分配内存空间时,如果我们能避免分配和释放string的额外开销,StrVec的性能会好得多。

1.8.移动构造函数和std::move

通过使用新标准库引入的两种机制,我们就可以避免string的拷贝。首先,有一些标准库类,包括string,都定义了所谓的“移动构造函数”。关于string的移动构造函数如何工作的细节,以及有关实现的任何其他细节,目前都尚未公开。但是,我们知道,移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。而且我们知道标准库保证“移后源”(moved-from)string仍然保持一个有效的、可析构的状态。对于string,我们可以想象每个string都有一个指向char数组的指针。可以假定string的移动构造函数进行了指针的拷贝,而不是为字符分配内存空间然后拷贝字符。

我们使用的第二个机制是一个名为move的标准库函数,它定义在utility头文件中。目前,关于move我们需要了解两个关键点。首先,当reallocate在新内存中构造string时,它必须调用move来表示希望使用string的移动构造函数。如果它漏掉了move调用,将会使用string的拷贝构造函数。其次,我们通常不为move提供一个using声明。当我们使用move时,直接调用std::move而不是move。

1.9.reallocate成员

现在就可以编写reallocate成员了。首先调用allocate分配新内存空间。我们每次重新分配内存时都会将StrVec的容量加倍。如果StrVec为空,我们将分配容纳一个元素的空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void StrVec::reallocate()
{
	//我们将分配当前大小两倍的内存空间
	auto newcapacity = size() ? 2 * size() : 1;
	//分配新内存
	auto newdata = alloc.allocate(newcapacity);
	//将数据从旧内存移动到新内存
	auto dest = newdata; //指向新数组中下一个空闲位置
	auto elem = elements; //指向旧数组中下一个元素
	for (size_t i = 0; i != size(); ++i)
		alloc.construct(dest++, std::move(*elem++));
	free(); //一旦我们移动完元素就释放旧内存空间
	//更新我们的数据结构,执行新元素
	elements = newdata;
	first_free = dest;
	cap = elements + newcapacity;
}