本文接着上篇,把 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 崩掉,因为编译器自动生成的拷贝构造是浅拷贝。