3、动态多态入门:虚函数的概念、virtual关键字、虚函数与普通成员函数的区别、运行时多态的基本实现

好,我们正式开始聊动态多态。

前面两章我们把继承和静态多态(重载)讲透了。现在要进入C++里最核心、也最容易让人栽跟头的部分——虚函数

说实话,我当年刚学C++时,对虚函数的理解就是“加个virtual关键字”。直到我在项目中写了一个基类指针数组,想统一调用派生类的行为,结果发现调用的全是基类的版本……那一刻我才真正意识到:虚函数不是语法糖,它是C++运行时多态的基石

3.1 为什么需要虚函数?

先想一个问题:

你有一个基类 Animal,里面有个 speak() 方法。派生类 DogCat 都重写了它。现在你用基类指针指向派生类对象:

Animal* p = new Dog();
p->speak();  // 你希望输出 "Woof!" 还是 "Animal speaks"?

如果不加 virtual,C++ 默认调用的是 指针类型(Animal) 的版本。这往往不是我们想要的。

你想想看,我们写代码时经常要“面向接口编程”,用基类指针统一管理各种派生类对象。如果调用的是基类版本,那多态就无从谈起。

核心结论:虚函数让程序在运行时根据对象的实际类型来决定调用哪个函数。这就是“运行时多态”的本质。

3.2 virtual关键字:怎么用?

用法很简单:在基类的成员函数声明前加上 virtual 关键字。

class Animal {
public:
    virtual void speak() {
        cout << "Animal speaks" << endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {  // C++11 推荐加 override
        cout << "Woof!" << endl;
    }
};

嗯,这里要注意:virtual 只在基类中写一次就够了。派生类重写时,即使不写 virtual,它依然是虚函数。但我个人习惯——派生类也写上 override。这不是为了修饰,而是为了让编译器帮你检查:你是不是真的重写了基类的虚函数。我曾经因为拼写错误(比如写成 speek)导致重写失败,排查了半天……

小技巧:从 C++11 开始,建议在派生类重写的函数后面加上 override 关键字。编译器会帮你检查签名是否匹配,避免“想重写却变成了隐藏”的坑。

3.3 虚函数 vs 普通成员函数:区别在哪?

我把区别整理成了一张表,方便你对照:

对比维度 普通成员函数 虚函数
绑定时机 编译期(静态绑定) 运行期(动态绑定)
调用依据 指针/引用的静态类型 对象的实际类型
性能开销 无额外开销 有虚函数表查找开销(很小)
能否内联 可以 通常不能(除非编译器能确定实际类型)
构造函数中调用 正常调用 不会触发多态(调用的是当前类的版本)

说白了,普通函数是“编译时决定”,虚函数是“运行时决定”。

这个区别看似简单,但影响深远。我举个例子:

Animal a;
Dog d;
Animal& ref = d;

ref.speak();  // 如果 speak 是普通函数:输出 "Animal speaks"
              // 如果 speak 是虚函数:输出 "Woof!"

为什么会这样?因为普通函数看的是 ref 的类型(Animal&),而虚函数看的是 ref 引用的对象类型(Dog)。

避坑指南:我曾经在构造函数里调用虚函数,以为能调用到派生类的版本。结果调用的全是基类的。记住:构造函数执行时,对象的动态类型还是基类,虚函数机制不会按你期望的方式工作。

3.4 运行时多态的基本实现

好了,理论说完了,我们来看一个完整的例子。这是运行时多态最经典的用法:

#include <iostream>
#include <vector>
using namespace std;

class Shape {
public:
    virtual void draw() const {
        cout << "Drawing a shape" << endl;
    }
    virtual ~Shape() {}  // 重要!基类析构函数要虚
};

class Circle : public Shape {
public:
    void draw() const override {
        cout << "Drawing a circle" << endl;
    }
};

class Square : public Shape {
public:
    void draw() const override {
        cout << "Drawing a square" << endl;
    }
};

int main() {
    vector<Shape*> shapes;
    shapes.push_back(new Circle());
    shapes.push_back(new Square());
    shapes.push_back(new Circle());

    for (Shape* s : shapes) {
        s->draw();  // 运行时多态:每个对象调用自己的版本
    }

    // 别忘了释放内存
    for (Shape* s : shapes) {
        delete s;
    }
    return 0;
}

输出结果:

Drawing a circle
Drawing a square
Drawing a circle

你看,同一个 draw() 调用,因为对象实际类型不同,行为也不同。这就是运行时多态的魅力。

我个人觉得,理解这个例子的关键在于:代码写的是“对接口编程”,而不是“对实现编程”。你不需要知道每个 Shape* 具体指向什么类型,你只需要调用 draw(),它自己会做正确的事。

3.5 虚函数背后的机制:虚函数表(vtable)

你可能好奇:运行时多态是怎么实现的?

答案是——虚函数表(vtable)

每个包含虚函数的类,编译器会为它生成一个虚函数表。这个表是一个函数指针数组,存储了该类所有虚函数的地址。每个对象内部会有一个隐藏的指针(vptr),指向所属类的虚函数表。

调用虚函数时,程序会:

  1. 通过对象的 vptr 找到虚函数表
  2. 在表中找到对应函数的地址
  3. 跳转到该地址执行

这个过程虽然多了一步间接寻址,但现代CPU的分支预测和缓存机制让这个开销几乎可以忽略不计。除非你在极端性能敏感的循环中调用虚函数,否则不用担心。

下面我用一张图来展示这个机制:

虚函数表(vtable)机制示意图 Circle 对象 vptr 其他成员数据... Circle::vtable Shape::draw() → Circle::draw() Shape::~Shape() → Circle::~Circle() Square 对象 vptr 其他成员数据... Square::vtable Shape::draw() → Square::draw() Shape::~Shape() → Square::~Square() 每个对象有一个隐藏的 vptr 指向所属类的虚函数表 调用虚函数时: ① 通过 vptr 找到 vtable ② 从表中取出函数地址 ③ 跳转执行

这张图展示了两个对象(Circle 和 Square)各自拥有独立的 vptr,指向不同的虚函数表。当调用 draw() 时,程序通过 vptr 找到对应的表,然后调用表中记录的地址——这就实现了“同一个调用,不同行为”。

3.6 关于虚函数的一些重要提醒

  • 基类析构函数一定要是虚函数。否则,当你用基类指针 delete 派生类对象时,只会调用基类的析构函数,派生类的资源不会被释放。这是内存泄漏的常见来源。
  • 静态函数不能是虚函数。因为静态函数不属于某个对象,没有 this 指针,无法通过 vptr 查找。
  • 构造函数不能是虚函数。虚函数调用依赖 vptr,而 vptr 是在构造函数执行期间初始化的。在构造函数执行时,vptr 指向的是当前正在构造的类的虚函数表,不是最终派生类的。
  • 内联和虚函数:理论上虚函数不能内联,因为内联发生在编译期,而虚函数调用在运行期才决定。但编译器在某些情况下(比如对象类型明确时)可能会优化掉虚函数调用,此时可以内联。

总结一句话:虚函数是C++运行时多态的灵魂。它通过虚函数表机制,让程序在运行时根据对象的实际类型选择正确的函数。理解虚函数,你就掌握了面向对象编程中最强大的武器之一。


公众号:蓝海资料掘金营,微信 deep3321