vector
底层机制
vector本质是一个动态分配连续内存的数组
维护了三个指针(迭代器)
_start:指向连续内存的起始物理地址_finish:指向当前最后一个有效元素下一个位置_end_of_storage:指向整块内存的末尾

有了三个指针,所有方法都是指针运算:
-
size()(当前元素个数): 就是finish - start -
capacity()(当前总容量): 就是end_of_storage - start -
empty()(是否为空): 就是判断start == finish
这里需要注意:
C++STL设计是左闭右开:[start, end)
动态扩容机制
什么时候会扩容,怎么扩容?
- 触发场景:
_finish == _end_of_storage时,没有空间塞数据了- 做法:一定不是原地扩容,OS很难在原内存后面找到一块合适的空间!
- 去堆上找一块全新的、更大的内存(GCC通常2倍,MSVC通常1.5倍)
- 把老内存里的数据,逐一**拷贝(或移动)**到新内存
- 调用老内存中所有对象的析构函数
- 释放老内存空间
为什么不能原地扩容?
涉及到缓存命中的问题:
- 内存碎片:堆上的内存分配是动态、无序的,
new了一个100字节内存,随着程序运行,这100字节前后的内存大概率都被其它对象占用了- 缓存命中:CPU从内存中读数据非常慢(本质是拷贝),CPU内部有L1/L2/L3三级缓存(SRAM),CPU每次去内存拿数据,会把目标地址附近的一整块连续内存(通常是64字节,一个Cache Line)一次性取完
如果
vector容量满了,你想在原地直接把这块内存拉长,现实中极大概率是做不到的,因为屁股后面紧挨着的那块物理内存,已经被别人占了!
深拷贝、浅拷贝、移动辨析
- 浅拷贝:纯粹按位赋值,对于内置类型没问题,但是对于自定义类型,浅拷贝只会拷贝指针的地址 ,这导致了新老
vector里的指针指向同一块内存,任意一方修改数据,会影响到另一方,而且会造成Double Free问题- 深拷贝:开辟一块和源
vector一样的内存空间,并拷贝源vector的数据到新的内存中- 移动语义:老内存若是快要销毁,直接把老对象的资源指针偷过来,把老对象的指针置空即可
注意:只有当类明确使用
noexcept标记了移动构造函数 时,vector扩容才会大胆地使用移动语义!vector是极其保守的!
迭代器机制
迭代器本质是封装了指针的类对象,,并重载了 ++、--、\*、-> 等运算符
对于使用者来说,无论底层是连续数组还是复杂的红黑树,都可以统一使用 it++ 来走向下一个元素,用 *it 来获取数据。它完美屏蔽了底层数据结构内存布局的差异
这就是C++封装的思想:屏蔽底层实现细节,暴露给用户一套标准的操作接口,用户不用关心底层到底是怎么实现的
但是,迭代器很容易失效!
我们先讲连续内存派:
vector、string为例
失效场景1:触发动态扩容
- 原因:
vector扩容是去堆上找一块新内存,然后把老内存释放掉。如果在扩容前保存了一个迭代器,扩容后,这个迭代器里面包着的指针还指着那块已经被系统回收的老内存!- 解决: 扩容后,绝对不要使用 任何之前保存的迭代器、指针或引用。如果需要记录位置,记录索引而不是迭代器
失效场景2:中间插入或删除
原因: 当调用
v.erase(it)删除中间某个元素时,为了保持物理连续性,vector会把该位置之后的所有元素统统往前挪一步。此时,手里的it虽然物理地址没变,但它指向的数据已经变成了原本在它后面的那个元素!如果你再执行一次it++,你就会漏掉一个元素解决:利用
erase的返回值。erase会自动返回一个指向被删除元素下一个位置的有效迭代器
cpp// 错误写法(会漏删元素,或者越界崩溃) while(it != v.end()) { if(*it == 2) v.earse(it); it++; } // 正确写法 while(it != v.end()) { if(*it == 2) it = v.earse(it); // 删除了,erase已经帮你走了一步,接管新迭代器就好 else it++; // 只有在没有删除的情况下,才会手动往后走 }
高频API
push_back()和emplace_back()
-
push_back的本质:接收一个已经构造好的对象(或者右值)。在底层会调用拷贝构造函数 或移动构造函数 ,将数据放进vector的内存中 -
emplace_back的本质:它的底层使用了 C++11 的可变参数模板和完美转发 。它不接收对象,而直接接收"构造对象所需的参数",然后在vector_finish指针指向的那块裸内存上,直接调用定位 new(Placement new) 原位构造对象
emplace_back伪代码:
cpp
template <typename... Args> // 1. 可变参数模板:接收任意数量参数
void emplace_back(Args&&... args) { // 万能引用:接住所有左值/右值
// 检查容量,不够就扩容...
if (_finish == _end_of_storage) {
// ... 扩容逻辑
}
// 核心动作
// 2. 完美转发:std::forward<Args>(args)... 原封不动地把参数状态传下去
// 3. 定位 new:在 _finish 裸指针指向的内存上,直接原地构造对象!
new ((void*)_finish) T(std::forward<Args>(args)...);
// 指针后移
++_finish;
}
为什么emplace_back比push_back快?
两者执行
v.push_back(User("张三", 18))和v.emplace_back("张三", 18)时的动作:
push_back路线:
- 在外面的栈上构造一个临时的
User对象- 把临时对象作为参数传进
push_backpush_back内部调用移动构造函数 (或拷贝构造),把数据挪到_finish的内存上- 析构外面栈上的那个临时
User对象
emplace_back路线:
- 把字符串
"张三"和整数18打包- 完美转发到底层
- 在
_finish内存上直接调用带参构造函数,一次性成型!零次临时对象创建,零次拷贝/移动构造,零次临时对象析构
总结:只要是往容器尾部追加元素,无脑优先使用 emplace_back
clear()
clear的本质是逻辑清空 ,循环调用所有有效元素的析构函数,然后把_finish指针拉回到_start的位置,size归零,但是capacity还占着呢,内存并没有释放,把这个释放时机交给用户处理
那怎么释放这块大内存呢?
cpp
std::vector<int> v;
// ... 假设 v 经历了大量插入,capacity 暴涨到了 100000,但现在 size 只有 10
// v.clear(); // 没用,capacity 还是 100000
// 利用匿名临时对象交换指针
std::vector<int>(v).swap(v);
-
std::vector<int>(v)触发拷贝构造,创建了一个匿名临时对象 ,只申请刚好能装下 10 个元素的内存 (它的capacity就是 10) -
.swap(v)将临时对象内部的三个指针,与原来v内部的三个指针进行互换。 -
互换后,
v的容量成功收缩到了 10。而那个匿名临时对象手里现在攥着原来那 100000 的庞大内存 -
这行代码一结束,匿名临时对象生命周期到期,自动调用析构函数,顺理成章地带着那块庞大的内存同归于尽
resize()和reserve()
reserve(n)预留空间,操作的是_end_of_storage指针
- 动作:和OS交涉:"给我准备能装n个元素的连续空间!"
- 结果:只改变
capacity,绝对不改变size,_finish指针保持不动,新开辟的空间未初始化,绝对不能v[index]去访问resize(n)调整大小,操作_finish指针- 动作:它不仅要求物理空间到位,还会在这些空间上真正去构造对象!
- 结果:它同时改变
capacity和size。如果n大于当前的size,它会调用对象的默认构造函数把多出来的坑填满。填满后,可以直接用v[index]去访问修改
未完待续~
