vector 的骨架:三根指针、模板陷阱与迭代器失效的第一现场

前言

-----别只会用 vector,来看看它是怎么咬人的

学完 string 的模拟实现之后,我以为 vector 会更简单------毕竟逻辑上不就是一个"动态数组"吗?结果发现坑的密度一点不低,只是坑的性质变了:string 的核心是资源管理语义,而 vector 的核心是模板机制、迭代器语义、以及无处不在的类型陷阱

这篇文章记录我实现 Jianyi::vector 的完整过程,包括每一个设计决策的原因、我代码里真实存在的 bug(以及为什么会犯这种错)、以及由此延伸出来的 C++ 底层原理。如果你也在这个阶段,这篇文章大概对你有用。


目录

[从零实现 std::vector:模板、内存与那些让我调试到凌晨的坑](#从零实现 std::vector:模板、内存与那些让我调试到凌晨的坑)

前言

[一、vector 是什么:三根指针的世界观](#一、vector 是什么:三根指针的世界观)

二、模板类的编译模型:为什么声明和定义不能分离

三、整体结构设计

四、构造函数族:五种构造,各有门道

[4.1 默认构造](#4.1 默认构造)

[4.2 拷贝构造](#4.2 拷贝构造)

[4.3 迭代器区间构造](#4.3 迭代器区间构造)

[4.4 n 个相同元素的构造](#4.4 n 个相同元素的构造)

[五、析构函数与 clear](#五、析构函数与 clear)

[六、深拷贝、浅拷贝,以及 memcpy 的致命误区](#六、深拷贝、浅拷贝,以及 memcpy 的致命误区)

七、reserve:扩容的核心

八、赋值运算符:我写的那个隐蔽的语义错误

[九、pushback 与 popback:一个逻辑 bug 的解剖](#九、pushback 与 popback:一个逻辑 bug 的解剖)

十、insert:迭代器失效的第一现场

十一、erase:两个版本,两种语义

[十二、resize:和 reserve 的本质区别](#十二、resize:和 reserve 的本质区别)

十三、Print_Vector:一个成员函数里的模板遮蔽问题

十四、迭代器:指针就是最好的迭代器

十五、operator[]:为什么要写两个版本

[十六、整体回顾:vector 和 string 的对比思考](#十六、整体回顾:vector 和 string 的对比思考)

总结



一、vector 是什么:三根指针的世界观

std::vector 本质上是一个堆上的动态数组,加上一套管理这块内存的元数据。标准库的经典实现用三根指针来描述它的完整状态:

复制代码
iterator _start;           // 数组起始地址
iterator _finish;          // 有效元素的末尾(最后一个元素的下一个位置)
iterator _end_of_storage;  // 已分配内存的末尾

这三根指针之间的关系,就是 vector 一切操作的基础:

  • size() = _finish - _start:当前有多少个元素
  • capacity() = _end_of_storage - _start:总共申请了多少空间
  • _finish_end_of_storage 之间是已分配但未使用的空间

用指针差代替整数存储 size 和 capacity,这是一个设计选择:三根指针已经隐含了全部信息,不需要额外的字段。代价是每次求 size() 和 capacity() 都是一次指针减法,但这是 O(1) 的,完全可以接受。

和 string 的对比:string 用 char* _str + size_t _size + size_t _capacity,直接存数值,语义更直白。vector 用三指针,适合泛型------因为元素类型 T 的大小在编译期才确定,用指针可以统一描述任意类型的数组,不需要手动乘 sizeof(T)


二、模板类的编译模型:为什么声明和定义不能分离

这是写 vector 之前必须搞清楚的问题,否则你按照 string 的做法把声明放 .h、定义放 .cpp,会得到一堆链接错误,而且错误信息完全看不出原因在哪。

普通类的编译流程:

编译器处理 .cpp 文件时,看到 #include "String.h",知道 String 类有哪些成员函数(声明)。具体的函数体在 String.cpp 里,编译器编译 String.cpp 生成目标文件,链接时把调用方的引用和定义连接起来。这是"分离编译"的标准流程。

模板类为什么不行:

模板类的函数体里出现了类型参数 TT 是什么,在模板定义的时候编译器根本不知道------只有在实例化的时候(比如写 vector<int>),编译器才知道 T = int,才能生成具体的代码。

问题来了:实例化发生在调用方的翻译单元里(比如 main.cpp),但模板定义在 vector.cpp 里。编译 main.cpp 时,编译器只看到了头文件里的声明,不知道函数体长什么样,无法实例化 。编译 vector.cpp 时,没有人告诉它要实例化 vector<int>,它什么也不做。链接时,main.cpp 需要 vector<int>::pushback,但没有任何目标文件提供这个符号------链接失败。

解决方案:把定义也放进头文件。

这样每个 #include "vector.h" 的翻译单元都能看到完整的模板定义,编译器在实例化时可以直接生成代码。代价是头文件变重、编译时间增加,但这是模板的基本代价,无法绕开。

实际上 .hpp 后缀的文件就是约定俗成的"既有声明又有定义的头文件",标准库的模板头文件(<vector><list> 等)都是这么做的。

还有一个相关的细节:类模板内部,编译器无法在实例化之前访问成员的具体信息。 比如你写了一个成员函数,想在函数体里定义一个嵌套模板:

cpp 复制代码
template<class T>
class vector {
    template<class T>   // 错误!外层已经有 T,这里的 T 遮蔽了外层的 T
    void foo() {}
};

这就是"模板参数遮蔽",后面在 Print_Vector 那节会具体讲。


三、整体结构设计

cpp 复制代码
namespace Jianyi
{
    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;
    };
}

放在 namespace Jianyi 里是为了和 std::vector 隔离,避免命名冲突。

成员初始化用了 C++11 的类内默认初始化= nullptr),这样默认构造函数什么都不用写,三根指针自动初始化为 nullptr,不会出现野指针。这是比在构造函数里手动赋值更现代、更安全的做法。


四、构造函数族:五种构造,各有门道

4.1 默认构造

cpp 复制代码
vector() {}

三根指针已经在成员初始化时置为 nullptr,默认构造函数什么都不用做,空函数体即可。

原理就是走的初始化列表

4.2 拷贝构造

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

逐元素追加。这个写法利用了 pushback 内部的扩容逻辑,不需要手动管理内存,代价是可能触发多次扩容。更高效的写法是先 reserve(v.size()),一次性分配好空间再追加,避免中间的扩容开销:

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

这里用范围 for 而不是下标循环,是因为 const vector<T> 需要 const 迭代器,范围 for 会自动调用 v.begin() 的 const 版本,代码更简洁。

4.3 迭代器区间构造

cpp 复制代码
template<class InputIterator>
vector(InputIterator first, InputIterator last)
{
    while (first != last)
    {
        pushback(*first);
        ++first;
    }
}

接受任意迭代器区间 [first, last),这是 STL 容器的通用设计。这里的模板参数 InputIterator 是一个新的参数,和类的模板参数 T 是两个独立的模板参数------函数模板嵌套在类模板里,完全合法。

这个构造函数允许你用另一个容器的迭代器来初始化 vector:

cpp 复制代码
std::list<int> lst = {1, 2, 3};
Jianyi::vector<int> v(lst.begin(), lst.end());  // 完全合法

比如当存整形的单链表不好排序时,可能就会不同的容器之间初始化或赋值。

4.4 n 个相同元素的构造

cpp 复制代码
vector(size_t n, const T& val = T())
{
    reserve(n);
    for (size_t i = 0; i < n; ++i)
        pushback(val);
}

T() 是 T 类型的值初始化 :对内置类型(int、double)是零初始化,对类类型是调用默认构造函数。这个写法使得 vector<int> v(5)vector<int> v(5, 0) 等价,和标准库行为一致。

这里有一个经典的歧义问题

cpp 复制代码
vector<int> v(3, 5);

编译器看到这行,既可以匹配 vector(size_t n, const T& val)(3 个 5),也可以匹配 vector(InputIterator first, InputIterator last)(把 3 和 5 当迭代器)。int 不是迭代器,但模板推导不管这个,它只看参数个数和类型能不能匹配------两个 int 完全可以匹配 InputIterator

标准库的解决方式是用 enable_if 或者 SFINAE 在模板推导时排除非迭代器类型,我们的实现没有做这个处理。在实际使用时,如果遇到歧义,可以显式把第一个参数写成 size_tvector<int> v((size_t)3, 5)


五、析构函数与 clear

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

void clear()
{
    _finish = _start;
}

析构函数释放 _start 指向的整块内存,然后把三根指针都清零。

clear() 只把 _finish 移回 _start,相当于"逻辑清空":元素个数变为 0,但内存不释放,capacity 不变。这和 std::vector::clear() 的语义一致------clear 之后 capacity() 不应该改变。

清空后不需要去逐个析构元素吗?对内置类型(int、double、指针)不需要,析构函数是空操作。对类类型,_finish 移回 _start 时,那些"已清除"的对象其实还留在内存里,只是被标记为无效------如果 T 持有资源(比如 T 是 string),这些资源不会被释放,直到整个 vector 析构时 delete[] _start 才会触发每个元素的析构函数。

这是我们的实现和标准库的一个差距。标准库的 clear() 会逐一调用每个元素的析构函数(通过 std::destroy),我们的实现跳过了这一步,对持有资源的类类型会有内存泄漏。


六、深拷贝、浅拷贝,以及 memcpy 的致命误区

我代码里有一行被注释掉的代码,值得单独讲清楚:

cpp 复制代码
void reserve(size_t n)
{
    size_t old_size = size();
    if (n > capacity())
    {
        T* tmp = new T[n];
        // memcpy(tmp, _start, size() * sizeof(T));  // ← 这行被注释掉了,为什么?
        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;
    }
}

memcpy 直接复制内存字节,对 T = int、double 这类内置类型 完全没问题。但如果 T = std::string 或者任何持有堆内存的类memcpy 就会制造灾难。

举个具体例子:vector<std::string>,每个 string 对象内部持有一个指向堆内存的指针。memcpy 把这些 string 对象的字节逐位复制到新数组,结果是新数组里的 string 和旧数组里的 string 持有相同的指针------同一块字符数据。

接下来 delete[] _start 析构旧数组,每个旧 string 的析构函数会释放它持有的字符内存。新数组里的 string 现在持有的是悬空指针,访问它们是未定义行为,稍后析构新数组时还会 double free。

正确做法是 tmp[i] = _start[i],这是赋值运算符,对 string 会调用深拷贝,给每个元素分配独立的内存。对内置类型,赋值就是复制值,和 memcpy 效果相同,但不会有问题。

结论:凡是涉及类类型的数组操作,必须走构造函数或赋值运算符,不能用 memcpy。 这是 C++ 的基本原则,违反它在内置类型上看起来没问题,但一旦换成类类型,bug 会藏得极深。


七、reserve:扩容的核心

cpp 复制代码
void reserve(size_t n)
{
    size_t old_size = size();
    if (n > capacity())
    {
        T* tmp = new T[n];
        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;
    }
}

有几个细节值得说:

为什么在 new 之前要保存 old_size

size() 的计算是 _finish - _start。执行 delete[] _start 之后,_start 被释放,但它的值(地址)还没变,而 _finish 也还是原来的值。用已释放的指针做运算是未定义行为。所以必须在 delete 之前把 size 存下来。

操作顺序是关键:

先 new,再复制数据,再 delete 旧内存,最后更新指针。这个顺序保证了异常安全:如果 new T[n] 抛出异常(内存不足),_start 还没被修改,对象状态完好。

reserve 只扩不缩:

if (n > capacity()) 保证了这一点。传入小于当前容量的 n,函数直接返回,不释放空间。这和标准库语义一致,也避免了不必要的内存操作。


八、赋值运算符:我写的那个隐蔽的语义错误

我的代码里有两个版本,一个注释掉的,一个实际使用的:

cpp 复制代码
// 实际使用的版本
vector<T>& operator=(const vector<T> v)  // 注意:传值,不是引用
{
    swap(v);
    return *this;
}

注释掉的版本错在哪?

if (*this != v) 这个判断本意是检测自赋值(v = v 这种情况),但 != 运算符比较的是对象的内容 ,不是地址。两个不同的 vector 对象完全可以内容相同,这样 *this != v 为 false,就会漏掉正常赋值。

举个 vector 的隐藏状态例子

但问题是:

赋值运算符不应该依赖"内容相同就不做"

因为:

内容比较本身代价巨大。

复制代码
if (*this != v)

这个 != 会干嘛?

vector 的 !=本质要:

复制代码
逐元素比较

复杂度:

O(n)


而标准自赋值检测:

复制代码
if (this != &v)

只是:

比较两个地址

复杂度:

O(1)


所以我现在其实是:

为了避免一次赋值,

先做了一次完整遍历比较。

这在工程上反而更亏。

自赋值判断应该比较对象地址:

复制代码
if (this != &v)

这是指针比较,精确判断"是不是同一个对象",和内容无关。这和 string 的赋值运算符里犯过的错是同一类:混淆了"指针相等"和"内容相等"。

实际使用的版本:传值参数 + swap

复制代码
vector<T>& operator=(const vector<T> v)
{
    swap(v);
    return *this;
}

参数 v传值 ,调用时会触发拷贝构造,v 是调用方传入对象的一份独立深拷贝。进入函数体后,swap(v)*thisv 的内部资源互换------*this 拿到新数据,v 拿走 *this 的旧资源,函数结束时 v 析构,旧资源被释放。

这是 copy-and-swap 惯用法,和 string 里用的是同一套思路。它天然处理了自赋值(v = v 时,传值构造出的 v 是独立的一份,swap 之后对象状态不变),也天然异常安全(拷贝失败直接抛异常,*this 不会被改动)。

但这里有一个微妙的语义问题swap 的参数应该是 vector<T>&(非 const 引用),而实际上 v 是一个值参数,调用 swap(v) 是合法的------v 是局部对象,可以被修改。但如果你不小心把 swap 的参数声明成 const vector<T>&,编译器会报错,这和 string 里犯过的那个 swap(const string& s) 错误完全一样。


九、pushback 与 popback:一个逻辑 bug 的解剖

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

bool Empty()
{
    return _start == _finish;
}

void popback()
{
    assert(Empty());   // ← 这里有 bug
    --_finish;
}

popback() 里的 assert(Empty()) 是一个逻辑 bug

Empty() 返回 true 表示容器为空,assert(Empty()) 的意思是"断言容器为空时才能 pop"------这完全反了。popback 的前提条件应该是容器非空,正确写法是:

cpp 复制代码
void popback()
{
    assert(!Empty());  // 非空才能 pop
    --_finish;
}

接下来时错误笔记,不需要可自行跳过

这类 bug 在编译期完全不会报错,测试时如果没有针对空容器 pop 的用例,也不会触发------在 debug 模式下 assert 会让程序在错误条件下崩溃,而 assert(Empty()) 反而会在容器非空(正常情况)时崩溃,在容器为空(错误情况)时通过。

这种"条件取反"的 bug 在逻辑判断密集的代码里极其常见,尤其是函数前置条件的检查。写 assert 的时候要问自己:我在断言的是调用这个函数的合法前提 ,还是断言的是我不希望发生的情况?

pushback 里的扩容策略是翻倍(capacity() * 2),初始给 4。翻倍策略保证均摊 O(1) 的追加代价:假设 n 次 pushback 触发了 \\log_2 n 次扩容,每次扩容复制的元素数量分别是 1、2、4、...、n,总复制次数是 O(n),均摊到每次 pushback 是 O(1)。这是动态数组的经典分析,面试里经常考。


十、insert:迭代器失效的第一现场

这是 vector 实现里最精彩的部分。我代码里注释掉了第一版,留着对比:

cpp 复制代码
// 注释掉的第一版(有严重 bug)
/*
void insert(iterator pos, const T& x)
{
    if (_finish == _end_of_storage)
    {
        reserve(capacity() == 0 ? 4 : capacity() * 2);
        // 扩容之后 pos 变成野指针!
    }
    for (iterator end = end(); end >= pos; --end)
    {
        _start[end + 1] = _start[end];  // 类型混用,end 是指针不是下标
    }
    *pos = x;
    ++_finish;
}
*/

// 正确版本
void insert(iterator pos, const T& x)
{
    if (_finish == _end_of_storage)
    {
        size_t len = pos - _start;  // ← 在 reserve 之前保存 pos 的相对位置
        reserve(capacity() == 0 ? 4 : capacity() * 2);
        pos = _start + len;         // ← 用新的 _start 重新计算 pos
    }
    iterator end = _finish - 1;
    while (end >= pos)
    {
        *(end + 1) = *end;
        --end;
    }
    *pos = x;
    ++_finish;
}

迭代器失效:

reserve 的内部操作是 new 一块新内存、把数据复制过去、delete 旧内存,然后让 _start 指向新内存。旧内存被释放了,但 pos 还指向旧内存------这就是迭代器失效 。继续使用 pos 是未定义行为,轻则崩溃,重则悄悄读到垃圾数据。

修复方法:

reserve 之前,用 len = pos - _start 记录 pos 相对于数组起点的偏移量(下标)。reserve 执行完后,_start 指向新数组,用 pos = _start + len 重新计算 pos,让它指向新数组中对应的位置。

这个"偏移量保存和恢复"的技巧在 vector 内部需要扩容的操作里是通用的。任何在扩容前保存的迭代器,扩容后都需要用这个方式更新,否则就失效了。

外部迭代器的失效:

一旦 reserve 被调用,任何外部持有的指向该 vector 元素的迭代器(指针)都会失效,包括调用方传入的 pos。这就是为什么标准库文档里说"insert 可能导致迭代器失效"------如果发生了扩容,所有旧迭代器都作废。

第一版的另一个问题:

复制代码
for (iterator end = end(); end >= pos; --end)
{
    _start[end + 1] = _start[end];  // end 是指针,不是整数下标
}

end 是一个 iterator(即 T*),_start[end + 1] 的下标应该是整数,用指针做下标在语法上合法(等价于 *((_start) + (end + 1))),但逻辑上是错的------这里把指针当成了整数来用,属于类型语义上的混乱。

正确的写法是在 while 循环里直接操作指针:*(end + 1) = *end,不需要通过 _start 下标。


十一、erase:两个版本,两种语义

cpp 复制代码
// 版本一:用下标
void erase(size_t pos)
{
    assert(pos < size());
    for (size_t i = pos; i < size(); ++i)
        _start[i] = _start[i + 1];
    --_finish;
}

// 版本二:用迭代器,返回迭代器
iterator erase(iterator pos)
{
    assert(pos >= _start);
    assert(pos < _finish);
    iterator it = pos + 1;
    while (it != end())
    {
        *(it - 1) = *it;
        ++it;
    }
    --_finish;
    return pos;
}

两个版本都是把 pos 之后的元素前移一格,然后把 _finish 往前移一个位置。

为什么 erase 要返回迭代器?

因为 erase 之后,原来 pos 位置的内容已经是"下一个元素"了(前移的结果)。如果调用方想继续遍历,直接用返回的 pos 就是正确的下一个元素,不需要 ++it(这是 vector 遍历中删除元素的正确模式,见下面的例子)。

如果 erase 不返回迭代器,调用方在删除元素后还继续 ++it,就会漏掉刚才移过来的那个元素:

cpp 复制代码
// 错误写法(会漏元素)
for (auto it = v.begin(); it != v.end(); ++it)
{
    if (*it == 要删除的值)
        v.erase(it);  // 删完之后 ++it 会跳过移上来的元素
}

// 正确写法
for (auto it = v.begin(); it != v.end(); )
{
    if (*it == 要删除的值)
        it = v.erase(it);  // erase 返回下一个位置,不需要 ++
    else
        ++it;
}

下标版本的越界问题:

for (size_t i = pos; i < size(); ++i) 循环里,当 i = size() - 1 时,_start[i + 1] 访问的是 _start[size()],即 _finish 所在的位置。这个位置不是有效元素,但内存是已分配的(capacity 还没到),访问不会导致越界崩溃,只是把越界位置的值复制了过来。

由于紧接着执行 --_finish,这个"额外复制的值"会立刻变成 _finish 指向的无效区域,不会被外部看到。所以这里虽然访问了"合法内存但无效元素",在我们的实现里是安全的。但从严格意义上说,访问有效元素范围之外(即使在 capacity 范围内)是设计边界问题,更健壮的写法是循环到 i < size() - 1,然后单独处理最后一步。


十二、resize:和 reserve 的本质区别

cpp 复制代码
void resize(size_t n, T val = T())
{
    if (n < size())
    {
        _finish = _start + n;
    }
    else
    {
        reserve(n);
        while (_finish < _start + n)
        {
            *_finish = val;
            ++_finish;
        }
    }
}

reserveresize 是两个经常被混淆的函数,它们操作的是不同的东西:

reserve resize
影响 capacity 可能是(如果 n > capacity)
影响 size
初始化新元素 是(用 val 填充)

reserve 只预留空间,不创建元素,size() 不变。resize 直接改变元素个数:缩小时把 _finish 前移(类似 clear 的逻辑),扩大时先 reserve 再逐一填充新元素。

扩大时用 val = T() 填充,T() 是值初始化,对 int 是 0,对 string 是空字符串,对自定义类是默认构造结果。


十三、Print_Vector:一个成员函数里的模板遮蔽问题

这是我代码里一个写法上的严重问题,值得单独拿出来讲:

cpp 复制代码
template<class T>
class vector
{
public:
    template<class T>           // ← 问题在这里
    void Print_Vector(const vector<T>& v)
    {
        // ...
    }
};

外层类模板已经有了类型参数 T。内部的成员函数又定义了一个同名的模板参数 T,内层的 T遮蔽 (shadow)外层的 T。在函数体内,T 指的是内层的那个,外层类的 T 被遮蔽,无法访问。这在语法上合法,但语义上混乱,编译器通常会给出警告。

更根本的问题是:Print_Vector 不应该是成员函数。

成员函数的职责是操作对象自身的状态(_start_finish_end_of_storage),但 Print_Vector 接受的是参数 v,打印的也是 v,和 *this 没有关系。这违反了成员函数的设计原则。

正确做法是把它写成非成员函数,放在类外面:

cpp 复制代码
template<class T>
void Print_Vector(const vector<T>& v)
{
    for (auto e : v)
        cout << e << " ";
    cout << endl;
}

或者重载 operator<<,这样可以直接 cout << v,更符合 C++ 的惯用法:

cpp 复制代码
template<class T>
ostream& operator<<(ostream& out, const vector<T>& v)
{
    for (auto e : v)
        out << e << " ";
    return out;
}

注意这里 operator<< 必须是非成员函数,原因和 string 博客里讲的一样:<< 的左操作数是 ostream,如果写成成员函数,this 就是左操作数,那就得是 ostream 的成员函数,而 ostream 是标准库的类,我们无法修改它。


十四、迭代器:指针就是最好的迭代器

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

iterator begin() { return _start; }
iterator end()   { return _finish; }

const_iterator begin() const { return _start; }
const_iterator end()   const { return _finish; }

vector 的迭代器直接用指针,没有任何包装。原因很简单:vector 的底层就是连续数组,指针支持 ++--+n-n*-> 等所有迭代器操作,完美满足 random access iterator 的要求。

begin() 和 end() 各有两个重载:一个返回 iterator(非 const 对象调用),一个返回 const_iterator(const 对象调用)。这是 C++ const 正确性的基本要求:对 const 对象,通过迭代器得到的元素不能被修改。

如果只有非 const 版本,const vector<T>& v 传进来的对象调用 v.begin() 就会报错------const 对象不能调用非 const 成员函数。这是实现容器时很容易遗漏的一对。


十五、operator[]:为什么要写两个版本

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

const T& operator[](size_t n) const
{
    assert(n < size());
    return *(_start + n);
}

同样的道理:第一个返回 T&,允许修改元素(v[0] = 5);第二个是 const 版本,返回 const T&,只读。

如果只有非 const 版本,const vector<T>& v 就无法用 v[i] 读取元素,因为 operator[] 不是 const 成员函数,const 对象不能调用它。

这对重载在 STL 里随处可见,是实现 const 正确性的标准模式。写任何容器类时,凡是读取元素的接口,都要想想:const 版本是否也需要提供?


十六、整体回顾:vector 和 string 的对比思考

写完这两个类之后,我觉得可以从更高的视角做一个对比:

资源管理语义上:

string 的资源是一块 char 数组,语义固定。vector 的资源是一块 T 类型的数组,语义依赖 T------如果 T 是内置类型,资源管理和 string 基本一样;如果 T 是类类型,每个元素自己也有资源,析构时的语义链更长。这也是 memcpy 在 vector 里不能用的根本原因。

模板的代价:

string 是具体类,定义可以放在 .cpp 里,编译单元独立。vector 是类模板,定义必须对所有调用方可见,实际上把"实现"暴露给了头文件。这是泛型编程在 C++ 里的基本代价。

迭代器失效:

string 也有类似的问题(扩容后旧指针失效),但在 string 内部,用户接触不到这个细节。vector 的迭代器失效是面向用户的问题------任何可能触发扩容的操作(push_back、insert、reserve、resize),之前保存的迭代器全部失效,这是 vector 使用时最大的陷阱之一。

初始化语义:

string 的元素是 char,初始化语义简单。vector 的元素是 T,扩容时 new T[n] 会对新分配的元素进行值初始化(调用默认构造函数),这对内置类型是零初始化,对类类型是默认构造。这个初始化成本在频繁扩容时不可忽略。


总结

这次实现 vector,让我对以下几件事有了清晰的认识:

memcpy 和赋值运算符的区别不是性能问题,而是语义问题------前者不懂类型,后者尊重类型的复制语义,对持有资源的类,两者的差距是"能用"和"undefined behavior"的差距。

模板类不能声明和定义分离,是编译模型决定的,不是语言限制,而是翻译单元在实例化时需要看到完整定义这一事实的结果。

迭代器失效不是"有时会发生的偶发问题",而是结构性的必然------任何重新分配内存的操作,旧迭代器一定失效,这是指针语义的基本属性。

assert 的条件是"函数正常工作的前提",不是"我不希望发生的情况"的反向描述。assert(!Empty())assert(Empty()) 的区别,就是程序在正常路径崩溃和在错误路径崩溃的区别。

成员函数的边界应该是"操作 *this 的状态",不操作自身状态的函数,应该是非成员函数或友元。模板参数同名遮蔽是编译器允许但人脑容易出错的陷阱,避免它最好的方式是从设计上消除它的必要性。

相关推荐
时空系1 小时前
第7篇:功能——打造你的工具箱 Rust中文编程
开发语言·网络·rust
csbysj20201 小时前
CSS !important:深度解析与最佳实践
开发语言
初心未改HD1 小时前
Go语言测试与Benchmark:测试驱动开发的实践指南
开发语言·golang
代码不停1 小时前
BFS解决floodfill算法题目练习
算法·宽度优先
chxii2 小时前
lua流程控制语句和table(表)数据结构
开发语言·junit·lua
上弦月-编程2 小时前
C语言指针从入门到实战
java·jvm·算法
WL_Aurora2 小时前
Python 算法基础篇之树和二叉树
python·算法
txzrxz2 小时前
关于前缀和
算法·动态规划·图论
杨连江2 小时前
载流子矩阵限域束缚实现常温常压超导的理论与结构设计
算法