专栏简介:本专栏主要面向C++初学者,解释C++的一些基本概念和基础语言特性,涉及C++标准库的用法,面向对象特性,泛型特性高级用法。通过使用标准库中定义的抽象设施,使你更加适应高级程序设计技术。希望对读者有帮助!
目录
- 13.5动态内存管理类
- StrVec类的设计
- StrVec类定义
- 使用construct
- free成员
- 拷贝控制成员
- 在重新分配内存的过程中移动而不是拷贝元素
- 移动构造函数和std::move
- reallocate成员
13.5动态内存管理类
某些类需要在运行时分配可变大小的内存空间。这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保存它们的数据。例如,我们的StrBlob类使用一个vector来管理其元素的底层内存。
但是,这一策略并不是对每个类都适用;某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
例如,我们将实现标准库vector类的一个简化版本。我们所做的一个简化是不使用
模板,我们的类只用于string。因此,它被命名为StrVec。
StrVec类的设计
回忆一下,vector类将其元素保存在连续内存中。为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素。vector的每个添加元素的成员函数会检查是否有宇间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有可用空间,vector就会重新分配空间:它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。
我们在StrVvec类中使用类似的策略。我们将使用一个allocator来获得原始内存。由于allocator分配的内存是未构造的,我们将在需要添加新元素时用allocator的construct成员在原始内存中创建对象。类似的,当我们需要删除一个元素时,我们将使用destroy成员来销毁元素。
每个StrVec有三个指针成员指向其元素所使用的内存:
- elements,指向分配的内存中的首元素
- first_free,指向最后一个实际元素之后的位置
- cap,指向分配的内存末尾之后的位置
除了这些指针之外,StrVec还有一个名为alloc的静态成员,其类型为allocator。alloc成员会分配StrVec使用的内存。我们的类还有4个工具函数:
alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。free会销毁构造的元素并释放内存。
*chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素, reallocate在内存用完时为StrVec分配新内存。
虽然我们关注的是类的实现,但我们也将定义vector接口中的一些成员。
StrVec类定义
有了上述实现概要,我们现在可以定义StrVec类,如下所示:
//类vector类内存分配策略的简化实现
class StrVec {
public:
StrVec()://allocator成员进行默认初始化
elements(nullptr),first_free(nullptr),cap(nullptzr){}
StrVec(constStrVecg)}//拷贝构造函数
StrVec&operator=(const StrVec&)}//拷贝赋值运算符
~StrVec();//析构函数
void push_back(const std::string&);//拷贝元素
size_t size()const{return first_free - elements;}
size_t size_capactty()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(conststd::string*,conststd::string*);
void free();// 销毁元素并释放内存
void reallocate();//获得更多内存并拷贝已有元素
std::string*elements;//指向数组首元素的指针
std::string*first_free;//指向数组第一个空闲元素的指针
std::string*cap;//指向数组尾后位置的指针
}
类体定义了多个成员:
- 默认构造函数(隐式地)默认初始化alloc并(显式地)将指针初始化为nullptr,表明没有元素。
- size成员返回当前真正在使用的元素的数目,等于ftrst_free-elements。
- capacity成员返回StrVec可以保存的元素的数量,等价于cap-elements。
- 当没有空间容纳新元素,即cap==first_free时,chk_n_alloc会为StrVec重新分配内存。
- begin和end成员分别返回指向首元素(即elements)和最后一个构造的元素
之后位置(即first_free)的指针。
使用construct
函数push_back调用chk_n_alloc确保有空间容纳新元素。如果需要,chk_n_alloc会调用reallocate。当chk_n_alloc返回时,push_back知道必有空间容纳新元素。它要求其allocator成员来construct新的尾元素:
void StrVec::push_back(const string&s)
{
chk_n_alloc();//确保有空间客纳新元素
// 在first_free指向的元素中构造s的副本
alloc.construct(first_free++,s)
}
当我们用allocator分配内存时,必须记住内存是未构造的。为了使用此原始内存,我们必须调用constzuct,在此内存中构造一个对象。传递给construct的第一个参数必须是一个指针,指向调用allocate所分配的未构造的内存空间。剩余参数确定用哪个构造函数来构造对象。在本例中,只有一个额外参数,类型为string,因此会使用string的拷贝构造函数。
值得注意的是,对construct的调用也会递增first_freey表示已经构造了一个新元素。它使用前置递增,因此这个调用会在first_free当前值指定的地址构造一个对象,并递增first_free指向下一个未构造的元素。
alloc_n_copy成员
我们在拷贝或赋值StrVec时,可能会调用alloc_n_copy成员。类似vector,我们的StrVec类有类值的行为。当我们拷贝或赋值StrVec时,必须分配独立的内存,并从原StrVec对象拷贝元素至新对象。
alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后的位置:
pair<string*,string*>
StrVec::alloc_n_copy(conststring*b,conststring*e)
{
//分配空间保存给定范围中的元素
auto data=alloc.allocate(a-b);
//初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
return〔data,uninitialized_copy(b,e,data)};
}
alloc_n_copy用尾后指针减去首元素指针,来计算需要多少空间。在分配内存之后,它必须在此空间中构造给定元素的副本。
它是在返回语句中完成拷贝工作的,返回语句中对返回值进行了列表初始化。返回的pair的first成员指向分配的内存的开始位置;second成员则是uninitialized_copy的返回值,此值是一个指针,指向最后一个构造元素之后的位置。
free成员
free成员有两个责任:首先destroy元素,然后释放StrVec自己分配的内存空间。for循环调用allocator的destroy成员,从构造的尾元素开始,到首元素为止,逆序销毁所有元素:
void StrVec::free()
{
//不能传递给deallocate一个空指针,如果elements为0,函数什么也不做
if(elements){
//递序销毁旧元素
for(autoP=first_free;p!=elements;/*空*/)
alloc.destroy(--p);
alloc.deallocate(elements,cap-elements);
}
}
destroy函数会运行string的析构函数。string的析构函数会释放string自己分配的内存空间。
一旦元素被销毁,我们就调用deallocate来释放本StrVec对象分配的内存空间。我们传递给deallocate的指针必须是之前某次allocate调用所返回的指针。因此,在调用deallocate之前我们首先检查elements是否为空。
拷贝控制成员
实现了alloc_n_copy和free成员后,为我们的类实现拷贝控制成员就很简单了。
拷贝构造函数调用allocn_copy:
StrVec::StrVec(const_StrVec&s)
{
//调用alloc_n_copy分配空间以客纳与s中一样多的元素
autonewdata=alloc_n_copy(s.begin(),s-.end());
elements=newdata.first;
first_free=cap=newdata.second;
}
并将返回结果赋予数据成员。alloc_n_copy的返回值是一个指针的pairz。其first成员指向第一个构造的元素,second成员指向最后一个构造的元素之后的位置。由于alloc_n_copy分配的家间恰好容纳给定的元素,cap也指向最后一个构造的元素之后的位置。
析构函数调用free:
StrVec::~StrVec(){free();}
拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值了
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;
}
类似拷贝构造函数,拷贝赋值运算符使用alloc_n_copy的返回值来初始化它的指针。
在重新分配内存的过程中移动而不是拷贝元素
在编写reallocate成员函数之前,我们稍微思考一下此函数应该做什么。它应该
- 为一个新的、更大的string数组分配内存
- 在内存空间的前一部分构造对象,保存现有元素
- 销毁原内存空间中的元素,并释放这块内存
观察这个操作步骤,我们可以看出,为一个StrVec重新分配内存空间会引起从旧内存空间到新内存空间逐个拷贝string。虽然我们不知道string的实现细节,但我们知道string具有类值行为。当拷贝一个string时,新string和原string是相互独立的。改变原string不会影响到副本,反之亦然。
由于string的行为类似值,我们可以得出结论,每个string对构成它的所有字符都会保存自己的一份副本。拷贝一个string必须为这些字符分配内存空间,而销毁一个string必须释放所占用的内存。
拷贝一个string就必须真的拷贝数据,因为通常情况下,在我们拷贝了一个string之后,它就会有两个用户。但是,如果是reallocate拷贝StrVec中的string,则在拷贝之后,每个string只有唯一的用户。一旦将元素从旧空间拷贝到了新空间,我们就会立即销毁原string。
因此,拷贝这些string中的数据是多余的。在重新分配内存空间时,如果我们能邀免分配和释放string的额外开销,StrVec的性能会好得多。
移动构造函数和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。
reallocate成员
了解了这些知识,现在就可以编写reallocate成员了。首先调用allocate分配新内存空间。我们每次重新分配内存时都会将StrVec的容量加借。如果StrVec为空,我们将分配容纳一个元素的空间:
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(*xelem++));
}
free();//一旦我们移动完元素就释放旧内存空间
//更新我们的数据结构,执行新元素
elements=newdata;
first_free= dest;
cap = elements + newcapacity;
}
for循环遍历每个已有元素,并在新内存空间中construct一个对应元素。我们使用dest指向构造新string的内存,使用elem指向原数组中的元素。我们每次用后置递增运算将dest(和elem)推进到各自数组中的下一个元素。
construct的第二个参数是move返回的值。调用move返回的结果会令construct使用string的移动构造函数。由于我们使用了移动构造函数,这些string管理的内存将不会被拷贝。相反,我们构造的每个string都会从elem指向的string那里接管内存的所有权。
在元素移动完毕后,我们调用free销毁旧元素并释放StrVec原来使用的内存。string成员不再管理它们曾经指向的内存;其数据的管理职责已经转移给新StrVec内存中的元素了。我们不知道旧strVec内存中的string包含什么值,但我们保证对它们执行string的析构函数是安全的。
剩下的就是更新指针,指向新分配并已初始化过的数组了。first_free和cap指针分别被设置为指向最后一个构造的元素之后的位置及指向新分配空间的尾后位置。