C++多态原理:虚函数表的内存布局与动态绑定的汇编真相

文章目录

多态是C++面向对象三大特性里最「值钱」的一个------它是设计模式、框架、运行时多态的基础。但多态也是最容易被学浅的:很多人背得出「虚函数 + 指针/引用调用」,却说不清虚函数表到底在哪、动态绑定和静态绑定在汇编层面差在哪。这篇从虚函数重写一路拆到虚表的内存布局,把多态的原理讲透。


一、多态是什么

先看一个经典例子:

cpp 复制代码
class Person
{
public:
    virtual void BuyTicket()
    {
        cout << "买票-全价" << endl;
    }
};

class Student : public Person
{
public:
    virtual void BuyTicket()
    {
        cout << "买票-半价" << endl;
    }
};

class Soldier : public Person
{
public:
    virtual void BuyTicket()
    {
        cout << "买票-优先" << endl;
    }
};

void Func(Person* ptr)
{
    ptr->BuyTicket();   // 同一个调用,行为由 ptr 指向的对象决定
}

int main()
{
    Person ps;
    Student st;
    Soldier sr;

    Func(&ps);   // 买票-全价
    Func(&st);   // 买票-半价
    Func(&sr);   // 买票-优先
    return 0;
}

Func 里的 ptr->BuyTicket() 是同一行代码,但传入不同对象就调用不同版本的 BuyTicket多态的本质:同一个函数调用,在运行时根据对象的实际类型,决定执行哪个版本的函数。

没有多态的话,FuncptrPerson*,编译器只会调用 Person::BuyTicket------不管你传进来的是 Student 还是 Soldier。


二、虚函数重写:多态的语法前提

2.1 两个必要条件

多态的发生必须同时满足:

  1. 虚函数重写:派生类重写基类的虚函数(函数名、参数、返回值都相同)。
  2. 基类指针或引用调用虚函数
cpp 复制代码
class Person
{
public:
    virtual void BuyTicket() { /* 全价 */ }   // 基类虚函数
};

class Student : public Person
{
public:
    virtual void BuyTicket() { /* 半价 */ }   // 重写基类虚函数
};

两个条件缺一不可。很多人写出多态 bug,就是因为只满足了第一个------用对象调用而不是指针/引用:

cpp 复制代码
Person p = Student();   // 切片!p 是纯 Person 对象
p.BuyTicket();          // 调的是 Person::BuyTicket,不是多态

对象调用不构成多态,因为对象在编译期类型就确定了。必须是指针或引用

2.2 协变:返回值可以不同(了解即可)

重写虚函数时,返回值类型通常要求完全一致。唯一的例外是协变------基类虚函数返回基类指针/引用,派生类返回派生类指针/引用:

cpp 复制代码
class A {};
class B : public A {};

class Person
{
public:
    virtual A* BuyTicket() { return nullptr; }
};

class Student : public Person
{
public:
    virtual B* BuyTicket() { return nullptr; }   // 协变:B* 是 A* 的派生
};

协变实际意义不大,知道有这个特例就行。

2.3 析构函数的重写:面试必考

基类的析构函数应该设计成虚函数。原因看这段代码:

cpp 复制代码
class A
{
public:
    ~A() { cout << "~A()" << endl; }   // 注意:没加 virtual
};

class B : public A
{
public:
    ~B()
    {
        cout << "~B()" << endl;
        delete[] _p;                    // 释放资源
    }
private:
    int* _p = new int[10];
};

int main()
{
    A* p = new B;   // 基类指针指向派生类对象
    delete p;       // 只调用了 ~A(),没调用 ~B() ------ _p 泄漏了!
    return 0;
}

delete p 时,pA*,编译器静态绑定到 ~A()~B() 没被调用,_p 指向的内存永远泄漏。

修复:给基类析构函数加 virtual

cpp 复制代码
class A
{
public:
    virtual ~A() { cout << "~A()" << endl; }
};

加了 virtual 之后,派生类的析构函数自动和基类析构构成重写(编译器把析构函数统一处理成 destructor,所以名字不同也能重写)。delete p 就会走虚表,正确调用 ~B()~A()

判断标准:只要一个类会被继承、且可能通过基类指针删除派生类对象,析构函数就必须是虚函数。

2.4 override 和 final:编译期的保险

虚函数重写要求严格,手一抖函数名写错或参数漏了,就「静默地」没构成重写------编译不报错,运行时结果不对,debug 半天。

cpp 复制代码
class Car
{
public:
    virtual void Dirve() {}   // 基类拼错了:Dirve
};

class Benz : public Car
{
public:
    virtual void Drive() override {}   // override 检测:报错!
    // error C3668: 没有重写任何基类方法
};

override 告诉编译器「我要重写基类的虚函数」,如果没重写上,直接编译报错。所有重写虚函数的场景都建议加 override

反过来,final 禁止派生类继续重写:

cpp 复制代码
class Car
{
public:
    virtual void Drive() final {}   // 到此为止,不允许再重写
};

class Benz : public Car
{
public:
    virtual void Drive() override {}   // 编译错误:final 禁止重写
};

三、纯虚函数和抽象类

cpp 复制代码
class Car
{
public:
    virtual void Drive() = 0;   // 纯虚函数:= 0,不需要实现
};

class Benz : public Car
{
public:
    virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

class BMW : public Car
{
public:
    virtual void Drive() override { cout << "BMW-操控" << endl; }
};
  • 纯虚函数:在虚函数后面加 = 0,只有声明没有实现(语法上可以实现,但没意义,反正要被重写)。
  • 抽象类 :包含纯虚函数的类。不能实例化
  • 派生类如果不重写纯虚函数,那它自己也是抽象类,同样不能实例化。
cpp 复制代码
// Car car;    // 编译错误:抽象类不能实例化
Car* p = new Benz;   // 可以:指针指向派生类对象
p->Drive();           // Benz-舒适

纯虚函数的本质是「强制派生类实现某个接口」。它定义了一种契约:凡是 Car,就必须会 Drive。这是设计模式里「接口」概念的C++实现。


四、多态的原理:虚函数表

4.1 对象里多了个指针

cpp 复制代码
class Base
{
public:
    virtual void Func1() { cout << "Func1()" << endl; }
protected:
    int _b = 1;
    char _ch = 'x';
};

int main()
{
    Base b;
    cout << sizeof(b) << endl;   // 输出 12(32位下)
    return 0;
}

_b 是 4 字节,_ch 是 1 字节,加上内存对齐应该是 8。但输出 12------多了 4 字节,这 4 字节就是虚函数表指针(_vfptr

只要一个类含虚函数,它的对象里就有一个虚函数表指针,指向这个类所有虚函数地址组成的「虚函数表(虚表)」。

复制代码
Base b 的内存布局:
┌──────────────┐
│   _vfptr     │ → 虚函数表 → [ Base::Func1 ]
├──────────────┤
│   _b (4字节) │
│   _ch(1字节) │  + 对齐填充
└──────────────┘

4.2 虚表是怎么工作的

看多态在底层怎么实现:

cpp 复制代码
class Person
{
public:
    virtual void BuyTicket() { cout << "全价" << endl; }
};

class Student : public Person
{
public:
    virtual void BuyTicket() { cout << "半价" << endl; }   // 重写
    virtual void Study()     { cout << "学习" << endl; }    // 新增虚函数
};

void Func(Person* ptr)
{
    ptr->BuyTicket();   // 运行时到 ptr 指向对象的虚表里查 BuyTicket 的地址
}

内存模型:

复制代码
Person 对象的虚表:           Student 对象的虚表:
┌──────────────────┐         ┌──────────────────┐
│ Person::BuyTicket │         │ Student::BuyTicket│  ← 重写后被覆盖
└──────────────────┘         │ Student::Study    │  ← 新增的虚函数
                              └──────────────────┘

关键三点:

  1. 派生类重写基类虚函数,派生类虚表中对应位置被覆盖成派生类版本
  2. 派生类新增的虚函数,追加在虚表后面
  3. 派生类继承的虚表指针和基类对象的虚表指针不是同一个------各自有独立的虚表。

所以 Func(&st) 时,ptr 指向 Student 对象,运行时从 Student 对象的虚表里查到 Student::BuyTicket 的地址并调用。这就是「运行时绑定」的来源------函数地址在运行期才确定,而不是编译期。

4.3 动态绑定 vs 静态绑定:汇编见真章

不满足多态条件的调用,编译器在编译期就确定了函数地址,叫静态绑定 ;满足多态的,运行期才确定,叫动态绑定。看汇编:

asm 复制代码
; 动态绑定(ptr->BuyTicket(),BuyTicket 是虚函数)
mov  eax, dword ptr [ptr]      ; 取对象的虚表指针
mov  edx, dword ptr [eax]      ; 取虚表第一个函数地址
call eax                        ; 间接调用------地址运行期才知道

; 静态绑定(BuyTicket 不是虚函数)
mov  ecx, dword ptr [ptr]
call Student::BuyTicket (0EA153Ch)   ; 直接调用------地址编译期就固定

动态绑定多了两次内存解引用(先取虚表指针,再取函数地址),这就是多态的运行时开销------虽然极小,但确实存在。这也是为什么C++坚持「不写 virtual 就零开销」:虚函数表只在需要多态时才引入。

4.4 虚表存在哪

虚函数本身和普通函数一样,编译后是代码段的指令。虚函数表存在常量区(代码段附近)------可以用下面的代码验证:

cpp 复制代码
int main()
{
    Base b;
    const char* p = "xxxx";   // 常量区
    Base* p3 = &b;

    printf("常量区:%p\n", p);
    printf("虚表地址:%p\n", *(int*)p3);   // 和常量区地址接近
    return 0;
}

虚表地址和常量区地址在同一个量级,说明虚表确实在常量区(只读数据段)。注意:虚表到底放哪,C++标准没有规定,不同编译器可能不同,VS 是放在常量区。


五、重载、重写、隐藏:三个概念一次分清

这是面试的固定考点,用一张表终结:

对比项 重载(overload) 重写(override) 隐藏(hide)
作用域 同一个类内 基类和派生类之间 基类和派生类之间
函数名 相同 相同 相同
参数列表 必须不同 必须相同 任意
返回值 任意 相同(协变除外) 任意
是否有 virtual 无所谓 基类必须是 virtual 无所谓
本质 编译期多态 运行期多态 名字屏蔽

最容易混的是重写和隐藏

cpp 复制代码
class Base
{
public:
    virtual void func(int) {}   // 虚函数
};

class Derived : public Base
{
public:
    void func(int) {}        // 参数相同、基类是virtual → 重写(构成多态)
    void func(double) {}     // 参数不同 → 隐藏(不构成多态,屏蔽基类所有func)
};

判断顺序:先看作用域(同类是重载,异类是重写或隐藏),再看参数列表和 virtual。


总结

四条核心认知:

  1. 多态的两个条件:虚函数重写 + 基类指针/引用调用。缺一个都不算多态,对象调用永远不是多态。
  2. 虚表是运行期多态的引擎:含虚函数的类对象里藏一个虚表指针,重写就是把派生类虚表里的对应项覆盖掉。调用时通过虚表间接跳转,这就是动态绑定的本质。
  3. 析构函数要设成虚函数:否则通过基类指针 delete 派生类对象时,派生类析构不会执行,资源泄漏。
  4. 重载/重写/隐藏:同类同名不同参是重载;异类同名同参且基类virtual是重写(多态);异类同名但参数不同是隐藏(屏蔽基类版本)。
相关推荐
铲灰1 小时前
做个总结而已
开发语言
西西弗Sisyphus1 小时前
Qt 实现一个 水波进度球
c++·qt·c
无忧.芙桃1 小时前
数据结构之排序算法(中):冒泡排序与快速排序,从相邻交换到工程级优化
c语言·c++·排序算法
IvanCodes1 小时前
Python 文件操作(十二):文件与目录的读写
开发语言·python
Hhy_11071 小时前
《C++深度解构04》类和对象(下)——类型转换、static、友元与编译器优化
c语言·c++·学习·类和对象·visual studio
qq_199886872 小时前
第7板块·第1节:通用算子分类与设计模式
c++·人工智能·gpu算力·cuda
6Hzlia3 小时前
【Classic 150 刷题计划】 LeetCode 205. 同构字符串 | C++ 双向绑定与哈希表映射
c++·算法·leetcode
神仙别闹9 小时前
基于 C++ 实现(控制台)学生成绩管理系统
开发语言·c++
会周易的程序员9 小时前
5 节点边缘冗余方案(上):基于 aiRaft 的物联网高可用控制面设计
c++·分布式·物联网·raft·iot·共识