vector 的模拟实现:扩容与边界的详细阐述(下)

文章目录

    • 引入
    • 一、三指针框架:哪些位置必须保持一致
      • [1.1 三个边界分别负责什么](#1.1 三个边界分别负责什么)
      • [1.2 模板决定元素类型](#1.2 模板决定元素类型)
    • 二、扩容和尾插:旧数据什么时候释放
      • [2.1 reserve 预开辟空间](#2.1 reserve 预开辟空间)
      • [2.2 push_back 尾部插入元素](#2.2 push_back 尾部插入元素)
      • [2.3 pop_back 删除尾部元素](#2.3 pop_back 删除尾部元素)
    • 三、插入与删除:偏移、方向和引用别名
      • [3.1 插入位置为何先变成偏移量](#3.1 插入位置为何先变成偏移量)
      • [3.2 erase 的返回值是继续遍历的入口](#3.2 erase 的返回值是继续遍历的入口)
    • [四、对象复制:为什么不能一律 memcpy](#四、对象复制:为什么不能一律 memcpy)
      • [4.1 容器复制与元素复制不是同一层](#4.1 容器复制与元素复制不是同一层)
      • [4.2 用一个动态整数把问题看清楚](#4.2 用一个动态整数把问题看清楚)
      • [4.3 拷贝构造](#4.3 拷贝构造)
      • [4.4 赋值重载](#4.4 赋值重载)
    • 五、扩展练习
      • [5.1 LeetCode 118「杨辉三角」](#5.1 LeetCode 118「杨辉三角」)
    • 六、小结

续接上篇:vector 的基本使用(大小、容量和位置)的详细阐述(上)

代码仓库:《vector测试与模拟实现》

引入

上篇已经详细回答了vector " 怎样用 " 。这一篇将介绍我们自己如何根据已知的机制与知识来模拟实现一个我们自己的vector,在此篇中,我更建议把此次模拟实现当成是检验我们对于标准库vector理解的方法,而不是自己实现一遍后,就断定标准库也是同一份代码。(具体源码可自行查找参照)

一、三指针框架:哪些位置必须保持一致


1.1 三个边界分别负责什么

cpp 复制代码
namespace by
{
	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;
	};
}
成员 含义 非空存储时的关系
_start 存储起始位置 对应 begin
_finish 有效元素的尾后位置 对应 end
_end_of_storage 存储容量的尾后位置 不是可解引用元素

有存储时,_start < =_finish <= _end_of_storage;有效区间是 [_start,_finish),容量区间是 [_start,_end_of_storage)。指针差以 T 元素为单位,不是字节数。

默认对象中三个成员都是 nullptr;有容量但 size 为 0 时,_finish == _start,但起始位置不必是 nullptr。

1.2 模板决定元素类型

template<class T> 中的 T 是元素类型参数;by::vector<int> 使用 int,by::vector<double> 使用 double

typedef T* iterator 给指针起别名,typedef const T* const_iterator const 成员函数中的 begin/end 返回后者,所以通过 const 容器不能改元素。

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

iterator end()
{
	return _finish;
}

const_iterator begin() const
{
	return _start;
}

const_iterator end() const
{
	return _finish;
}

size_t size() const
{
	return _finish - _start;
}

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

bool empty() const
{
	return _start == _finish;
}
  • iterator begin() { return _start; }
  • iterator end() { return _finish; }
  • 普通对象调用,返回可修改迭代器。

  • const_iterator begin() const { return _start; }
  • const_iterator end() const { return _finish; }
  • const修饰的 vector 对象,调用这一组,返回const_iterator,不能修改元素。

  • size_t size() const { return _finish - _start; }
  • size = 有效元素个数;连续内存指针相减得到元素数量。

  • size_t capacity() const { return _end_of_storage - _start; }
  • capacity 总容量,内存可容纳的最大元素数。

  • bool empty() const { return _start == _finish; }
  • 有效元素区间起点等于终点,代表容器为空。

二、扩容和尾插:旧数据什么时候释放


2.1 reserve 预开辟空间

cpp 复制代码
//预开辟空间
void reserve(size_t n)
{
	if(n <= capacity())
    return;
	size_t old_size = size();
	T* tmp = new T[n];
	//浅拷贝,对于普通内置类型可行,但对于类类型会出错
	//memcpy(tmp, _start, old_size * sizeof(T));

	//深拷贝,逐个赋值,对于内置类型与自定义类型均适用
	for (size_t i = 0; i < old_size; i++)
	{
		tmp[i] = _start[i];
	}
	delete[] _start;

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

如果n <= capacity:不做任何操作。

挪动空间前,先保存旧 size,再申请新数组,逐个复制有效元素,成功后才能销毁旧数组并更新边界。

为什么必须先保存 old_size?因为 _finish - _start 要用属于同一块存储的两个位置计算,不能先改其中一个,再拿新旧指针求差。

2.2 push_back 尾部插入元素

cpp 复制代码
//追加单个元素
void push_back(const T& x)
{
	//检查容量
	if (_finish == _end_of_storage)
	{
		reserve(capacity() == 0 ? INIT_NUM : 2 * capacity());
	}
	*_finish = x;
	++_finish;
}

当 size 小于 capacity 时,_finish 指向数组中一个尚未计入有效区间的对象;赋值完成后再递增 _finish,新的元素才进入逻辑序列。

这里不能先递增 _finish 再赋值:如果赋值失败,size 就会把尚未成功加入的元素算进去。

2.3 pop_back 删除尾部元素

cpp 复制代码
//删除单个元素
void pop_back()
{
	assert(!empty());
	--_finish; 
}
  1. assert(!empty()):断言,禁止对空 vector 调用 pop_back,空的时候直接报错。
  2. --_finish:只把结束指针向前挪一格。(内置类型基本够用)

三、插入与删除:偏移、方向和引用别名


3.1 插入位置为何先变成偏移量

cpp 复制代码
//指定位置前插入元素
iterator insert(iterator pos, const T& x)
{
	assert(pos >= _start);
	assert(pos <= _finish);
	//检查容量
	if (_finish == _end_of_storage)
	{
		size_t len = pos - _start;
		reserve(capacity() == 0 ? INIT_NUM : 2 * capacity());
		pos = _start + len;
	}
	//挪动数据
	iterator end = _finish - 1;
	while (end >= pos)
	{
		*(end + 1) = *(end);
		--end;
	}

	*pos = x;
	++_finish;

	return pos;
}

例如 pos 指向第三个元素,它与旧 _start 相距 2。扩容后旧 pos 失效,但数字 2 可以保留;新的_start + 2 就得到新存储中的插入位置。

对于尚未分配存储的空对象,偏移直接记为 0,不依赖空指针求差来表达位置。扩容成功后 _start 指向真实数组,才按数组模型恢复位置。

3.2 erase 的返回值是继续遍历的入口

cpp 复制代码
//删除指定位置元素
void erase(iterator pos)
{
	assert(pos >= _start);
	assert(pos < _finish);

	iterator cur = pos + 1;
	while (cur != end())
	{
		*(cur - 1) = *cur;
		++cur;
	}
	--_finish;
}
  • 删除 pos 的元素后,后续值从前向后补位,最后缩小有效区间。前提条件必须是 pos < _finish,不能允许单元素删除 end。
  • 删最后一个元素时不需要搬移,递减 finish 后,pos 恰好等于新的 end。

四、对象复制:为什么不能一律 memcpy


4.1 容器复制与元素复制不是同一层

vector 对象拥有自己的元素存储。若只把三个指针复制给新容器,两个对象会指向同一个数组;一方销毁后另一方悬空,随后还可能重复 delete[]。

所以复制容器需要申请独立存储,再按 T 的复制语义复制每个有效元素。对 int 就是复制数值;对拥有资源的类,需要该类自己定义正确的复制行为。

4.2 用一个动态整数把问题看清楚

下面的 IntBox 类中只有一个 int*,对象拥有它指向的动态整数。resources 则用来观察资源数量,不参与功能逻辑。

cpp 复制代码
#include <iostream>
#include <string>

class IntBox 
{
public:
    static int resources;

    IntBox(int value = 0) 
        : _value(new int(value))
    {
        ++resources;
    }

    IntBox(const IntBox& other) 
        : _value(new int(*other._value))
    {
        ++resources;
    }

    IntBox& operator=(const IntBox& other)
    {
        if (this != &other)
            *_value = *other._value;
        return *this;
    }

    ~IntBox()
    {
        delete _value;
        --resources;
    }

    int& value() { return *_value; }
    const int& value() const { return *_value; }

private:
    int* _value;
};

int IntBox::resources = 0;

int main()
{
    IntBox i(1);
    std::cout << IntBox::resources << std::endl;
    IntBox n = i;
    std::cout << IntBox::resources << std::endl;

    return 0;
}

运行示例:

构造时申请一个整数,拷贝构造时申请另一个整数并复制值。赋值对象已经有自己的整数,直接修改它的值就足够,不需要先释放再申请。

对于非平凡对象,用 memcpy 覆盖整个对象并不能替代复制操作。除了没有调用拷贝构造或赋值,复制出的指针也会指向同一资源;

若随后删除旧数组,旧对象的析构会释放资源,新对象中复制来的指针随即悬空;以后再使用、再次析构都有风险。

4.3 拷贝构造

cpp 复制代码
//拷贝构造
vector(const vector<T>& v)
{
	reserve(v.size());
	for (auto& e : v)
	{
		push_back(e);
	}
}
  1. reserve(v.size()):提前开好足够空间 ,避免push_back循环里频繁扩容,提升效率。
  2. 范围 for 遍历源v,push_back(e)把每个元素拷贝进新对象,调用元素的拷贝构造。

v 的 capacity 中可能有大量备用位置,它们不是有效元素,没有理由作为内容复制。

  • 拷贝构造出来的新vector:size == v.size();
  • capacity至少等于v.size(),不一定和原 vector 的 capacity 相等 ,标准库 vector 拷贝构造只保证容量≥ size,不会拷贝原容器多余的备用空间。

4.4 赋值重载

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

//=赋值(深拷贝)
//现代写法
vector<T> operator=(vector<T> v)
{
	swap(v);
	return *this;
}

此处仍然采用现代写法,安全有效。

  • 以 a = b 为例:先用 b 构造参数 v,得到独立副本;
  • 交换后 a 持有新内容,v 持有 a 的旧数组;
  • 离开函数后 other 析构,旧数组被释放。

a = a 时同样先构造独立副本,再交换,所以不会先清空自己再发现"源也被清空了"。

五、扩展练习


5.1 LeetCode 118「杨辉三角」

LeetCode 118「杨辉三角」

生成前 numRows 行;每行首尾是 1,中间元素等于上一行相邻两个元素之和。

思路: 先创建外层的行对象,再把第 i 行 resize 到 i + 1 个元素并填 1。只计算内部列,避免边界访问上一行不存在的位置。

参考答案:

cpp 复制代码
std::vector<std::vector<int>> pascal(int numRows)
{
    if (numRows <= 0)
        return {};
    std::vector<std::vector<int>> result(numRows);
    for (size_t i = 0; i < result.size(); ++i) 
    {
        result[i].resize(i + 1, 1);
        for (size_t j = 1; j < i; ++j)
            result[i][j] = result[i - 1][j - 1] + result[i - 1][j];
    }
    return result;
}

pascal(5) 得到 [1]、[1,1]、[1,2,1]、[1,3,3,1]、[1,4,6,4,1]。i 为 0、1 时没有内部列,内循环自然不执行;i 为 2、j 为 1 时第一次计算 1 + 1。

六、小结


这次模拟实现最重要的进步不只是让实现的接口更多,而是能把一个操作拆成有顺序的责任:保存旧信息、保护来源值、取得新存储、转移有效元素、释放旧资源、更新边界。

回到标准库使用时,也应该保留两个区分:连续存储不等于位置永远有效;容器管理存储不等于可以无视元素自身的复制与析构。

对照资料:

相关推荐
Seraphina363 小时前
SQL注入(二)盲注
网络·数据库·经验分享·笔记·sql
余额瞒着我当琳4 小时前
红黑树的实现--红黑树的概念、规则、效率、结构、旋转、查找、验证
数据结构·c++·算法
青少儿编程课堂4 小时前
AC自动机多模式串匹配精讲:校园关键词巡检实战解析
c++·python·算法·bfs·信息学竞赛
longlongzihan5 小时前
LeetCode 5. 最长回文子串:从暴力匹配到中心扩展的思维演进
c++·算法·leetcode
多弗朗皮卡丘5 小时前
C++继承
c++·继承
汉克老师6 小时前
GESP2026年9月认证C++七级( 第三部分编程题(2、括号序列))精讲
c++·gesp·小学生·学c++编程
zixingcai7 小时前
电信手机营业厅开发票流程
经验分享
jimy17 小时前
虚函数vtable,链接时看到构造函数,linker把符号地址写入vtable(三)
开发语言·c++
潼心1412o8 小时前
C++初阶(长期更新)第9讲:string(上)
开发语言·c++