跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
C++

C++ 继承与多态机制详解

综述由AI生成详细阐述了 C++ 面向对象编程中的继承与多态机制。内容包括继承的本质、单继承与多继承的语法及内存布局、虚函数与动态绑定原理、抽象类设计以及虚析构函数的必要性。重点讲解了向上转型、对象切割、虚表指针 vptr 与虚函数表 vtable 的实现细节,并探讨了私有继承、纯虚函数接口设计、菱形继承的虚继承解决方案以及 override 与 final 关键字的安全用法。通过代码示例分析了构造函数调用顺序、访问控制权限变化及缺省参数陷阱,旨在帮助开发者深入理解 C++ 类型系统与安全编程实践。

赛博行者发布于 2026/3/16更新于 2026/4/2710 浏览

继承概览

继承的本质

继承不仅仅是代码的复制粘贴,它是一种对事物进行分类的逻辑。核心在于:让新代码利用已有的代码,实现增量开发。

为什么我们需要继承?

  • 基于目标代码的复用 (Code Reuse)
  • 对事物进行层级分类 (Classification)
    • 在 C++ 中,上面的层级就是基类 (Base Class),下面的层级就是派生类 (Derived Class)。核心关系是 Is-a 关系。继承建立了一种'是'的关系。
    • 派生类是基类的具体化:比如,'学生'是'人'的具体化。
    • 基类是派生类的抽象化:比如,'人'是'学生'的抽象概念。
  • **增量开发 (Incremental Development)**这是一种很聪明的开发方式。我们不需要一开始就设计一个无所不能的超级大类。我们可以先写一个基础的(比如 Shape),然后根据需求,一点点增加新的具体类(比如 Circle, Square)。
    • 好处:把复杂问题拆解成层次结构,更有利于描述和解决问题。

单继承 (Single Inheritance)

基础语法

所谓单继承,就是一个派生类只从一个基类那里继承。

格式定义:class 派生类名 : 继承方式 基类名

// 基类 (父亲) 
class Student { ... };

// 派生类 (孩子) -> 继承了 Student
class Undergraduate_Student : public Student { ... };

内存布局:像'搭积木'一样

当我们在内存中创建一个 Undergraduate_Student 对象时,内存里是什么样子的?

  • 派生类对象的大小 = 基类部分大小 + 派生类新增部分大小 (+ 内存对齐)。
  • 指针转换:这也是为什么基类指针可以指向派生类对象的基础(因为开头部分是一样的)。

基类在前,派生类在后:编译器会先由上而下,把 Student (基类) 的数据成员放进去,紧接着再放 Undergraduate_Student (派生类) 自己的成员。**举例:**假设 Student 有 id, nickname,派生类有 dept_no。内存样子如下(连续空间):

[ id ] <-- 来自 Student (基类)
[ nickname ] <-- 来自 Student (基类)
[ dept_no ] <-- 来自 Undergraduate_Student (派生类)

核心推论:

权限控制:Protected 的引入

我们在 C 语言结构体或者 C++ 类封装时,只知道 public 和 private。但在继承中,我们需要一种'传家宝'级别的权限。

  1. private (私有):
  • 规则:除了基类自己,谁都不能动。连派生类(亲儿子)都看不见、摸不着。
  • protected (保护):
    • 规则:对外人(类外代码)来说,它是 private 的(不可访问);但对派生类(自家人)来说,它是 public 的(可以访问)。
    • 用途:专门为了让派生类能复用基类的内部数据,同时又不暴露给全世界。
  • public (公有):
    • 规则:谁都可以访问。
  • 声明的一个'坑'

    继承关系必须在类定义时指定,不能在前向声明时指定。

    • 错误写法:class Undergraduated_Student : public Student; // 编译器不知道 Student 多大,没法分配内存
    • 正确写法:class Undergraduated_Student; // 只是告诉编译器有这么个名字

    对象的构造与析构次序

    批注:这里有个高频考点:如果基类没有默认构造函数(即 A()),而是只有带参数的构造函数(如 A(int i)),编译器就不会自动调用了。这时候,派生类必须在自己的'初始化列表'里显式地调用基类的构造函数!

    析构函数的执行次序 (Destruction Order) 核心原则:与构造完全相反(对称美)。

    Derived (派生类自己) ----> Member Objects (成员对象) ----> Base (基类)
    

    构造函数的执行次序 (Construction Order)

    Base (基类) ----> Member Objects (成员对象) ----> Derived (派生类自己)
    

    构造函数

    默认情况下的'隐形操作'

    如果基类有一个默认构造函数(就是那个不带参数的 A()),那你什么都不用做。

    编译器会在派生类构造函数执行之前,偷偷帮你调用一次 A()。

    真正的挑战:非默认构造函数

    • 问题场景: 如果基类只定义了一个带参数的构造函数 A(int x),没有默认的 A(),怎么办?这时候,编译器不知道该传什么参数给 A,如果你不显式指定,程序就会报错。

    解决方式:成员初始化表 (Member Initialization List) 这是唯一的合法途径。我们必须在派生类构造函数的冒号后面,手动'喂'给基类参数。

    • 语法格式:派生类构造函数(...) : 基类名 (参数), 成员 (...) { ... }

    **为什么不能在 {} 里赋值?**很多同学喜欢这样写(这是错误的):

    B(int i, int j) {
        A(i); // 错!这不是初始化,这是在创建一个临时的 A 对象然后马上扔掉
        y = j;
    }
    

    记住原理:在进入 { 之前,基类部分必须已经存在。初始化列表就是在 { 之前干活的地方。

    实例解析:

    class A {
    public:
        A(int i) { x = i; } // 只有带参构造
    };
    
    class B : public A {
        int y;
    public:
        // 正确写法
        // 意思就是:生 B 的时候,先把参数 i 拿去把老爸 A 初始化了,然后再初始化自己的 y
        B(int i, int j) : A(i) {
            y = j;
        }
    };
    

    '偷懒'的高级写法:继承构造函数 (C++11)

    课堂中提到了 using Student::nickname 来解决名字掩盖,这里有个异曲同工的妙用!

    • 用法:using A::A;
    • 含义:这句话告诉编译器:'把基类 A 的所有构造函数,都直接拿来给 B 用!'这样你就不用苦哈哈地把 B(int i) : A(i) {} 这种代码重写一遍了。这也是一种复用。
    • 如果你用了 using 继承构造函数,同时派生类又有新变量,一定要给新变量类内初始值:

    类型相容与赋值兼容

    如果说前面的继承是'拼积木',那这里就是'变魔术'。我们要研究的是:当把一个派生类对象当成基类用的时候,会发生什么?

    核心法则:向上转型 (Upcasting)

    只有'派生类'可以被当作'基类'来使用。反之不行。

    • 逻辑:因为'学生'肯定是一个'人',但'人'不一定是'学生'。
    • 结论:在需要基类的地方,我们可以把派生类传进去。

    这里有两种截然不同的方案,一定要区分开,这是常见考点!

    玩法一:对象赋值 —— '切割' (Slicing)

    派生类赋值给基类,反之不可以。

    • **发生了什么?**这叫对象切割 (Object Slicing)。
      • b 中属于 A (基类) 的那部分数据,被拷贝给了 a。
      • b 中属于 B (派生类特有) 的那部分数据(比如 dept_no),直接丢失了!被切掉了!
      • 结果:变量 a 只是一个普通的基类对象,它完全不知道自己曾经和'学生'有过接触。

    场景:

    A a; // 基类对象 (比如:人)
    B b; // 派生类对象 (比如:学生)
    class B: public A
    a = b; // ✅ 合法:把 b 赋值给 a
    

    玩法二:指针/引用 —— '戴上面具' (Pointer/Reference)

    基类指向派生类,反之不可以。

    • 发生了什么?
      • 内存没变:内存里依然完整地躺着一个 B 对象(包含 id, nickname, dept_no)。
      • 视线变窄:但是!指针 p 是 A*类型的。它就像戴了一副'有色眼镜',它只看得到 b 里面属于 A 的那部分。
      • 特有成员不可见:如果你试图写 p->dept_no,编译器会报错,因为在 p 眼里,这就是个 A。

    场景:

    B b; // 派生类对象
    A *p = &b; // ✅ 合法:基类指针指向派生类
    A &r = b; // ✅ 合法:基类引用绑定派生类
    

    ⚠️ 灵魂发问:到底调用的谁?(The "Binding" Problem)

    有如下的假设:

    • 基类 A 有个函数 void f() { cout << "I am A"; }
    • 派生类 B 重写了这个函数 void f() { cout << "I am B"; }

    代码如下:

    B b;
    A *p = &b; // p 指向了 b
    p->f(); // ❓ 这里会输出什么?
    

    残酷的现实(在没有 virtual 之前):

    • 输出结果:"I am A"
    • 原因:静态绑定 (Static Binding)。编译器很'死板',它只看 p 的类型是 A*,不管它实际指向谁,直接就去调用 A::f() 了。

    这就好比:你虽然是学生(B),但你穿了便装(被 A*指向),老师(编译器)没认出你,只把你当路人甲(A)对待了。

    虚函数和动态绑定 (Virtual Functions)

    绑定的革命:前期 vs 后期

    所谓'绑定 (Binding)',就是把函数调用和函数体代码连接起来的过程。也就是解决 p->f() 到底去执行哪一段代码的问题。

    1. 前期绑定 (Early Binding / Static Binding)
      • 时间:编译时刻就决定好了。
      • 依据:对象的静态类型(即指针/引用的类型 A*)。
      • 特点:效率极高(编译器直接生成跳转指令),但灵活性差(就是刚才的'近视眼')。
      • C++ 的默认设置。
    2. 动态绑定 (Late Binding / Dynamic Binding)
      • 时间:运行时刻才决定。
      • 依据:对象的实际类型(即内存里真正躺着的是 A 还是 B)。
      • 特点:灵活性高(多态),但效率略低(需要多查一次表,后面会讲)。
      • 开启方式:必须显式使用 virtual。

    开启多态的钥匙:virtual

    只要在基类的函数声明前加上 virtual,动态绑定的魔法就生效了。

    class A {
    public:
        virtual void f(); // ✨ 我是虚函数,请看我实际是谁!
    };
    
    class B : public A {
    public:
        void f(); // 重写(Override)基类的 f
    };
    

    效果对比:

    B b;
    A *p = &b;
    p->f();
    
    • 没加 virtual:调用 A::f()(看衣服)。
    • 加了 virtual:调用 B::f()(看灵魂)。这就是多态!

    虚函数的'传家宝'规则

    'Once Virtual, Always Virtual'

    1. 自动继承:如果在基类中 f() 被声明为虚函数,那么在所有的派生类(儿子、孙子...)中,它自动成为虚函数。
    2. 写法:派生类里写不写 virtual 关键字都可以(建议写上,或者用 override,为了可读性)。

    举例

    #include <iostream>
    using namespace std;
    
    // 1. 爷爷:明确开启了 virtual
    class Animal {
    public:
        virtual void speak() {
            cout << "Animal speaks" << endl;
        }
    };
    
    // 2. 爸爸:并没有写 virtual 关键字
    class Dog : public Animal {
    public:
        void speak() {
            cout << "Wang! Wang!" << endl;
        }
    };
    
    // 3. 孙子:这是第 3 代了
    class Husky : public Dog {
    public:
        void speak() {
            cout << "Awoooo~~ (Husky style)" << endl;
        }
    };
    
    int main() {
        Animal* p1 = new Dog(); // 爷爷指针 -> 指向爸爸
        Animal* p2 = new Husky(); // 爷爷指针 -> 指向孙子
        // 如果没有'传家宝'规则,中间断了,p2->speak() 可能就会调错。
        // 但因为规则存在:
        p1->speak(); // 输出:Wang! Wang! (正确!多态生效)
        p2->speak(); // 输出:Awoooo~~ (正确!多态依然生效)
        return 0;
    }
    

    ❌ 谁不能当虚函数?(避坑清单)

    面试常考题!请在笔记里打个大大的星号。

    1. 构造函数:绝对不行!
      • 原因:虚函数调用依赖于对象里的'虚表指针'(vptr),而在构造函数执行时,对象还没生出来呢,哪来的指针?
    2. 静态成员函数 (static):不行。
      • 原因:static 函数属于类,不属于对象。
    3. 内联函数 (inline):通常不行。
      • 原因:inline 是编译期展开,virtual 是运行时决定,这俩逻辑是矛盾的。
    • 注意析构函数:可以,而且往往必须是!
      • 伏笔:这点非常重要,后面会有专门一页讲这个。现在先记下:基类的析构函数最好写成 virtual。

    虚函数表 —— vptr 与 vtable

    接上面,我们需要知道这里的动态绑定是怎么实现的!这也是整个虚函数机制的心脏!

    两个核心概念 (The Big Two)

    为了实现'动态绑定',编译器在幕后偷偷做了两件事:

    • 虚函数表 (vtable):
      • 是什么:一个函数指针数组(你可以理解为一张'菜单')。
      • 存哪里:每个类只有一张表(静态的,所有对象共用这一张菜单)。
      • 存什么:里面按顺序存放了这个类所有虚函数的地址。
    • 虚表指针 (vptr):
      • 是什么:一个隐藏的指针变量(就是刚才说的'身份证')。
      • 存哪里:存在每个对象的内存里(通常是对象内存的最开头)。
      • 存什么:指向该对象所属类的 vtable 的地址。

    结合 PPT 实例图解

    我们来看看 Class A 和 Class B 到底发生了什么。

    • 基类 A (Class A)
      • 定义:virtual f(), virtual g()
      • A 的 vtable 菜单:
        1. &A::f
        2. &A::g
    • 派生类 B (Class B)
      • 定义:继承了 A,但是重写 (Override) 了 f(),没改 g()。
      • B 的 vtable 菜单(关键点!):
        1. &B::f <-- 注意!这里被替换成了 B 自己的 f,这就是多态的根本!
        2. &A::g <-- B 没改 g,所以还是指向爸爸的 g。
      • 找指针:通过 p 找到对象内存。
      • 找表:读取对象头部的 vptr(比如 p 指向 b 对象,那就读到了 B_vtable 的地址)。
      • 找函数:根据 f() 在声明中的顺序(比如是第 0 个),去表里拿第 0 项的地址(拿到了 &B::f)。
      • 调用:跳转去执行那个地址的代码。

    当编译器看到 p->f() 且 f 是虚函数时它不会直接生成 call A::f 的指令,而是生成类似下面的一套'动作':

    伪代码翻译(本质逻辑): (*p->vptr)[0](p) 翻译:通过 p 找到 vptr,去表里取第 0 个函数指针,然后调用它。

    普通函数 h() 怎么办?

    如果有一个 h(),它不是 virtual。

    • 处理方式:静态绑定。
    • 编译器编译时直接确定地址,不需要查表,不需要 vptr,效率最高。所以图里的 vtable 只有 f 和 g,没有 h。

    构造函数为什么不能是虚函数?

    上面只给出了哪些地方不能用虚函数,这里我们尝试去解决这个重要的问题。

    • **核心规则:由于时间差导致的'降级'为什么?(原理回顾)**还记得我们刚才说的 vptr(身份证)吗?
      1. 当我们创建一个 B 对象时,先执行 A 的构造函数。
      2. 此时,B 的部分还没生出来(内存可能是随机值)。
      3. 为了防止你访问到不存在的 B 成员,编译器做了一个保护措施:
        • 在进入 A 的构造函数时,把 vptr 指向 A 的虚表。
        • 只有等到 A 构造完,开始执行 B 的构造函数时,才把 vptr 改成 B 的虚表。
      4. 所以,在 A 的构造函数里,编译器'认为'当前就是个 A 对象。
      5. 执行流程拆解:
        • 调用 A::A() (基类构造)。
          • A 构造函数里调用了 f()。
          • 关键点:此时对象还是 A,vptr 指向 A。
          • 结果:调用 A::f()。(多态失效)
        • 调用 B::B() (派生类构造)。
          • f 是虚函数。
          • 此时对象已经构造完毕,vptr 已经是 B 的了。
          • 结果:调用 B::f()。(多态生效)
        • g 在 A 中不是虚函数。
          • 虽然 B 里也有个 g,但 p 是 A*类型。
          • 结果:编译器只看指针类型,直接绑定 A::g()。(无多态,看衣服)
        • h 是 A 的成员函数,它在 A 中定义:void h() { f(); g(); }
          • 这里的 f() 其实是 this->f(),g() 是 this->g()。
          • 分析 f():
            • f 是虚函数。
            • this 指向的是一个完整的 B 对象。
            • 结果:查表,调用 B::f()。
          • 分析 g():
            • g 不是虚函数。
            • this 的类型是 A* (因为 h 是 A 的函数)。
            • 结果:静态绑定,调用 A::g()。
      6. 为什么呢?原则:进入哪个函数,这里的指针变为哪种,这里是 A*,普通函数会由此去静态判断,虚函数根据 vptr 判断

    还有一个很大的坑

    class A {
    public:
        virtual void f();
        void g();
    };
    class B: public A{
    public:
        void f() { g(); }
        void g();
    };
    B b;
    A* p = &b;
    p -> f()
    B::g();
    

    接下来我们就很好理解 PPT 上面的实例代码了

    class A {
    public:
        A() { f(); }
        virtual void f();
        void g();
        void h() { f(); g(); }
    };
    class B: public A{
    public:
        void f();
        void g();
    };
    B b; // 1. 创建对象 => A::A(),A::f, B::B(),
    A *p = &b; // 2. 基类指针指向派生类
    p->f(); // 3. 多态调用 => B::f
    p->g(); // 4. 普通调用 => A::g
    p->h(); // 5. 混合调用 => A::h, B::f, A::g
    

    现代 C++ 的安全写法——override 与 final

    override:防手滑神器

    作用:显式告诉编译器,'我是来重写基类虚函数的,请帮我检查一下!'。

    旧时代的痛点:如果你在派生类里重写函数时,不小心把参数类型写错了(比如 int 写成了 float),或者函数名少打了一个字母。

    • 结果:编译器不会报错,而是认为你新定义了一个完全无关的函数。多态直接失效!

    新时代的写法:在函数声明后面加上 override。

    struct B {
        virtual void f1(int) const;
    };
    struct D : B {
        // ✅ 正确:完全匹配,编译器放行
        void f1(int) const override;
        // ❌ 报错:基类里没有接受 float 的 f1,编译器直接拦截!
        // void f1(float) override;
    };
    

    建议:以后写派生类虚函数,无脑加上 override。

    final:到此为止

    作用:禁止继承,或禁止重写。就像给代码做了'绝育手术'。

    两种用法:

    修饰虚函数:这个函数不能再被后代修改了。

    struct Base {
        virtual void f() final;
    };
    struct Derived : Base {
        // void f() override; // ❌ 编译报错,Base 说不能改了
    };
    

    修饰类:这个类不能再有孩子了(不能做基类)。

    struct Base final { ... };
    // struct Derived : Base { ... }; // ❌ 编译报错
    

    访问控制的'双重人格'

    PPT 展示了一个非常反直觉但合法的操作:子类可以修改虚函数的访问权限。

    • 基类 B:f() 是 protected(只能自家或者孩子用,外人不能调)。
    • 派生类 D:重写了 f(),并把它改成了 public(大家都能用)。

    场景重现

    struct B {
        protected:
            virtual void f() {}
    };
    struct D : B {
        public:
            void f() override {} // ✅ 居然可以放宽权限!
    };
    int main() {
        D d; // 1. 直接用 D 对象调用
        d.f(); // ✅ 通过!编译器看 d 是 D 类型,D::f 是 public,允许访问。
        // 2. 用 B 指针调用
        B* pb = &d;
        pb->f(); // ❌ 报错!编译器编译时看 pb 是 B* 类型,B::f 是 protected,类外禁止访问!
    }
    

    极其分裂的现象:虽然 pb->f() 在运行时真正执行的是 D::f(它是 public 的),但编译器在编译阶段就被 B::f 的 protected 挡回去了。

    结论:访问权限检查 (Access Control) 发生在 编译期,看静态类型;函数逻辑调用 发生在 运行期,看动态类型。

    Protected 和友元的一个问题

    核心规则:Protected 的'自私'

    protected 成员:允许派生类访问,但有一个严格的前提——派生类只能访问'属于它自己'的那部分基类成员,不能访问'别人家'基类对象的成员。

    代码破案
    class Base {
    protected:
        int prot_mem; // 传家宝
    };
    class Sneaky : public Base {
        friend void clobber(Sneaky&); // 这里的友元,拥有 Sneaky 的所有权限
        friend void clobber(Base&);
        int j;
    };
    

    情景一:访问自己的 (OK ✅)

    void clobber(Sneaky &s) {
        s.j = s.prot_mem = 0;
    }
    
    • 分析:s 是一个 Sneaky 对象。作为 Sneaky 的友元,我可以访问 s 继承下来的 prot_mem。这是合法的'继承权'。

    情景二:访问别人的 (Error ❌)

    void clobber(Base &b) {
        b.prot_mem = 0;
    }
    
    • 分析:参数 b 是一个独立的 Base 对象。
    • 编译器逻辑:虽然 Sneaky 继承自 Base,但这不代表 Sneaky 可以随意去动任意一个 Base 对象的私有财产!
    • 通俗比喻:你(派生类)可以花你爸爸给你的零花钱(继承来的 protected),但你不能跑去隔壁老王(也是个父亲,Base)家里拿他的零花钱,哪怕老王和你爸爸是同一类人。

    纯虚函数与抽象类

    它把继承从'代码复用'提升到了'接口设计'的高度。

    纯虚函数 (Pure Virtual Function)

    定义:一个只有声明,没有实现(或者不需要基类给实现)的虚函数。

    • 语法:virtual 返回值 函数名 (参数) = 0;
      • 注意:这个 = 0 不是说函数值是 0,而是告诉编译器:'不用给我生成函数体,我就是个空壳/接口。'

    抽象类 (Abstract Class)

    定义:只要一个类里至少包含一个纯虚函数,这个类就变成了抽象类。

    • 特点(铁律):
      1. 不能实例化:你不能写 AbstractClass a; 或者 new AbstractClass。
        • 为什么? 因为里面有函数是空的,如果创建了对象,调用那个空函数怎么办?程序会崩。
      2. 只能做基类:它的存在就是为了给派生类提供一个'规范'或'框架'。
      3. 可以用指针指向派生类

    派生类的义务

    如果派生类继承了一个抽象类,它有两个选择:

    1. 实现所有纯虚函数:这样派生类就变成了'具体类',可以创建对象了。
    2. 继续摆烂(不实现):那么派生类也会继承那个纯虚函数,它自己也变成了抽象类,依然不能创建对象。

    多态数组与异构容器

    我们为什么要费劲去写抽象类。目标:用统一的方式,管理不同的东西。

    • 场景设定
      • 基类 (抽象):Figure (图形)。它不知道怎么画自己,所以 display() 是纯虚函数。
      • 派生类 (具体):Rectangle (矩形), Ellipse (椭圆), Line (线)。它们都知道怎么画自己。
      • 优势:循环体内的代码不需要修改。哪怕以后你加了一个新的图形 Circle,这个 for 循环一行代码都不用动!这就是'开闭原则'(对扩展开放,对修改关闭)。

    核心代码解析

    // 1. 定义一个基类指针数组
    // 这就像一个万能收纳盒,虽然类型是 Figure*,但可以装任何图形的地址
    Figure *a[100];
    
    // 2. 存入不同的对象 (向上转型)
    a[0] = new Rectangle();
    a[1] = new Ellipse();
    a[2] = new Line();
    
    // 3. 统一调用 (多态的魔法时刻 ✨)
    for (int i=0; i<num; i++) {
        a[i]->display();
    }
    

    进阶:抽象工厂模式 (Abstract Factory Pattern)

    • **遇到的麻烦 (The Problem)**假设我们要写一个软件,既要跑在 Windows 上,又要跑在 Mac 上。如果我们在代码里到处写 if (isWindows) ... else ...,代码会变得极其丑陋,难以维护
      • Windows 的按钮叫 WinButton。
      • Mac 的按钮叫 MacButton。
      • Button (抽象按钮) -> 派生出 WinButton, MacButton
      • Label (抽象标签) -> 派生出 WinLabel, MacLabel
      • *代码中的 client 只认 Button 和 Label,不关心具体是哪个系统的。
      • 这就是那个'中介'的标准。
      • WinFactory:生产 WinButton 和 WinLabel。
      • MacFactory:生产 MacButton 和 MacLabel。

    如何使用?(The Client)

    AbstractFactory* fac; // 工厂指针
    // 1. 在程序启动时,只做一次决定 (Configuration)
    if (style == WIN) fac = new WinFactory();
    else fac = new MacFactory();
    
    // 2. 后续所有业务代码 (Business Logic)
    // 只有这里用到了多态!
    // 程序员说:'给我个按钮。'
    // 工厂说:'给你。' (如果 fac 是 WinFactory,给的就是 WinButton)
    Button* btn = fac->CreateButton();
    

    解决方案:抽象工厂 (The Solution)

    核心思想:我们不直接 new 具体的按钮,而是找一个'中介'(工厂)来帮我们要按钮。

    我们把'创建零件'这件事提取出来,做成接口。

    第一步:定义抽象产品 (Abstract Products)

    第二步:定义抽象工厂 (Abstract Factory)

    class AbstractFactory {
    public:
        virtual Button* CreateButton() = 0; // 纯虚函数:我要个按钮
        virtual Label* CreateLabel() = 0;   // 纯虚函数:我要个标签
    };
    

    第三步:实现具体工厂 (Concrete Factories)

    虚析构函数

    • p 的静态类型是 B* (基类指针)。
    • p 实际指向的是 D (派生类对象)。
    • 如果基类 B 的析构函数不是虚函数(没有 virtual),那么 delete p 时采用的是静态绑定。
    • 后果就是编译器只去将调用了基类的析构函数,拆去了 p 中基类的资源,但是第 2 层(派生类特有的资源)悬在空中没人管了!
    • 一旦析构函数变成了虚函数,delete p 就会触发动态绑定。
    • 编译器会去查 vptr(身份证),发现 p 指向的其实是个 D 对象。
    • 它会先调用 ~D()(清理派生类资源),再调用 ~B()(清理基类资源)。这里的顺序问题可以参考前面的说明。
    • 完美释放,无残留! ✅

    解决方案:加上 virtual (The Fix)

    class B {
    public:
        virtual ~B() = default; // ✨ 加上 virtual!
    };
    

    发生了什么?

    危险的场景 (The Trap)

    B* p = new D; // 基类指针指向派生类对象
    delete p; // 💣 隐患就在这一行!
    

    问题出在哪?

    公有继承的里氏代换原则

    这里面实际在讲里氏替换原则 (LSP)

    生物学 vs 代码学:企鹅是鸟吗?

    PPT 案例:class Penguin : public FlyingBird 现实中企鹅是鸟,但在代码里,如果 FlyingBird 定义了 virtual fly(),而企鹅不会飞,这就会出大问题。

    • 错误做法:在企鹅的 fly() 里报错 error("Penguins can't fly!")。
      • 这违反了'里氏替换原则':派生类必须能完全替代基类。如果我调用 bird->fly(),我期望它飞,而不是程序崩溃。
    • 结论:Public 继承必须是严格的 "Is-a" 关系(行为上的一致,而不仅仅是概念上)。

    禁忌:遮盖非虚函数 (Right Box)

    PPT 案例:class B 和 class D 都有 void mf(),但它不是 virtual。

    何为遮盖 (Shadowing/Hiding)?

    • 现象:D 定义了一个和 B 名字一模一样的非虚函数 mf()。
    • 后果(精神分裂):
      • B* pB = &x; -> pB->mf() 调用的是 B::mf。
      • D* pD = &x; -> pD->mf() 调用的是 D::mf。
      • 同一个对象 x,仅仅因为指针类型不同,表现出的行为就不同!这会让使用你代码的人疯掉。
    • 规则:绝对不要重新定义继承而来的非虚函数。要改写行为,基类必须是 virtual。

    经典的几何悖论:正方形是矩形吗?

    这是一个极其经典的面试题!数学上'正方形是矩形',但在 C++ 里不是。

    场景还原:

    • 矩形 (Rectangle):setWidth 只改宽,不改高。这是矩形的特性。
    • 正方形 (Square):继承自矩形。但为了保证是正方形,重写了 setWidth -> 同时也修改高度。

    出 Bug 的过程 (函数 Widen):

    1. 函数 Widen(Rectangle& r) 的目的是:把矩形拉宽。
    2. 它有个假设:改宽度不会影响高度 (oldHeight 应该保持不变)。
    3. 传入正方形:
      • 你传了个 Square 进去(向上转型为 Rectangle&)。
      • 调用 r.setWidth()。
      • 因为是 virtual(假设),正方形把宽和高都改了。
      • assert(r.height() == oldHeight) -> 断言失败!程序崩溃!💥

    核心教训:正方形的行为(改宽同时也改高)和矩形的预期行为(改宽不改高)不兼容。所以,不要让 Square 继承 Rectangle。

    💡 总结:

    如果继承导致你需要写 if (type == Penguin) 或者导致 assert 失败,那就说明不要用继承!

    私有继承:一种'不可告人'的关系

    我们通常用的继承是 public(公有)的,代表 "Is-a"(是)。但 C++ 还允许 private 继承,这代表什么呢?

    核心定义

    Private Inheritance 意味着 "Implemented-in-terms-of" (根据...实现)。

    • 关系:派生类内部利用了基类的代码来实现功能,但在外界看来,派生类不是基类。利用关系,就像汽车和引擎的关系
    • 此时私有继承中,编译器不会把子类指针自动转换为父类指针(向上转型失效)。// Engine* p = &myCar; // ❌ 报错!
    举例
    • Human (人类):有 eat()(吃饭)的功能。
    • Student (学生):需要吃饭,所以想复用 Human 的代码。
    • **但!**我们不想让别人把 Student 当作普通 Human 对待(比如不希望 Student 暴露某些 Human 的接口)。
    • 做法:class Student : private Human { ... }
      • 在 Student 内部,可以调用 Human::eat()。
      • 在 main 函数里,eat(student)报错!❌ 因为对外来说,他和 Human 没关系。

    接口继承的'三种境界'

    对虚函数机制的哲学总结,非常经典!它把类里的函数分成了三类,每一类都有明确的语义。

    纯虚函数 (Pure Virtual)

    • 形式:virtual void draw() = 0;
    • 含义:你必须提供这个接口,但我没办法给你实现。
    • 继承内容:只继承接口 (Interface)。

    普通虚函数 (Virtual)

    • 形式:virtual void error(msg);
    • 含义:你应该提供这个接口,如果你懒得写,我这里有一个默认版本给你用。
    • 继承内容:继承接口 + 缺省实现 (Default Implementation)。

    非虚函数 (Non-Virtual)

    • 形式:int objectID();
    • 含义:这个行为是强制的、不变的,所有派生类都必须一样,谁也不许改!
    • 继承内容:继承接口 + 强制实现 (Mandatory Implementation)。

    建议:我们在设计基类时,要想清楚每一个函数属于哪一类。如果是想让子类改的,一定要加 virtual;如果不想让子类改(比如获取 ID),千万别加 virtual。

    ⚠️ 缺省参数的陷阱

    问题场景

    class A {
    public:
        // 基类:默认参数是 0
        virtual void f(int x = 0) = 0;
    };
    class B : public A {
    public:
        // 派生类:默认参数改成了 1 (作死行为)
        virtual void f(int x = 1) {
            cout << x;
        }
    };
    

    诡异的输出

    B b;
    A* p = &b; // 基类指针指向派生类
    p->f(); // ❓ 输出多少?
    
    • 你的直觉:调用的是 B::f,B 的默认值是 1,所以输出 1。
    • 残酷的现实:输出 0!

    原理解析:静态绑定 vs 动态绑定

    为什么会这样?因为 C++ 编译器为了效率,把参数和函数体分开处理了:

    • 函数体 (Function Body):是动态绑定的。
      • p->f() 确实调用了 B::f() 的函数体(多态生效)。
    • 缺省参数 (Default Param):是静态绑定的!
      • 编译器在编译 p->f() 这行代码时,只看 p 的静态类型是 A*。
      • 所以它直接把 A 的默认值 0 填到了参数里。

    结果就是:你调用了子类的函数,却用了父类的参数! —— 这就是所谓的'身首异处'。

    怎么避坑?

    铁律:绝对不要重新定义继承而来的缺省参数值!

    如果非要用默认参数,可以由非虚函数(NVI 模式)来做跳板:

    class A {
    public:
        // 普通函数负责处理默认参数 (静态绑定,安全)
        void f(int x = 0) {
            do_f(x);
        }
    private:
        // 虚函数只负责干活,不带默认参数
        virtual void do_f(int x) = 0;
    };
    

    多继承

    定义

    多继承:一个派生类同时拥有两个或以上的基类。就像现实生活中,你既继承了爸爸的特征,也继承了妈妈的特征。

    • 能力:SleepSofa 对象既能调用 Bed 的方法(比如 Sleep()),也能调用 Sofa 的方法(比如 WatchTV())。
      • 这也是 PPT P27 展示的理想状态,看起来很美好。 ✨

    语法:用逗号分隔。

    class SleepSofa : public Bed, public Sofa { ... };
    

    灾难降临:菱形继承

    当这两个基类(爸妈)又来自同一个祖先(爷爷)时,麻烦大了。

    • 场景还原 (The Scenario)
      • 爷爷:Furniture (家具)。有一个成员变量 weight (重量)。
      • 爸爸:Bed (床)。继承自 Furniture。
      • 妈妈:Sofa (沙发)。继承自 Furniture。
      • 孙子:SleepSofa (沙发床)。同时继承 Bed 和 Sofa。
    • **内存里的'双重人格' (The Problem)**当我们创建一个 SleepSofa 对象时,内存里发生了什么?结果:SleepSofa 里竟然有两份 Furniture,也就有两个 weight 变量!
      1. 它包含一个 Bed 对象。而 Bed 里包含了一个 Furniture(爷爷 A)。
      2. 它包含一个 Sofa 对象。而 Sofa 里也包含了一个 Furniture(爷爷 B)。
      3. 这叫二义性。

    **二义性 (Ambiguity)**如果你写这行代码:

    SleepSofa ss;
    ss.setWeight(10); // ❌ 编译报错!
    

    编译器崩溃了:'大哥,你让我改哪个 weight?是'床'那一脉的爷爷,还是'沙发'那一脉的爷爷?'

    拯救者:虚继承 (Virtual Inheritance)

    为了解决爷爷分身的问题,C++ 引入了虚继承。

    • 内存变化:现在,SleepSofa 的对象里,只有唯一的一份 Furniture 对象了。
    • 逻辑变化:ss.setWeight(10) ✅ 成功!因为只有一个 weight,没有歧义了。

    效果:万法归一

    虚继承的作用:告诉编译器,'如果我们拥有同一个祖先,请在孙子辈里共享这一份祖先,不要复制多份。'

    语法:virtual 关键字注意,virtual 是加在中间层(爸爸妈妈)身上的。

    class Furniture { ... };
    // ✨ 关键点:爸爸妈妈要在继承爷爷时,加上 virtual
    class Bed : virtual public Furniture { ... };
    class Sofa : virtual public Furniture { ... };
    // 孙子正常写,不用变
    class SleepSofa : public Bed, public Sofa { ... };
    

    💡 灵魂拷问:谁负责初始化爷爷? 在普通继承里,Bed 负责初始化它的爷爷,Sofa 负责初始化它的爷爷。但在虚继承里,既然爷爷只有一份,那到底听谁的?

    • 结论:由孙子 (SleepSofa) 直接负责初始化爷爷 (Furniture)!中间层 (Bed, Sofa) 对爷爷的初始化会被忽略。
    多继承的'交通规则'
    • 构造顺序:B() -> C() -> D()

    • 析构顺序:~D() -> ~C() -> ~B() (完全相反)

    • **虚基类的特权 ⭐**这是虚继承(菱形继承)里最特殊的规则:越级管理。

      • 规则:虚基类(爷爷)的构造函数,由最新派生出 的类(孙子)直接调用!
      • 普通继承:孙子调爸爸,爸爸调爷爷。
      • 虚继承:孙子直接调爷爷。爸爸和妈妈对爷爷的初始化请求会被编译器无视。
      • 执行顺序:虚基类优先。virtual Base 的构造函数会比 non-virtual Base 先执行,不管它在继承列表里排第几。

    **名字冲突怎么办?(Name Conflict)**如果 B 有个成员 x,C 也有个成员 x,D 继承 B 和 C

    D d;
    // d.x = 10; // ❌ 报错!二义性,编译器不知道是哪个 x
    

    解决方式:显式指明'户籍'。

    d.B::x = 10; // ✅ 访问 B 的 x
    d.C::x = 20; // ✅ 访问 C 的 x
    

    **谁先谁后?(构造/析构顺序)**在多继承中,基类构造函数的调用顺序,只取决于类声明时的顺序!与你在初始化列表中怎么写无关。

    // 声明顺序:先 B,后 C
    class D : public B, public C { ... };
    
    硬核底层:多继承的内存布局

    当一个对象有多个基类时,它的内存和指针发生了什么扭曲。

    • **对象里有多个 vptr!**在单继承中,对象只有一个 vptr。但在多继承中(比如 D 继承 B1, B2, B3),对象内存里会有多个 vptr!
      • B1 部分有一个 vptr(指向 B1 家族的虚表)。
      • B2 部分也有一个 vptr(指向 B2 家族的虚表)。
      • p1 == p2 吗?
        • 如果你直接比地址值:不一样。
        • 如果你在 C++ 代码里比 if (p1 == p2):一样(编译器会帮你隐式调整后再比较)。
    • **thunk 与 this 指针调整 (Thunk)**PPT 左下角有个词叫 this adjustor。这是啥?
      • 场景:
      • 问题:
      • 解决 (Thunk):编译器在虚函数表里动了手脚。它不直接跳到 D::f(),而是先跳到一个叫 Thunk 的小代码段。
        1. D 重写了 B2 的虚函数 f()。
        2. 你用 B2* p2 调用了 p2->f()。
        3. 程序跳转到了 D::f()。
      • D::f() 作为一个成员函数,它预期的 this 指针应该指向 D 对象的开头。
      • 但是你传进来的 p2 指向的是 D 对象中间的 B2 部分。
      • 如果直接用,this 指针就偏了,访问成员变量就会错位!💥
      • Thunk 的作用:this -= offset; (把指针往回拨,修正到 D 的开头)。
      • 然后再跳转到真正的 D::f()。

    **指针的'漂移' (Pointer Adjustment)**这是最反直觉的地方。请看图:

    D d;
    B1* p1 = &d; // 指向对象的【开头】
    B2* p2 = &d; // 指向对象的【中间】(B2 子对象开始的地方)
    

    现象:虽然 p1 和 p2 都是指向同一个对象 d,但它们的 内存地址值是不一样的!

    目录

    1. 继承概览
    2. 继承的本质
    3. 为什么我们需要继承?
    4. 单继承 (Single Inheritance)
    5. 基础语法
    6. 内存布局:像“搭积木”一样
    7. 权限控制:Protected 的引入
    8. 声明的一个“坑”
    9. 对象的构造与析构次序
    10. 构造函数
    11. 默认情况下的“隐形操作”
    12. 真正的挑战:非默认构造函数
    13. “偷懒”的高级写法:继承构造函数 (C++11)
    14. 类型相容与赋值兼容
    15. 核心法则:向上转型 (Upcasting)
    16. 玩法一:对象赋值 —— “切割” (Slicing)
    17. 玩法二:指针/引用 —— “戴上面具” (Pointer/Reference)
    18. ⚠️ 灵魂发问:到底调用的谁?(The "Binding" Problem)
    19. 虚函数和动态绑定 (Virtual Functions)
    20. 绑定的革命:前期 vs 后期
    21. 开启多态的钥匙:virtual
    22. 虚函数的“传家宝”规则
    23. ❌ 谁不能当虚函数?(避坑清单)
    24. 虚函数表 —— vptr 与 vtable
    25. 两个核心概念 (The Big Two)
    26. 结合 PPT 实例图解
    27. 普通函数 h() 怎么办?
    28. 构造函数为什么不能是虚函数?
    29. 现代 C++ 的安全写法——override 与 final
    30. override:防手滑神器
    31. final:到此为止
    32. 访问控制的“双重人格”
    33. Protected 和友元的一个问题
    34. 核心规则:Protected 的“自私”
    35. 代码破案
    36. 纯虚函数与抽象类
    37. 纯虚函数 (Pure Virtual Function)
    38. 抽象类 (Abstract Class)
    39. 派生类的义务
    40. 多态数组与异构容器
    41. 进阶:抽象工厂模式 (Abstract Factory Pattern)
    42. 虚析构函数
    43. 公有继承的里氏代换原则
    44. 生物学 vs 代码学:企鹅是鸟吗?
    45. 禁忌:遮盖非虚函数 (Right Box)
    46. 经典的几何悖论:正方形是矩形吗?
    47. 私有继承:一种“不可告人”的关系
    48. 核心定义
    49. 举例
    50. 接口继承的“三种境界”
    51. 纯虚函数 (Pure Virtual)
    52. 普通虚函数 (Virtual)
    53. 非虚函数 (Non-Virtual)
    54. ⚠️ 缺省参数的陷阱
    55. 问题场景
    56. 诡异的输出
    57. 原理解析:静态绑定 vs 动态绑定
    58. 怎么避坑?
    59. 多继承
    60. 定义
    61. 灾难降临:菱形继承
    62. 拯救者:虚继承 (Virtual Inheritance)
    63. 多继承的“交通规则”
    64. 硬核底层:多继承的内存布局
    • 💰 8折买阿里云服务器限时8折了解详情
    • 💰 8折买阿里云服务器限时8折购买
    • 🦞 5分钟部署阿里云小龙虾了解详情
    • 🤖 一键搭建Deepseek满血版了解详情
    • 一键打造专属AI 智能体了解详情
    极客日志微信公众号二维码

    微信扫一扫,关注极客日志

    微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

    更多推荐文章

    查看全部
    • Whisper 语音转文字工具安装与使用指南
    • Go 1.24 Map 底层重构解析与性能收益
    • C++ AVL 树功能实现原理剖析
    • 废旧 MacBook 改造家庭 AI 网关:OpenClaw + 内网穿透
    • 华为交换机首次开局配置步骤:Console 连接与 Web 管理
    • OpenClaw:AI Agent 框架的安全挑战与未来演进
    • FAST-LIVO2 体素地图:八叉树数据结构深度解析
    • C++ 双指针算法实战:有效三角形个数与和为 S 的两个数字
    • Python 毕业生就业追踪系统 Vue3 架构与实现
    • WeBASE 一键部署配置与下载问题解决方案
    • OpenClaw 跨平台部署指南:Mac、Windows 与阿里云环境
    • OpenClaw 本地部署与 QQ 机器人接入教程
    • 腾讯云 WorkBuddy 桌面 AI 智能体部署与配置实战
    • Stable Diffusion 部署案例:中小企业低成本构建二次元内容中台
    • 低代码平台中Python插件的关键应用场景
    • 教育元宇宙 VR 协作工具交互延迟测试详解
    • PX4 开源飞控系统详解:架构、生态与入门指南
    • 使用本地大模型 Llama3 进行数据分类标记
    • Flutter 在 OpenHarmony 中集成 nanoid 替代 UUID 方案
    • 前端文件下载实战:从原理到最佳实践

    相关免费在线工具

    • Base64 字符串编码/解码

      将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

    • Base64 文件转换器

      将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

    • Markdown转HTML

      将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

    • HTML转Markdown

      将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

    • JSON 压缩

      通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

    • JSON美化和格式化

      将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online