重生之从0开始学习c++之——vector-中篇

本文接着上篇,把 vector 的增删补上:resize、insert、erase,以及它们各自踩的坑。

上篇写到 reserve,这篇写到 erase。下篇讲拷贝控制。

文章目录

    • 一、总体浏览
      • [1.1 vector.h](#1.1 vector.h)
      • [1.2 test.cpp](#1.2 test.cpp)
    • 二、先把尾巴上的几个短接口说完
      • [2.1 push_back](#2.1 push_back)
      • [2.2 pop_back:为什么只动 _finish](#2.2 pop_back:为什么只动 _finish)
      • [2.3 clear](#2.3 clear)
      • [2.4 operator\[\]](#2.4 operator[])
    • 三、改长度:resize
      • [3.1 两个分支](#3.1 两个分支)
      • [3.2 这里我卡了很久:resize(5, 2) 里的 2 到底干嘛用的](#3.2 这里我卡了很久:resize(5, 2) 里的 2 到底干嘛用的)
    • 四、插入:insert
      • [4.1 先想想怎么腾位置](#4.1 先想想怎么腾位置)
      • [4.2 那么为什么要从后往前搬呢](#4.2 那么为什么要从后往前搬呢)
      • [4.3 迭代器失效:这个坑最阴](#4.3 迭代器失效:这个坑最阴)
      • [4.4 完整代码](#4.4 完整代码)
    • 五、删除:erase
      • [5.1 从前往后搬](#5.1 从前往后搬)
      • [5.2 这里我把 <= 写成了 <,用 int 测不出来](#5.2 这里我把 <= 写成了 <,用 int 测不出来)
    • 六、小结

一、总体浏览

先把这篇要用的完整代码贴出来,后面再一个个拆。

1.1 vector.h

cpp 复制代码
#pragma once

#include <assert.h>
#include <iostream>

namespace Confidence
{
    template<class T>
    class vector
    {
    public:
        typedef T* iterator;
        typedef const T* const_iterator;

        vector() = default;

        ~vector()
        {
            if (_start != nullptr)
            {
                delete[] _start;
                _start = _finish = _end_of_storage = nullptr;
            }
        }

        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;
        }

        void reserve(size_t n)
        {
            if (n > capacity())
            {
                size_t old_size = size();
                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;
            }
        }

        T& operator[](size_t i)
        {
            assert(i < size());
            return _start[i];
        }

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

        void push_back(const T& x)
        {
            if (_finish == _end_of_storage)
            {
                reserve(capacity() == 0 ? 4 : capacity() * 2);
            }
            *_finish = x;
            ++_finish;
        }

        void pop_back()
        {
            assert(!empty());
            --_finish;
        }

        void clear()
        {
            _finish = _start;
        }

        void resize(size_t n, const T& x = T())
        {
            if (n < size())
            {
                _finish = _start + n;
            }
            else
            {
                reserve(n);
                while (_finish < _start + n)
                {
                    *_finish = x;
                    ++_finish;
                }
            }
        }

        iterator insert(iterator pos, const T& x = T())
        {
            assert(pos >= _start);
            assert(pos <= _finish);

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

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

            return pos;
        }

        iterator erase(iterator pos)
        {
            assert(pos >= _start);
            assert(pos < _finish);

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

            return pos;
        }

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

1.2 test.cpp

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string>
using namespace std;
#include "vector.h"

int main()
{
    Confidence::vector<int> v;
    for (int i = 1; i <= 5; i++) v.push_back(i);

    v.insert(v.begin() + 2, 99);
    for (auto e : v) cout << e << " ";
    cout << endl;

    v.erase(v.begin() + 2);
    for (auto e : v) cout << e << " ";
    cout << endl;

    v.resize(8, 7);
    for (auto e : v) cout << e << " ";
    cout << endl;

    return 0;
}

运行结果:

text 复制代码
1 2 99 3 4 5
1 2 3 4 5
1 2 3 4 5 7 7 7

那么接下来我们一个个说,这些玩意儿到底是怎么写的。


二、先把尾巴上的几个短接口说完

2.1 push_back

上篇已经写过了,这里再贴一下。

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

就是先判断满没满,满了扩容,然后往 _finish 那个位置写,写完 _finish 往前走一格。

2.2 pop_back:为什么只动 _finish

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

那么为什么一行 --_finish 就算删掉了呢?

因为在这个实现里,「元素到底有几个」这件事完全由 _finish 决定。size() 就是 _finish - _start,你把 _finish 往前挪一格,size 就少一个,最后一个元素就不算数了。

text 复制代码
删之前:
  _start                          _finish
    |                                |
    v                                v
  ┌───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │
  └───┴───┴───┴───┘
    └──── size=4 ────┘

pop_back 之后:
  _start                  _finish
    |                       |
    v                       v
  ┌───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │      ← 这个 4 还在内存里,但已经不算数了
  └───┴───┴───┴───┘
    └── size=3 ──┘

那有没有人问,这个 4 不删掉不会漏内存吗?不会。它是 int,没有析构函数。而且下次 push_back 的时候会直接从 _finish 这个位置写,正好把它覆盖掉。

所以删元素这件事,本质上是改指针,不是擦内存。

2.3 clear

cpp 复制代码
void clear()
{
    _finish = _start;
}

一样的意思,_finish 直接回到 _start,size 变成 0,整个容器就空了。容量不还,下次 push_back 不用重新申请。

2.4 operator\[\]

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

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

那么这里为什么返回的是引用而不是值呢?

因为我们要能改它。vector v 里 v0 = 999 这行能生效,靠的就是 operator\[\] 返回的是 _start0 这个位置上元素的引用,改它等于直接改内存。要是返回 T(按值),那改的就是一个临时拷贝,v 里面屁事没有。

所以两个版本都要写,非 const 的返回 T&,const 的返回 const T&,跟前面 begin/end 一个道理。

至于那个 assert,是拦越界的。i < size() 说明只允许访问已经放进去的元素,容量后面那些空位不能碰。


三、改长度:resize

3.1 两个分支

resize(n, x) 的意思是,把 size 改成 n。这里分两种情况,n 比原来的 size 小,或者 n 比原来的 size 大。

cpp 复制代码
void resize(size_t n, const T& x = T())
{
    if (n < size())                       // 一、变短
    {
        _finish = _start + n;
    }
    else                                  // 二、变长
    {
        reserve(n);
        while (_finish < _start + n)
        {
            *_finish = x;
            ++_finish;
        }
    }
}

变短那条分支简单得让人怀疑,就一行 _finish = _start + n;,跟 pop_back 一个套路,_finish 前移,后面的元素就不算数了。

变长那条分支要做两件事。第一步 reserve(n) 保证容量够,第二步从当前位置一路填到第 n 个。

那么这里为什么用 while 不用 for?用 for 也行,写成这样。

cpp 复制代码
for (size_t i = size(); i < n; i++)
{
    _start[i] = x;
}
_finish = _start + n;

两种都对。我这里用指针版,是因为 vector 的母语就是指针运算,后面 insert 和 erase 全是这种 ++ / -- 的动作,早点习惯有好处。

3.2 这里我卡了很久:resize(5, 2) 里的 2 到底干嘛用的

我写到这里的时候是真的卡住了。当时的疑惑是:假如原先有 10 个 1,那我 resize(5, 2),这个 2 怎么办?是把元素都改成 2 了吗?还是被忽略掉了?

后来想了想才明白,这个 2 是被完全忽略的,一次都不会被读。

为什么?因为 resize 的第二个参数是「填充值」,它的使命只有一个,就是在需要加长的时候,拿去填新长出来的格子。缩短不产生新格子,所以它用不上。

我们把三种调用摆在一起看就清楚了。

调用 size 变化 第二个参数 2 的命运 已有元素
resize(5, 2) 10 → 5 完全被忽略(没有新格子) 前 5 个保留,后 5 个作废
resize(10, 2) 10 → 10 完全被忽略(一个格子都不加) 10 个 1 全部保留
resize(15, 2) 10 → 15 填进 5 个新格子 10 个 1 全部保留

画个图看看。

text 复制代码
【情况一】resize(5, 2),缩短

  resize 之前: size = 10
  ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
  │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │
  └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
    ↑                                   ↑
  _start                              _finish

  resize(5, 2) 之后: size = 5
  ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
  │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │
  └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
    ↑           ↑
  _start      _finish
    └─ size=5 ─┘
                        └── 后面这 5 个还在内存里,但不算数了

  那个 2:全程没被读过一次


【情况二】resize(15, 2),变长

  resize 之后: size = 15
  ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
  │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 2 │ 2 │ 2 │ 2 │ 2 │
  └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
    └────────── 原来 10 个 1,一个都没变 ──────────┘└── 新填 5 个 2 ──┘

所以这里要记住一句话:resize 的第二个参数,永远不会修改你已经有的元素,它只负责填新长出来的格子。

如果你想把已有的元素全部改成某个值,那得用 assign 或者自己写个循环,resize 不干这个事。


四、插入:insert

4.1 先想想怎么腾位置

insert 要做的事情是,在 pos 这个位置插一个元素进去。

那么问题来了,那个位置本来就有元素,你插进去以后它的位置不就没了吗?

所以必须先腾位置。办法是把 pos 以及它后面的所有元素,全部往后挪一格,这样 pos 那个位置就空出来了,然后把新值写进去。

text 复制代码
【原始状态】size = 5,要在 3 的前面插入 99

  _start       pos                        _finish
    |           |                            |
    v           v                            v
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │
  └───┴───┴───┴───┴───┘
              ↑
        想把 99 插在这


【挪完之后】

  ┌───┬───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 99│ 3 │ 4 │ 5 │
  └───┴───┴───┴───┴───┴───┘
                                    size 从 5 变成 6

4.2 那么为什么要从后往前搬呢

这个是我当时想了一下才通的地方。

假设我们偷懒,从 pos 开始往后面搬,会怎么样?

text 复制代码
  原数据:  ┌───┬───┬───┬───┬───┐
            │ 1 │ 2 │ 3 │ 4 │ 5 │
            └───┴───┴───┴───┴───┘
                    ↑
                   pos

  第一步:把 3 搬到后面一格
            ┌───┬───┬───┬───┬───┐
            │ 1 │ 2 │ 3 │ 3 │ 5 │     ← 原来的 4 被 3 覆盖掉了!
            └───┴───┴───┴───┴───┘

  第二步:把(本来是 4 的)位置的值搬到后面
            ┌───┬───┬───┬───┬───┐
            │ 1 │ 2 │ 3 │ 3 │ 3 │     ← 4 已经没了,只能搬 3
            └───┴───┴───┴───┴───┘

看到没有,数据被自己踩坏了。

所以必须从最后一个元素开始,一个接一个往后搬。后面那格的数据先被搬走,前面才敢覆盖。

text 复制代码
  从后往前(对的):搬 5 → 搬 4 → 搬 3,每次搬的目标格都是还空着的
  从前往后(错的):搬 3 把 4 覆盖了,4 永远丢了

这个思路其实我们在 C 语言里见过,就是「在数组中间插一个数」那道题,vector 的 insert 就是那道题的标准答案。

4.3 迭代器失效:这个坑最阴

上面那套搬法,是假设容器还有空位的情况。如果满了呢?

满了就得先扩容,而扩容会把内存搬家。那么问题来了,我们手里的 pos 是个指针,扩容之后它还指得对地方吗?

text 复制代码
【扩容前】
  旧内存 0x1000:
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │      size = 5, capacity = 5,满了
  └───┴───┴───┴───┴───┘
            ↑
          pos 的值是 0x1000 + 2 那个地址

【reserve 内部干了什么】
  一、new T[10]        → 申请新内存,假设是 0x2000
  二、把旧数据搬过去
  三、delete[] _start  → 旧内存 0x1000 还给系统了
  四、_start 指向 0x2000

【扩容后】
  旧内存 0x1000:已经不归你了,pos 还指着它 → 野指针
  新内存 0x2000:
  ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │   │   │   │   │   │
  └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
            ↑
        这才是「3 的位置」,但 pos 不知道

所以扩容前必须先把「第几格」这个数字记下来,扩容之后用新地址加上这个数字重新算一遍。

cpp 复制代码
size_t len = pos - _start;                                    // 一、记下"第几格"
reserve(capacity() == 0 ? 4 : capacity() * 2);                // 二、扩容,地址全变了
pos = _start + len;                                           // 三、用新地址重算

那么这个地方,就是「迭代器失效」这个词具体指的东西。

迭代器(对 vector 来说就是指针)只在内存没搬过家的时候有效。扩容、reserve 之后,之前拿到的所有指针全部作废,必须重新取。

对比一下 list,list 的元素是一个个独立节点,扩容(其实是插节点)不会移动已有节点,所以 list 的迭代器不会失效。vector 快但迭代器脆,list 慢但迭代器稳,就是这么来的。

顺便说一个我当时也踩过的小坑:我在那行写成了 iterator end = _finish - 1;,这个变量名叫 end,跟成员函数 end() 重名了。这个函数里没调用过 end() 所以没事,但你要是哪天在里面写了一句 end(),编译器会报一堆很莫名其妙的错。改个名字(比如 cur、last)就没事了。

4.4 完整代码

cpp 复制代码
iterator insert(iterator pos, const T& x = T())
{
    assert(pos >= _start);
    assert(pos <= _finish);              // 注意是 <=,插到末尾也算合法

    // 一、满了要先扩容,扩容会搬内存,pos 得重新算
    if (_finish == _end_of_storage)
    {
        size_t len = pos - _start;
        reserve(capacity() == 0 ? 4 : capacity() * 2);
        pos = _start + len;
    }

    // 二、从最后一个元素开始,一个个往后挪一格
    iterator end = _finish - 1;
    while (end >= pos)
    {
        *(end + 1) = *(end);
        --end;
    }

    // 三、空出来的位置放新值,size 加一
    *pos = x;
    ++_finish;

    return pos;
}

这里为什么返回 pos 呢?因为我们可能想接着用这个位置,比如 auto it = v.insert(v.begin(), 100); 之后 *it = 200; 直接改它。要是返回 void,那你就得自己再算一遍。


五、删除:erase

5.1 从前往后搬

erase 跟 insert 正好是反过来的。insert 是腾出一格,从后往前搬。erase 是填上一个坑,从前往后搬。

text 复制代码
【删除前】删掉 3

  _start       pos                        _finish
    |           |                            |
    v           v                            v
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │
  └───┴───┴───┴───┴───┘

【第一步:把 4 拉过来填坑】
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 4 │ 4 │ 5 │      ← 3 被 4 覆盖,3 就没了
  └───┴───┴───┴───┴───┘

【第二步:把 5 拉过来填 4 留下的坑】
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 4 │ 5 │ 5 │
  └───┴───┴───┴───┴───┘

【第三步:_finish 前移一格,最后一个 5 不算数】
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 4 │ 5 │ 5 │
  └───┴───┴───┴───┴───┘
    └──── size=4 ────┘

那么这里为什么是从前往后呢?因为你是拿后面的元素来填前面的坑,填完第一个坑,第二个坑就出现了,接着填。要是从后往前搬,那后面的数据就先被你踩坏了。

5.2 这里我把 <= 写成了 <,用 int 测不出来

这个坑是我这几个坑里最阴的一个,我到现在还记得。

当时我写的是 while (end <= _finish),多了一个等号。

程序不但没崩,输出还完全正确。我当时还以为写对了。

直到我拿 vector 测了一次,直接崩了。

为什么会这样?我们拿 1 2 3 4 5 来推一遍。

轮次 end 指向 end <= _finish 干了什么 数组变成
1 下标 3 3 <= 5 成立 2 = 3,4 搬过来 1 2 4 4 5
2 下标 4 4 <= 5 成立 3 = 4,5 搬过来 1 2 4 5 5
3 下标 5 5 <= 5 成立 4 = 5,读了下标 5 1 2 4 5 垃圾
4 下标 6 6 <= 5 不成立 退出 ---

然后 --_finish,size 从 5 变成 4,有效元素是下标 0 到 3,也就是 1 2 4 5,看起来完全正确。

那么问题出在第三轮。

第三轮干的事是 _start4 = _start5。而 _start5 是什么?

text 复制代码
  ┌───┬───┬───┬───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │ ? │ ? │ ? │
  └───┴───┴───┴───┴───┴───┴───┴───┘
   0   1   2   3   4   5
                     ↑
                  _finish 在这

_start5 这个格子,申请了内存,但从来没往里放过东西,里面是内存里的随机残留。

所以第三轮等于把一块垃圾搬进了第 5 格。

那为什么看起来没事呢?因为紧接着 --_finish,size 变成 4,第 5 格不算数了,垃圾躺在一个作废的格子里,谁也不会去读它。下次 push_back 正好把它覆盖掉。

但是有两种情况会真的出事。

第一种,容量正好等于 size 的时候。

如果 capacity() 就是 5,那 _start5 已经在申请范围之外了,读它叫越界读,属于未定义行为。

第二种,T 是 string 这种自己管内存的类型。

这一条更狠。vector 里,_start5 那个位置从来没构造过 string 对象,你对它做赋值,等于读一个「还不存在」的对象,它内部的指针是随机垃圾,赋值的时候按那个随机地址去访问,程序直接崩。

所以对 int 是浪费一次读写,对 string 是当场崩溃。这也是为什么这个坑拿 int 测不出来。

正确的写法是把等号去掉。

cpp 复制代码
iterator erase(iterator pos)
{
    assert(pos >= _start);
    assert(pos < _finish);           // 注意是 <,不是 <=

    iterator end = pos + 1;
    while (end < _finish)            // 注意是 <,不是 <=
    {
        *(end - 1) = *(end);
        ++end;
    }
    --_finish;

    return pos;
}

那么这里还有一个地方要小心,就是那个 assert。

erase 的断言是 pos < _finish,比 insert 的 pos <= _finish 少一个等号。为什么呢?因为 pos == _finish 说明你指向的是最后一个元素的下一格,那里是空的,没有元素可以让你删。要是放过去,后面 --_finish 会把一个本来不该删的元素删掉。

现象:程序输出全对,但换成 vector 直接崩。

原因:循环多跑了一轮,读了一个从未构造过的对象。

教训:小数据和简单类型测试通过,不等于代码正确。


六、小结

到这里增删就全写完了。

函数 要做的事 要搬数据吗
push_back 尾部加一个 不用
pop_back 尾部减一个 不用
clear 全清 不用
resize(变短) _finish 前移 不用
resize(变长) 用 x 填新格子 不用
insert 腾位置,从后往前搬 要
erase 填坑,从前往后搬 要

那么这里有个规律看出来了吧,所有「不搬数据」的操作,干的都是同一件事,就是动 _finish。所有「要搬数据」的操作,都是在 insert 和 erase 这两个位置中间动刀子。

下篇我们讲拷贝控制。那一篇会讲清楚一件事,就是现在这份代码,你要是写一句 vector v2 = v1; 会发生什么。答案是程序会在析构的时候 double free 崩掉,因为编译器自动生成的拷贝构造是浅拷贝。

相关推荐
架构师伯子明15 分钟前
ffmpeg学习4:opencv在音视频领域常用指令
opencv·学习·ffmpeg
ShineWinsu3 小时前
对于Qt:QDial常见属性以及信号的解析
开发语言·c++·qt·gui·客户端·旋钮·qdial
科技苑11 小时前
C++ CAS与ABA问题详解,无锁编程的经典陷阱怎么解决?
c++·人工智能
GoAI11 小时前
DeepSeek Harness Memory (一)主流记忆插件总结
后端·算法·面试
Pniubi12 小时前
力扣54.螺旋矩阵
算法·leetcode·矩阵
wangqiaowq13 小时前
Ontology 学习
学习
choumou_M14 小时前
算法_1:由浅入深学习快速排序QuickSort
学习·算法·排序算法
Coder_Shenshen15 小时前
罗克韦尔ACD工程归档离线变量树解析
算法·c#