vector 嵌套与 list 开篇:memcpy 的坑、operator= 的 swap 写法,与缓存命中率
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
一、memcpy 的问题:它只认字节,不认对象
vector<T> 扩容时要把旧空间的数据搬到新空间。第一直觉是 memcpy——快、一个函数搞定。但它埋着大雷:
memcpy 的问题就是逐字节拷贝,它在内存级视角看数据,根本不知道 T 是什么。
T是int:逐字节拷贝 = 值拷贝,没毛病;T是string/vector<int>这种自己管资源的自定义类型:逐字节拷贝把指针原样抄了一份——两个对象指着同一块堆空间。旧空间析构一释放,新空间里的指针集体悬空;析构两次、double free。
所以正确写法是 for 循环逐个拷贝:
for (size_t i = 0; i < size(); i++) {
tmp[i] = _start[i]; // 走 T 的 operator=,深拷贝
}
为什么 for 循环拷贝而不是 memcpy 拷贝?因为 operator= 可以循环递归,直至到内置类型——tmp[i] = _start[i] 调 T 的赋值重载,T 里如果又嵌着自定义类型,就再往下递归调它们的 operator=,一路递归到内置类型退化为字节拷贝为止。拷贝的责任层层下放,每一层只管自己这一层。这就是逐对象拷贝和逐字节拷贝的本质区别。
顺带钉死一个语义:delete[] _arr,数组元素是自定义类型时,还会调用其中每一个元素的析构函数——跟生成(构造逐个调)对称,销毁也逐个调。new 和 delete[] 这两次要配套用。
二、operator= 的现代写法:先拷贝,再 swap
赋值重载是这一章最重要的一课。现代写法两步走:
vector<T>& operator=(vector<T> v) { // 故意传值传参
swap(v); // this 的三个指针与 v 的整体交换
return *this;
}
传值传参的瞬间,拷贝构造已经帮你把 v 造好了(for 循环深拷贝引用传参进来的 a);然后 swap 只交换最外面的 _start、_finish、_endofstorage 三个指针:
void swap(vector<T>& v) {
std::swap(_start, v._start);
std::swap(_finish, v._finish);
std::swap(_endofstorage, v._endofstorage);
}
旧资源不用手动释放——它现在挂在参数 v 名下,函数栈帧销毁时 v 析构,顺手把旧空间带走了。这里全是函数栈帧、参数销毁、返回值拷贝、传参引用拷贝这些前几章的东西在串联。
一个容易晕的点:this 指针始终指向谁? 在这个写法里,lei = lei 会调用 operator=,this 指向的是左边的对象,临时生成的拷贝是参数 v——swap 完,左边拿到新值,v 带着旧值消亡。搞清楚 this 指针指向哪个 class,这类写法就不会晕。
再钉一遍那组最基础的区分(笔者的原话保留):
A a = b; // 拷贝构造 —— 用已存在的 b 创建 a
a = b; // 赋值重载 —— a.operator=(b),两个都已存在
"都是要调用拷贝函数"的场景(传值传参、传值返回)背后,都是先生成 tmp 拷贝,再整体 swap这一套。
三、嵌套与深拷贝:边开边构造
vector<vector<int>> 这种嵌套,拷贝时外层开 j 个元素的空间,每个元素又是一个 vector<int>,各自管自己的堆空间——外层 for 循环赋值时,内层的 operator= 再各自深拷贝。知道嵌套复合类是怎么边开边构造的,深层次的深浅拷贝就通了:每一层只对自己的资源负责,责任链就是这么叠出来的。
四、list 开篇:双向循环链表,迭代器是模仿指针的类
进入 list。标准库的 list 是双向循环链表——头尾相接,一个哨兵头节点串全场。
它给我们的第一个认知冲击是:迭代器的行为是模仿数组指针的。数组里 int* 一下一个准,链表的节点地址在内存里东一个西一个,没法用裸指针 ++——所以 list 的迭代器必须是一个类,把 ++、--、*、!= 都重载成"沿链表走"的语义。用的人感觉不到区别,这就是封装的力量。
迭代器按能力分三种(这张表值得背):
| 类别 | 支持操作 | 典型容器 |
|---|---|---|
| 单向 | ++ | 单链表 / 哈希表 |
| 双向 | ++ / -- | 红黑树 / 双向链表-list |
| 随机 | ++ / -- / + / - | string / vector / 双端队列 deque |
通用算法只要求 ++ * != 这几个最基本的能力——越往上的迭代器,能喂的算法越多。
还有一个语法细节:成员函数调用、语法糖都是不用写声明域符的——it++ 而不是 it.operator++,域的事编译器替你办了。
五、为什么数据量大时不用 list.sort()
一个反直觉的实测(截图里的数据):
list copy → vector sort → copy back: 74
list.sort(): 185
list 自己的 sort 反而慢一倍多。原因一句话:数组的效率高,是因为缓存命中率高。
CPU 和内存之间隔着三级缓存——数据在不在缓存,在缓存就叫命中,不在就叫不命中;不命中,先加载到缓存,再访问。缓存的加载不是论字节,是一整块一整块地搬(缓存行):数组内存连续,读 a[0] 时 a[1]~a[n] 顺势进缓存,后面全是命中;链表每个节点 new 出来地址分散,走一步加载一次,命中率惨不忍睹。
所以数据量大要排序时,把 list 拷进 vector、sort 完再拷回去,加上两次搬运的成本还是比 list.sort() 快——硬件的局部性原理,压过了算法层面的理论。这也解释了那句话:"快排/归并"是对内存连续的序列说的。
收个尾
这两章拼起来正好是 STL 的设计哲学:
- memcpy 快是快,但它把"拷贝"降级成了字节搬运——泛型容器的拷贝必须走
operator=的递归链,责任下放到每个类型自己; - operator= 的现代写法 = 传值传参 + swap,旧资源让参数的析构顺手带走;
- list 的迭代器是一个类,用重载模拟指针语义;
- 连续内存赢在缓存命中率——
list.sort()输给"拷进 vector 排完再拷回来",硬件局部性 > 理论复杂度。
下一位置:vector 的完整收尾(resize/reverse 与更多接口)→ deque → stack/queue。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/xiongditian/article/details/169155726



