知识的学习在于点滴记录,坚持不懈;知识的学习要有深度和广度,不能只流于表面,坐井观天;知识要善于总结,不仅能够理解,更知道如何表达!

实现一个简单的vector容器


C++ STL所有容器的实现都需要依赖一个空间配置器allocator,虽然我们平时使用容器的时候并没有注意,但是我们却一直在使用它,C++ STL库提供了一个默认的空间配置器allocator的实现,比较简单,当然我们需要深入了解空间配置器的原理,然后就可以提供自定义的allocator了。

先看一个简单的vector容器的实现,代码如下:

#include <iostream>
using namespace std;
/*
这篇文章主要讲述空间配置器,所以实现的vector方法比较简单,
方法没有提供相应的带右值引用参数的移动函数,没有考虑过多
的异常情况
*/
template<typename T>
class Vector
{
public:
	// 构造函数
	Vector(int size = 0)
		:mcur(0), msize(size)
	{
		mpvec = new T[msize];
	}
	// 析构函数
	~Vector()
	{
		delete[]mpvec;
		mpvec = nullptr;
	}
	// 拷贝构造函数
	Vector(const Vector<T> &src)
		:mcur(src.mcur), msize(src.msize)
	{
		mpvec = new T[msize];
		for (int i = 0; i < msize; ++i)
		{
			mpvec[i] = src.mpvec[i];
		}
	}
	// 赋值重载函数
	Vector<T>& operator=(const Vector<T> &src)
	{
		if (this == &src)
			return *this;

		delete []mpvec;

		mcur = src.mcur;
		msize = src.msize;
		mpvec = new T[msize];
		for (int i = 0; i < msize; ++i)
		{
			mpvec[i] = src.mpvec[i];
		}
		return *this;
	}
	// 尾部插入数据函数
	void push_back(const T &val)
	{
		if (mcur == msize)
			resize();
		mpvec[mcur++] = val;
	}
	// 尾部删除数据函数
	void pop_back()
	{
		if (mcur == 0)
			return;
		--mcur;
	}
private:
	T *mpvec; // 动态数组,保存容器的元素
	int mcur; // 保存当前有效元素的个数
	int msize; // 保存容器扩容后的总长度

	// 容器2倍扩容函数
	void resize()
	{
	    /*默认构造的vector对象,内存扩容是从0-1-2-4-8-16-32-...的2倍方式
		进行扩容的,因此vector容器的初始内存使用效率特别低,可以使用reserve
		预留空间函数提供容器的使用效率。*/
		if (msize == 0)
		{
			mpvec = new T[1];
			mcur = 0;
			msize = 1;
		}
		else
		{
			T *ptmp = new T[2 * msize];
			for (int i = 0; i < msize; ++i)
			{
				ptmp[i] = mpvec[i];
			}
			delete[]mpvec;
			mpvec = ptmp;
			msize *= 2;
		}
	}
};

上面是一个简单的vector容器的代码实现,如下代码对上面的Vector进行使用:

// 一个简单的测试类A
class A
{
public:
	A() { cout << "A()" << endl; }
	~A() { cout << "~A()" << endl; }
};
int main()
{
	Vector<A> vec(10); // 10表示底层开辟的空间大小,但是却构造了10个A对象
	A a1, a2, a3;
	cout << "---------------" << endl;
	vec.push_back(a1);
	vec.push_back(a2);
	vec.push_back(a3);
	cout << "---------------" << endl;
	vec.pop_back(); // 删除a3没有对对象进行析构
	cout << "---------------" << endl;

	// vec容器析构时,内部只有2个有效的A对象,但是却析构了10次
	return 0;
}

运行上面的代码,打印结果如下
A()
A()
A()
A()
A()
A()
A()
A()
A()
A() // 上面到这个是vector容器中,构造了10个对象
A() // 这里开始下面的三个A构造函数,是构造了a1, a2, a3三个对象
A()
A()
+++++++++++++++
+++++++++++++++
+++++++++++++++ // 这里有问题,vec.pop_back()删除末尾A对象,但是并没有进行析构调用,有可能造成资源泄露
~A()
~A()
~A() // 上面到这里的三个析构函数,析构了a1, a2, a3三个对象
~A()
~A()
~A()
~A()
~A()
~A()
~A()
~A()
~A()
~A() // 上面到这里的10个析构函数,是把vector容器中的对象全部进行析构

容器面临的问题


上面的代码有这样三个问题如下

  1. 定义容器时Vector< A > vec(10),我们希望底层开辟可以容纳10个元素的空间,并不需要给我构造10个A对象,因为此时我还没有打算给容器添加数据,这么多构造函数的调用,纯粹是效率的浪费。
  2. 从容器中删除元素时vec.pop_back(),这句代码的意思是删除了容器末尾的对象A,但是并没有调用A对象的析构函数,如果A对象占用了外部资源,那么资源的释放代码肯定在A的析构函数里面,这样就造成了资源泄露的问题。
  3. vec容器在出函数作用域析构的时候,并没有析构有效的A对象,其实上面代码中,最终vec容器只有两个我们放入的有效对象a1和a2,a3被删除了,应该只析构两次就可以,但是却析构了10次,不合理。

对于这三个问题我们的解决方式是这样的

  1. 当定义一个容器的时候,应该只申请内存,不用构造那么多无效对象,当给容器添加元素的时候,在相应的位置再构造对象就可以了。
  2. 从容器中删除元素的时候,不应该只做- -mcur,还应该调用删除对象的析构函数,释放对象占用的外部资源。
  3. 当容器vec析构的时候,应该把容器中有效的对象进行析构,然后再释放整个容器的内存资源。

基于以上的问题解决方式,我们有这样的一个需求

  1. 把对象的内存开辟和构造函数调用两个过程分开
  2. 把对象析构和内存释放两个过程分开

所以,此时new和delete就不能在容器中直接使用了,因为new不仅可以开辟内存,还自动调用构造函数构造对象;delete要先析构对象,然后紧接着释放内存。那么,上面这个需求任务由谁来完成呢?就是今天要介绍的这个容器的空间配置器allocator

空间配置器介绍


空间配置器的核心功能就是把对象的内存开辟和对象构造的过程分解开,对象析构和内存释放的过程分解开,因此空间配置器主要提供了以下四个函数:

空间配置器的函数功能
allocate负责开辟内存
deallocate负责释放内存
construct负责在容器中构造对象
destroy负责析构对象

下面提供一个类似C++ STL库里面的空间配置器代码的实现

// 自定义空间配置器
template<typename T>
struct myallocator
{
	// 开辟内存空间
	T* allocate(size_t size) 
	{
		return (T*)::operator new(sizeof(T)*size);// 相当于malloc分配内存
	}
	// 释放内存空间
	void deallocate(void *ptr, size_t size)
	{
		::operator delete(ptr, sizeof(T)*size);// 相当于free释放内存
	}
	// 负责对象构造
	void construct(T *ptr, const T &val)
	{
		new ((void*)ptr) T(val);// 用定位new在指定内存上构造对象
	}
	// 负责对象析构
	void destroy(T *ptr)
	{
		ptr->~T();// 显示调用对象的析构函数
	}
};

上面实现的空间配置器比较简单,内存管理依然用的是operator new和operator delete,其实就是malloc和free的内存管理,当然我们还可以给allocator附加一个内存池的实现,相当于是自定义内存管理方式。

可以看看C++ STL库中vector容器的类模板定义头,代码如下:

template<class _Ty,
	class _Alloc = allocator<_Ty>>
	class vector

从上面的vector容器类模板定义处可以看到,它有两个模板类型参数,_Ty是容器存放的数据的类型,_Alloc就是空间配置器的类型,如果用户没有自定义,会使用库里面默认的allocator,类似于我们上面提供的空间配置器代码的实现。

实现带空间配置器的vector容器


把我们最开始提供的vector代码,添加上我们自己实现的空间配置器allocator,修改代码如下:

#include <iostream>
using namespace std;

// 自定义空间配置器
template<typename T>
struct myallocator
{
	// 开辟内存空间
	T* allocate(size_t size) 
	{
		return (T*)::operator new(sizeof(T)*size);// 相当于malloc分配内存
	}
	// 释放内存空间
	void deallocate(void *ptr, size_t size)
	{
		::operator delete(ptr, sizeof(T)*size);// 相当于free释放内存
	}
	// 负责对象构造
	void construct(T *ptr, const T &val)
	{
		new ((void*)ptr) T(val);// 用定位new在指定内存上构造对象
	}
	// 负责对象析构
	void destroy(T *ptr)
	{
		ptr->~T();// 显示调用对象的析构函数
	}
};

/*
给Vector容器的实现添加空间配置器allocator
*/
template<typename T, typename allocator = myallocator<T>>
class Vector
{
public:
	// 构造函数,可以传入自定以的空间配置器,否则用默认的allocator
	Vector(int size = 0, const allocator &alloc = allocator())
		:mcur(0), msize(size), mallocator(alloc)
	{
		// 只开辟容器底层空间,不构造任何对象
		mpvec = mallocator.allocate(msize);
	}
	// 析构函数
	~Vector()
	{
		// 先析构容器中的对象
		for (int i = 0; i < mcur; ++i)
		{
			mallocator.destroy(mpvec+i);
		}
		// 释放容器占用的堆内存
		mallocator.deallocate(mpvec, msize);
		mpvec = nullptr;
	}
	// 拷贝构造函数
	Vector(const Vector<T> &src)
		:mcur(src.mcur)
		, msize(src.msize)
		, mallocator(src.mallocator)
	{
		// 只开辟容器底层空间,不构造任何对象
		mpvec = mallocator.allocate(msize);
		for (int i = 0; i < mcur; ++i)
		{
			// 在指定的地址mpvec+i上构造一个值为src.mpvec[i]的对象
			mallocator.construct(mpvec+i, src.mpvec[i]);
		}
	}
	// 赋值重载函数
	Vector<T> operator=(const Vector<T> &src)
	{
		if (this == &src)
			return *this;

		// 先析构容器中的对象
		for (int i = 0; i < mcur; ++i)
		{
			mallocator.destroy(mpvec + i);
		}
		// 释放容器占用的堆内存
		mallocator.deallocate(mpvec, msize);

		mcur = src.mcur;
		msize = src.msize;
		// 只开辟容器底层空间,不构造任何对象
		mpvec = mallocator.allocate(msize);
		for (int i = 0; i < mcur; ++i)
		{
			// 在指定的地址mpvec+i上构造一个值为src.mpvec[i]的对象
			mallocator.construct(mpvec + i, src.mpvec[i]);
		}
		return *this;
	}
	// 尾部插入数据函数
	void push_back(const T &val)
	{
		if (mcur == msize)
			resize();
		mallocator.construct(mpvec + mcur, val);
		mcur++;
	}
	// 尾部删除数据函数
	void pop_back()
	{
		if (mcur == 0)
			return;
		--mcur;
		// 析构被删除的对象
		mallocator.destroy(mpvec + mcur);
	}
private:
	T *mpvec; // 动态数组,保存容器的元素
	int mcur; // 保存当前有效元素的个数
	int msize; // 保存容器扩容后的总长度
	allocator mallocator; // 定义容器的空间配置器对象

	// 容器2倍扩容函数
	void resize()
	{
		if (msize == 0)
		{
			mpvec = mallocator.allocate(sizeof(T));
			mcur = 0;
			msize = 1;
		}
		else
		{
			T *ptmp = mallocator.allocate(2 * msize);
			for (int i = 0; i < msize; ++i)
			{
				mallocator.construct(ptmp + i, mpvec[i]);
			}
			// 先析构容器中的对象
			for (int i = 0; i < msize; ++i)
			{
				mallocator.destroy(mpvec + i);
			}
			// 释放容器占用的堆内存
			mallocator.deallocate(mpvec, msize);
			mpvec = ptmp;
			msize *= 2;
		}
	}
};
// 一个简单的测试类A
class A
{
public:
	A() { cout << "A()" << endl; }
	~A() { cout << "~A()" << endl; }
};
int main()
{
	Vector<A> vec(10); // 此处只开辟内存,没有构造任何对象
	A a1, a2, a3;
	cout << "+++++++++++++++" << endl;
	vec.push_back(a1);
	vec.push_back(a2);
	vec.push_back(a3);
	cout << "+++++++++++++++" << endl;
	vec.pop_back(); // 删除a3并析构a3对象
	cout << "+++++++++++++++" << endl;

	// vec容器析构时,内部只有2个有效的A对象,析构了2次,正确
	return 0;
}

代码运行打印如下
A()
A()
A() // 上面三个是A a1, a2, a3;三个栈上对象的打印
+++++++++++++++
+++++++++++++++
~A() // 此处是vec.pop_back()析构了a3对象
+++++++++++++++
~A()
~A()
~A() // 上面三个是A a1, a2, a3;三个对象的析构调用
~A()
~A() // 上面两个是vec容器中两个A对象的析构

通过打印可以看到,最开始实现的容器,我们提到的这三个问题

  1. 定义容器时Vector< A > vec(10),我们希望底层开辟可以容纳10个元素的空间,并不需要给我构造10个A对象,因为此时我还没有打算给容器添加数据,这么多构造函数的调用,纯粹是效率的浪费。
  2. 从容器中删除元素时vec.pop_back(),这句代码的意思是删除了容器末尾的对象A,但是并没有调用A对象的析构函数,如果A对象占用了外部资源,那么资源的释放代码肯定在A的析构函数里面,这样就造成了资源泄露的问题。
  3. vec容器在出函数作用域析构的时候,并没有析构有效的A对象,其实上面代码中,最终vec容器只有两个我们放入的有效对象a1和a2,a3被删除了,应该只析构两次就可以,但是却析构了10次,不合理。

现在都通过空间配置器allocator解决了,仔细对比最开始的Vector和修改后带空间配置器版本的Vector的代码实现,体会allocator在容器中的具体使用

【扩展】SGI STL是标准STL的另一个实现版本,使用比较广泛,它内置了一级和二级空间配置器的实现,其中二级空间配置器携带了一个内存池的实现,可以参考我的相关博客。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐