当前位置: 代码网 > it编程>编程语言>C/C++ > C++多态原理深度解析之虚函数表的内存布局与动态绑定的汇编真相

C++多态原理深度解析之虚函数表的内存布局与动态绑定的汇编真相

2026年09月20日 C/C++ 我要评论
多态是c++面向对象三大特性里最「值钱」的一个——它是设计模式、框架、运行时多态的基础。但多态也是最容易被学浅的:很多人背得出「虚函数 + 指针/引用调用」,却说不清虚函数表到

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

一、多态是什么

先看一个经典例子:

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. 基类指针或引用调用虚函数
class person
{
public:
    virtual void buyticket() { /* 全价 */ }   // 基类虚函数
};
class student : public person
{
public:
    virtual void buyticket() { /* 半价 */ }   // 重写基类虚函数
};

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

person p = student();   // 切片!p 是纯 person 对象
p.buyticket();          // 调的是 person::buyticket,不是多态

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

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

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

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 析构函数的重写:面试必考

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

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

class a
{
public:
    virtual ~a() { cout << "~a()" << endl; }
};

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

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

2.4 override 和 final:编译期的保险

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

class car
{
public:
    virtual void dirve() {}   // 基类拼错了:dirve
};
class benz : public car
{
public:
    virtual void drive() override {}   // override 检测:报错!
    // error c3668: 没有重写任何基类方法
};

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

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

class car
{
public:
    virtual void drive() final {}   // 到此为止,不允许再重写
};
class benz : public car
{
public:
    virtual void drive() override {}   // 编译错误:final 禁止重写
};

三、纯虚函数和抽象类

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,只有声明没有实现(语法上可以实现,但没意义,反正要被重写)。
  • 抽象类:包含纯虚函数的类。不能实例化
  • 派生类如果不重写纯虚函数,那它自己也是抽象类,同样不能实例化。
// car car;    // 编译错误:抽象类不能实例化
car* p = new benz;   // 可以:指针指向派生类对象
p->drive();           // benz-舒适

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

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

4.1 对象里多了个指针

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 虚表是怎么工作的

看多态在底层怎么实现:

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 静态绑定:汇编见真章

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

; 动态绑定(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 虚表存在哪

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

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无所谓
本质编译期多态运行期多态名字屏蔽

最容易混的是重写和隐藏

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是重写(多态);异类同名但参数不同是隐藏(屏蔽基类版本)。

到此这篇关于c++多态原理深度解析之虚函数表的内存布局与动态绑定的汇编真相的文章就介绍到这了,更多相关c++虚函数表的内存布局与动态绑定内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com