C++学习(11):拾遗-对9和10的复习_A

你讲得很完整,逻辑链基本是顺的。我没有发现明显的错误,你对智能指针、对象切片、vptr重置的理解都到位了。如果你希望补充,还有几点可以想一下:

  1. virtual函数表(vtable)本身存在哪里?vptr指向的是一张全局的表,而不是对象内部的一部分。你讲了vptr,但没提到vtable的存在位置。这个可以补充一句:vtable是编译时生成的静态数组,存放在只读数据段里,所有同类对象共享同一张表。

  2. override关键字的作用你讲了吗?你提到的部分,在文字里似乎没有出现。它是在派生类中显式标记“我要重写这个虚函数”的,如果写错函数签名,编译器会报错,而不是默默地新建一个函数。

  3. std::make_uniquenew+ 自动包装成智能指针的组合,可以在讲push_back时顺便提一句,说明它如何避免显式使用newdelete

  4. 引用一旦绑定就不能改变指向,你讲了引用是别名,但没有提到这点。这区分了引用和指针,指针可以重新指向别的对象,引用不行。

Q:嗯,好。vtable是一张全局的表,而不是对象内部的一部分。vtable存在于只读数据段中,所有同类对象共享一张表。这里,我想问为什么说vtable是一张全局的表?它不应该只能被同类对象使用吗?

override关键字,写了才能做覆盖,这个我知道。

make_unique这里我确实也没有讲到,但我知道这是直接把新建对象和把对象绑定在智能指针上的两个操作结合了,是这样吗?

我想听你讲讲引用一旦绑定就不能改变指向了这点。这个我之前不知道。

A:

1. 为什么说 vtable 是“全局的表”?

你理解的“只能被同类对象使用”是对的。这里的“全局”不是指“整个程序只有一个表”,而是指“每个类只有一张表,所有该类的对象共享这一张”。

当你定义一个class AndGate时,编译器会生成一张AndGate的虚函数表,里面放着AndGate的虚函数地址。程序中有多少个AndGate对象不重要,它们都指向同一张表。

vtable 是类型级别的,不是对象级别的。

你问“全局的表”会不会造成混乱——不会。因为这张表属于类型,而不是属于整个程序。所以,说“全局”确实容易产生歧义。更准确的说法是:每个类有一张独立的 vtable,同一类的所有对象共享它。


2. 引用一旦绑定就不能改变指向

引用和指针有一个关键区别:指针可以重新指向别的对象,引用不行。

text

int a = 10, b = 20; int* p = &a; // p 指向 a p = &b; // 合法,p 现在指向 b int& r = a; // r 是 a 的别名 r = b; // 这不是让 r 指向 b,而是把 b 的值赋给 a

最后一行r = b;不会让r变成b的别名,而是把b的值(20)赋给a。这就是引用和指针最本质的区别。所以当你写auto& ref = a;时,ref就是a的别名,一旦绑定,终身不变。这个规则消除了“这个引用可能指向别的对象吗”的疑虑,也让代码更安全。


3.std::make_unique<AndGate>()的作用

你的理解完全正确。它相当于执行了:

  1. 在堆上创建一个AndGate对象(new AndGate)。

  2. 把这个对象包装进std::unique_ptr<AndGate>里,由这个智能指针独占管理它的生命周期。

auto ptr = std::make_unique<AndGate>();比写std::unique_ptr<AndGate> ptr(new AndGate());更安全,因为它能避免某些极端情况下的内存泄漏(比如构造函数抛异常时,new分配的内存不会被释放)。

Q:噢,我明白了。
关于引用:因为引用里面int &p=a把二者绑定了,它们是等价的。所以此后p=某数就是a=某数,为了语句没有二义性,引用变量p=某数只能是赋值而不能是重新指向。那就算我再写一次int &p=b也不行吗?以及我要怎么销毁这个引用呢?
关于make_unique:其实我不太明白为什么写 std::unique_ptr<AndGate> ptr(new AndGate());不那么安全。因为,好像这样也是把我们new出来的这个对象交给了一个RAII的智能指针做生命周期管理。以及,既然new不安全,为什么不写ptr(对象构造函数)?

A:

关于引用:重新绑定与销毁

  1. 再写一次int &p = b;可以吗?取决于你在哪里写。在同一个作用域内,不行。编译器会报错,说p已经被声明过了。如果你是在一个新的作用域里(比如再写一对{}包起来),可以,它会覆盖掉外层的p,但外层那个还在,只是暂时被遮住了。不过你问的应该是同一个作用域内能否重新绑定——答案是不能

  2. 要如何销毁这个引用?没法主动销毁。引用本身不占资源(它只是一个别名),它是随作用域自动消亡的。就像int a = 10;你也不会去“销毁a”,它会在离开}时自动消失。引用也一样,你不需要手动销毁它,也不需要delete(那只会把原始对象删掉)。


关于std::make_unique:为什么不安全?

你这句话:“好像这样也是把我们 new 出来的这个对象交给了一个 RAII 的智能指针做生命周期管理。”——在单独写这一句时是安全的,但在复杂的表达式里会出问题。经典的陷阱是:

text

void func(std::unique_ptr<A> a, std::unique_ptr<B> b); func(std::unique_ptr<A>(new A()), std::unique_ptr<B>(new B()));

C++17 之前,编译器可以自由选择求值顺序。如果new A()成功,然后new B()抛异常,那么A的指针还没来得及交给unique_ptr,就被丢掉了,内存泄漏。而make_unique把分配和构造打包成一个函数调用,避免了这种求值顺序导致的漏洞,所以更安全。

为什么不直接写ptr(对象构造函数)

因为std::unique_ptr的构造函数接收的是一个原始指针(T*),而不是一个对象本身。你不能直接传一个对象进去。你写的ptr(对象构造函数),如果指的是传一个已有的AndGate对象给它,行不通,因为智能指针要的是指向堆内存的地址。

这就是make_unique存在的价值:它把“在堆上构造对象”和“用这个对象的地址去构造unique_ptr”合成了一步。如果你想传参数给构造函数,标准写法是:

text

auto p = std::make_unique<AndGate>(arg1, arg2);

这不比写两遍AndGate清爽得多吗?

Q:C++17 之前,编译器可以自由选择求值顺序。如果 new A() 成功,然后 new B() 抛异常,那么 A 的指针还没来得及交给 unique_ptr,就被丢掉了,内存泄漏。而 make_unique 把分配和构造打包成一个函数调用,避免了这种求值顺序导致的漏洞,所以更安全。

嗯?这里不太懂。意思是一个创建成功另一个失败吗?什么是“抛异常”呀?

A:

“抛异常”是C++里一种错误处理机制。当程序遇到无法继续执行的错误(比如内存不足、文件打不开)时,可以用throw关键字“扔”出一个错误信号。程序就会立刻中断当前流程,跳转到专门处理这类错误的catch代码块。如果没有catch,程序就会崩溃。

你问“一个创建成功另一个失败”——是的,这正是那种危险情况。

假设编译器按“先A后B”的顺序执行:

  1. new A()在堆上创建了一个A对象,返回了它的原始指针。

  2. 接下来new B(),如果此时内存耗尽(或B的构造函数抛出了异常),程序会立刻跳转去处理异常。new A()返回的那个指针还没来得及传递给unique_ptr,也来不及释放,这就造成了内存泄漏。

std::make_unique解决的正是这个问题:它把“分配内存 + 构造对象 + 包装进智能指针”合并为一个独立的函数调用。无论参数求值顺序如何,new产生的原始指针在返回之前,就已经被unique_ptr接管了,不会出现“裸指针”暴露在外的窗口期。所以它更安全。

Q:噢!这里讲得好好!还有这里:
你写的 ptr(对象构造函数),如果指的是传一个已有的 AndGate 对象给它,行不通,因为智能指针要的是指向堆内存的地址。
是因为构造函数本身也是RAII的,所以它的构造分为栈上和堆上,因此它返回的不是堆地址吗?所以,我们要使用构造函数,就得先构造再把堆上地址传递给unique_ptr?但是这又有内存问题了,因为现在栈和智能指针同时管理着同一片堆内存。

A:理解对了,而且你指出的那一点,正是很多初学智能指针的人会踩进去的坑。

“栈和智能指针同时管理着同一片堆内存”——正是这个问题。

为什么不能把栈上对象的地址传给智能指针?

问题在于“栈上对象的生命周期”和“智能指针管理的堆内存”是两套完全不同的系统。

栈上对象的生命周期由作用域控制:离开{},它自动销毁。智能指针的生命周期由它自己控制:离开作用域时,它会delete它指向的堆内存。

如果你把栈上对象的地址传给unique_ptr,并让unique_ptr去管理它,当unique_ptr被销毁时,它会去delete那块内存。但那块内存是栈上的,从来没有被new分配过。delete一块栈内存是未定义行为,程序大概率会崩溃。

为什么不能把“已经在堆上”的对象的地址直接交给智能指针?

确实可以,但你得确保是原始指针,而不是一个已经封装好的对象。唯一正确的做法是:

text

int* raw = new int(42); std::unique_ptr<int> ptr(raw);

这么做是合法的,但通常不推荐。因为rawptr在构造完成之前,有一段“裸指针”暴露在外的时期。如果在这段期间发生了异常,或者你忘记把raw交给ptr,那块内存就会泄漏。更糟糕的是,如果你把raw同时传给了两个unique_ptr,它们会在销毁时重复释放同一块内存,导致程序崩溃。

安全的替代方案

直接用std::make_unique<int>(42)。它把“分配内存、构造对象、包装进智能指针”合并在一个函数调用里完成,从根源上杜绝了上述所有问题。

所以,你的推理是完整的:std::make_unique确实是首选的构造方式。它不是为了“省一行代码”,而是为了消灭裸指针暴露的窗口期。现在你能理解为什么现代 C++ 的代码里几乎看不到newdelete了吧?

A:明白了,因为我们不需要用到new~