头图

前言:

大家好,我是小康。今天我们聊聊 C++ 多态的底层原理。

不少初学者对多态可能停留在“用虚函数表实现”这几个字,但真搞懂这几个字背后的故事了吗?如果你看完这篇文章,能直接拍着桌子说:“原来是这么回事儿!太简单了吧!”那我今天的目标就达成了。

学技术不能只会用,底层的原理更要懂。尤其是多态,它可是C++的灵魂之一。今天,我们就用简单有趣的方式,拆解清楚多态的底层到底是怎么工作的。用不了多长时间,你就能彻底掌握 “多态中基类指针为啥能调用派生类的虚函数” 这个问题。

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

什么是多态?(用大白话说)

多态的核心是:同一个接口,不同的实现。什么意思呢?假设咱们有一台通用机器人(基类),它能干很多事,比如打扫、修理等。咱们根据实际需要,造出了清洁机器人(派生类)和修理机器人(另一个派生类),它们具体干的活就不一样了。

用代码演示:

#include <iostream>
using namespace std;

class Robot {
public:
    virtual void performTask() { cout << "Robot is performing a general task!" << endl; }
    virtual void recharge() { cout << "Robot is recharging!" << endl; }
};

class CleaningRobot : public Robot {
public:
    void performTask() override { cout << "Cleaning robot is sweeping the floor!" << endl; }
    void recharge() override { cout << "Cleaning robot is charging its cleaning module!" << endl; }
};

int main() {
    Robot* robot = new CleaningRobot();
    robot->performTask();  // 派生类的任务函数
    robot->recharge();     // 派生类的充电函数
    delete robot;
    return 0;
}

看到了吧,基类指针 robot 调用的是派生类 CleaningRobotperformTaskrecharge 方法。这就是多态,牛不牛?可是,底层到底是怎么做到的? 这才是今天要聊的重点!

多态的底层原理是什么?

1. 虚函数表(Virtual Table,简称 vtable)

简单来说,C++ 多态是靠虚函数表实现的。

每个含虚函数的类在编译时会生成一张表,叫虚函数表(vtable),里面存了当前类所有虚函数的地址。每个含虚函数的类对象有一个隐藏指针,叫 vptr(虚指针),指向它所属类的虚函数表。

编译期与运行期的分工

多态的实现分为两部分:编译期运行期

  • 编译期:编译器会为每个有虚函数的类生成一张虚函数表(vtable),表里存着这个类所有虚函数的地址。同时,每个对象都会被悄悄加上一个指针(vptr),用来指向对应的虚函数表。编译器还会在构造函数里安排好代码,负责初始化这个 vptr。这些工作都在编译阶段完成,决定了虚函数表的结构和对象的内存布局。
  • 运行期:当程序运行时,构造函数会把 vptr 指向正确的虚函数表。如果类有继承关系,构造过程中会从基类到派生类逐步更新 vptr,最终指向派生类的虚函数表。真正的多态调用发生时,程序会通过 vptr 找到虚函数表,再从表里取出具体要执行的函数地址,完成调用。

简单说,编译期搞定虚函数表和 vptr 的准备工作,运行期用它们来决定函数调用,完成多态。

2. 动态绑定

多态的关键是 动态绑定,意思是虚函数的调用会在运行时根据对象的实际类型确定具体调用哪个函数。

静态绑定 vs 动态绑定

  • 静态绑定:函数调用在编译期就固定了,比如普通的函数(非虚函数)。
  • 动态绑定:函数调用在运行时根据对象类型确定,适用于虚函数。

动态绑定依赖虚函数表来实现:

  • 当通过基类指针调用虚函数时,程序会通过 vptr 定位到虚函数表,再查找函数地址。这就是动态绑定的魔法!

2. 内存布局揭秘

为了更好理解动态绑定的过程,我们稍微扩展一下代码:

class Robot {
public:
    virtual void performTask() { cout << "Robot is performing a general task!" << endl; }
    virtual void recharge() { cout << "Robot is recharging!" << endl; }
    int id;  // 机器人ID
};

class CleaningRobot : public Robot {
public:
    void performTask() override { cout << "Cleaning robot is sweeping the floor!" << endl; }
    void recharge() override { cout << "Cleaning robot is charging its cleaning module!" << endl; }
    int cleaningPower;  // 清洁能力
};

当我们创建了一个 CleaningRobot 对象,无论是通过直接定义(在栈上创建)还是使用 new 运算符(在堆上分配),它的内存布局大致如下:

对象的内存布局:

偏移量内容
0x00vptr(虚函数表指针)-> 指向 CleaningRobot 对象 的 VTable
0x08id(基类的成员变量)
0x10cleaningPower(派生类成员)

虚函数表的结构:

虚函数表是一个指针数组,存放了所有虚函数的地址。假设上面代码中的类对应的虚函数表如下:

  • 基类 Robot 的虚函数表:
索引函数地址函数名
0&Robot::performTaskperformTask()
1&Robot::rechargerecharge()
  • 派生类 CleaningRobot 的虚函数表:
索引函数地址函数名
0&CleaningRobot::performTaskperformTask()
1&CleaningRobot::rechargerecharge()

调用流程详解(动态绑定的实现)

执行下面这几行代码时发生了什么?

Robot* robot = new CleaningRobot();
robot->performTask();

1.编译期:编译器为类Robot 和 CleaningRobot 生成各自的虚函数表(vtable)。虚函数表中存储该类的所有虚函数地址。对于派生类 CleaningRobot,如果重写了基类 Robot 的某个虚函数(例如 performTask),编译器会将派生类的函数地址覆盖基类虚函数表中对应的位置。这一覆盖过程是编译器在生成 CleaningRobot 的虚函数表时完成的。

与此同时,编译器会为支持虚函数的类(如 Robot 和 CleaningRobot)的对象添加一个隐式指针,称为 vptrvptr 是对象内存布局的一部分,用于指向该对象对应类的虚函数表。编译器还会在构造函数中插入代码,用于初始化对象的 vptr

2.基类指针指向派生类对象robot 是一个基类类型的指针,但它实际指向的是 CleaningRobot 类型的对象。在对象构造过程中,vptr 被依次初始化。首先调用基类的构造函数,将 vptr 指向 Robot 的虚函数表;接着调用派生类的构造函数,更新 vptr,最终指向 CleaningRobot 的虚函数表。

3.运行期通过 vptr 查找虚函数地址:当调用 robot->performTask() 时,程序通过 robot 指针访问到 CleaningRobot 对象的 vptrvptr 指向 CleaningRobot 的虚函数表,程序在虚函数表中根据函数的索引找到 CleaningRobot::performTask 的地址。

4.跳转到派生类函数执行:最终,程序根据虚函数表中记录的地址跳转到 CleaningRobot::performTask 并执行该函数。

补充点: 在这整个过程中,编译器的角色是铺设虚函数表和设置调用的基础结构,而运行时则是通过 vptr 和虚函数表来动态决策调用哪个具体函数。这种动态绑定的机制使得多态成为可能,即便通过基类指针调用函数,最终执行的仍然是派生类的版本。

画一张内存结构图,让调用过程更清楚!

假设 robot 是一个指向 CleaningRobot 对象的基类指针,其内存结构和调用流程如下:

内存结构图示:

调用过程:

  1. robot->vptr 定位到 CleaningRobot 虚函数表。
  2. 查找虚函数表的索引 0,获取 CleaningRobot::performTask 的地址。
  3. 跳转执行 CleaningRobot::performTask。完成动态绑定。

为什么多态这么重要?

多态的底层实现并不复杂,但它是 C++ 面向对象编程的灵魂。它让我们可以写出更灵活、扩展性更强的代码。比如工厂模式、状态机等设计模式,少了多态根本玩不转。

关于虚函数表,你可能会问的几个问题:(面试经常考,必须掌握)

1. 虚表会不会拖慢程序?

不会太慢。虚函数表查找的时间复杂度是 O(1),可以理解为一次“定向跳转”。
虽然比普通函数调用稍慢一点,但性能差距微乎其微,几乎可以忽略不计。

2. 虚函数表是在编译期间还是运行期间生成的?

虚函数表是在 编译阶段 生成的,每个含有虚函数的类都会有一张自己的虚函数表。
在运行时,程序会通过对象的 vptr 指向对应的虚函数表。

3. 如果一个类没有虚函数,还会有虚函数表吗?

不会。如果一个类没有虚函数,就不会生成虚函数表,也没有 vptr(虚指针)。
这种情况下,函数调用就是普通的“静态绑定”,编译器在编译时就决定了调用哪个函数。

只有当类中包含虚函数时,编译器才会为这个类生成虚函数表。

4. 虚函数表是对象级别的吗?

不是!虚函数表是类级别的,每个类只会生成一张虚函数表。

所有属于这个类的对象共享同一张虚函数表,每个对象只需要一个虚指针(vptr)指向这张表。

如果每个对象都存储一整张虚函数表,会浪费大量内存,因此虚函数表设计为类级别的,共享使用。

5. 非虚函数会参与虚函数表吗?

不会。如果基类的函数不是虚函数,那无论派生类怎么重写这个函数,基类指针调用的始终是基类的实现。

只有虚函数才能实现动态绑定,也只有虚函数会参与虚函数表。

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

6. 构造函数是否可以是虚函数?为什么?

不能!构造函数不能是虚函数,因为它的设计和工作方式决定了这一点。简单来说,构造函数负责“建造对象”,而虚函数需要对象已经建好才能正常工作。对象还没完全准备好时,是不可能使用虚函数的。

为什么?

1.虚函数需要动态绑定

  • 虚函数的特点是 动态绑定:程序在运行时根据对象的实际类型决定调用哪个函数。
  • 这种动态绑定依赖 虚指针(vptr) 和 虚函数表(vtable),但在构造阶段有一个限制:

    • 基类构造函数执行时,vptr 指向的是基类的虚函数表。
    • 派生类的 vptr 只有等到派生类的构造函数执行后,才会更新为指向派生类的虚函数表。
  • 所以在基类构造函数中调用虚函数,只能用基类的版本,动态绑定无法生效。

2.构造函数是静态绑定的

  • 构造函数是用来初始化对象的,比如分配内存、给数据赋值。这些工作必须是明确的、一步步来的,不能像虚函数那样依赖运行时决定调用哪个函数。
  • 如果构造函数支持动态绑定,就可能导致基类构造函数调用派生类的逻辑,而此时派生类的部分还没初始化,可能出错。
  • 所以,C++ 规定构造函数只能是静态绑定的,绝不允许动态绑定。

举个简单例子:

假设你有一个基类 Animal 和派生类 Dog

class Animal {
public:
    Animal() { speak(); }  // 在构造时调用 speak()
    virtual void speak() { cout << "Animal speaks" << endl; }
};

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

如果构造函数可以是虚函数,会发生什么?

  1. Dog 的对象被创建时,基类 Animal 的构造函数会先执行。
  2. 如果此时调用 speak(),理论上应该调用派生类 Dog 的版本。但问题是,Dog 的部分还没被初始化完成! 这会导致未定义行为。

实际上,C++ 的机制会让 speak() 调用基类自己的版本,结果输出的是:

Animal speaks

因此:构造函数不能是虚函数,因为它的任务是“建造对象”,而虚函数需要对象已经建好了才能正常工作。如果允许构造函数是虚函数,就好比对象还在“出生”,却被要求展现所有技能,显然是行不通的。

所以,C++ 从一开始就避免这个问题,规定:构造函数只能是静态绑定,不能是虚函数

7.为什么基类的析构函数需要声明为虚函数?

为什么需要虚析构函数?

当基类的析构函数是虚函数时,程序可以通过虚函数表实现动态绑定,根据实际对象的类型正确调用析构函数。这保证了:

  • 先调用派生类的析构函数清理派生类的资源。
  • 再调用基类的析构函数清理基类的资源。

如果基类的析构函数不是虚函数,用基类指针删除派生类对象时,只会调用基类的析构函数,导致派生类的资源无法释放,可能引发内存泄漏

来看个具体示例

没有虚析构函数的情况:

class Base {
public:
    ~Base() { cout << "Base Destructor Called" << endl; }
};

class Derived : public Base {
public:
    ~Derived() { cout << "Derived Destructor Called" << endl; }
};

int main() {
    Base* obj = new Derived();  // 基类指针指向派生类对象
    delete obj;                // 删除派生类对象
    return 0;
}

内存布局 :

对于 Derived 对象:

+------------------+
| 成员变量 (Derived) |
+------------------+
| 成员变量 (Base)   |
+------------------+

输出

Base Destructor Called

分析

  • 静态绑定:由于 Base 的析构函数不是虚函数,delete obj 时只调用 Base::~Base。编译器只知道 obj 是一个 Base,不会检查对象的实际类型。
  • 后果Derived 的析构函数没有被调用,Derived 的资源未释放,可能导致内存泄漏。

有虚析构函数的情况:

class Base {
public:
    virtual ~Base() { cout << "Base Destructor Called" << endl; }
};

class Derived : public Base {
public:
    ~Derived() { cout << "Derived Destructor Called" << endl; }
};

int main() {
    Base* obj = new Derived();  // 基类指针指向派生类对象
    delete obj;                // 删除派生类对象
    return 0;
}

内存布局:

对于 Derived 对象:

+-----------------------------+
| vptr -> Derived 的虚函数表     | ---> [ Derived 的虚函数表 ]
+-----------------------------+
| 成员变量 (Derived)           |
+-----------------------------+
| 成员变量 (Base)              |
+-----------------------------+

虚函数表结构:

  • Base 的虚函数表:
+------------------------+
| 析构函数地址 -> Base::~Base |
+------------------------+
  • Derived 的虚函数表:

Derived 会在它自己的虚函数表中覆盖基类的析构函数地址

+-----------------------------+
| 析构函数地址 -> Derived::~Derived |
+-----------------------------+

输出

Derived Destructor Called
Base Destructor Called

分析

  • 动态绑定delete obj 时,程序通过 vptr 找到 Derived 的虚函数表,调用 Derived::~Derived
  • 析构顺序

    1. 先调用 Derived::~Derived,释放派生类的资源。
    2. 再调用 Base::~Base,释放基类的资源。
  • 结果:派生类和基类的资源都被正确释放,程序运行安全。

小结:

1.如果基类析构函数不是虚函数,用基类指针删除派生类对象时,无法调用派生类的析构函数,可能导致资源泄漏。

2.虚析构函数通过虚函数表实现动态绑定,确保根据对象的实际类型调用析构函数。

3.正确的析构顺序是:先调用派生类的析构函数,释放派生类资源;再调用基类的析构函数,释放基类资源。

所以,只要一个类可能被继承,并通过基类指针或引用操作派生类对象,就应该将析构函数声明为虚函数。

总结:小白也能看懂的多态原理

通过前面的讲解,你已经了解了多态的底层原理,其实它并不复杂!多态的实现依赖于虚函数表(vtable),这是它的核心。

  1. 虚函数表: 每个含虚函数的类都有一张虚函数表,存放虚函数的地址。
  2. 虚函数表指针(vptr): 每个含有虚函数的类对象有一个 vptr,指向所属类的虚函数表。
  3. 调用流程: 基类指针通过 vptr 查表找到派生类函数地址,然后跳转执行。

看到这里,是不是感觉多态的实现特别简单?多态的魔法,其实全靠虚函数表帮忙。希望你能真正理解它的原理,这样以后再遇到相关问题时,能从容应对!

记住:“学会用是一回事,搞懂背后的原理才是王道。”

另外,我们也讨论了一些常见的虚函数面试题,帮助你更好地准备面试,如果你觉得这篇文章有帮助,就点赞、收藏、关注。或者转发给需要的朋友吧!😊

有问题?评论区等你,咱们一起讨论学习!欢迎关注我的公众号「跟着小康学编程」,获取更多有趣又实用的技术干货。技术路上不孤单,一起成长!

怎么关注我的公众号?

扫下方公众号二维码即可关注。

另外,小康还建了一个技术交流群,专门聊技术、答疑解惑。如果你在读文章时碰到不懂的地方,随时欢迎来群里提问!我会尽力帮大家解答,群里还有不少技术大佬在线支援,咱们一起学习进步,互相成长!


小康
33 声望4 粉丝

一枚分享编程技术和 AI 相关的程序员 ~