【C++ 基础入门】C++ 多态(中):虚函数表、多态原理与动静绑定

👋 欢迎阅读

🔥愿旖旎 · 个人主页

📘学习专栏: 《算法专栏》《LangChain学习》《贪心算法》

🌄 钱塘江上潮信来,今日方知我是我

✨当前学习内容: 《C++学习》

📑 目录


一、前置讲解

在进入正文前,先花 10 秒了解本篇会反复用到的核心概念,带着印象去读正文,学习效率更高。

🧠 前置知识一:虚函数表指针(vfptr)

含虚函数的对象里多出一个指针 ,放在对象最前面 ,指向该类的虚函数表。

📋 前置知识二:虚函数表(vtable)

本质是存虚函数指针的数组 ,末尾一般放 nullptr;每个含虚函数的类各有一份。

🔁 前置知识三:重写 = 覆盖

语法层叫重写 ,原理层叫覆盖 ------指子类函数地址替换掉虚表里父类对应位置。

⚙️ 前置知识四:动态绑定 vs 静态绑定

虚函数走运行时 查表(动态);普通函数在编译期就定死(静态)。

🧩 前置知识五:多态两条件的原理

虚函数覆盖 保证表里有正确地址,指针/引用调用保证运行时才去查表。


二、虚函数表

2.1 sizeof(Base) 是多少?

cpp 复制代码
// 这里常考一道笔试题:sizeof(Base) 是多少?
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
private:
    int _b = 1;
};

答案是:跟平台有关。

平台 组成 sizeof(Base)
32 位 虚表指针 4 字节 + int _b 4 字节 8 字节
64 位 虚表指针 8 字节 + int _b 4 字节 + 对齐填充 4 字节 16 字节

通过观察和测试发现:对象里除了 _b 成员,还多一个 __vfptr 放在对象的最前面 。对象中的这个指针我们叫虚函数表指针 (v 代表 virtual,f 代表 function)。

一个含有虚函数的类中,都至少有一个虚函数表指针 ------因为虚函数的地址要放到虚函数表中 ,虚函数表也简称虚表。

💡 面试常问"为什么加了 virtual 对象就变大了":因为每个对象都多存了一个指针 (注意是每个对象一个 vfptr,不是每个函数)。

2.2 派生类的虚表长什么样

先给代码加上三个变化:派生类 Derive 继承 Base、Derive 中重写 Func1、Base 再增加一个虚函数 Func2 和一个普通函数 Func3。

cpp 复制代码
class Base
{
public:
    virtual void Func1()
    {
        cout << "Base::Func1()" << endl;
    }
    virtual void Func2()
    {
        cout << "Base::Func2()" << endl;
    }
    void Func3()
    {
        cout << "Base::Func3()" << endl;
    }
private:
    int _b = 1;
};
​
class Derive : public Base
{
public:
    virtual void Func1()
    {
        cout << "Derive::Func1()" << endl;
    }
private:
    int _d = 2;
};
​
int main()
{
    Base b;
    Derive d;
    return 0;
}

两个类的虚表内容对比:

虚表中的槽位 Base 的虚表 Derive 的虚表
第 1 项 Base::Func1 Derive::Func1(被重写覆盖)
第 2 项 Base::Func2 Base::Func2(继承来的虚函数)
末尾 nullptr nullptr
Func3(普通函数) 不进虚表 不进虚表

2.3 六个关键结论

序号 结论
1 派生类对象 d 中也有一个虚表指针 :d 对象由两部分构成------一部分是父类继承下来的成员 + 虚表指针 (注意父类的虚表指针和子类的虚表指针是不同的 ),另一部分是自己的成员
2 基类 b 对象和派生类 d 对象的虚表不一样 :Func1 完成了重写,所以 d 的虚表中存的是重写后的 Derive::Func1 。所以虚函数的重写也叫作覆盖 ------重写是语法的叫法,覆盖是原理层的叫法
3 Func2 继承下来后仍是虚函数 ,所以放进虚表 ;Func3 也继承下来了,但不是虚函数 ,所以不会放进虚表
4 虚函数表本质是一个存虚函数指针的指针数组 ,一般情况下数组最后面放了一个 nullptr
5 派生类虚表的生成过程见下表(三步)
6 虚函数存在哪?虚表存在哪? 很多人的回答是"虚函数存在虚表,虚表存在对象中"------这个回答是错的 ,但很多人深以为然。虚表存的是虚函数指针,不是虚函数 ;虚函数和普通函数一样,都存放在代码段 ,只是它的指针又存到了虚表中 。另外对象中存的不是虚表,存的是虚表指针 。至于虚表本身存在哪 ------实际验证会发现 VS 下虚表存在代码段

派生类虚表的生成过程:

步骤 做法
a 先将基类中的虚表内容拷贝一份到派生类的虚表中
b 如果派生类重写了 基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c 派生类自己新增加的虚函数 ,按其在派生类中的声明次序 ,增加到虚表的最后

再强调一遍(这段没看懂就多看几遍):

东西 存在哪 说明
成员函数(含虚函数) 代码段 只要是成员函数都在代码段,不会在对象内显示
虚函数表 代码段 表里存的是函数指针
虚函数表指针 __vfptr 对象内部 对象里只有指针,没有表

再总结成五条:

序号 总结
1 虚表归属 :每个包含虚函数的类都有自己独立的虚表 ;继承会对父类虚表复制 ,再通过重写 修改;虚表存放的是虚函数指针,不是虚函数
2 重写的作用 :子类重写虚函数时,会替换虚表中父类对应的函数地址 ,这就是多态的核心
3 新虚函数 :新增虚函数按声明次序 进入虚表;虚函数和普通函数一样都存放在代码段 ,不在虚表里
4 非虚函数 :不会进虚表 ,调用时直接静态绑定(编译时确定调用哪个类的函数)
5 虚表何时产生 :虚表内容在编译期 就由编译器安排好(函数地址在链接期 确定);而对象里的虚表指针在构造时设置

三、多态原理

cpp 复制代码
class Person   // 父类(含虚函数,支持多态)
{
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
​
class Student : public Person   // 子类,重写虚函数
{
public:
    virtual void BuyTicket() { cout << "买票-半价" << endl; }   // 覆盖父类实现
};
​
void Func(Person& p)   // 多态调用:通过基类引用调用虚函数
{
    p.BuyTicket();     // 运行时根据实际对象类型动态绑定
}
​
int main()
{
    Person Mike;
    Func(Mike);        // 买票-全价(调用 Person::BuyTicket)
​
    Student Johnson;
    Func(Johnson);     // 买票-半价(调用 Student::BuyTicket,体现多态)
    return 0;
}

运行结果:

复制代码
买票-全价
买票-半价
观察 结论
p 指向 Mike 对象时 p->BuyTicket 在 Mike 的虚表 中找到的是 Person::BuyTicket
p 指向 Johnson 对象时 p->BuyTicket 在 Johnson 的虚表 中找到的是 Student::BuyTicket
结果 这样就实现了不同对象去完成同一行为时,展现出不同的形态

反过来思考:为什么多态需要那两个条件?

条件 在原理上对应什么
虚函数覆盖 保证虚表里那个槽位存的是子类的函数地址------表里地址不对,查表也查不到正确的函数
对象的指针或引用调用 保证调用动作推迟到运行时------用对象直接调用,编译器早就按静态类型定死了,根本不会去查表

再看汇编层面: 满足多态以后的函数调用,不是在编译时确定的 ,而是运行起来以后到对象中去找的 ;不满足多态的函数调用,是编译时确认好的。

💡 一句话概括原理:虚表是"虚函数地址的目录",vfptr 是"目录在哪" 。运行时先顺着 this 找到 vfptr → 找到虚表 → 取出对应槽位的地址 → 再 call。所以多态是**"多一次查表"换来的灵活性**。


四、动态绑定与静态绑定

对比项 静态绑定(前期绑定 / 早绑定) 动态绑定(后期绑定 / 晚绑定)
确定时机 编译期间 运行期间
依据 变量的静态类型 具体拿到的对象类型
又被称为 静态多态 动态多态
典型例子 函数重载、普通函数调用 虚函数 + 基类指针/引用调用
效率 更高(直接 call 地址) 略低(要查虚表)

💡 记住这条对应关系:重载是静态多态(编译期挑版本),虚函数重写是动态多态(运行期查虚表)。两者都能叫"多态",但机制完全不同。


五、复盘(附答案)

💡 思考题

  1. sizeof(Base) 为什么会比只有成员变量时更大? 32 位和 64 位下分别是多少?

  2. 派生类对象里有没有自己的虚表指针? 父类和子类的虚表指针是同一个吗?

  3. 为什么说"重写"和"覆盖"是同一件事的两种叫法? 分别在什么语境下用?

  4. 派生类的虚表是怎么生成的? 基类的 Func2 和普通函数 Func3 分别会不会进虚表?

  5. "虚函数存在虚表里、虚表存在对象里"这句话错在哪? 正确说法是什么?

  6. 满足多态的调用为什么不满足多态更快? 动态绑定和静态绑定分别是什么?

  7. 多态的两个条件在原理上分别保证了什么? 缺一个为什么就不行?

📝 答案

  1. 因为只要类里有虚函数,对象里就会多一个虚表指针 __vfptr (放在对象最前面),对象大小因此增加。32 位 下:vfptr 4 + int 4 = 8 字节 ;64 位 下:vfptr 8 + int 4 + 对齐填充 4 = 16 字节。

  2. 有 。派生类对象 d 中也有一个虚表指针 ,d 由"父类继承下来的成员 + 虚表指针"和"自己的成员"两部分构成。父类的虚表指针和子类的虚表指针是不同的 (d 的 vfptr 指向 d 自己的虚表,b 的指向 b 的虚表)。

  3. 因为它们描述的是同一件事的两个层面 :语法层说派生类写了一个和基类一模一样的虚函数,叫"重写" ;原理层说派生类函数地址把虚表里基类那个槽位的内容替换掉了,叫"覆盖" 。所以可以记成"重写是语法,覆盖是原理"。

  4. 生成过程三步:a. 先把基类虚表内容拷贝一份 到派生类虚表;b. 重写了的虚函数,用派生类版本的地址覆盖 掉基类的那一项;c. 派生类新增的虚函数按声明次序追加到虚表末尾 。基类的 Func2 是虚函数,会进虚表 (原样继承);Func3 是普通函数,不进虚表。

  5. 错在把"指针"和"实物"混为一谈 。正确说法:虚表里存的是虚函数的指针,不是虚函数本身 ;虚函数和普通函数一样存在代码段 ;对象里存的不是虚表,而是虚表指针 ;虚表本身(VS 下)也存在代码段 。对象内只有那个 __vfptr。

  6. 因为不满足多态(静态绑定)的调用在编译期就把目标函数地址定死了,直接 call 即可 ;而满足多态(动态绑定)的调用必须运行时先取 vfptr、再查虚表、最后才能拿到函数地址 ,多一次内存访问。静态绑定 =前期绑定/早绑定,编译期确定行为(如函数重载);动态绑定=后期绑定/晚绑定,运行期按实际对象类型确定(如虚函数通过基类指针/引用调用)。

  7. 虚函数覆盖 保证虚表里那个槽位存的确实是子类函数的地址 ------否则查表也查不到正确的实现;指针/引用调用 保证调用动作被推迟到运行时 ------如果用对象直接调用,编译器按静态类型就把函数调用定死了,根本不会走查表这条路。两者缺一不可。


结语

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言交流你的疑问或补充

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

相关推荐
你看我名字23 分钟前
产品参数昨天还对,今天已过期:数眼智能与大模型应用如何做资料版本校验
开发语言·架构
老秦包你会1 小时前
计算机网络第二课 ----Socket(套接字)编程预备
开发语言·计算机网络·php
旖旎夜光1 小时前
【C++ 基础入门】C++多态(下):单/多继承虚表、多态补充与 21 道高频题
开发语言·c++·笔记·学习
叼烟扛炮6 小时前
[LangChain] 学习笔记(07):大模型长出手脚——工具(Tools)定义与调用闭环
笔记·学习·langchain
Wang's Blog11 小时前
Java 项目实战: 交友项目 - FastDFS:入门案例
java·开发语言·交友
叼烟扛炮11 小时前
[LangChain] 学习笔记(06):突破大模型限制——结构化输出(Structured Output)
windows·笔记·学习·langchain
apcipot_rain11 小时前
C++转Java日记(03)
java·开发语言·c++
布莱克60512 小时前
C++ 中 map 的 insert 与 emplace:key 已存在时会覆盖吗?
开发语言·数据结构·c++·算法·map
且从容.12 小时前
VUE3学习笔记(根据黑马教程总结)
笔记·学习