ARTICLE DETAIL

资讯详情

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

C++入门篇(十四):vector 模拟实现——三指针、扩容流程与 memcpy 陷阱

C++入门篇(十四):vector 模拟实现——三指针、扩容流程与 memcpy 陷阱 0.1 开篇导读这里是白杨。上篇我们把 vector 用起来了传送门C入门篇十三vector上——动态数组构造、空间增长与迭代器失效这篇直接动手把它的内部实现扒开自己写一个。这篇要过四件事三根指针怎么管内存扩容时到底发生了什么insert/erase 为什么会让迭代器失效还有害人无数的 memcpy 陷阱完整实现代码文末自取也可以直接去仓库vector_底层实现 · BaiYang_SQ/博客代码测试 - 码云 - 开源中国。一、三个指针vector 的全部家当先说结论这句建议直接背vector 的底层就是三根指针——_start空间起点、_finish数据末尾、_endofstorage空间末尾。size 和 capacity 都由指针相减算出来。_start _finish _endofstorage ↓ ↓ ↓ ┌────┬────┬────┬────┬────┬─────┬─────┬─────┬─────┐ │ 1 │ 2 │ 3 │ 4 │ 5 │ │ │ │ │ └────┴────┴────┴────┴────┴─────┴─────┴─────┴─────┘ ←── size() 5 ──→ ←──────── capacity() 9 ────────→size()_finish - _start装了 5 个capacity()_endofstorage - _start能装 9 个_finish _endofstorage时满了再尾插就要扩容面试爱追问为什么用三个指针而不是指针 size capacity三个变量因为位置运算能统一用指针表示_start i就是第 i 个元素的迭代器不用在指针和下标两套表示之间来回换算。调试时你在监视窗口里看到的也正好是这三根指针二、骨架成员变量与构造函数namespace bit // 和标准库区分用命名空间包起来 { templateclass T class vector { public: typedef T* iterator; // 我们这一版的迭代器就是裸指针 typedef const T* const_iterator; // 无参构造三根指针全部置空 vector() : _start(nullptr), _finish(nullptr), _endofstorage(nullptr) {} // 带参构造开 n 个空间全部填 val vector(size_t n, const T val T()) { _start new T[n]; for (size_t i 0; i n; i) _start[i] val; _finish _start n; _endofstorage _finish; // 初始 capacity 就等于 size } ~vector() { delete[] _start; _start _finish _endofstorage nullptr; } size_t size() const { return _finish - _start; } size_t capacity() const { return _endofstorage - _start; } T operator[](size_t i) { return _start[i]; } const T operator[](size_t i) const { return _start[i]; } iterator begin() { return _start; } iterator end() { return _finish; } private: iterator _start; // 空间起点 iterator _finish; // 数据末尾 iterator _endofstorage; // 空间末尾 }; }三个细节扫一眼typedef T* iterator我们的简化版迭代器就是裸指针。迭代器失效的本质一看就懂扩容后_start搬了家而外面的it还指着旧地址。带参构造里_endofstorage _finish初始 capacity 等于 size插满一个就得扩容。先记住这个最直观的起点。这里先不写拷贝构造和赋值重载下文第六节补。你先想一秒不补的话bit::vectorint v2 v1;会怎样——第十二篇的浅拷贝 重复释放Rule of Three 在这照样成立。三、reserve 与 push_back扩容的完整流程void reserve(size_t n) { if (n capacity()) { size_t sz size(); T* tmp new T[n]; // 1.开新空间 for (size_t i 0; i sz; i) tmp[i] _start[i]; // 2.逐个拷贝旧数据深拷贝 delete[] _start; // 3.释放旧空间 _start tmp; // 4.更新三根指针 _finish _start sz; _endofstorage _start n; } } void push_back(const T x) { if (_finish _endofstorage) // 满了先扩容 { size_t newcap capacity() 0 ? 4 : capacity() * 2; reserve(newcap); // 简化版按 2 倍VS 实际约 1.5 倍 } *_finish x; // 尾部放入 _finish; }扩容四步开新空间 → 逐个拷贝 → 释放旧空间 → 更新指针。测试bit::vectorint v; v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); cout size v.size() capacity v.capacity() endl; for (auto e : v) cout e ;输出小贴士想看扩容曲线把上篇 4.2 的狂插 100 次实验套到手撕的 vector 上每轮打印 capacity能看到它按 4, 8, 16, 32… 增长。四、resize改 sizevoid resize(size_t n, const T val T()) { if (n size()) { _finish _start n; // 减少只回退 _finish截断 } else { if (n capacity()) reserve(n); // 不够装先扩容 while (_finish _start n) // 增多依次填 val { *_finish val; _finish; } } }上篇那些接口行为现在都能对着实现解释了上篇的接口行为本节的实现原因resize(n)变小只改sizecapacity 不变只回退_finish不动_endofstorageresize(n)变大可能扩容n capacity()时先reserve(n)变大部分填充valwhile循环逐个赋值到新位置reserve(n)只开空间、不影响 size只改_endofstorage_finish原样五、insert 与 erase迭代器失效的根源5.1 insert先记下标防扩容后 pos 失效iterator insert(iterator pos, const T x) { assert(pos _start pos _finish); if (_finish _endofstorage) // 要扩容 { size_t len pos - _start; // 1.先记下 pos 的相对位置 reserve(capacity() 0 ? 4 : capacity() * 2); pos _start len; // 2.扩容后重新算出新 pos } iterator end _finish; // 3.从后往前搬避免提前形成 pos-1 while (end ! pos) { *end *(end - 1); --end; } *pos x; // 4.插入 _finish; return pos; // 5.返回有效位置扩容后必须用它更新迭代器 }扩容前先存相对位置下标扩容后用_start 下标重新算出绝对位置。另外两个容易忽略的点搬移必须从后往前否则会把还没搬的源数据覆盖掉循环条件用end ! pos原因见下面的踩坑对照。再看一种常见的踩坑写法注意// 踩坑写法两个边界会形成越界指针 iterator end _finish - 1; // 空 vector 时_finish - 1 已经是 _start - 1 while (end pos) // pos 在头部时end 还会递减到 _start - 1 { *(end 1) *end; --end; }它在空 vector 插入和插在头部两个场景下都会形成_start - 1这样的越界指针换成正确写法iterator end _finish; while (end ! pos) { *end *(end - 1); --end; }循环在 end pos 时自然停止避免了这两个边界。测试bit::vectorint v; v.insert(v.begin(), 7); // 空 vector 头插会先扩容再插入 for (auto e : v) cout e ; cout | size v.size() capacity v.capacity() endl; v.erase(v.begin()); cout clean size v.size() endl;划重点写 insert 漏掉先记下标那两行就是白给另外扩容后调用方手里的旧迭代器已经失效要写成it v.insert(it, x);用返回值接管新位置。这和上篇it v.erase(it)是同一条规则。5.2 erase返回下一个元素位置iterator erase(iterator pos) { assert(pos _start pos _finish); iterator it pos 1; while (it ! _finish) // 后面的元素依次前移一位 { *(it - 1) *it; it; } --_finish; return pos; // 返回原位置前移后即下一个元素 }上篇说erase 返回被删元素的下一个位置——正是指这个return pos前移之后pos 处正好是原来的下一个元素。看懂这一点it v.erase(it)就不用死记了。测试bit::vectorint v; v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); v.insert(v.begin() 1, 99); v.erase(v.begin()); for (auto e : v) cout e ; cout endl;六、拷贝构造与赋值深拷贝 三指针 swap// 拷贝构造开新空间逐个深拷贝 vector(const vectorT v) { _start new T[v.capacity()]; for (size_t i 0; i v.size(); i) _start[i] v[i]; _finish _start v.size(); _endofstorage _start v.capacity(); } // 赋值重载值传递 swapstring 篇的老朋友临时工思路 vectorT operator(vectorT v) { swap(v); // v 出作用域自动析构顺带释放旧空间 return *this; } // swap只换三根指针O(1) void swap(vectorT v) { std::swap(_start, v._start); std::swap(_finish, v._finish); std::swap(_endofstorage, v._endofstorage); }这时有同学又会有疑问了:vector 的 swap 为什么是 O(1)因为它不搬数据只交换三根指针的指向和 string 篇交换资源一个思路。答 O(1)理由就四个字三指针交换。测试bit::vectorint v; v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); v.insert(v.begin() 1, 99); // 触发扩容capacity 变 8 v.erase(v.begin()); bit::vectorint v2(v); // 拷贝构造 for (auto e : v2) cout e ; cout | capacity v2.capacity() endl; bit::vectorint v3; v3 v; // 赋值重载 for (auto e : v3) cout e ; cout | capacity v3.capacity() endl;七、memcpy 陷阱三组对照实验先说结论这句当铁律记reserve 里拷贝旧数据如果图省事用memcpy(tmp, _start, sizeof(T) * sz)——内置类型没事管资源的自定义类型就是灾难memcpy 是二进制浅拷贝两个对象共用同一块堆内存析构时重复释放。一句话管资源别 memcpy。重点先准备被搬运的类 ——第十二篇那个管资源的 string这次把赋值重载也补上只写拷贝构造不写赋值重载一样是浅拷贝namespace bit { class string { public: string(const char* str ) { _str new char[strlen(str) 1]; strcpy(_str, str); } string(const string s) : _str(new char[strlen(s._str) 1]) { strcpy(_str, s._str); } string operator(const string s) // 传统深拷贝写法 { if (this ! s) { char* tmp new char[strlen(s._str) 1]; strcpy(tmp, s._str); delete[] _str; _str tmp; } return *this; } ~string() { delete[] _str; _str nullptr; } const char* c_str() const { return _str; } private: char* _str; }; }实验 Amemcpy 自定义类型 → 崩把第三节 reserve 的第 2 步换成 memcpyvoid reserve(size_t n) { if (n capacity()) { size_t sz size(); T* tmp new T[n]; if (_start) memcpy(tmp, _start, sizeof(T) * sz); // 浅拷贝指针被原样复制 delete[] _start; _start tmp; _finish _start sz; _endofstorage _start n; } }测试代码bit::vectorbite::string v; v.push_back(1111); cout push 1 ok endl; v.push_back(2222); cout push 2 ok endl; v.push_back(3333); cout push 3 ok endl; v.push_back(4444); cout push 4 ok endl; v.push_back(5555); cout push 5 ok endl; cout leaving scope: destructors are about to run... endl;注意崩的时机扩容那一步其实没出事是离开作用域、开始析构之后才崩的。这就是它最坑的地方——当时看不出毛病等你以为没事了它才炸。实验 B循环逐元素赋值 → 正常把 reserve 换回正确写法第三节的版本驱动代码与实验 A 完全相同for (size_t i 0; i sz; i) tmp[i] _start[i]; // 逐个深拷贝程序正常结束。同样的扩容、同样的析构这次全程安全。管资源的类型逐个深拷贝就对了。实验 Cmemcpy 内置类型 → 表面正常对照组同样用 memcpy 版 reserve但装的是intbit::vectorint v; for (int i 1; i 5; i) v.push_back(i * 10); cout [memcpy int] data; for (auto e : v) cout e ; cout | size v.size() capacity v.capacity() endl; cout still running: main is about to return normally endl;为什么 int 没事int 没有堆资源要管按字节复制和按值复制结果一样。危险也危险在这用 int 测memcpy 一路绿灯换到vectorstring就未必了。它为什么会崩顺着这条链看memcpy 按字节原样复制把 string 里的_str指针地址复制了一份新旧两个 string 指向同一块堆空间但现在有 “4 个旧元素 4 个新元素” 共 8 个对象以为那 4 块缓冲区是自己的delete[] _start时旧元素析构缓冲区被释放新元素析构时又释放同一块空间——重复释放 → 堆损坏 → 崩溃。说到底这就是第十二篇浅拷贝在 vector 里的翻版管资源的类型拷贝必须走深拷贝千万不能用 memcpy。八、简化版 vs 标准库差在哪先看内核我们手撕的简化版和 VS 的真实实现成员变量这一层都是三根指针那和标准库比我们差在哪一张表列完维度我们的简化版标准库真实实现成员变量三根裸指针同样是三指针但包在 allocator 压缩对里MSVC_Mypair下的_Myfirst/_Mylast/_Myendlibstdc_M_start/_M_finish/_M_end_of_storage扩容拷贝new T[n]默认构造 逐个赋值allocator 分配原始内存 placement new 拷贝构造不要求 T 可默认构造且异常安全扩容倍数固定 2 倍初始 4MSVC 约 1.5 倍且至少 1libstdc 2 倍均无标准保证拷贝构造复制 capacity通常只按 size 分配移动语义无声明拷贝操作后不会再隐式生成移动移动构造/移动赋值指针交接 O(1)异常安全基本没有按操作提供基本/强保证迭代器裸指针发布模式裸指针MSVC Debug 为带检查的包装类接口范围核心子集构造/容量/读写/增删/交换完整接口 allocator initializer_list等所以别被源码长度吓到源码很长内核很薄。剩下主要是 allocator 和异常安全这两块工程细节进阶篇再精读。九、最终总结vector 的家当三根指针_start/_finish/_endofstoragesize 和 capacity 都是指针相减扩容四步开新空间 → 逐个拷贝 → 释放旧空间 → 更新指针简化版 2 倍、初始 4MSVC 实际约 1.5 倍insert 防迭代器失效先记下标len pos - _start扩容后pos _start len搬移从后往前扩容后必须用返回值更新调用方的迭代器erase 返回原位置前移后即下一个元素it erase(it)由此而来swap 只换三根指针O(1)面试常问拷贝构造逐个深拷贝赋值重载用值传递 swap空 vector 等边界用例也要过一遍memcpy坑浅拷贝 → 共享堆内存 → 重复释放内置类型下看起来正常不代表安全真实源码内核就是这三根指针其余是 allocator 与异常安全的包装移动语义留给进阶篇十、附录 最小可运行骨架 完整代码仓库完整工程含第七节三个实验程序在 Giteevector-sim仓库blog正文 · BaiYang_SQ/博客代码测试 - 码云 - 开源中国仓库文件对应内容说明vector_sim.cpp第一~八节 附录完整教学版输出与文章一一对应memcpy_crash.cpp第七节 实验 A复现堆损坏Windows 退出码 0xC0000374memcpy_int_ok.cpp第七节 实验 Cint 对照组看起来一切正常每个文件都能单独编译。应为篇幅原因就不再展示所以代码下面留一个最小骨架是完整版的核心部分// vector 模拟实现·最小可运行骨架 // 完整版含 insert/erase、深浅拷贝、杨辉三角、三组实验见 Gitee // https://gitee.com/BaiYang_SQ/vector-sim #include iostream #include algorithm using namespace std; namespace bit { template class T class vector { public: typedef T* iterator; vector() : _start(nullptr), _finish(nullptr), _endofstorage(nullptr) {} ~vector() { delete[] _start; _start _finish _endofstorage nullptr; } size_t size() const { return _finish - _start; } size_t capacity() const { return _endofstorage - _start; } T operator[](size_t i) { return _start[i]; } iterator begin() { return _start; } iterator end() { return _finish; } void reserve(size_t n) { if (n capacity()) { size_t sz size(); T* tmp new T[n]; for (size_t i 0; i sz; i) tmp[i] _start[i]; // 逐个深拷贝 delete[] _start; _start tmp; _finish _start sz; _endofstorage _start n; } } void push_back(const T x) { if (_finish _endofstorage) reserve(capacity() 0 ? 4 : capacity() * 2); *_finish x; _finish; } private: iterator _start; iterator _finish; iterator _endofstorage; }; } int main() { bit::vectorint v; v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); cout size v.size() capacity v.capacity() data; for (auto e : v) cout e ; cout endl; return 0; }好了vector 两篇到此完结下一篇进入 STL 第三站——list预告链表、迭代器分类、面试最爱问的list 为什么没有 operator[]。如果对你有帮助不要忘记点赞三连一波哦我是白杨我们下期见。
返回列表