🔷 第十一章 继承与派生
📄 一句话概括

虚函数(virtual)="这个函数,请在运行时按对象的真实身份来决定调谁的版本"。它要生效需要三个条件同时满足:① 通过指针或引用调用;② 函数是虚函数;③ 子类的签名和父类完全一致。机制上,编译器给每个对象藏一个指针(指向"函数地址表"),调用时"顺着指针去表里查"——这就是动态绑定。必须背下来的铁律:只要类会被"父类指针 + delete"管理,它的析构函数就必须是 virtual,否则子类的析构函数根本不会跑。

📝 本节和前面几节的关系

11.4 用 Demo 展示了"加一个 virtual,行为就变了",本节回答"为什么"和"要注意什么"。它是 11.1.5"隐藏"的镜像:没有 virtual → 编译期按类型找名字(隐藏);有 virtual → 运行期按对象身份找函数(重写)。另外,本节的虚析构函数会兑现 10.3 埋下的伏笔(那时说"析构函数在继承里有个新坑")。

一、三个必要条件(缺一不可)

动态绑定要生效,必须同时满足:

# 条件 说明
① 通过"指针"或"引用"调用 Base *p = &d; p->f(); 或 Base &r = d; r.f();。如果用"对象本身"调用(d.f()),编译器根本不需要动态——它明明白白知道是 Derived
② 调用的函数是虚函数 父类那个函数前面有 virtual
③ 签名完全一致(函数名、参数类型、const 修饰) 差一个字(比如 const)就变成了隐藏(11.1.5),而不是重写

第③条最容易出错,看这个经典 bug:

class Base {
public:
    virtual void  f()       { cout << "Base" << endl; }
};

class Derived : public Base {
public:
    void f() const          { cout << "Derived" << endl; }   // ★ 多了 const!
};

Base *p = new Derived;
p->f();      // 输出 "Base"!—— 因为签名不同(一个 const 一个不 const),
             // Derived::f 不是"重写",而是"隐藏"

明明是想要多态,结果却调到了父类版本——因为"签名差一个 const",两条路就岔开了。这类 bug 编译器不报错,只在你运行时发现"怎么调用的是父类的"——极难查。

解药(现代写法):override

class Derived : public Base {
public:
    void f() const override { ... }     // ★ 加 override
};

override 是个"保险栓":它告诉编译器"我本意是重写父类的虚函数"。如果签名不一致(比如少写了 const),编译器会直接报错——把"运行时才发现"变成"编译时就发现"。

强烈建议:凡是重写虚函数,都写上 override(老编译器不认,那就不写;但能写就写)。

二、机制:对象里藏了一个指针(知道原理就够了)

编译器在背后做了两件事:

  1. 给每个"有虚函数的类"造一张表(一般叫 vtable,虚函数表),表里按固定顺序存着各个虚函数的地址。
  2. 给每个"这种类的对象"加一个隐藏的指针成员(一般叫 vptr),指向上面那张表。这个指针构造函数里自动设定——构造哪一层,就指向那一层的表。

调用 p->f() 时:

① 取 p 指向的对象里的隐藏指针(vptr)
② 顺着它找到"这个对象真正属于哪个类"的那张表(vtable)
③ 在表里查到 f 的地址
④ 跳过去执行

一句话:"看对象的表,不看指针的类型"——所以是"运行时才定"。

由此推出三个实用结论:

结论 说明
每个对象多一个隐藏指针 sizeof 会变大(别用 sizeof 猜虚函数类的字节数,让它自己算)
有虚函数的类,构造/析构期间"表"在切换 所以构造/析构函数里调用虚函数,拿到的永远是"当前这一层"的版本,不是最子类的(11.1.3 提过)
一点点性能开销 一次间接跳转。绝大多数场合可以忽略

三、铁律:基类的析构函数必须是虚的

这是全章最实用、最救命的一条规则。

问题现场:

class Base {
public:
    ~Base() { cout << "~Base" << endl; }         // ★ 不是虚的
};

class Derived : public Base {
    int *data;
public:
    Derived() { data = new int[100]; }           // 子类申请了资源
    ~Derived() { delete[] data; cout << "~Derived" << endl; }
};

int main() {
    Base *p = new Derived;      // 父类指针,管子类对象
    delete p;                   // ★ 只输出 ~Base!~Derived 根本没跑
    return 0;
}

发生了什么:delete p 时,p 的类型是 Base*、而 ~Base 不是虚的,于是编译器直接调 Base::~Base——子类的析构函数从来不执行,data 那块内存永久泄漏。

改一个字就好了:

class Base {
public:
    virtual ~Base() { cout << "~Base" << endl; }   // ← virtual
};

现在 delete p 会依次输出 ~Derived、~Base(子类先、父类后,11.1.3 的顺序)。

规则一句话:

只要"可能被父类指针 delete",父类的析构函数就必须是 virtual。 判据:这个类有没有虚函数?有虚函数 → 它就会被"当父类用" → 析构函数也要虚。

为什么"有虚函数就要虚析构":因为有虚函数,就说明你打算用"父类指针/引用"来管理子类对象(那是虚函数唯一的使用场景)——那么 delete 也一定会通过父类指针发生。两件事总是同时出现。

四、动手 Demo:虚函数 + 虚析构

#include <iostream>
using namespace std;

class Base {
public:
    virtual void speak() { cout << "Base::speak" << endl; }
    virtual ~Base() { cout << "~Base" << endl; }        // ★ 虚析构
};

class Derived : public Base {
public:
    void speak() { cout << "Derived::speak" << endl; }  // 自动是虚的(可省 virtual)
    ~Derived() { cout << "~Derived" << endl; }
};

int main() {
    Derived d;
    Base *p = &d;
    p->speak();                                          // ★ 多态生效

    cout << "── delete 一个堆上的子类对象 ──" << endl;
    Base *q = new Derived;
    delete q;                                            // 因为 ~Base 是虚的,~Derived 也会跑

    cout << "── main 结束,局部对象 d 开始析构 ──" << endl;
    return 0;
}

看到什么算成功:

Derived::speak
── delete 一个堆上的子类对象 ──
~Derived
~Base
── main 结束,局部对象 d 开始析构 ──
~Derived
~Base

读结果:

现象 说明
p->speak() 输出 Derived::speak ① 指针调用 + ② 虚函数 + ③ 签名一致 → 动态绑定生效
delete q 时先 ~Derived 再 ~Base 虚析构让子类析构也执行了——资源被正确释放

30 秒实验(这个实验的本质要记住): 1. 把 virtual ~Base() 里的 virtual 删掉,重新编译运行。 2. 观察 delete q; 那一段的输出:只剩 ~Base,~Derived 不见了。 3. 这就是内存泄漏——真实项目里,Derived 构造里 new 的东西永远回收不了。 4. 加回 virtual,就好。 5. 然后问自己一句话:"如果 Base 里任何虚函数都没有(也没有 virtual),我还要虚析构吗?"——不需要(因为没人会拿 Base* 去 delete 一个 Derived;那本身就是设计禁止的)。这条推理就是"有虚函数 → 要有虚析构"的由来。

五、常见误区

  1. "子类的 virtual 必须也写上"——不用(虚性会遗传)。但写完更清楚,而且现代做法是写 override——它连"签名写错了"都能替你抓出来。
  2. "对象本身调用也能多态"——不需要,也不会。d.f() 时编译器明知道是 Derived,直接调就行。"动态绑定"存在的理由,恰恰是"编译器不知道 p 到底指谁"。
  3. "签名差一个 const 也算重写"——不算。差一个字就是"隐藏"(11.1.5),运行时你会发现"怎么调的是父类的",而且编译器不报错。这就是要写 override 的原因。
  4. "析构函数可以不虚,我不 delete 就没事"——你不能保证。类会被别人用,用的人可能把对象放进容器、交给智能指针、传进别的函数(那些地方接受的往往是父类指针/引用)。只要类有虚函数,就把它当"要被继承的基类"对待,析构就写 virtual。
  5. "虚函数越多越好"——不是。虚函数意味着"设计上的开放点",每次调用都多一层间接;而且一旦虚了,就再也不能"变回不虚"。只在真正需要"变化点"的地方放虚函数(形状的 area、设备的 send……)。其余的写成普通函数。
  6. "构造函数可以是虚函数"——不行。因为虚函数机制依赖"对象里那个隐藏指针",而在构造函数执行前,那个指针还没设好。构造函数不是虚的,也不能虚。
自测三问(点开对答案) 1. 动态绑定生效的三个必要条件是什么?——**① 通过指针或引用调用**;**② 函数是虚函数**;**③ 子类签名与父类完全一致**(函数名、参数类型、`const` 都要一样)。**差一条就退化成"隐藏"或"静态绑定"。** 2. 为什么有虚函数的类,析构函数也要写成 `virtual`?——因为**有虚函数就意味着这个类会被"父类指针/引用"管理**,那么 `delete` 也很可能通过父类指针发生。**`~Base` 不虚时,`delete p;` 只调 `~Base`,子类的析构函数不执行 → 子类申请的资源永久泄漏。** 3. `override` 有什么好处?——它是"**我本意是重写父类虚函数**"的声明:**如果签名不一致(忘了 `const`、参数写错),编译器会直接报错**,把"运行期才发现的诡异 bug"提前到**编译期**。

相关笔记