文章目录
书接上回:C++ STL标准库解析|简单实现STL容器vector代码和容器空间配置器
之前在文末,我们三大灵魂拷问,空间配置器是什么?为什么要空间配置器?没有会发生什么呢?
本篇文章我们就从这三个问题入手,然后实现空间配置器。
本节导读:
- 务必请配合上一篇文章:C++ STL标准库解析|简单实现STL容器vector代码和容器空间配置器一起食用。
- 一定要掌握没有空间配置器会发生什么?和写自己的空间配器这两个章节。
- 最后请回答三大灵魂拷问:空间配置器是什么?为什么要空间配置器?没有会发生什么呢?
没有空间配置器会发生什么?
擅自调用构造函数
我们来测试这样一个事件,定义一个类,然后我们声明一下存储Test类的vector容器,然后输出看打印:
我们竟然构造了10个Test对象,并且在最后进行了析构,这显然不是我们期望的。
正常的逻辑应该是,它应该是开辟10个存放Test对象的容器才对,他不应该擅作主张得对类进行构造函数调用。
这是为什么呢?
我们来看vector容器对构造函数:
vector(int size = 10) {
_first = new T[size];
_last = _first;
_end = _first + size;
}
我们使用了new!众所周知,new不仅仅为我们开辟内存空间,他还会为我们调用对应类类型的构造函数!所以这显然是不符合期望的。
我们可以想到一个很简单的处理思路:
在构造函数中需要把内存开辟和随想构造分开处理
擅自调用析构函数
我们看到,我们把Test类对象析构了10次,但是我们的场景应该是这样的:
我们的数组开辟的空间可能很大,但是我们应该仅仅析构有效元素,而不是开辟多大空间就析构多少个,那也太离谱了。
这又是为什么呢?
~vector() {
delete[] _first;
_first = _last = _end =nullptr
}
原来是因为我们直接用了delete!delete会自动去调用析构函数,所以我们开辟了多大的内存,这个vector就回去析构多少类对象。这是绝对不被允许的!
在析构函数中应该析构容器有效的元素,然后释放_first指向的堆内存
push_back和pop_back逻辑错误
我们进行一下测试:
int main () {
Test t1, t2, t3;
std::cout << "-------------------------" << std::endl;
vector<Test> vec;
vec.push_back(t1); //先把这些对象推进去
vec.push_back(t2);
vec.push_back(t3);
std::cout << "-------------------------" << std::endl;
vec.pop_back();
std::cout << "-------------------------" << std::endl;
return 0;
}
我们期望以上代码发生什么样的事情呢?
我们使用vec.push_back(t1);
我们希望在容器第一个位置上构造一个新的具体的对象t1;
t2, t3以此类推。
我们使用vec.pop_back();
我们希望析构容器的最后一个有效元素。
vec.push_back(t1)
但是代码明显不符合我们的预期,因为我们在vector<Test> vec
的时候已经在容器的每一个位置上都放了一个未知的Test()
对象,而我们现在vec.push_back(t1);
操作,相当于在底层已有的Test()
进行一个赋值操作,不符合预期!我们容器生成的时候底层应该只有内存而没有对象。
vec.pop_back()
容器底层的类对象已经管理一个外部资源了,而我们现在的pop_back只是把last指针做了一个–。下一次我们进行push_back操作会直接覆盖原来的类对象,那原类对象管理的资源可不就成野指针了?因为他没有做正常的析构啊!
为了验证以上猜想,我们看一下代码执行结果:
很明显,我们可以看到,当我们调用pop_back()真正想要删除一个类对象的时候,却没有调用析构函数。所以说pop_back必须实现析构对象的操作,而且它还不能去释放那个内存。
总结:
其实最核心的问题就是,我们在容器空间的开辟和释放使用了new和delete。但是我们的需求又必须要求内存开辟和对象构造要分开;析构时只析构容器的有效元素,然后释放堆内存,pop_back必须析构对象,还不能释放内存。
如何解决?
答案就是写容器的空间配置器allocator
写自己的空间配置器
容器的空间配置器主要做四件事情:
- 内存开辟
- 内存释放
- 对象构造
- 对象析构
template<typename T>
class Allocator {
}
实现内存开辟
只搞个内存开辟,很明显就是用 malloc
T* allocator(size_t size) {//只负责内存开辟
return (T*)malloc(sizeof(T) * size);
}
实现内存释放
void deallocate(void *p) {
free(p);
}
只负责内存释放
实现对象构造
void construct(T *p, const T &val) {
new (p) T(val);
}
在指定的地址里面进行对象的构造。
实现对象析构
void destroy(T *p) {
p->~T();
}
一定只进行对象的析构!~T()代表了T类型的析构函数
总结:
请其底层内存开辟、内存释放、对象构造和析构都应该通过空间配置器来实现。
改造我们的vector容器
首先我要说明的是,我们的这个vector类模版的定义还是有点讲究的。
template <typename T, typename Alloc = Allocator<T>>
class vector {
}
这里的typename Alloc = Allocator<T>
是一个模版参数的默认值设置,他的意思是当你在实例化模板时不显式地提供一个类型为 Alloc 的参数时,编译器会使用 Allocator<T>作为默认值。
- typename Alloc:这里声明了一个模板参数,名为 Alloc,它表示一个类型。typename 关键字在这里是必需的,因为 Alloc 是一个类型名。如果没有 typename,编译器会将 Alloc 解释为一个非类型参数。
- Allocator<T>:这是一个类型表达式,表示一个分配器类型,用于为模板中的对象动态分配内存。在模板参数中,Alloc 被初始化为 Allocator<T>,意味着如果你不提供分配器类型作为模板参数,编译器会默认使用 Allocator<T> 作为分配器。
然后我们需要在成员属性里面添加空间配置器:
class vector {
private:
Alloc _allocator; //定义容器的空间配置器对象
}
重写构造函数
如果构造函数定义成:
vector(int size = 10,
const Alloc &alloc = Allocator<T>)
:_allocator(alloc)
这样就允许用户传递自己定义的空间配置器,为了简单起见,我们这里就不写了。
首先需要明确的就是我们现在不能使用new来开辟空间了,而是将内存开辟和对象构造分开(借助刚才写的空间配置器):
vector(int size = 10) {
//_first = new T[size];
//这里只做内存的开辟,底层是malloc
_first = _allocator.allocate(size);
_last = _first;
_end = _first + size;
}
重写析构函数
析构函数包括两部分,首先析构有效元素,然后释放堆上的内存。(之前直接用的delete,直接把堆上的所有内存都当成类对象来析构)
~vector() {
//delete[] _first;
for(T *p = _first; p != _last; ++p) {
_allocator.destroy(p); //把_first指针指向的数组的有效元素进行析构操作
}
_allocator.deallocate(_first); //释放堆上的内存
_first = _last = _end =nullptr;
}
重写拷贝构造
之前我们的拷贝构造就是很直接的 _first = new T[size];然后在for循环里面将当前指针指向源资源的指针,来进行有效元素的拷贝。
现在我们需要首先开辟内存,然后在有效空间范围内做类对象的构造:
vector(const vector<T> &rhs) {
int size = rhs._end - rhs._first;
//_first = new T[size];
_first = _allocator.allocate(size);
int len = rhs._last - rhs._first;
for (int i = 0; i < len; ++i) {
//_first[i] = rhs._first[i];
_allocator.construct(_first + i, rhs._first[i]);
}
_last = _first + len;
_end = _first + size;
}
重写赋值运算符
跟拷贝构造同理,前面再加上一个析构有效元素数组的操作(之前我们直接delete掉被赋值的原内存块)。
vector<T>& operator=(const vector<T> &rhs) {
if (this == rhs) return *this;
//delete[]_first;
for(T *p = _first; p != _last; ++p) {
_allocator.destroy(p); //把_first指针指向的数组的有效元素进行析构操作
}
_allocator.deallocate(_first); //释放堆上的内存
int size = rhs._end - rhs._first;
//_first = new T[size];
_first = _allocator.allocate(size);
int len = rhs._last - rhs._first;
for (int i = 0; i < len; ++i) {
//_first[i] = rhs._first[i];
_allocator.construct(_first + i, rhs._first[i]);
}
_last = _first + len;
_end = _first + size;
return *this;
}
重写push_back
之前使用的 *_last++ = val;
现在我们应该是在有效元素最后一个位置进行一个类对象的构造:
void push_back(const T &val) {
if (full()) expand();
//*_last++ = val; _last指针指向的内存构造一个值为val的对象
_allocator.construct(_last, val);
_last++;
}
重写pop_back
之前我们单纯的做了一个 --_last ,这是绝对不允许的,当我们pop_back,类对象应该被析构才符合预期:
void pop_back() {
if (empty()) return ;
//--_last; 不仅要把_last--,还需要析构删除的元素
--_last;
_allocator.destroy(_last);
}
重写内部扩容expand函数
这里我们要先对进行内存的开辟,然后对有效元素进行构造,再然后析构原来位置的类对象,最后释放原内存空间:
void expand() { //容器的二倍扩容操作
int size = _end - _first;
//T *ptemp = new T[2 * size];
T *ptemp = _allocator.allocate(2 * size);
for (int i = 0; i < size; ++i) {
//ptemp[i] = _first[i];
_allocator.construct(ptemp + i, _first[i]);
}
//delete[] _first;
for (T *p = _first; p != _last; ++p) {
_allocator.destroy(p);
}
_allocator.deallocate(_first);
_first = ptemp;
_last = _first + size;
_end = _first + 2 * size;
}
实现效果
还记得之前发生了什么吗?
现在我们重新做一次测试,测试代码跟前面的一样:
前三个构造就是 t1 t2 t3 的构造函数
然后我们定义vector<Test> vec;
我们只开辟空间,不进行类对象的构造。
把对象push_back进vector,调用的是空间配置器的construct
方法,在指定内存上构造函数,这里调用的是拷贝构造。
最后调用pop_back是删除末尾元素,结果上也能看出我们析构了Test类对象,这里析构的是vector容器中的t3(该对象在堆上)。
最后程序结束,开始析构类对象,依次是vector中的t2, t1(他们两个在堆上)和之前我们初始化的 t3, t2, t1(他们都在栈上)。一共需要调用析构函数5次,符合预期。