15、多态与虚函数:虚函数、纯虚函数、抽象类、虚函数表、运行时类型识别(RTTI)

多态,说白了就是「同一个接口,多种形态」。这是面向对象编程里最核心、也最迷人的特性之一。我刚开始学C++时,觉得虚函数就是个加了个virtual关键字的函数,后来在项目中踩过几次坑,才真正理解了它背后的设计哲学。

这一章,我会带你从虚函数的基础用法,一路深入到虚函数表的底层原理,再到纯虚函数、抽象类,以及运行时类型识别(RTTI)。嗯,内容不少,但每一点都是实战中绕不开的。

15.1 虚函数:让基类指针调用派生类方法

先看一个最经典的例子:

class Animal {
public:
    virtual void speak() {
        cout << "动物发出声音" << endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        cout << "汪汪!" << endl;
    }
};

int main() {
    Animal* ptr = new Dog();
    ptr->speak();  // 输出:汪汪!
    delete ptr;
    return 0;
}

如果没有virtualptr->speak()会调用Animal::speak(),因为编译器只看指针的静态类型。加了virtual后,运行时才会根据对象的实际类型去调用对应的函数。这就是动态绑定。

我的习惯:从C++11开始,我建议在派生类重写的虚函数后面加上override关键字。这能帮编译器检查你是否真的重写了基类的虚函数,避免拼写错误导致的隐蔽bug。我在代码审查时,看到没有override的代码,都会提醒加上。

15.2 虚函数表(vtable):多态的底层秘密

为什么会这样?虚函数是怎么找到正确的函数的?

每个包含虚函数的类,编译器都会为它生成一张虚函数表(vtable)。这张表是一个函数指针数组,里面存放着该类所有虚函数的地址。每个对象内部还藏着一个虚函数指针(vptr),指向所属类的虚函数表。

我画了一张图,帮你理解这个结构:

Dog 对象 vptr → 指向 Dog vtable Dog 虚函数表 [0]: &Dog::speak() Cat 对象 vptr → 指向 Cat vtable Cat 虚函数表 [0]: &Cat::speak() Animal* ptr 指向 Dog 对象 调用流程 1. ptr->speak() 2. 通过 vptr 找到 vtable 3. 从 vtable[0] 取出函数地址 4. 调用 Dog::speak()
关键点:虚函数表是类级别的,每个类只有一张。虚函数指针是对象级别的,每个对象都有一个。调用虚函数时,先通过对象的vptr找到vtable,再通过偏移量找到对应的函数指针,最后调用。这就是动态绑定的全部秘密。

15.3 纯虚函数与抽象类

有时候,基类中的某个函数根本没有合理的默认实现。比如「形状」类的「计算面积」方法——你没法给一个抽象的「形状」定义面积公式。这时候就需要纯虚函数。

class Shape {
public:
    virtual double area() const = 0;  // 纯虚函数
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius;
public:
    Circle(double r) : radius(r) {}
    double area() const override {
        return 3.14159 * radius * radius;
    }
};

含有纯虚函数的类就是抽象类。抽象类不能实例化——你不能写Shape s;,编译器会报错。它的作用就是定义接口,强制派生类去实现。

我曾经踩过的坑:有一次我写了一个抽象基类,忘了把析构函数声明为虚函数。结果通过基类指针删除派生类对象时,只调用了基类的析构函数,派生类中申请的资源全部泄漏了。排查了半天才发现问题。记住:只要类中有虚函数,析构函数就应该声明为虚函数。

15.4 运行时类型识别(RTTI)

RTTI 允许你在运行时获取对象的实际类型信息。主要用两个操作符:typeiddynamic_cast

15.4.1 typeid 操作符

Animal* animal = new Dog();
const type_info& info = typeid(*animal);
cout << info.name() << endl;  // 输出可能为 "class Dog"

typeid返回一个type_info对象,包含类型名称等信息。注意,如果对指针本身使用typeid,得到的是指针的类型,而不是指向对象的类型。

15.4.2 dynamic_cast 安全向下转型

Animal* animal = new Dog();
Dog* dog = dynamic_cast<Dog*>(animal);
if (dog) {
    dog->fetchBall();  // 只有 Dog 类有的方法
} else {
    cout << "转型失败,不是 Dog 类型" << endl;
}

dynamic_cast会在运行时检查转型是否合法。如果转型失败,对指针返回nullptr,对引用抛出std::bad_cast异常。

我的建议:RTTI 虽然方便,但不要滥用。如果你发现自己频繁使用dynamic_casttypeid,这往往说明你的类设计有问题——多态应该通过虚函数来解决,而不是靠手动判断类型。我在项目中见过有人用dynamic_cast写出一大串if-else链,那还不如直接用switch语句呢。

15.5 虚函数与多态的注意事项

注意事项 说明
构造函数不能是虚函数 虚函数表在构造完成后才建立,构造函数调用时vptr还未初始化
不要在构造函数/析构函数中调用虚函数 此时对象的动态类型是当前正在构造/析构的类,不是最终派生类
虚函数有性能开销 每次调用需要多一次间接寻址,且无法内联
静态函数不能是虚函数 静态函数属于类,不属于对象,不参与动态绑定
内联函数可以是虚函数吗? 可以声明,但动态调用时不会内联,只有通过对象直接调用时才可能内联
避坑指南:我曾经在构造函数中调用了一个虚函数,以为会调用派生类的版本。结果调试了半天,发现调用的总是基类的版本。原因就是上面说的——构造时vptr指向的是当前类的虚函数表,派生类的还没建立呢。这个坑,几乎每个C++开发者都会踩一次。

15.6 知识体系总览

下面这张图总结了本章的核心知识点和它们之间的关系:

多态与虚函数 虚函数 (virtual) 动态绑定 / 运行时多态 override 关键字 纯虚函数 (=0) 抽象类(不可实例化) 接口定义 / 强制派生类实现 虚函数表 (vtable) vptr → vtable → 函数指针 类级别一张表,对象级别一个指针 RTTI typeid 操作符 dynamic_cast 注意事项 构造/析构中不要调虚函数

多态是C++面向对象编程的精华所在。理解了虚函数表,你就理解了动态绑定的本质;掌握了纯虚函数和抽象类,你就能设计出清晰的接口层次;而RTTI则是在必要时给你一把「安全向下转型」的钥匙。

嗯,这一章的内容就到这里。记住,多态不是为了炫技,而是为了让代码更灵活、更可扩展。下次写代码时,想想能不能用多态来替代那些冗长的if-elseswitch——你会发现,世界一下子清爽了很多。


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