接上篇C学习9继承与多态-auto、引用、const变量和const函数一、智能指针unique_ptrQ好明白了。std::vectorstd::unique_ptrLogicGategates;帮我拆一下这个东西A第一层最外层std::vector...这是什么一个动态数组容器。它管理着一块连续的内存在堆上可以存放任意数量的元素。特点当你往里面加东西时它会自动扩容当它被销毁时它会自动销毁里面存放的所有元素。在你的场景里它用来存放一组“门”的集合一个或门一个与门。你可以随时push_back加入更多门不用操心内存够不够。第二层中间层std::unique_ptrLogicGate这是vector里存放的元素类型。std::unique_ptrT是一个独占所有权智能指针。它拥有什么它拥有一个指向T类型对象的指针在堆上。独占的含义同一时刻只能有一个unique_ptr拥有某个特定的堆对象。它不能被复制copy只能被移动move。这确保了“一块堆内存只有一个主人”。自动释放当这个unique_ptr被销毁时比如vector被销毁或者它被从vector里移除它会自动调用delete释放它指向的堆内存。这就是 RAII。第三层内层LogicGate这是unique_ptr指向的对象类型。LogicGate是一个抽象基类有纯虚函数 0。关键点我们不能直接创建LogicGate的实例但我们可以创建它的子类AndGate、OrGate的实例然后通过LogicGate*指针去操作它们。这就是多态。把它们组合起来这行代码的完整含义我们逐步念出来gates是一个变量。它是一个std::vector动态数组。这个数组里存放的每一个元素都是一个std::unique_ptrLogicGate独占智能指针。这个智能指针指向的是一个LogicGate类型的对象实际上会是AndGate或OrGate的实例。翻译成白话gates是一个动态数组数组里存着“指向逻辑门对象”的独占指针。这些指针自己会管理门的生命周期你不用管delete。内存里长什么样这对你很重要存储位置内容栈Stackgates对象本身大约 24 字节包含指向堆数组的指针、大小、容量堆Heap——连续数组一块连续的内存用来存放智能指针对象每个unique_ptr通常是一个原始指针的大小8 字节堆Heap——分散的对象AndGate对象和OrGate对象它们可能分散在堆的不同位置。每个unique_ptr存储着其中一个对象的地址。为什么必须这么写为什么不是std::vectorLogicGate如果你写成std::vectorLogicGate编译器会报错或者产生对象切片。对象切片如果你把AndGate对象放进vectorLogicGate编译器会把AndGate的“子类部分”切掉只保留LogicGate的基类部分。这样你就会丢失AndGate的行为多态失效而且因为LogicGate是抽象类有纯虚函数根本不能实例化。解决办法存储指针而不是存储对象本身。unique_ptr负责管理指针同时保留了多态特性。为什么用unique_ptr而不是原始指针LogicGate*如果写成std::vectorLogicGate* gates;你得手动在vector销毁时遍历它一个一个delete掉里面的指针否则内存泄漏。用std::unique_ptrvector销毁时它会先销毁每个unique_ptr每个unique_ptr自动释放它指向的AndGate/OrGate对象。你不需要写任何清理代码。一个隐藏的小细节vector需要元素可移动std::unique_ptr不能复制只能移动std::move。你的代码里使用了push_back(std::make_uniqueAndGate())。std::make_uniqueAndGate()构造了一个临时unique_ptr。push_back会把这个临时对象移动到vector里而不是复制这完全合法。这也是vector能存放unique_ptr的原因。总结你只需要记住三件事vector自动扩容的数组容器。unique_ptr自动删除堆对象的智能指针独占、不能复制。LogicGate抽象接口多态的载体。这样组合起来的gates就是一个存满了“会自动销毁的各种门”的动态数组。这是现代 C 里最常用的多态容器写法。你会在 EDA 工具里反复看到这个模式。Q先给我讲讲这里的push_backApush_back是std::vector最常用的成员函数之一。它的作用非常直接在 vector 的末尾添加一个新元素。1. 基础行为不考虑unique_ptr假设你有一个std::vectorint numbers;cppnumbers.push_back(10); // numbers 现在是 [10] numbers.push_back(20); // numbers 现在是 [10, 20] numbers.push_back(30); // numbers 现在是 [10, 20, 30]push_back会把传进去的值10、20拷贝或移动一份放到 vector 的末尾。vector 的size()实际元素个数会加 1。如果当前分配的内存capacity不够用了vector 会自动在堆上申请一块更大的连续内存把旧元素搬过去再放入新元素。这一切对你是透明的你不需要手动管理内存。开销2. 在你的代码里push_back(std::make_uniqueAndGate())这行代码在向gates这个 vector 里添加一个新创建的AndGate对象。执行步骤分解std::make_uniqueAndGate()在堆上创建一个AndGate对象并把它包装进一个临时的std::unique_ptrAndGate智能指针里。gates.push_back( ... )把这个临时的智能指针移动进 vector 里。这里有一个关键点std::unique_ptr是不能被拷贝的因为它独占所有权。如果push_back试图拷贝它编译会报错。但是push_back有两个重载版本void push_back(const T value);拷贝插入void push_back(T value);移动插入当你传入一个临时对象即std::make_unique返回的那个没有名字的对象时编译器会自动选择移动插入版本。它会把这个临时智能指针“内部持有的堆地址”偷过来交给 vector 里的那个新元素。临时对象本身在语句结束后就销毁了但AndGate对象已经安全地留在了 vector 里。3. 内存操作push_back内部发生了什么检查容量如果 vector 当前还有空闲位置直接把新元素构造在末尾。如果容量不足在堆上分配一块新的、更大的连续内存通常是当前大小的 2 倍。把旧内存里的所有元素移动到新内存对于unique_ptr就是移动指针开销极小。释放旧内存。把新元素放到新内存的末尾。重要结论因为unique_ptr只是移动指针8 字节即使 vector 扩容移动成本也极低。而且所有的内存申请和释放都在 vector 内部自动完成你不需要写new或delete。4. 补充push_backvsemplace_back你知道一下就好在现代 C 里你偶尔会看到emplace_back。它和push_back的区别是push_back需要一个已经构造好的对象传入像你的make_unique就是现造了一个。emplace_back直接把构造函数的参数传进去让 vector在它的内存里就地构造对象。对于你的代码如果改用emplace_back可以写成cppgates.emplace_back(std::make_uniqueAndGate()); // 和 push_back 几乎一样 // 或者更极致 gates.emplace_back(new AndGate()); // 但不推荐直接用 newpush_back(std::make_uniqueT())是你代码里最安全、最清晰的写法目前完全够用。一句话总结push_back就是“把这个东西放到 vector 的末尾”。对于unique_ptr它会把指针“移动”进去确保新创建的AndGate对象归 vector 管理且会在 vector 销毁时自动释放。Q独占的含义同一时刻只能有一个 unique_ptr 拥有某个特定的堆对象。它不能被复制copy只能被移动move。这确保了“一块堆内存只有一个主人”。这里我不太明白独占的解释。毕竟vector里不是有很多个unique_ptr吗它们指向不同的实例。那在这个角度看来应该同一时刻多个unique_ptr各自拥有分别的堆对象啊。还是说其实你讲错了真相是一个特定的堆对象只能由一个unique_ptr指向这是为了防止该堆对象被四处乱改A结论一个特定的堆对象只能由一个unique_ptr指向。但在同一时刻可以有无数个unique_ptr存在只要它们各自指向不同的堆对象。1. 纠正歧义“一个”指的是“特定的一个对象”在你的vector里第 0 个元素unique_ptr指向AndGate对象 A。第 1 个元素unique_ptr指向OrGate对象 B。这里确实有很多unique_ptr并且它们各自拥有不同的堆对象。规则并不是“同一时刻只能有一个unique_ptr变量存在”而是“同一个堆对象在同一时刻只能被一个unique_ptr拥有”。2. 为什么要这样设计是不是为了防止乱改你猜“这是为了防止该堆对象被四处乱改”——逻辑上非常接近但本质是“防止内存管理混乱”而不是“防止修改数据”。如果你用原始指针LogicGate*会出现这种情况cppLogicGate* p1 new AndGate(); LogicGate* p2 p1; // 两个指针指向同一个 AndGate 对象 delete p1; // p1 释放了内存 //此时 p2 变成了“悬空指针”指向一块已经被释放的内存//如果后续不小心 delete p2程序就会崩溃双重释放这叫做所有权不清——不知道谁负责最后释放这块内存。unique_ptr通过禁止拷贝、只允许移动彻底杜绝了这种情况cppstd::unique_ptrLogicGate p1 std::make_uniqueAndGate(); // std::unique_ptrLogicGate p2 p1; // 编译错误不能拷贝std::unique_ptrLogicGate p2 std::move(p1); // 只能移动//移动后p1 变成了空指针nullptrp2 现在独占那个 AndGate 对象这样任何时候都只有一个unique_ptr持有该对象的地址这块内存的释放责任极其明确当这个唯一的unique_ptr销毁时内存必然被释放。关于“乱改数据”unique_ptr并不限制你修改对象的内容。你可以通过gate-compute(...)或者*gate去调用函数、改变对象内部状态。它禁止的是“复制所有权”而不是禁止“修改值”。防止“内存泄漏”和“重复释放”才是它的核心使命。3. 总结你的认知你的猜测准确性“一个特定的堆对象只能由一个unique_ptr指向”完全正确这就是独占所有权的定义。“这是为了防止该堆对象被四处乱改”部分正确。它主要防止的是“四处乱释放”内存安全修改数据本身依然可以通过unique_ptr的-操作符进行。你现在对这个“独占”的理解已经非常准确了这是一个很重要的认知突破。你掌握它之后再看到vectorunique_ptr时你就会清楚知道每一个门对象都有一个专属管家管家之间互不干涉且绝不会出现两个管家争着拆房子双重释放的情况。二、多态、虚函数与智能指针Q我们不能直接创建 LogicGate 的实例但我们可以创建它的子类AndGate、OrGate的实例然后通过 LogicGate* 指针去操作它们。这就是多态。我想听听你对多态的解释。继承即多态吗A不继承不等于多态。继承是代码复用和建立父子关系的手段而多态是“一个接口多种实现”的行为特征。在 C 里只有当继承和虚函数virtual以及基类指针/引用三者结合时才形成真正的运行时多态。我们可以通过两个对比实验来看清楚到底谁才是多态的“灵魂”。1. 只有继承没有虚函数多态失效如果去掉virtual关键字即使你继承也不会有多态行为cppclass LogicGate { public: // 注意没有 virtual const char* name() const { return LogicGate; } }; class AndGate : public LogicGate { public: const char* name() const { return AND; } }; int main() { AndGate and_obj; // 子类对象 LogicGate* ptr and_obj; // 基类指针指向子类对象 std::cout ptr-name() std::endl; // 输出LogicGate不是 AND return 0; }因为name()不是虚函数编译器在编译时直接根据指针的类型LogicGate*决定调用LogicGate::name()而不是根据指针实际指向的对象类型AndGate。这个行为叫静态绑定编译时决定多态没有发生。2. 继承 虚函数 基类指针多态生效当你在基类中加上virtual就是你代码里的那种写法编译器会做另一件事它在对象内存里藏一个指针vptr指向一张函数地址表vtable。调用compute()时程序会运行时去查找这张表找到AndGate自己的compute版本而不是LogicGate的。cppclass LogicGate { public: virtual const char* name() const { return LogicGate; } // 加 virtual }; class AndGate : public LogicGate { public: const char* name() const override { return AND; } }; int main() { AndGate and_obj; LogicGate* ptr and_obj; std::cout ptr-name() std::endl; // 输出AND多态生效 return 0; }因为加了virtual程序会在运行时去查ptr真正指向的对象AndGate的函数表从而调用正确的版本。这叫动态绑定运行时决定这才是真正的多态。3. 为什么你的代码里必须用指针/引用cppstd::vectorstd::unique_ptrLogicGate gates;这里的LogicGate*被封装在unique_ptr里是基类指针。如果直接按值存储比如std::vectorLogicGate会发生对象切片当你把AndGate对象复制进vectorLogicGate时只有基类LogicGate的部分被保留下来AndGate特有的部分被切掉。这样对象就退化成普通的LogicGate多态调用compute就失效了。所以多态必须通过指针或引用操作对象。你代码里的unique_ptr正好提供了这种“通过基类指针操控子类对象”的能力从而让多态得以运行。4. 总结继承是骨架虚函数是灵魂概念角色继承提供了“父子关系”的骨架AndGate继承自LogicGate虚函数virtual提供了“运行时查找”的机制灵魂让程序知道该调用哪个版本的函数基类指针/引用提供了“统一接口”的载体LogicGate*让程序能用同一套代码操作不同的子类当你写for (const auto gate : gates)时gate是LogicGate的指针/引用。你调用gate-compute(a,b)程序会根据指针实际指向的对象类型是AndGate还是OrGate在运行时决定执行哪个compute版本。这就是多态最直观的体现。Q如果直接按值存储比如 std::vectorLogicGate会发生对象切片当你把 AndGate 对象复制进 vectorLogicGate 时只有基类 LogicGate 的部分被保留下来AndGate 特有的部分被切掉。这样对象就退化成普通的 LogicGate多态调用 compute就失效了。照这么说我能否推测基类的存储大致是这样先存储基类本身而后再基类之后或另开一部分内存存储virtual的函数表否则不可能做到精准的对象切片A猜对了方向而且猜得非常接近事实。内存布局确实像你推测的那样但有一个关键区分对象内部存的是虚指针vptr而真正的虚函数表vtable是单独存放的。1. 对象内存里到底有什么假设有一个基类Base和一个派生类Derived单继承cppclass Base { public: int m_base_data; virtual void func() { std::cout Base; } }; class Derived : public Base { public: int m_derived_data; void func() override { std::cout Derived; } };内存布局按地址从低到高大致是这样的偏移量内容说明0vptr8字节64位系统隐藏指针指向该对象实际类型对应的虚函数表8m_base_data4字节基类的成员变量12内存填充对齐可能空出 4 字节16m_derived_data4字节派生类的成员变量紧跟在基类之后注意虚指针在对象内部而且总是位于对象的最开头大多数编译器实现如此Itanium ABI标准。这样无论指向的是派生类还是基类只要拿到对象首地址就能立刻找到虚指针去查函数表。2. 虚函数表vtable在哪虚函数表不在对象内部它是编译时生成的一个静态数组存放在程序的只读数据段类似你之前看到的AND字符串。它的内容是一系列函数地址。Base 类的 vtable存放Base::func()的地址。Derived 类的 vtable存放Derived::func()的地址覆盖了基类的槽位。对象的 vptr 指向所属类的 vtable。当Derived对象被构造时编译器会把它的 vptr 初始化为指向Derived类的 vtable。3. 对象切片到底切掉了什么你的问题核心当你把Derived对象按值赋值给Base类型变量时复制过程是按字节拷贝内存cppDerived d; // 内存[vptr指向Derived表][Base数据][Derived数据] Base b d; // 切片发生编译器做的操作只拷贝Base大小的内存块vptr Base成员变量。不拷贝m_derived_data因为Base不知道它存在且装不下。关键步骤由于b是一个新构造的Base对象它的 vptr 会被编译器重新设置指向Base类的 vtable而不是Derived的。所以虽然b的 vptr 依然是对象首地址但它指向的表变了。调用b.func()时程序查的是Base的表自然就调用了Base::func()多态丢失。你原话是“基类之后或另开一部分内存存储virtual的函数表”答基类或派生类之后、另开内存存储的是子类自己的新增成员变量。虚函数表本身并不在对象内部而在全局只读区域。对象内部只有一个指向该表的指针vptr而切片正好把vptr指向的表给“重置”了。4. 切片后为什么无法恢复切片后b已经失去了子类特有的m_derived_data内存区域和Derived的 vtable 信息。编译器从类型系统Base b角度已经不知道b曾经是一个Derived。你通过任何方式都无法再恢复它。这就是为什么要用指针或引用而不是值来操作多态对象——指针/引用不会修改原始内存只是传递地址不存在“复制内存”和“重置 vptr”的过程。Q噢所以刚才的表格是一个派生对象的存储方式。它不仅仅包括了基类的内容还有派生对象的内容所以切割才会发生A完全正确一个字都没错。之前的那个表格就是一个完整的Derived对象在内存里的真实布局。它确实包含了从Base继承来的部分vptrm_base_data。自己新加的部分m_derived_data。正因为派生对象比基类对象大sizeof(Derived) sizeof(Base)切片才会发生。为什么切片必然发生用物理空间解释当你写Base b d;时b是在栈上分配的它的大小是sizeof(Base)比如 16 字节。d的大小是sizeof(Derived)比如 24 字节。编译器要把d的内存内容拷贝到b里去。但b只有 16 字节的空间根本塞不下d的全部 24 字节。所以编译器只拷贝前 16 字节即vptrm_base_data也就是基类子对象的部分。后 8 字节m_derived_data由于无处安放直接被截断丢弃了。更关键的是在拷贝过程中编译器还会“纠正”b的vptr。因为b的类型现在是Base不是Derived编译器会把b的vptr指向Base的虚函数表。所以即使你拿着b去调用虚函数它也会变成基类的行为。总结你现在的认知链条继承的本质派生类对象内部嵌着一个完整的基类子对象。多态的本质通过vptr查表运行时决定调用哪个函数。切片的本质当按值赋值时目标变量只有基类大小的空间装不下派生类新增的部分且vptr被重置为基类版本。Q所以编译器是自动纠正了vptr的内容这关于编译器是怎么书写的我们再向里看会很复杂了对吗而这种纠正的原因很可能是派生类的成员变量没了所以这个被切片后的对象就算拥有派生对象的vptr使用函数表里的函数也会报错甚至是严重错误所以只能使vptr为基类函数表的指针A是的。1. 你的第一个判断再往里看会很复杂对吗对的那属于“实现层”的细节。编译器具体怎么写入这个 vptr在什么时候写入涉及到 Itanium C ABI应用程序二进制接口标准、构造函数链的调用顺序、以及异常处理栈展开等信息。对于你目前“机制层”的认知来说确实不需要往里看了。你只需要知道编译器会在构造函数和赋值操作中自动维护这个指针就够了。2. 你的第二个判断如果不纠正 vptr会发生严重错误吗你的直觉完全正确而且切中了要害。我们模拟一下“如果不纠正 vptr”会发生什么也就是编译器没有修复b的 vptr让它依然指向Derived的虚函数表b现在只有 16 字节vptrm_base_data。但b的 vptr 却指向Derived的虚函数表里面存着Derived::func()的地址。当你调用b.func()时程序会跳转到Derived::func()。如果Derived::func()里写了m_derived_data 100;访问派生类自己的成员变量那么它会试图在b的内存地址只有 16 字节基础上去偏移到原本m_derived_data所在的位置偏移量 16。这片内存可能属于栈上的其他变量或者完全是无效的。结果就是内存访问违规Segmentation Fault或者静默地改写了其他变量的值更恐怖的 Bug。所以如果不纠正 vptr程序在运行时几乎必崩或产生极其隐蔽的数据损坏。编译器为了防止这种灾难在切片时强制把 vptr 指向基类的虚函数表。因为基类的虚函数不会访问不存在的派生类成员所以调用是安全的即便失去了多态功能但程序不会崩溃。3. 补充一个细微点切片不仅发生在赋值也发生在传参除了Base b d;切片还经常发生在一个你意想不到的地方函数传参。cppvoid func(Base obj) { ... } // 按值传递 int main() { Derived d; func(d); // 这里发生切片Derived 被切掉只把基类部分传进函数 }这和赋值一样同样会导致 vptr 被重置为基类版本。所以在 EDA 工具代码里如果你要传递多态对象几乎总是写成void func(const Base obj)引用传递或void func(Base* obj)指针传递绝不会写成void func(Base obj)值传递。4. 你现在可以安心停在这里了你已经站在了“机制层”的尽头你知道 vptr 存在但不关心它具体怎么生成。你知道切片发生因为内存大小不同。你知道编译器会重置 vptr为了防止灾难。你对“编译器为什么要这么做”的猜测极其精准而且你完全理解了背后的因果链。这个认知深度足够你以后在写 EDA 工具时一眼看出“这里用vectorBase会出错必须用vectorunique_ptrBase”了。Q啊~所以本质上说是不能进行值传递因为会识别少。所以我们要传指针传指针就能正常访问了~A正确。我帮你把这句话翻译成更“工程化”的表达“当你想保持对象的完整身份多态时必须传递它的地址指针/引用而不是它的值。因为按值传递会生成一个临时副本编译器只给这个副本分配基类大小的内存派生类信息会被截断虚表指针也会被重置对象就‘退化’了。”你以后写 C 代码时可以把这个规则刻在脑海里多态对象有虚函数的类永远按指针或引用传递。按值传递只适用于普通数据类POD或没有继承关系的轻量对象。在 EDA 工具里电路对象门、线网、模块几乎都是通过智能指针或引用传递因为它们的类型结构复杂而且你需要保持多态行为。