深刻理解C++STL库常见容器功能和底层

vector

底层机制

vector本质是一个动态分配连续内存的数组

维护了三个指针(迭代器)

  • _start:指向连续内存的起始物理地址
  • _finish:指向当前最后一个有效元素下一个位置
  • _end_of_storage:指向整块内存的末尾

有了三个指针,所有方法都是指针运算:

  1. size()(当前元素个数): 就是 finish - start

  2. capacity()(当前总容量): 就是 end_of_storage - start

  3. 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++封装的思想:屏蔽底层实现细节,暴露给用户一套标准的操作接口,用户不用关心底层到底是怎么实现的

但是,迭代器很容易失效!

我们先讲连续内存派:vectorstring为例

  • 失效场景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_backpush_back快?

两者执行v.push_back(User("张三", 18))v.emplace_back("张三", 18) 时的动作:

push_back 路线:

  1. 在外面的栈上构造一个临时的 User 对象
  2. 把临时对象作为参数传进 push_back
  3. push_back 内部调用移动构造函数 (或拷贝构造),把数据挪到 _finish 的内存上
  4. 析构外面栈上的那个临时 User 对象

emplace_back 路线:

  1. 把字符串 "张三" 和整数 18 打包
  2. 完美转发到底层
  3. _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);
  1. std::vector<int>(v) 触发拷贝构造,创建了一个匿名临时对象只申请刚好能装下 10 个元素的内存 (它的 capacity 就是 10)

  2. .swap(v) 将临时对象内部的三个指针,与原来 v 内部的三个指针进行互换。

  3. 互换后,v 的容量成功收缩到了 10。而那个匿名临时对象手里现在攥着原来那 100000 的庞大内存

  4. 这行代码一结束,匿名临时对象生命周期到期,自动调用析构函数,顺理成章地带着那块庞大的内存同归于尽

resize()和reserve()

  • reserve(n)预留空间,操作的是 _end_of_storage 指针
    • 动作:和OS交涉:"给我准备能装n个元素的连续空间!"
    • 结果:只改变capacity,绝对不改变size_finish指针保持不动,新开辟的空间未初始化,绝对不能v[index]去访问
  • resize(n)调整大小,操作_finish指针
  • 动作:它不仅要求物理空间到位,还会在这些空间上真正去构造对象
  • 结果:它同时改变 capacitysize 。如果 n 大于当前的 size,它会调用对象的默认构造函数把多出来的坑填满。填满后,可以直接用 v[index] 去访问修改

未完待续~

相关推荐
cch891828 分钟前
汇编与Java:底层与高层的编程对决
java·开发语言·汇编
荒川之神1 小时前
拉链表概念与基本设计
java·开发语言·数据库
chushiyunen2 小时前
python中的@Property和@Setter
java·开发语言·python
小樱花的樱花2 小时前
C++ new和delete用法详解
linux·开发语言·c++
froginwe112 小时前
C 运算符
开发语言
fengfuyao9852 小时前
低数据极限下模型预测控制的非线性动力学的稀疏识别 MATLAB实现
开发语言·matlab
摇滚侠2 小时前
搭建前端开发环境 安装 nodejs 设置淘宝镜像 最简化最标准版本 不使用 NVM NVM 高版本无法安装低版本 nodejs
java·开发语言·node.js
t198751283 小时前
MATLAB十字路口车辆通行情况模拟系统
开发语言·matlab
yyk的萌3 小时前
AI 应用开发工程师基础学习计划
开发语言·python·学习·ai·lua
Amumu121384 小时前
Js:正则表达式(一)
开发语言·javascript·正则表达式