愿旖旎头像
【C++ 基础入门】C++ 多态(中):虚函数表、多态原理与动静绑定封面图

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

 👋 欢迎阅读

   🔥 愿旖旎 · 个人主页

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

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

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

📑 目录


一、前置讲解

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

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

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

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

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

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

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

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

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

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

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


二、虚函数表

2.1 sizeof(Base) 是多少?

// 这里常考一道笔试题: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。

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::Func1Derive::Func1(被重写覆盖)
第 2 项Base::Func2Base::Func2(继承来的虚函数)
末尾nullptrnullptr
Func3(普通函数)不进虚表不进虚表

2.3 六个关键结论

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

派生类虚表的生成过程:

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

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

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

再总结成五条:

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

三、多态原理

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


结语

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

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

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

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2601_96587588/article/details/169337984

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--