NJUのC++课:面向对象之继承

「C++ 40 周年」主题征文大赛(有机会与C++之父现场交流!) 10w+人浏览 686人参与

继承概览

写在继承 (Inheritance)之前,我们需要弄懂继承的本质以及我们为什么要继承这个面向对象的性质

继承的本质

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

为什么我们需要继承?

  • 基于目标代码的复用 (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 (派生类) 自己的成员。

    举例: 假设 Studentid, nickname,派生类有 dept_no。 内存样子如下(连续空间):

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

    核心推论:

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

权限控制:Protected 的引入

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

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

声明的一个“坑”

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

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

对象的构造与析构次序

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

    Base (基类) ----> Member Objects (成员对象) ----> Derived (派生类自己)
    
  • 析构函数的执行次序 (Destruction Order)

    核心原则:与构造完全相反(对称美)。

    Derived (派生类自己) ----> Member Objects (成员对象) ----> Base (基类)
    
  • 💡 批注: 这里有个高频考点

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

构造函数

默认情况下的“隐形操作”

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

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

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

  • 问题场景 (The "Pain" Point):

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

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

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

  • PPT 实例解析

    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;
        }
    };
    
    
  • 为什么不能在 { } 里赋值?

    很多同学喜欢这样写(这是错误的):

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

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

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

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

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

类型相容与赋值兼容

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

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

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

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

这里有两种截然不同的方案,一定要区分开,这是期末考试必考的坑!💥

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

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

  • 场景

    A a;        // 基类对象 (比如:人)
    B b;        // 派生类对象 (比如:学生)
    class B: public A
    a = b;      // ✅ 合法:把 b 赋值给 a
    
    
  • 发生了什么? 这叫 对象切割 (Object Slicing)

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

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

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

  • 场景

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

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

⚠️ 灵魂发问:到底调用的谁?(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() { // 👈 注意:这里没写 virtual,但它实际上仍然是虚函数!
            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 是运行时决定,这俩逻辑是矛盾的。
  • 注意析构函数可以,而且往往必须是! (PPT 第 9 页最后一行)
    • 伏笔:这点非常重要,后面会有专门一页讲这个。现在先记下:基类的析构函数最好写成 virtual

虚函数表 —— vptr 与 vtable

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

两个核心概念 (The Big Two)

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

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

结合 PPT 实例图解

我们来看看 PPT 里的 Class AClass 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->f()f 是虚函数时

    它不会直接生成 call A::f 的指令,而是生成类似下面的一套“动作”:

    1. 找指针:通过 p 找到对象内存。
    2. 找表:读取对象头部的 vptr(比如 p 指向 b 对象,那就读到了 B_vtable 的地址)。
    3. 找函数:根据 f() 在声明中的顺序(比如是第 0 个),去表里拿第 0 项的地址(拿到了 &B::f)。
    4. 调用:跳转去执行那个地址的代码。

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

普通函数 h()怎么办?

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

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

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

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

  • 核心规则:由于时间差导致的“降级”

    为什么?(原理回顾) 还记得我们刚才说的 vptr(身份证)吗?

    1. 当我们创建一个 B 对象时,先执行 A 的构造函数。
    2. 此时,B 的部分还没生出来(内存可能是随机值)。
    3. 为了防止你访问到不存在的 B 成员,编译器做了一个保护措施:
      • 在进入 A 的构造函数时,把 vptr 指向 A 的虚表
      • 只有等到 A 构造完,开始执行 B 的构造函数时,才把 vptr 改成 B 的虚表
    4. 所以,在 A 的构造函数里,编译器“认为”当前就是个 A 对象。
  • 接下来我们就很好理解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
    
    • 执行流程拆解:

      1. B b; —— 对象出生

      • 调用 A::A() (基类构造)。
        • A 构造函数里调用了 f()
        • 关键点:此时对象还是 A,vptr 指向 A。
        • 结果:调用 A::f()(多态失效)
      • 调用 B::B() (派生类构造)。

      2. p->f(); —— 经典的虚函数调用

      • f 是虚函数。
      • 此时对象已经构造完毕,vptr 已经是 B 的了。
      • 结果:调用 B::f()(多态生效)

      3. p->g(); —— 静态绑定的坑

      • g 在 A 中不是虚函数。
      • 虽然 B 里也有个 g,但 pA* 类型。
      • 结果:编译器只看指针类型,直接绑定 A::g()(无多态,看衣服)

      4. p->h(); —— 这里的 this 指针是谁?

      • 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()
  • 还有一个很大的坑

    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();
    
    • 为什么呢?原则:进入哪个函数,这里的指针变为哪种,这里是A*,普通函数会由此去静态判断,虚函数根据vptr判断

现代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:到此为止

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

两种用法

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

    struct Base final { ... };
    // struct Derived : Base { ... }; // ❌ 编译报错
    
    
  2. 修饰虚函数:这个函数不能再被后代修改了。

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

访问控制的“双重人格”

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

  • 场景重现

    • 基类 Bf()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::fprotected 挡回去了。

结论:访问权限检查 (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 (线)。它们都知道怎么画自己。
  • 核心代码解析

    // 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();
    }
    
    
    • 优势:循环体内的代码不需要修改。哪怕以后你加了一个新的图形 Circle,这个 for 循环一行代码都不用动!这就是**“开闭原则”**(对扩展开放,对修改关闭)。

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

  • 遇到的麻烦 (The Problem)

    假设我们要写一个软件,既要跑在 Windows 上,又要跑在 Mac 上。

    • Windows 的按钮叫 WinButton
    • Mac 的按钮叫 MacButton

    如果我们在代码里到处写 if (isWindows) ... else ...,代码会变得极其丑陋,难以维护

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

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

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

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

    • Button (抽象按钮) -> 派生出 WinButton, MacButton
    • Label (抽象标签) -> 派生出 WinLabel, MacLabel
    • 代码中的 client 只认 ButtonLabel,不关心具体是哪个系统的。

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

    • 这就是那个“中介”的标准。
    class AbstractFactory {
    public:
        virtual Button* CreateButton() = 0; // 纯虚函数:我要个按钮
        virtual Label*  CreateLabel()  = 0; // 纯虚函数:我要个标签
    };
    
    

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

    • WinFactory:生产 WinButtonWinLabel
    • MacFactory:生产 MacButtonMacLabel
  • 如何使用?(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 Trap)

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

    问题出在哪?

    • p 的静态类型是 B* (基类指针)。
    • p 实际指向的是 D (派生类对象)。
    • 如果基类 B 的析构函数不是虚函数(没有 virtual),那么 delete p 时采用的是静态绑定
    • 后果就是编译器只去将调用了基类的析构函数,拆去了p中基类的资源,但是第 2 层(派生类特有的资源)悬在空中没人管了!
  • 解决方案:加上 virtual (The Fix)

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

    发生了什么?

    1. 一旦析构函数变成了虚函数,delete p 就会触发动态绑定

    2. 编译器会去查 vptr(身份证),发现 p 指向的其实是个 D 对象。

    3. 它会先调用 ~D()(清理派生类资源),再调用 ~B()(清理基类资源)。

      这里的顺序问题可以参考前面的说明。

    4. 完美释放,无残留!

公有继承的里氏代换原则

这里面实际在讲里氏替换原则 (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 Bclass 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;
};

多继承

定义

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

  • 语法:用逗号分隔。

    class SleepSofa : public Bed, public Sofa { ... };
    
    
  • 能力SleepSofa 对象既能调用 Bed 的方法(比如 Sleep()),也能调用 Sofa 的方法(比如 WatchTV())。

    • 这也是 PPT P27 展示的理想状态,看起来很美好。

灾难降临:菱形继承

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

  • 场景还原 (The Scenario)

    • 爷爷Furniture (家具)。有一个成员变量 weight (重量)。
    • 爸爸Bed (床)。继承自 Furniture
    • 妈妈Sofa (沙发)。继承自 Furniture
    • 孙子SleepSofa (沙发床)。同时继承 BedSofa
  • 内存里的“双重人格” (The Problem)

    当我们创建一个 SleepSofa 对象时,内存里发生了什么?

    1. 它包含一个 Bed 对象。而 Bed 里包含了一个 Furniture(爷爷 A)。
    2. 它包含一个 Sofa 对象。而 Sofa 里也包含了一个 Furniture(爷爷 B)。

    结果SleepSofa 里竟然有 两份 Furniture,也就有 两个 weight 变量!

  • 二义性 (Ambiguity)

    如果你写这行代码:

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

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

    • 这叫二义性

拯救者:虚继承 (Virtual Inheritance)

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

  • 语法:virtual 关键字

    注意,virtual 是加在中间层(爸爸妈妈)身上的。

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

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

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

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

  • 结论由孙子 (SleepSofa) 直接负责初始化爷爷 (Furniture)! 中间层 (Bed, Sofa) 对爷爷的初始化会被忽略。

多继承的“交通规则”

  • 谁先谁后?(构造/析构顺序)

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

    // 声明顺序:先 B,后 C
    class D : public B, public C { ... };
    
    
    • 构造顺序B() -> C() -> D()
    • 析构顺序~D() -> ~C() -> ~B() (完全相反)
  • 名字冲突怎么办?(Name Conflict)

    如果 B 有个成员 xC 也有个成员 x,D继承B和C

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

    解决方式:显式指明“户籍”。

    d.B::x = 10; // ✅ 访问 B 的 x
    d.C::x = 20; // ✅ 访问 C 的 x
    
    
  • 虚基类的特权 ⭐

    这是虚继承(菱形继承)里最特殊的规则:越级管理

    规则:虚基类(爷爷)的构造函数,由最新派生出的类(孙子)直接调用!

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

硬核底层:多继承的内存布局

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

  • 对象里有多个 vptr!

    在单继承中,对象只有一个 vptr。 但在多继承中(比如 D 继承 B1, B2, B3),对象内存里会有 多个 vptr

    • B1 部分有一个 vptr(指向 B1 家族的虚表)。
    • B2 部分也有一个 vptr(指向 B2 家族的虚表)。
  • 指针的“漂移” (Pointer Adjustment)

    这是最反直觉的地方。请看图:

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

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

    • p1 == p2 吗?
      • 如果你直接比地址值:不一样
      • 如果你在 C++ 代码里比 if (p1 == p2)一样(编译器会帮你隐式调整后再比较)。
  • thunk 与 this 指针调整 (Thunk)

    PPT 左下角有个词叫 this adjustor。这是啥?

    场景

    1. D 重写了 B2 的虚函数 f()
    2. 你用 B2* p2 调用了 p2->f()
    3. 程序跳转到了 D::f()

    问题

    • D::f() 作为一个成员函数,它预期的 this 指针应该指向 D 对象的开头
    • 但是你传进来的 p2 指向的是 D 对象中间的 B2 部分
    • 如果直接用,this 指针就偏了,访问成员变量就会错位!💥

    解决 (Thunk)

    编译器在虚函数表里动了手脚。它不直接跳到 D::f(),而是先跳到一个叫 Thunk 的小代码段。

    • Thunk 的作用this -= offset; (把指针往回拨,修正到 D 的开头)。
    • 然后再跳转到真正的 D::f()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值