ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写 vector:从源码到测试,逐函数拆解一个 STL 容器

手写 vector:从源码到测试,逐函数拆解一个 STL 容器 一句话读懂这份源码它用三个指针_start / _finish / _endofstorage加裸指针迭代器复刻了标准库 vector 的核心行为。三个必会点①size() / capacity()本质就是两个指针差②扩容会让所有指针失效所以insert要在扩容后用“偏移量重定位”③模板区间构造靠enable_if_t的 SFINAE 技巧与int / size_t双重载避免和“个数 值”构造冲突。文末列出了几处与标准库不一致、值得你修改的隐患。概览这份 vector 的结构与设计源码放在命名空间v里是一个templateclass T的类模板。整个类只有三个指针成员所有操作都围绕它们展开这也是 STL 容器的核心抽象用一个对象封装“一段连续内存”及其边界。iterator _start nullptr; // 指向元素数组的起始iterator _finish nullptr; // 指向最后一个元素的下一个位置iterator _endofstorage nullptr; // 指向已分配内存的末尾迭代器就是裸指针T*因为 vector 的元素是连续存放的所以begin()直接返回_start、end()返回_finish。这正是 vector 迭代器是“随机访问迭代器”、且效率与裸指针一样高的原因。两个关键量都是指针差size() _finish - _start已用元素个数capacity() _endofstorage - _start已分配容量。读者在后面的测试里会反复看到它们。三个指针的相对关系恒有_start ≤ _finish ≤ _endofstorage其中[_start, _finish)是有效元素[_finish, _endofstorage)是预留的空白空间。图中_finish与_endofstorage之间的灰色格子就是“预留容量”——平时 push 元素不必扩容只有填满_finish _endofstorage才需要扩容。构造家族五个构造 两个防歧义技巧构造是这份代码里最讲究的部分。它一共有五个构造其中“个数 值”构造和“迭代器区间”构造之间有一个很容易打架的地方作者用了两个技巧来化解。默认构造vector() defaultvector() default; default是 C11 的“显式默认”写法让编译器生成默认构造。三个指针成员都有类内初始化 nullptr所以默认构造得到一个空的 vector。被注释掉的vector(){}也能用但不推荐一旦你手写了构造函数编译器就可能不再生成某些默认行为 default语义更清晰、更安全。“个数 值”构造size_t与int两个重载vector(size_t n, const T val T()) { resize(n, val); } vector(int n, const T val T()) { resize(n, val); }这是填充构造造出n个值为val的元素val默认是T()。为什么写两个重载因为调用vector(10, 5)时实参是int。如果只有size_t版本int也能隐式转成size_t而调用成功但更大的问题是它会和下面的“区间构造模板”产生歧义——两个int会被当成“迭代器区间”去匹配模板。多给一个int重载就排除了这种歧义。迭代器区间构造与 SFINAEenable_if_t_Is_iterator_vInputIterator, int 0templateclass InputIterator,enable_if_t_Is_iterator_vInputIterator, int 0 vector(InputIterator first, InputIterator last) { while (first ! last) { push_back(*first); first; } }这是区间构造从[first, last)两个迭代器之间把元素依次拷进新 vector。测试里v3(v1.begin() 1, v1.end() - 1)走的就是这条路。关键难点如果写成普通的templateclass InputIterator那么vector(10, 5)里的10和5也会匹配这个模板把int当成 InputIterator整个调用就含糊了。解决模板参数里的enable_if_t_Is_iterator_vInputIterator, int 0就是模板元编程 / SFINAESubstitution Failure Is Not An Error替换失败不算错误。当InputIterator不是真正的迭代器类型时enable_if_t会变成非法类型这个重载被移出候选集合于是vector(10, 5)老老实实走“个数 值”重载。注释里那句“判断是不是迭代器如果不是就不调用实例化”说的正是这个机制。拷贝构造vector(const vectorT v) { reserve(v.capacity()); for (auto e : v) push_back(e); }先reserve(v.capacity())一次把容量预留够再逐个push_back——避免在拷贝过程中反复扩容。注意这里预留的是v.capacity()而不是v.size()所以拷贝后 capacity 与原对象一致这比标准库“恰好装下”略奢侈但逻辑没错。列表初始化构造initializer_listvector(initializer_listT li) { reserve(li.size()); for (auto e : li) push_back(e); }花括号v4 {1, 2, 3, 4, 5}会触发这个构造。先按列表大小预留容量再逐个拷贝。这是“用花括号初始化容器”的底层支持也是日常写vectorint v{1,2,3}能用的原因。构造触发写法要点默认构造vector() default三个指针为 nullptr个数 值vector(10, 5)size_t / int 双重载防与区间构造歧义迭代器区间vector(it1, it2)enable_if 的 SFINAE 只接受真迭代器拷贝构造vector v2(v1)reserve 预留后逐个 push_back列表初始化vector v{1,2,3}initializer_list按大小预留资源管理析构与“拷贝交换”赋值析构函数~vector() { delete[] _start; _start _finish _endofstorage nullptr; }用delete[]释放整块动态数组注意必须带[]因为是用new[]分配的。释放后把三个指针都置空是良好的防御习惯避免悬空指针。赋值运算符copy-and-swap拷贝交换void swap(vectorT v) { std::swap(_start, v._start); std::swap(_finish, v._finish); std::swap(_endofstorage, v._endofstorage); } vectorT operator(vectorT v) { swap(v); return *this; }operator以值传递接收参数v这本身就是一次拷贝再调用swap交换三个指针。这就是经典的copy-and-swap既处理了自赋值又做到强异常安全——如果拷贝构造抛异常原对象保持不变指针交换本身不会失败。代价是赋值必有一次拷贝但作为教学实现完全可接受。标准库用的是移动语义 拷贝并交换的混合优化。容量与元素操作扩容reserve为什么不能用 memcpyvoid reserve(size_t n) { if (n capacity()) { size_t old_size size(); T* tmp new T[n]; if (_start) { // memcpy(tmp, _start, sizeof(T) * old_size); // 浅拷贝危险 for (size_t i 0; i old_size; i) tmp[i] _start[i]; delete[] _start; } _start tmp; _finish _start old_size; _endofstorage _start n; } }reserve只改变capacity不改变size申请新数组 → 拷贝旧元素 → 释放旧数组 → 更新三个指针。核心不能用memcpy必须逐个赋值。因为memcpy是浅拷贝——如果T是std::string这类带资源的类型浅拷贝会让新旧数组里的两个 string 指向同一块堆内存析构时二次释放 / 悬空。逐元素tmp[i] _start[i]走的是拷贝赋值能正确地深拷贝。if (_start)处理第一次扩容此时_start为 nullptr不需要拷贝也不用 delete。扩容会让所有旧指针 / 迭代器失效这是下一节 insert 必须“重定位”的根本原因。调整大小resizevoid resize(size_t n, T val T()) { if (n capacity()) { reserve(n); while (_finish ! _start n) { *_finish val; _finish; } } else { _finish _start n; } }变长时先扩容再把[_finish, _startn)的新位置全部填成val。变短时直接把_finish拨回到_start n逻辑上丢弃后面的元素不释放内存。push_back / pop_back / empty / clear / operator[]void push_back(T n) { if (_finish _endofstorage) { size_t newcapacity capacity() 0 ? 4 : 2 * capacity(); reserve(newcapacity); } *_finish n; _finish; } void pop_back() { assert(!empty()); --_finish; } void clear() { _finish _start; } //重载[] T operator[](size_t n) { assert(n size()); return _start[n]; } const T operator[](size_t n)const { assert(n size()); return _start[n]; }push_back满了就按“空给 4否则翻倍”扩容再在_finish位置写入并前移一位。pop_back用assert(!empty())拦住空容器弹出然后--_finish。clear把_finish拨回_start逻辑清空元素仍留在内存里。operator[]带assert(n size())越界检查并提供 const / 非 const 两个版本const 版本返回 const 引用保证只读。难点insert 与 erase 的迭代器失效这两个函数是手写容器最考验功力的部分因为扩容和删除都会让旧指针失效必须用返回值或偏移量把“当前位置”重新拿回来。insert扩容重定位 后移搬移 返回插入位置iterator insert(iterator pos, const T x) { assert(pos _start); assert(pos _finish); if (_finish _endofstorage) { //开辟新空间 pos的指针指向也发生改变 size_t s pos - _start; size_t newcapacity capacity() 0 ? 4 : 2 * capacity(); reserve(newcapacity); pos _start s; } iterator end _finish - 1 ; while(end pos) { *(end1) *end; --end; } *pos x; _finish; return pos; }扩容重定位是关键reserve会把旧数组整个搬走扩容前拿到的pos裸指针就悬空了。所以先在扩容前记下s pos - _start相对偏移扩容后再pos _start s找回新位置。搬移必须从后往前从最后一个元素开始逐个后移一位给pos腾出空位否则会互相覆盖。返回pos让调用方拿到“新插入元素的迭代器”。即使扩容这个返回值也有效——这正是测试里it v.insert(it, 3)之后还能*it 1000的原因。erase前移覆盖 返回被删位置iterator erase(iterator pos) { assert(pos _start); assert(pos _finish); iterator it pos 1; while (it ! _finish) { *(it - 1) *it; it; } --_finish; return pos; }把pos之后的元素整体前移覆盖再把_finish减 1。被删位置的元素“消失”size 减 1capacity 不变。erase 也会让被删位置之后的迭代器失效元素挪了位置。返回pos让调用方拿到“下一个有效位置”方便it v.erase(it)边删边遍历。逐段看测试代码test01 ~ test07前面把容器内部讲完了这一节把测试文件里的代码一段段拿出来逐行注释着看。每段测试都只验证前面讲过的某一个或几个模块。test01插入 两种遍历void test01() { v::vectorint v1; // 默认构造三个指针都是 nullptr v1.push_back(1); v1.push_back(2); // 尾部依次插入 1、2 v1.push_back(3); v1.push_back(4); v1.push_back(5); // push_back满了翻倍扩容平时直接写 *_finish for (size_t i 0; i v1.size(); i) cout v1[i] ; // ① 下标遍历operator[] 带 assert 越界检查 for (auto e : v1) // ② 范围 for等价于 begin/end 迭代器遍历 cout e ; }第一段只验证“能存进去、能读出来”。下标和范围 for 走的是同一个底层数组只是访问方式不同。test02insert 头插与任意位置插void test02() { v1.push_back(1); ... v1.push_back(5); // {1,2,3,4,5} Print(v1); // 模板 Print对任意容器打印 v1.insert(v1.begin(), 0); // 头插 0 → {0,1,2,3,4,5}后面元素整体后移 Print(v1); v1.insert(v1.begin() 2, 0); // 在第 2 个元素之前插 0 → {0,1,0,2,3,4,5} Print(v1); }insert 的参数是“位置 值”。头插和任意位置插都是 O(n)——因为要把后面所有元素搬移一位这点从输出顺序的变动里能直观看到。test03find insert 与“迭代器失效”int x; cin x; // 输入要查找的数 auto it find(v1.begin(), v1.end(), x); // algorithm 线性查找返回迭代器 if (it ! v1.end()) // 找到了才插 v1.insert(it, 3); // 在 x 前面插入 3 // *it 1000; // ⚠ 危险insert 可能触发扩容扩容后 it 已失效这是野指针写入 Print(v1);被注释的*it 1000是迭代器失效的教材案例insert 一旦扩容旧it指向已被释放的旧数组再解引用就是悬空指针。所以“insert 之后 it 失效、不能再使用”。test04find eraseint x; cin x; auto it find(v1.begin(), v1.end(), x); if (it ! v1.end()) v1.erase(it); // 删除找到的元素后面元素前移 Print(v1);erase 把被删位置之后的元素前移覆盖_finish减 1。被删位置及其之后的迭代器同样失效。test05insert 返回值续用 边删边遍历 clear// 被注释的部分演示“边删边遍历”的正确写法 // it v.begin(); // while (it ! v.end()) // if (*it % 2 0) it v.erase(it); // 用返回值续用不能 it // else it; int x; cin x; auto it find(v1.begin(), v1.end(), x); if (it ! v1.end()) { it v1.insert(it, 3); // 用返回值拿回“新插入元素的位置”扩容后依然有效 *it 1000; // 现在安全把刚插入的 3 改成 1000 } Print(v1); v.clear(); // 逻辑清空_finish _startsize 变 0 Print(v1);这一段的核心是“用返回值续用迭代器”insert返回新插入位置erase返回被删位置的下一个。即使发生过扩容返回值也指向新内存里的正确位置。test06构造家族 泛型 深拷贝v::vectorint v2(v1); // 拷贝构造reserve push_back v::vectorint v3(v1.begin() 1, v1.end() - 1); // 区间构造去掉首尾两个元素 v::vectorint v4 {1,2,3,4,5}; // 列表初始化initializer_list v::vectorint v7 v6; // 赋值拷贝交换copy-and-swap v::vectorstring v8; // string 泛型 v8.push_back(aaaaaa); ...; // push 5 个 string验证深拷贝这一段几乎把五种构造都用了一遍。最值得关注v8是vectorstring如果 reserve 里用了 memcpy这里会浅拷贝、析构时二次释放改成逐元素赋值才正确。test07个数 值构造v::vectorint v1(10, 5); // 10 个元素值全是 5int 重载 v::vectorsize_t v2(14, 2); // 14 个元素值全是 2size_t 重载 Print(v1); Print(v2);两个int实参能正确走“个数 值”构造而不是被当成迭代器区间——这正是 SFINAE 和 int/size_t 双重载在起作用。test03 的注释是重点它解释了 insert 之后如果扩容it指向旧内存*it 1000很可能是野指针写入——所以“insert 后 it 失效、不能再用”。test05 的正解用返回值it v.insert(it, 3)重新拿回有效位置扩容后也能安全*it 1000被注释的“删偶数”代码展示了 erase 后必须it v.erase(it)而不是it的经典写法。test06 的v::vectorstring验证了模板泛型与深拷贝——reserve里逐个赋值保证了 string 正确拷贝不会出现 memcpy 的浅拷贝问题。test07v(10, 5)的两个 int 能正确走“个数 值”构造恰恰证明前面的 SFINAE 和双重载起作用了。总结一份函数速查清单函数作用关键点begin / end取首尾迭代器就是_start/_finish裸指针reserve扩容改 capacity逐元素拷贝不能 memcpy扩容使旧指针失效resize调整 size变长填默认值 / 变短拨 _finishpush_back / pop_back尾部增删满则翻倍扩容pop 前 assert 非空operator[]下标访问带 assert 越界检查const / 非 const 两版insert任意位置插入扩容重定位 从后往前搬移 返回插入位置erase删除指定位置前移覆盖返回被删位置方便续用clear逻辑清空拨 _finish 到 _start不释放内存把这张表连起来看一个手写 vector 的全部秘密就是一句话用三个指针圈住一段连续内存所有的增删查改都是在挪这三个指针和它圈住的元素。
返回列表