【C++】vector的模拟实现详解(二)

【C++】vector的模拟实现详解(二)

前言

在上一篇文章中,我们已经认识了 C++ 标准库中的 vector,并学习了构造、遍历、扩容、插入和删除等常用接口。

只会调用这些接口还不够。如果想进一步理解 vector 为什么能够自动扩容、sizecapacity 是怎样维护的、插入数据时为什么要从后向前移动,以及扩容后原来的迭代器为什么会失效,就可以自己模拟实现一个简化版本。

本文将在 qen 命名空间中实现一个 vector 类,主要包括:

  1. 使用三个指针管理连续空间;
  2. 普通迭代器、const 迭代器和下标访问;
  3. sizecapacityemptyreserveresize
  4. push_backpop_backinserterase
  5. 析构、拷贝构造、swap 和赋值运算符;
  6. 扩容与删除引起的迭代器失效;
  7. 为什么保存类类型时不能直接使用 memcpy 搬移元素。

这个版本的目标是理解 vector 的核心结构,并不是重新实现完整的标准库。为了让空间变化更加直观,本文采用 new[]delete[] 和原生指针完成模拟,暂时不涉及空间配置器、定位 new、移动语义和完整的异常安全。


一、vector 的整体设计

1. 类模板与命名空间

vector 需要保存不同类型的数据,因此使用类模板实现。为了与标准库中的 std::vector 区分,把自己实现的版本放在 qen 命名空间中:

cpp 复制代码
namespace qen
{
	template<class T>
	class vector
	{
	public:
		typedef T* iterator;
		typedef const T* const_iterator;

		// ...

	private:
		iterator _start = nullptr;
		iterator _finish = nullptr;
		iterator _end_of_storage = nullptr;
	};
}

模板参数 T 表示元素类型。例如:

cpp 复制代码
qen::vector<int> v1;
qen::vector<double> v2;

同一份类模板可以根据 T 的不同,生成保存不同元素类型的 vector

2. 三个核心指针

整个实现中最重要的是下面三个指针:

cpp 复制代码
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _end_of_storage = nullptr;

它们各自负责的内容如下:

成员变量 作用
_start 指向连续空间的起始位置
_finish 指向最后一个有效元素的下一个位置
_end_of_storage 指向整块已申请空间的下一个位置

当对象已经拥有一块有效空间时,三个指针需要满足:

text 复制代码
_start <= _finish <= _end_of_storage

有效元素保存在左闭右开的区间:

text 复制代码
[_start, _finish)

整个容量对应:

text 复制代码
[_start, _end_of_storage)

因此:

text 复制代码
size     = _finish - _start
capacity = _end_of_storage - _start

_finish 既是有效数据的末尾,也是下一次尾插应该写入的位置。当 _finish == _end_of_storage 时,说明当前空间已经没有空余位置,再插入元素就需要扩容。


二、默认构造与析构

1. 默认构造

三个指针已经通过类内成员初始化设为 nullptr,因此默认构造函数可以直接让编译器生成:

cpp 复制代码
// C++11 引入的可以强制生成默认构造函数
vector() = default;

默认构造完成后,对象处于空状态:

text 复制代码
_start = nullptr
_finish = nullptr
_end_of_storage = nullptr

= default 表示显式要求编译器生成默认构造函数。这里不需要在函数体中重复给三个指针赋空值。

2. 析构函数

vector 管理的是动态申请的数组空间,因此对象销毁时需要释放它:

cpp 复制代码
~vector()
{
	if (_start)
	{
		delete[] _start;
		_start = _finish = _end_of_storage = nullptr;
	}

}

这里使用 delete[],是因为扩容时通过 new T[n] 申请了一组对象。new[] 必须与 delete[] 配对,不能写成普通的 delete

析构函数执行结束后对象本来就不会继续使用,把三个指针重新置空并不是必需步骤,但能让资源状态更加清楚。


三、迭代器与遍历

1. 普通迭代器与 const 迭代器

这个简化版本的底层空间是连续的,因此可以直接使用原生指针充当迭代器:

cpp 复制代码
typedef T* iterator;
typedef const T* const_iterator;

普通迭代器可以读取和修改元素,const_iterator 只能读取元素,不能通过它修改元素内容。

对应的 beginend 如下:

cpp 复制代码
iterator begin()
{
	return _start;
}

iterator end()
{
	return _finish;
}

const_iterator begin() const
{
	return _start;
}

const_iterator end() const
{
	return _finish;
}

当对象本身是普通对象时,调用非 const 版本;当对象是 const 对象或 const 引用时,调用带有 const 修饰的版本。

end() 返回的是最后一个有效元素的下一个位置,因此不能对 end() 直接解引用。

2. 使用 const_iterator 遍历

代码中使用一个独立函数输出 vector 中的元素:

cpp 复制代码
template<class T>
void print_vecotr(const vector<T>& v)
{
	// 规定,没有实例化的类模板里面取东西,编译器不能区分这里const_iterator
	// 是类型还是静态成员变量
	typename vector<T>::const_iterator it = v.begin();
	while (it != v.end())
	{
		std::cout << *it << ' ';
		++it;
	}
	std::cout << std::endl;

}

函数名在当前实现中就是 print_vecotr,虽然单词顺序写成了 vecotr,但所有调用保持一致,所以这里不改变原来的命名。

vector<T>::const_iterator 依赖模板参数 T。编译器在模板实例化前无法确定它表示类型还是静态成员,因此前面需要添加 typename

cpp 复制代码
typename vector<T>::const_iterator

这个遍历函数接收 const vector<T>&,不会复制容器,也不会修改其中的元素。

3. const 对象的遍历测试

cpp 复制代码
void test_const()
{
    std::cout << "===== const迭代器测试 =====" << std::endl;

    qen::vector<int> v;


    v.push_back(1);
    v.push_back(2);
    v.push_back(3);


    const qen::vector<int>& cv = v;


    qen::print_vecotr(cv);
}

输出为:

text 复制代码
1 2 3

这里传入的是 const 引用,因此 print_vecotr 中调用的是 const 版本的 begin()end()


四、size、capacity、empty 与 operator\[\]

1. size 与 capacity

三个指针确定之后,sizecapacity 可以通过指针相减得到:

cpp 复制代码
size_t size() const
{
	return _finish - _start;
}

size_t capacity() const
{
	return _end_of_storage - _start;
}

二者的区别为:

接口 计算方式 含义
size() _finish - _start 当前有效元素个数
capacity() _end_of_storage - _start 当前空间最多可以容纳的元素个数

这里有一个严格标准层面需要注意的细节:默认构造后三个指针都是 nullptr,而空指针并不属于同一个数组,直接对两个空指针做减法没有标准保证。常见环境通常会得到 0,所以当前测试能够运行;如果继续完善实现,应该在 _start == nullptr 时单独返回 0

本文保留原来的代码。如果希望用最小改动避开空指针相减,可以把两个接口补充为:

cpp 复制代码
size_t size() const
{
	return _start == nullptr ? 0 : _finish - _start;
}

size_t capacity() const
{
	return _start == nullptr ? 0 : _end_of_storage - _start;
}

后面再把这类教学版实现与标准库的区别统一整理。

2. empty

_finish_start 相等时,有效区间为空:

cpp 复制代码
bool empty() const
{
	return _finish == _start;
}

测试代码如下:

cpp 复制代码
void test_empty()
{
    std::cout << "===== empty测试 =====" << std::endl;

    qen::vector<int> v;


    std::cout << "空vector:"
        << v.empty()
        << std::endl;


    v.push_back(1);


    std::cout << "插入数据后:"
        << v.empty()
        << std::endl;
}

默认情况下,bool 会以 01 输出:

text 复制代码
空vector:1
插入数据后:0

3. operator\[\]

连续空间可以直接通过 _start[pos] 访问指定下标:

cpp 复制代码
T& operator[](size_t pos)
{
	assert(pos < size());
	return _start[pos];
}

const T& operator[](size_t pos) const
{
	assert(pos < size());

	return _start[pos];
}

两个版本的区别在返回值:

  • 普通对象返回 T&,可以修改元素;
  • const 对象返回 const T&,只能读取元素。

对应的修改测试为:

cpp 复制代码
void test_operator()
{
    std::cout << "===== operator[]测试 =====" << std::endl;

    qen::vector<int> v;

    for (int i = 0; i < 5; i++)
    {
        v.push_back(i);
    }


    std::cout << "修改前:" << std::endl;
    qen::print_vecotr(v);


    v[2] = 100;


    std::cout << "修改后:" << std::endl;
    qen::print_vecotr(v);
}

输出为:

text 复制代码
修改前:
0 1 2 3 4
修改后:
0 1 100 3 4

assert(pos < size()) 用于在调试阶段检查越界。需要注意,定义 NDEBUG 后断言可能被关闭,因此它不能代替所有正式的错误处理。


五、reserve:扩容并转移数据

1. reserve 的完整实现

reserve 是整个模拟实现中最核心的接口之一。后面的 resizepush_backinsert 都会复用它完成扩容:

cpp 复制代码
// 扩容
void reserve(size_t n)
{
	
	if (n > capacity())
	{
		//开新空间,拷贝旧空间数据
		size_t old_size = size();
		T* tmp = new T[n];


		// memcpy(tmp, _start, old_size * sizeof(T));
		// 不能使用memcpy来直接拷贝数据,否则会有浅拷贝的问题
		// 前面我们模拟实现string是因为里面的char是内置类型
		// 但对于vector中还要支持内置类型的深拷贝
		for (size_t i = 0; i < old_size; i++)
		{
			tmp[i] = _start[i];
		}

		delete[] _start;

		_start = tmp;
		_finish = _start + old_size;
		_end_of_storage = _start + n;
	}


	
}

整个扩容过程可以分成五步:

  1. 只有 n > capacity() 时才扩容;
  2. 先保存原来的 size
  3. 通过 new T[n] 申请更大的连续空间;
  4. 把旧空间中的有效元素逐个复制到新空间;
  5. 释放旧空间,并重新设置三个指针。

扩容时一定要提前保存 old_size。旧空间释放以后,原来的三个指针都不能再参与新空间中的位置计算。

新空间中的三个指针分别为:

cpp 复制代码
_start = tmp;
_finish = _start + old_size;
_end_of_storage = _start + n;

reserve 只改变容量,不增加有效元素,因此 _finish 仍然根据原来的 old_size 计算。

2. reserve 与迭代器失效

如果 reserve 真正申请了新空间,旧空间就会被释放。原来指向旧空间的迭代器、指针和引用都会失效:

text 复制代码
旧迭代器 -> 已经释放的旧空间

所以扩容后不能继续访问旧迭代器,应重新调用 begin()end(),或者根据扩容前保存的相对下标恢复位置。

如果 n <= capacity(),这个实现不会重新分配空间,已有迭代器不会因为这次 reserve 调用而失效。

3. 原注释中需要更正的一个表述

为了保持代码一致,上面的注释没有改动。其中这一句:

cpp 复制代码
// 但对于vector中还要支持内置类型的深拷贝

更准确的意思应该是:vector 还要支持 string 等自定义类型,尤其是内部管理动态资源的类类型。

intchar 等内置类型直接复制即可,一般不存在多个对象重复释放同一资源的问题。真正需要避免 memcpy 浅拷贝问题的是管理资源的类类型,后面会单独展开。


六、resize:改变有效元素个数

resize 改变的是 size,而不是只改变 capacity

cpp 复制代码
// 其实对于内置类型而言也是有构造函数的
// 也是为了更好支持 T val = T() 这种写法
// 比如: int i = int(); int j = int(2);
void resize(size_t n, T val = T())
{
	// 小于说明要删除数据到n
	if (n < size())
	{
		_finish = _start + n;
	}
	else
	{
		reserve(n);
		while (size() < n)
		{
			*_finish = val;
			++_finish;
		}

	}


}

根据 n 的大小,可以分成三种情况。

1. n 小于当前 size

cpp 复制代码
_finish = _start + n;

只需要让 _finish 向前移动,逻辑上的有效元素个数就会缩小,capacity 不变。

2. n 大于 size,但不超过 capacity

reserve(n) 不会申请新空间,循环直接在已有空余位置写入 val

cpp 复制代码
while (size() < n)
{
	*_finish = val;
	++_finish;
}

3. n 大于 capacity

先通过 reserve(n) 扩容,再把新增位置填成 val

默认参数写成:

cpp 复制代码
T val = T()

对于 intT() 得到 0;对于类类型,会调用对应的默认构造逻辑来产生默认值。

当前实现使用 new T[n] 一次性构造整块数组,所以缩小 size 时只是移动 _finish,不会立即析构被移出有效区间的类对象。这与真正的 std::vector 不完全相同,属于简化对象生命周期带来的差别。


七、push_back 与 pop_back

1. push_back

尾插时,_finish 正好指向新元素应该写入的位置:

cpp 复制代码
void push_back(const T& x) 
{
	if (_finish == _end_of_storage)
	{
		reserve(capacity() == 0 ? 4 : capacity() * 2);
	}

	*_finish = x;
	++_finish;
}

如果空间已满,就按照下面的策略扩容:

cpp 复制代码
capacity() == 0 ? 4 : capacity() * 2
  • 第一次插入时申请 4 个元素的空间;
  • 后面容量不足时扩成原来的 2 倍。

这只是当前模拟实现选择的增长策略,不代表所有标准库实现都必须按照相同倍数扩容。

扩容完成后,在 _finish 位置写入新元素,再让 _finish 向后移动一个位置。

测试代码如下:

cpp 复制代码
void test_push_back()
{
    std::cout << "===== push_back测试 =====" << std::endl;

    qen::vector<int> v;

    for (int i = 1; i <= 10; i++)
    {
        v.push_back(i);

        std::cout << "插入:" << i
            << " size=" << v.size()
            << " capacity=" << v.capacity()
            << std::endl;
    }

    qen::print_vecotr(v);
}

按照当前增长策略,容量变化为:

text 复制代码
插入:1  size=1  capacity=4
插入:2  size=2  capacity=4
插入:3  size=3  capacity=4
插入:4  size=4  capacity=4
插入:5  size=5  capacity=8
插入:6  size=6  capacity=8
插入:7  size=7  capacity=8
插入:8  size=8  capacity=8
插入:9  size=9  capacity=16
插入:10 size=10 capacity=16

2. pop_back

尾删不需要搬移前面的元素,只要让 _finish 向前移动即可:

cpp 复制代码
void pop_back()
{
	assert(!empty());
	--_finish;
}

调用前必须保证 vector 不为空,否则 _finish 会越过 _start。这里使用断言检查:

cpp 复制代码
assert(!empty());

对应的测试为:

cpp 复制代码
void test_pop_back()
{
    std::cout << "===== pop_back测试 =====" << std::endl;

    qen::vector<int> v;


    for (int i = 1; i <= 5; i++)
    {
        v.push_back(i);
    }


    std::cout << "删除前:" << std::endl;
    qen::print_vecotr(v);


    v.pop_back();
    v.pop_back();


    std::cout << "删除后:" << std::endl;
    qen::print_vecotr(v);
}

输出为:

text 复制代码
删除前:
1 2 3 4 5
删除后:
1 2 3

resize 缩小时一样,当前版本只是移动 _finish,没有立即析构被尾删的类对象。


八、insert:指定位置插入

1. insert 的实现

cpp 复制代码
iterator insert(iterator pos, const T& x)
{
	assert(pos >= begin() && pos <= end());

	if (_finish == _end_of_storage)
	{
		size_t len = pos - _start;
		reserve(capacity() == 0 ? 4 : capacity() * 2);
		pos = _start + len;
	}

	iterator end = _finish;
	while (end > pos)
	{
		*end = *(end - 1);
		--end;
	}

	*pos = x;
	++_finish;
		
	return pos;
}

插入位置允许位于:

text 复制代码
[begin(), end()]

pos == end() 表示尾插,因此断言中需要使用 <= end()

2. 扩容后为什么要重新计算 pos

pos 的底层类型是 T*。如果插入引起扩容,旧空间被释放以后,原来的 pos 就指向无效空间。

因此扩容前先保存 pos 相对于 _start 的距离:

cpp 复制代码
size_t len = pos - _start;

扩容完成后再根据新 _start 恢复位置:

cpp 复制代码
pos = _start + len;

这种方法保存的是相对下标,而不是旧空间中的绝对地址,所以空间发生变化后仍然能够找到对应位置。

3. 为什么从后向前移动

插入会让 pos 位置及其后面的元素整体向后移动一个位置:

cpp 复制代码
iterator end = _finish;
while (end > pos)
{
	*end = *(end - 1);
	--end;
}

必须从后向前移动。如果从前向后覆盖,前面的元素写入下一个位置后,会把后面还没有搬走的数据提前覆盖掉。

移动完成后,再写入新值并更新 _finish

cpp 复制代码
*pos = x;
++_finish;

最后返回指向新插入元素的迭代器:

cpp 复制代码
return pos;

4. insert 的迭代器失效规则

需要区分是否发生扩容:

  • 如果发生扩容,原有全部迭代器、指针和引用失效;
  • 如果没有扩容,插入位置及其后面的迭代器、指针和引用失效;
  • insert 的返回值指向新插入的元素,可以接收返回值继续使用。

即使某个旧指针在当前简化实现中看起来仍然指向同一个地址,也不应该继续依赖它表示插入前的元素位置。

5. 中间插入测试

cpp 复制代码
void test_insert_middle()
{
    std::cout << "===== insert中间测试 =====" << std::endl;

    qen::vector<int> v;


    for (int i = 1; i <= 5; i++)
    {
        v.push_back(i);
    }


    v.insert(v.begin() + 2, 100);


    qen::print_vecotr(v);
}

输出为:

text 复制代码
1 2 100 3 4 5

头部插入、中间插入都可能移动后面的元素,因此时间复杂度通常为 O(N);在 end() 位置插入且不扩容时,不需要搬移已有元素。


九、erase:删除指定位置元素

1. erase 的实现

cpp 复制代码
iterator erase(iterator pos)
{
	assert(pos >= begin() && pos < end());
	iterator it = pos + 1;
	while (it != end())
	{
		*(it - 1) = *(it);
		++it;
	}
	--_finish;

	return pos;
}

删除位置必须是真正存在的元素,因此范围为:

text 复制代码
[begin(), end())

end() 指向有效区间之后,不能作为单个元素传给这个 erase

pos + 1 开始遍历,把后面的元素依次向前覆盖:

cpp 复制代码
*(it - 1) = *it;

数据移动完成后,让 _finish 向前移动,并返回原来的 pos。此时 pos 已经指向被删除元素后面的新位置。

如果删除的是最后一个元素,没有元素需要搬移,--_finish 后返回的 pos 正好等于新的 end()

2. 遍历过程中连续删除偶数

代码中已经给出了正确的更新方式:

cpp 复制代码
void test_vector02()
{
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);
	v.push_back(4);
	v.push_back(4);
	v.push_back(5);

	auto it = v.begin();
	while (it != v.end())
	{
		if ((*it) % 2 == 0)
		{
			// 迭代器在删除数据后我们认为它失效了,一般不建议使用
			// 要用的话需要更新一下,它会指向下一个数据
			// 这是对于我写的vector而言,如果是std中的在vs下会直接报错
			// 同理对于insert也一样的
			it = v.erase(it);
		}
		else
		{
			++it;
		}

	}

	print_vecotr(v);


}

输出为:

text 复制代码
1 3 5

原注释中"在 VS 下会直接报错"描述的是某些调试模式下的实现表现。调试迭代器可能主动检测失效并给出错误,但 C++ 标准只规定继续使用失效迭代器没有正确性保证,并不承诺所有编译器、所有运行模式都会立即报错。

删除成功后不能无条件再执行一次 ++it,否则会跳过刚刚移动到当前位置的元素。正确逻辑是:

  • 删除时使用 it = v.erase(it) 接收下一个位置;
  • 不删除时才执行 ++it

erase 会使删除位置及其后面的迭代器和引用失效,删除位置之前的迭代器仍然有效。这个实现不会因为 erase 缩小容量,所以 _end_of_storage 保持不变。

中间删除需要移动后面的元素,时间复杂度通常为 O(N)


十、为什么不能默认使用 memcpy 搬移任意元素

1. memcpy 只复制二进制内容

如果 Tintchar 等简单类型,直接复制对象的字节通常能够得到相同的值。但是 vector 还要能够保存 string 等类对象:

cpp 复制代码
#include <string>

qen::vector<std::string> v;
v.push_back("1111");
v.push_back("2222");
v.push_back("3333");

string 对象内部通常会管理自己的资源。如果直接使用:

cpp 复制代码
memcpy(tmp, _start, old_size * sizeof(T));

复制的只是对象的二进制表示。假设对象内部保存一个指向动态空间的指针,那么复制后两个对象可能得到相同的地址。旧空间释放时,其中一个对象会释放该资源,新对象中的指针就可能变成悬空指针,后面还可能发生重复释放。

2. 逐个复制会调用元素类型自己的逻辑

当前 reserve 使用循环:

cpp 复制代码
for (size_t i = 0; i < old_size; i++)
{
	tmp[i] = _start[i];
}

对于类类型,这会调用 T 的赋值运算符。只要 T 自己正确实现了资源管理,复制后的对象就能够拥有独立、有效的状态。

这里更准确的说法不是"vector 自动完成深拷贝",而是:vector 通过正常的元素赋值操作,让元素类型自己的复制语义得到执行。

因此,只有当类型满足适合按字节复制的条件时,才能考虑底层二进制搬移。通用容器不能默认把任意 T 都交给 memcpy


十一、拷贝构造、swap 与赋值

vector 拥有动态资源,如果直接使用编译器生成的浅拷贝,多个对象会保存相同的 _start,最终导致同一块空间被重复释放。因此需要自己处理拷贝控制。

1. 拷贝构造

cpp 复制代码
vector(const vector<T>& v)
{
	reserve(v.capacity());
	for (const auto& e : v)
	{
		push_back(e);
	}
}

先按照源对象的容量申请自己的空间,再遍历源对象,把每个有效元素依次尾插到新对象中。

因为参数 vconst vector<T>&,范围 for 会调用 const 版本的 begin()end()

拷贝完成后,两个 vector 的三个指针分别指向各自的空间,修改或销毁其中一个对象不会影响另一个对象。

当前实现选择保留源对象的 capacity。真正的 std::vector 进行拷贝后具体得到多大容量并不是这里需要依赖的固定规则。

2. swap

cpp 复制代码
void swap(vector<T>& v)
{
	std::swap(_start, v._start);
	std::swap(_finish, v._finish);
	std::swap(_end_of_storage, v._end_of_storage);
}

交换两个 vector 时,不需要逐个交换元素。只要交换三个指针,两个对象管理的整块空间就完成了互换,时间复杂度为 O(1)

3. 赋值运算符

cpp 复制代码
vector<T>& operator=(vector<T> v)
{
	
	swap(v);

	return *this;
}

这里的参数不是引用,而是按值接收:

cpp 复制代码
vector<T> v

调用赋值运算符时,形参 v 会先通过拷贝构造得到一份独立副本。进入函数以后,把当前对象与副本交换:

cpp 复制代码
swap(v);

交换后:

  • 当前对象接管副本中的新数据;
  • 形参 v 接管当前对象原来的旧空间;
  • 函数结束时,v 析构并释放旧空间。

这种写法通常称为 copy-and-swap。它把复制、资源替换和旧资源释放组合在一起,自赋值也能够自然处理。

4. 拷贝与赋值测试

cpp 复制代码
void test_vector03()
{
	vector<int> v1;
	v1.push_back(1);
	v1.push_back(2);
	v1.push_back(3);
	v1.push_back(4);
	print_vecotr(v1);

	vector<int> v2 = v1;
	print_vecotr(v2);

	vector<int> v3;
	v3.push_back(10);
	v3.push_back(20);
	v3.push_back(30);

	v1 = v3;
	print_vecotr(v1);
	print_vecotr(v3);
}

输出为:

text 复制代码
1 2 3 4
1 2 3 4
10 20 30
10 20 30

前两行验证拷贝构造,后两行验证赋值运算符。赋值后 v1v3 内容相同,但底层空间彼此独立。


十二、当前测试的调用关系

Main.cpp 中已经准备了 push_backoperator[]、头部插入、中间插入、尾部插入、尾删、判空和 const 遍历等测试。

不过当前 main 实际执行的是:

cpp 复制代码
void test2()
{
    //qen::test_vector02();
    qen::test_vector03();
}

int main()
{

    //test1()
    test2();

    return 0;
}

因此当前一次运行只会执行 test_vector03(),也就是拷贝构造和赋值测试。其他测试函数虽然已经写好,但在 test1() 中处于注释状态;连续删除偶数的 test_vector02() 也处于注释状态。

如果需要逐项观察,可以一次放开一个测试,避免多组输出混在一起。已有测试的覆盖关系如下:

测试函数 主要验证内容
test_push_back 尾插、size 与扩容过程
test_operator const 下标访问与修改
test_insert_head 头部插入
test_insert_middle 中间插入与元素后移
test_insert_tail end() 位置插入
test_pop_back 连续尾删
test_empty 空状态判断
test_const const_iterator 遍历
test_vector02 使用 erase 返回值连续删除
test_vector03 拷贝构造与赋值运算符

还可以继续补充下面这些边界测试:

  1. 显式调用 reserveresize
  2. 插入时刚好触发扩容;
  3. 接收并使用 insert 的返回值;
  4. 删除最后一个元素,检查返回值是否等于新的 end()
  5. vector 的拷贝与赋值;
  6. 自赋值 v = v
  7. 拷贝后修改其中一个对象,验证另一个对象不受影响。

十三、当前实现与 std::vector 的区别

到这里,一个简化的 vector 已经能够完成常见的空间管理、访问、插入、删除和拷贝操作。不过它仍然不能直接等同于标准库中的 std::vector

1. 当前实现的接口范围

类别 已实现 还未实现
构造与拷贝 默认构造、拷贝构造、赋值、析构 指定个数构造、区间构造、初始化列表、移动构造
迭代器 beginend 反向迭代器、cbegincend
容量 sizecapacityemptyreserveresize max_sizeshrink_to_fit
元素访问 operator[] atfrontbackdata
修改 push_backpop_backinserteraseswap clearassignemplace

2. 对象生命周期是简化模型

当前扩容使用:

cpp 复制代码
T* tmp = new T[n];

这会直接构造 nT 对象,所以当前实现要求 T 能够默认构造。push_backinsert 使用的是赋值,而不是在未构造的原始空间中创建对象。

真正的 std::vector 会把"申请原始空间"和"在指定位置构造元素"分开,只在 [0, size) 范围内维护已经构造的对象。因此标准库可以更精确地控制元素的构造与析构,也能支持更多类型。

这也解释了为什么当前版本的 pop_backerase 和缩小 resize 只移动 _finish,不会立即析构被移除元素。对于管理资源的类对象,资源可能一直保留到整块数组被 delete[]

3. 暂时不支持只移动、不可复制的类型

扩容时使用:

cpp 复制代码
tmp[i] = _start[i];

这要求元素能够默认构造并进行复制赋值。只支持移动、不允许复制的类型暂时无法正常使用。

4. 参数引用容器内部元素时还有边界问题

例如:

cpp 复制代码
v.push_back(v[0]);

如果这次尾插刚好引起扩容,参数 x 引用了旧空间中的元素。reserve 释放旧空间以后,x 会变成悬空引用,后面再执行:

cpp 复制代码
*_finish = x;

就没有正确性保证。insert(pos, x)x 引用容器内部元素时,也要考虑扩容和元素搬移带来的影响。

这属于当前简化实现还可以继续完善的地方,不能把它当成完整标准库直接替换使用。

5. 异常安全还不完整

reserve 申请 tmp 后,如果某个元素的赋值操作抛出异常,当前代码没有自动释放 tmp 的保护措施。拷贝构造过程中如果复制元素失败,也可能留下未释放资源。

对于当前 int 测试没有影响,但通用容器需要进一步使用 RAII 等方式保证异常发生时也不会泄漏资源。

6. 迭代器没有调试检查

这里的迭代器就是 T*,不会记录自己属于哪个容器,也不会主动检测是否已经失效。失效迭代器有时看起来还能得到一个数值,但继续使用仍然属于错误行为,不能根据"这一次没有崩溃"判断它仍然有效。

7. 头文件与工程组织还可以完善

当前代码使用了 std::swap,为了减少对其他头文件间接包含的依赖,更完整的写法应显式包含 <utility>

另外,test_vector02test_vector03 是定义在头文件中的普通非模板函数。当前只有一个 .cpp 包含该头文件时没有问题;如果多个 .cpp 同时包含它们,链接时可能出现重复定义。测试函数更适合放进单独的 .cpp 文件,或者根据需要使用合适的内联方式。

这些问题不影响当前单文件测试,但能够帮助我们区分"用于理解原理的模拟实现"和"真正可复用的通用容器"。


十四、总结

本文完成了一个简化 vector 类的模拟实现。

首先使用 _start_finish_end_of_storage 管理连续空间,三个指针分别表示空间起点、有效数据末尾和容量末尾。根据指针距离可以得到 sizecapacity,而普通指针又可以直接作为 iteratorconst_iterator

容量不足时,reserve 申请新空间、逐个复制有效元素、释放旧空间,再重新设置三个指针。真正发生扩容后,所有指向旧空间的迭代器、指针和引用都会失效。resize 在此基础上改变有效元素个数,push_back 则复用 reserve 完成自动扩容与尾插。

中间插入时,需要先保存 pos 相对于 _start 的距离,扩容后再重新计算位置,并从后向前移动元素;删除时则把后面的元素向前覆盖。erase 返回删除位置之后的新迭代器,所以遍历删除时应使用 it = v.erase(it) 更新当前位置。

由于元素类型可能是管理资源的类对象,扩容不能简单地使用 memcpy 进行二进制拷贝,而要让元素自身的复制语义得到执行。容器本身也需要正确管理资源,通过析构函数释放空间,通过拷贝构造建立独立副本,再利用 swap 和传值形参完成 copy-and-swap 赋值。

当前版本仍然采用简化的对象生命周期模型,没有完整实现标准库的全部接口、移动语义和异常安全。即使如此,把这些功能串联起来以后,已经能够看清 vector 最核心的几个问题:连续空间管理、扩容搬移、元素构造与复制、迭代器失效以及资源所有权。


相关推荐
j7~9 小时前
【C++】C++ 继承全解析:从基本语法到菱形继承的底层原理
开发语言·c++·继承·组合·虚继承·#暑假·七月创作之星博客挑战赛
数行拙笔9 小时前
C++客户端---String类型
开发语言·c++·bootstrap
YM52e9 小时前
鸿蒙 Flutter 渐变效果详解:LinearGradient、RadialGradient、SweepGradient
android·学习·flutter·华为·harmonyos·鸿蒙
YM52e9 小时前
鸿蒙 Flutter BoxDecoration装饰:打造精美UI效果
学习·flutter·ui·华为·harmonyos·鸿蒙
草莓熊Lotso9 小时前
【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战
linux·运维·服务器·c语言·网络·数据库·c++
承渊政道10 小时前
【Python学习】(了解使用库、标准库、第三方库以及综合案例实操)
python·学习·pycharm·标准库·第三方库·使用库·综合案例
YM52e11 小时前
鸿蒙Flutter Card组件:Material设计风格卡片
android·学习·flutter·华为·harmonyos·鸿蒙
YangYang9YangYan12 小时前
2026通信工程专业学生无项目经验学习数据分析的价值与路径
学习·数据挖掘·数据分析
selfsongs20 小时前
指针、内存管理、现代 C++ 特性
c++