从小白的视角探究 vector 第一章补充内容
-
1-补充内容
1 对象到底在哪?
在C++中,不管是基本类型还是类,都可以在栈和堆上
struct MyClass{ int num = 0; } signed main() { // 栈上,也就是在main函数内部的空间里 MyClass m1; int i1 = 100; // 创建的对象在堆内存中,但是提供了一个门牌号给你访问对应的内容 // 但是m2这个指针变量本身还是在栈空间上。 MyClass* m2 = new MyClass; int* i2 = new int(100); return 0; }由这段代码,我们可以有两个认识
- 对象可以在
任何位置,不管是函数的栈空间内,还是在堆内存上 - 在堆内存中的对象,
函数内只有它的指针。但指针这个内容本身,还是一个在栈上的内容
正是这个指针的存在,割裂了我们对用new申请出来内容的认知,也让大部分初学RAII的人对如何运用智能指针产生了犹豫
- RAII要求资源需要绑定到
对象上,用对象的生命周期去管理那片申请出来的资源- 首先,许多初学者只看到了如何编写
构造函数和析构函数这些内容,但是没有认识到,对象才是RAII产生作用的主体! - 其次,既然是对象,那就必须是对象! 是的,这是一句废话!
- 错误的,
这不是一句废话!很神奇对吧。在上面的例子中,尽管我们申请了4个内容,但是只有两个是对象
分别是 MyClass m1; 和 int i1 = 100; 那么另外两个对象在哪呢?
是的,我们根本没 m2和i2指向对象的直接控制权这就是两个破烂牌子,不是对象本身!
- 首先,许多初学者只看到了如何编写
flowchart LR subgraph S[main 作用域] m1[MyClass m1<br/>对象本体] i1[int i1<br/>对象本体] p1[MyClass* m2<br/>指针变量] p2[int* i2<br/>指针变量] end subgraph H[动态存储区] o1[new MyClass<br/>对象本体] o2["new int(100)<br/>对象本体"] end p1 --> o1 p2 --> o2通过以上的讲解,大伙终于发现了盲点,我们一切申请内存得到的内容,都只是指针,而不是对象本身,对象本身不在了,那我们就无法用它的任何内容去进行资源的处理(RAII)
- 之前在
1.3章的3节中,讲过用智能指针进行套娃,套娃的本质就是,我们需要对象!new得到的不是对象,因此我们需要再把它交给一个托管的对象,也就是 std::unique_ptr<Classxxxxx> 对象uuuuu(new Classxxxxx) ,这里唯一有用的只有这个对象uuuuu。正是这个不是指针的美好东西,给了我们一切随意申请空间和资源的权力。
以上的内容我相信可以帮助初学者建立起对RAII的认知,之所以不强调如何对资源进行封装,而是强调对象,是要破除初学者对指针的迷茫和恐惧,一切申请资源却没有直接得到对象的内容,都需要再用智能指针包裹,即使我们申请资源的类本身已经在构造函数和析构函数里对资源进行了封装。当我们认识到了这一点,那么我们就正确知道了何时需要使用智能指针。
2 栈空间和作用域
好吧,虽然标题有作用域,但还是不太想讲
此处不做复杂的讲解,而仅仅是一个简单类比,这个类比对于我们编写vector并理解其思想至关重要。当一个函数被调用时,通常会形成一个新的 栈帧(stack frame)。你可以把它先粗略理解成“这次函数调用专属的一小块工作区”。它里面经常会放这些东西:
- 返回地址等调用信息
- 函数参数
- 局部变量
- 某些临时对象
函数调用提供了一个自动管理的生存边界。对象在这个边界里创建,在边界结束时被清理。 这个边界也就是作用域
函数应该是每一个学习编程的人能够使用到的最简单、无副作用的内容,在这块系统为我们提供的区域中,我们写的内容(不涉及申请资源)会被这块区域自动管理和销毁。我们丝毫没有意识到,在函数中写下 int a = 10; 其中既涉及到使用了空间,又涉及到函数结束后的自动清理。倘若堆内存也有这样的功能,那么程序员在申请内存时将会毫无包袱。
-
得益于离开作用域后自动调用的析构函数,我们得以使用RAII这样的桥梁,链接到管理的堆内存空间中,销毁资源
-
以下是函数栈和vector的相似之处
函数栈模型 vector 模型 函数调用开始,得到一块受管理的生存边界 vector 构造完成,内部得到一块和vector对象绑定的空间 在作用域里声明局部变量 在连续存储里构造元素 作用域结束,局部对象自动析构 vector 析构时,元素自动析构 栈帧整体被回收 底层连续存储被释放 flowchart LR subgraph F[函数作用域] f1[进入作用域] f2[创建局部对象] f3[离开作用域] f4[对象自动析构] f1 --> f2 --> f3 --> f4 end subgraph V[vector 对象] v1[构造 vector] v2[申请一块存储] v3[在槽位上构造元素] v4[析构 vector] v5[元素析构并释放存储] v1 --> v2 --> v3 --> v4 --> v5 endvector需要显式地提供 申请空间 管理内部对象生命周期
栈则自动提供了空间且能够自动管理内部对象生命周期- 函数栈帧通常是这次调用
固定的一块区域;vector 可以扩容、更换区域地址。 - 函数的
自动清理由语言规则提供;vector 的清理由类的构造/析构逻辑提供。 - 栈上的局部对象通常一声明就开始生命周期;vector 可以先只有原始存储,再逐个决定哪些槽位真的构造成对象。
- 当然,得益于RAII,栈空间和vector都能通过调用对象析构函数,销毁对象自己持有的资源。只不过一个是自动,一个需要手动
对于这样的一个内容
// 没有实现RAII struct Vec { Vec(int s) { size = s; data = new int(xxx); } int size; int *data; }; signed main() { Vec v{10}; return 0; }flowchart LR subgraph S[main 作用域] v["Vec v<br/>size: 10<br/>data"] end subgraph H[动态存储区] arr["new int[10] 内存块<br/>(泄漏!)"] end v -- data 指针 --> arr style arr fill:#ffcccc,stroke:#ff0000- 可以看出,直接创建对象,没有使用RAII技术,内存泄漏了
使用RAII后
// 实现RAII struct Vec { Vec(int s) { size = s; data = new int(xxx); } ~Vec() { delete data; } int size; int *data; }; signed main() { Vec v{10}; return 0; }flowchart LR subgraph S[main 作用域] v2["Vec v<br/>size: 10<br/>data<br/>(析构函数自动 delete[])"] end subgraph H[动态存储区] arr2["new int[10] 内存块<br/>(自动释放)"] end v2 -- data 指针 --> arr2 style arr2 fill:#ccffcc,stroke:#00aa00通过
对象和RAII,自动释放了以下是使用RAII,即使使用了new将vec对象放在了堆内存上,但是通过托管,也成功释放了
signed main() { std::unique_ptr<Vec> up(new Vec(10)); return 0; }flowchart LR subgraph S[main 作用域] up["std::unique_ptr<Vec> up<br/>(持有 Vec*)"] end subgraph H[动态存储区] vecobj["new Vec<br/>size: 10<br/>data"] arr3["new int[10] 内存块<br/>(由 Vec 析构释放)"] end up -- 管理 --> vecobj vecobj -- data 指针 --> arr3 style vecobj fill:#ccffcc,stroke:#00aa00 style arr3 fill:#ccffcc,stroke:#00aa00- 双重自动释放:unique_ptr 析构 → delete vecobj → Vec::~Vec() → delete[] arr3,所有资源全部安全回收。
- 以上类比最大程度关注了两者的相似之处。真正的难点在于二者不同的地方。这些不同的内容,也将是这篇文章对于一般的vector教学最大的不同之处。虽然我这个也挺一般的.....
- 对象可以在