深入理解C++多态(三):多态的原理——虚函数表机制(上)

xml 复制代码
hello,这里是AuroraWanderll。
兴趣方向:C++,算法,Linux系统,游戏客户端开发
欢迎关注,我将更新更多相关内容!

这是系列的第三篇文章,上篇指引:抽象类与接口继承

深入理解C++多态(三):多态的原理------虚函数表机制(上)

1. 虚函数表的基本概念

1.1 什么是虚函数表?

虚函数表(Virtual Function Table,简称虚表或vtable)是C++实现多态的核心机制。每个包含虚函数的类 都有一个虚函数表,它是一个函数指针数组存储该类所有虚函数的地址

1.2 虚函数表指针

通过测试包含虚函数类的大小,我们可以得出结论:

每个包含虚函数的对象内部都有一个隐藏的成员------虚函数表指针(vfptr),它指向该对象所属类的虚函数表。

复制代码
class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    int data = 10;
};

int main() {
    Base b;
    cout << "对象大小: " << sizeof(b) << " bytes" << endl;
    // 在32位系统输出:8 bytes (4字节int + 4字节vptr)
    // 在64位系统输出:16 bytes (4字节int + 8字节vptr + 4字节对齐)
    return 0;
}

一个虚函数类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中

2. 虚函数表的结构与工作原理

2.1 基本结构演示

让我们通过一个简单例子来理解虚表的结构:

复制代码
#include <iostream>
using namespace std;

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 Derived : public Base {
public:
    virtual void func1() override {  // 重写基类虚函数
        cout << "Derived::func1()" << endl;
    }
    virtual void func4() {  // 新增虚函数
        cout << "Derived::func4()" << endl;
    }
private:
    int _d = 2;
};

int main() {
    Base b;
    Derived d;
    
    Base* pb = &b;
    pb->func1();  // 调用 Base::func1()
    
    pb = &d;      // 基类指针指向派生类对象
    pb->func1();  // 注意,这里他也去调用多态:调用 Derived::func1(),这正是派生类也有虚函数指针的体现
    
    return 0;
}

从上面的代码,我们可以观察到:派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚 表指针也就是存在部分的另一部分是自己的成员。

2.2 虚函数表的内存布局

让我们通过图示来理解上面的内存布局:

复制代码
Base 对象 b:
+---------+
| vptr    | --> Base虚表: [0]Base::func1, [1]Base::func2
+---------+
| _b = 1  |
+---------+

Derived 对象 d:
+---------+
| vptr    | --> Derived虚表: [0]Derived::func1, [1]Base::func2, [2]Derived::func4
+---------+
| _b = 1  |
+---------+
| _d = 2  |
+---------+

1.从上面两张图我们可以看到,基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表 中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数 的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

2.后Func2是继承下来的虚函数,所以放进了虚表,而Func3也被继承下来的,但没有放进虚表,因为不是虚函数。

3.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

4.总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生 类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己 新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后:基类拷贝先入,重写覆盖,新添的虚函数放最后面

5.易错:这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答是错的。

正确答案:虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是相比普通函数, 他的指针又存到了虚表中。

另外对象中存的不是虚表,存的是虚表指针。

那么虚表存在哪的呢?

实际我们去验证一下会发现vs下是存在代码段

3. 多态的原理深度解析

3.1 多态调用的底层机制

当通过基类指针或引用调用虚函数时,编译器会生成代码:

  1. 通过对象的vptr找到虚表

  2. 在虚表中找到对应的函数指针

  3. 通过函数指针调用函数

    void testPolymorphism(Base* p) {
    p->func1(); // 运行时决定调用哪个函数
    }

    // 对应的伪汇编代码:
    // mov eax, [p] ; 获取对象地址
    // mov edx, [eax] ; 获取vptr(对象前4/8字节)
    // mov eax, [edx] ; 获取虚表中第一个函数地址
    // call eax ; 调用函数

观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚

函数是Person::BuyTicket。

蓝色箭头同理。

我们可以知道,多态调用的流程大概是,根据不同指针或引用,去找对应的对象;然后根据对象去找到对应对象的虚表,再从虚表中存的虚函数指针,最后找到对应的虚函数完成调用

再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行

起来以后才到对象中去找的。不满足多态的函数调用时是编译时就确认好的。(再往下看,我会解释)

c++ 复制代码
void Func(Person* p)
{
 p->BuyTicket();
}
int main()
{
 Person mike;
 Func(&mike);
 mike.BuyTicket();
    
 return 0;
}
void Func(Person* p)
{
...
 p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE  mov         eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1  mov         edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE  mov         eax,dword ptr [edx]
// 1:call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来
以后到对象的中取找的。
001940EA  call        eax  
00头1940EC  cmp         esi,esp  
}
int main()
{
... 
// 2:首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调
用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
 mike.BuyTicket();
00195182  lea         ecx,[mike]
00195185  call        Person::BuyTicket (01914F6h)  

问题一:这里2:的不满足多态条件是什么意思呢?

从上面的论述我们可以总结出:

多态需要两个条件:

  1. 通过基类的指针或引用调用虚函数
  2. 被调用的函数是虚函数

我们可以看到1:这一部分是满足多态的条件1的,因为它使用p就是一个Mike对象的指针;而2:这一部分,他直接用的是Mike.方法的方式来调用,而Mike是一个对象。所以1:是多态而2:不是。

然后再对比汇编代码。我们就可以知道满足多态以后的函数调用,不是在编译时确定的,是运行

起来以后才到对象中去找的。不满足多态的函数调用是编译时确认好的

问题二:这里的运行时确定和编译时确定是怎么看出来的?

  1. call指令后面跟的是什么

    • 如果跟的是寄存器名eax, edx等)→ 运行时确定
    • 如果跟的是固定地址01914F6h)→ 编译时确定
  2. 看地址是否在运行时计算

    • 动态绑定:需要先执行多条指令计算地址(mov, mov, mov

    • 静态绑定:地址直接写在指令中,不需要计算(关于这两点会在下面解释)

      固定地址是写死的,所以是在编译时便确定,而寄存器名则说明这是一个变量,在运行起来之前没有人知道他是什么,所以是运行时确定。

3.2 静态绑定 vs 动态绑定

静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为也称为静态多态

比如:函数重载

动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体

行为,调用具体的函数也称为动态多态

复制代码
class Animal {
public:
    virtual void speak() { cout << "Animal sound" << endl; }
    void eat() { cout << "Animal eating" << endl; }  // 普通函数
};

class Dog : public Animal {
public:
    virtual void speak() override { cout << "Woof!" << endl; }
    void eat() { cout << "Dog eating" << endl; }  // 隐藏,不是重写
};

void demonstrateBinding() {
    Dog dog;
    Animal* animal = &dog;
    
    // 动态绑定(多态)
    animal->speak();  // 输出: Woof! (运行时决定)
    
    // 静态绑定
    animal->eat();    // 输出: Animal eating (编译时决定)
    dog.eat();        // 输出: Dog eating
}

下一篇,我们将介绍单继承关系和多继承关系中的虚函数表。重点关注派生类对象的虚表模型

xml 复制代码
感谢你能够阅读到这里,如果本篇文章对你有帮助,欢迎点赞收藏支持,关注我,
我将更新更多有关C++,Linux系统·网络部分的知识。
相关推荐
liulilittle1 小时前
C++ 17 字符串填充函数(PaddingLeft、PaddingRight)填充左侧、右侧。
c++·算法
mit6.8241 小时前
预统计
算法
lly2024061 小时前
Python Number(数字)
开发语言
阿沁QWQ1 小时前
STL库vector模拟实现
开发语言·c++
未来之窗软件服务1 小时前
操作系统应用(三十二)python版本选择系统—东方仙盟筑基期
开发语言·python·东方仙盟·操作系统应用
Ustinian_3101 小时前
【python】图片转PDF工具【附完整源码】
开发语言·python·pdf
WongKyunban1 小时前
什么是内存踩踏及其危害
c语言
数智化架构师-Aloong1 小时前
⚡️ PowerJob深度解析:Java生态下高并发分布式调度的终极选择
java·开发语言·分布式·系统架构