C++ 运行时多态与虚函数机制全面剖析【面试重点】

多态

  • 导言
  • [1. 运行时多态的定义与构成条件](#1. 运行时多态的定义与构成条件)
    • [1.1 虚函数的定义与语法规范](#1.1 虚函数的定义与语法规范)
    • [1.2 虚函数的重写/覆盖(三同原则)](#1.2 虚函数的重写/覆盖(三同原则))
      • [示例 1:买票场景的多态重写](#示例 1:买票场景的多态重写)
      • [示例 2:动物叫声的多态实现](#示例 2:动物叫声的多态实现)
  • [2. 虚函数重写中的经典难题与特例剖析](#2. 虚函数重写中的经典难题与特例剖析)
    • [2.1 虚函数重写与缺省参数的静态绑定陷阱【面试常考👔】](#2.1 虚函数重写与缺省参数的静态绑定陷阱【面试常考👔】)
    • [2.2 协变(Covariance)返回值重写特例](#2.2 协变(Covariance)返回值重写特例)
    • [2.3 虚析构函数与内存泄漏防护【面试高频👔】](#2.3 虚析构函数与内存泄漏防护【面试高频👔】)
    • [2.4 隐藏(重定义)机制与防坑指南](#2.4 隐藏(重定义)机制与防坑指南)
      • [示例 1:函数名相同、参数不同触发隐藏](#示例 1:函数名相同、参数不同触发隐藏)
      • [示例 2:函数名与参数均相同,但因未加 `virtual` 触发隐藏](#示例 2:函数名与参数均相同,但因未加 virtual 触发隐藏)
    • [2.5 override 与 final 关键字(C++11)](#2.5 override 与 final 关键字(C++11))
      • [示例 1:使用 `override` 进行强制重写检查](#示例 1:使用 override 进行强制重写检查)
      • [示例 2:使用 final 禁止虚函数重写](#示例 2:使用 final 禁止虚函数重写)
    • [2.6 函数关系辨析:重载、重写(覆盖)与隐藏(重定义)](#2.6 函数关系辨析:重载、重写(覆盖)与隐藏(重定义))
  • [3. 纯虚函数与抽象类](#3. 纯虚函数与抽象类)
  • [4. 运行时多态的底层逻辑与虚函数表](#4. 运行时多态的底层逻辑与虚函数表)
    • [4.1 对象内存布局与虚函数表指针(__vfptr)](#4.1 对象内存布局与虚函数表指针(__vfptr))
    • [4.2 多态的底层实现原理](#4.2 多态的底层实现原理)
      • [4.2.1 运行时多态的底层调优过程](#4.2.1 运行时多态的底层调优过程)
      • [4.2.2 动态绑定与静态绑定的汇编级对比](#4.2.2 动态绑定与静态绑定的汇编级对比)
      • [4.2.3 虚函数表(vtable)的构成与存放位置实测](#4.2.3 虚函数表(vtable)的构成与存放位置实测)
    • [4.3 多重继承的虚表数量【面试题】](#4.3 多重继承的虚表数量【面试题】)
    • [4.4 内存区域剖析实测【面试高频👔】](#4.4 内存区域剖析实测【面试高频👔】)
  • [5. 章节微总结💡](#5. 章节微总结💡)

导言

多态在 C++ 中分为两大类:

  • 编译时多态(静态多态): 主要指函数重载与函数模板。它们通过传入不同类型的参数,在编译期完成参数匹配与目标函数的绑定。

  • 运行时多态(动态多态): 当去完成某个特定行为(函数调用)时,传入不同的对象就会完成不同的行为。例如:普通人全价买票、学生打折买票、军人优先买票;或者向接口传入 Cat 对象发出 "(>^ω^<)喵",传入 Dog 对象发出 "汪汪"。本章将聚焦于运行时多态,从语法条件、重写规则到虚函数表(vtable)的运行机制进行全方位的拆点解析。

1. 运行时多态的定义与构成条件

构成 C++ 运行时多态必须同时满足以下两个关键条件:

  1. 必须是基类的指针或者引用调用虚函数。
  2. 被调用的函数必须是虚函数,并且派生类必须对该虚函数完成重写/覆盖(Override)。

只有基类的指针或引用才能既指向基类对象,又指向派生类对象(切片机制);而只有完成了虚函数重写,派生类与基类才具备不同的函数行为,达到"指向谁就调用谁"的效果。

1.1 虚函数的定义与语法规范

在类成员函数 的前面加上 virtual 关键字修饰,该成员函数就成为了虚函数。

cpp 复制代码
namespace fuyo
{
    class Person
    {
    public:
        // 成员函数前加 virtual 修饰,声明为虚函数
        virtual void BuyTicket() { cout << "买票-全价" << endl; }
    };
}

⭐ 语法细节与行为机制:

  • virtual 的作用指令: virtual 关键字本质上是给编译器下达的指令------"别只看指针或引用的静态声明类型!当有人调用这个函数时,请在运行时查验指针指向的真实对象,去调用该具体对象的方法!"

  • 修饰限制: virtual 只能修饰类的非静态成员函数 ,普通全局函数或静态成员函数(static 函数)不能加 virtual 修饰。

1.2 虚函数的重写/覆盖(三同原则)

派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型 、函数名 、参数列表 完全相同),称派生类的虚函数重写了基类的虚函数。

示例 1:买票场景的多态重写

cpp 复制代码
namespace fuyo
{
    class Base {
    public:
        virtual void func(int x) { // 父虚函数
            cout << "Base func" << endl;
        }
        virtual ~Base() {} // 虚析构函数,防止内存泄漏
    };

    class Son : public Base {
    public:
    	// 派生类重写基类虚函数(语法上可省略 virtual,但规范写法建议加上)
        void func(int x) { 
            cout << "Son func" << endl;
        }
    };

    void TestVirtualOverride()
    {
        Base* p = new Son();
        p->func(10); // 多态:调用Son::func,这就是虚函数作用
        delete p;
    }
}

⭐ 语法细节与行为机制:

  • 标准重写(三同): 派生类 Son 中的 func 与基类 Base 中的 func 函数名相同、参数列表相同、返回值相同,且基类函数加了 virtual,成功构成虚函数重写。

  • 派生类省略 virtual 的隐患: 在重写基类虚函数时,派生类的虚函数即使不加 virtual 关键字也可以构成重写(因为继承后基类的虚函数属性保留了起来)。但这种写法极不规范,考试与面试中属于高频坑点,建议一律显式加上。

  • 动态绑定触发: 通过基类指针 p 指向派生类对象 Son 并调用虚函数 p->func(10) 时,程序不会看指针的声明类型(Base*),而是在运行时找到 Son 对象的虚函数表,精准调用 Son::func,实现多态。

示例 2:动物叫声的多态实现

cpp 复制代码
namespace fuyo
{
    class Animal
    {
    public:
        virtual void talk() const
        {}
    };

    class Dog : public Animal
    {
    public:
        virtual void talk() const
        {
            std::cout << "汪汪" << std::endl;
        }
    };

    class Cat : public Animal
    {
    public:
        virtual void talk() const
        {
            std::cout << "(>^ω^<)喵" << std::endl;
        }
    };

    void letsHear(const Animal& animal)
    {
        animal.talk(); // 基类 const 引用触发多态
    }

    void TestAnimal()
    {
        Cat cat;
        Dog dog;
        letsHear(cat); // 输出:(>^ω^<)喵
        letsHear(dog); // 输出:汪汪
    }
}

⭐ 语法细节与行为机制:

  • 常量引用触发: const Animal& 同样能触发多态,重点在于 letsHear 函数形参是基类引用 const Animal& ,传入 cat 绑定 Cat::talk(),传入 dog 绑定 Dog::talk()。
  • 且 talk() 被修饰为 virtual 并保持参数与 const 属性完全一致。❌️错误反例:
    如果 letsHear 写成 Animal animal(值传递 ),会发生对象切片 ,永远调用 Animal::talk,没有多态。
cpp 复制代码
void letsHear(Animal animal) // ❌ 值传递,切片!
{
    animal.talk();
}

2. 虚函数重写中的经典难题与特例剖析

2.1 虚函数重写与缺省参数的静态绑定陷阱【面试常考👔】

试分析以下程序的输出结果:

cpp 复制代码
namespace fuyo
{
    class A
    {
    public:
        virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
        virtual void test() { func(); }
    };

    class B : public A
    {
    public:
        void func(int val = 0) { std::cout << "B->" << val << std::endl; }
    };
}

int main(int argc, char* argv[])
{
    fuyo::B* p = new fuyo::B;
    p->test();
    p->func();

    return 0;
}

✅️正确答案: p->test() 输出 B->1;p->func() 输出 B->0。

⭐ 语法细节与行为机制拆解:

对于 p->test() 的执行流程,拆解为以下 4 个步骤:

  1. 寻找 test() 函数:

    p 的类型是 B*。由于类 B 没有重写 test(),直接调用继承自基类的 A::test()。

  2. 确定 test() 内部的 this 指针:

    在 A::test() 内部,隐式调用的 func() 实际上是 this->func()。此时 this 指针的静态类型是 A*,但指向的动态类型依然是 new B 创建出来的 B 类对象。

  3. 决定执行哪个函数体(动态绑定):

    由于 func() 被声明为 virtual,根据 this 指针指向的实际对象类型(B)查找虚表,最终调用的函数体是 B::func()(控制台打印 B->)。

  4. 决定缺省参数 val 的值(静态绑定 - 最关键点📌):

    缺省参数的值在编译期就已经固定了 ,编译器只会根据表达式的静态类型 决定填入什么默认值。在 A::test() 函数内部调用 this->func() 时,this 指针的静态类型是 A*。编译器在编译 A::test() 时,看到 A 类中 func(int val = 1) 的默认值是 1,直接把参数 1 压入栈中,调用被替换为:this->func(1)。

💡 核心总结:

  • 实际指向的对象(动态类型) ,决定调用的函数体 (B::func())。

  • 指针本身的类型(静态类型) ,决定缺省参数 的取值(采用 A::func 中的缺省值 1)。

2.2 协变(Covariance)返回值重写特例

派生类重写基类虚函数时,与基类虚函数返回值类型不同。

即基类虚函数 返回基类对象 的指针或者引用,派生类虚函数 返回派生类对象 的指针或者引用时,称为协变。

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

    // 协变示例
    class Person {
    public:
        virtual A* BuyTicket()
        {
            cout << "买票-全价" << endl;
            return nullptr;
        }
    };

    class Student : public Person {
    public:
        // 协变:返回值由 A* 变为了 B* (B 继承自 A)
        virtual B* BuyTicket()
        {
            cout << "买票-打折" << endl;
            return nullptr;
        }
    };

    void Func(Person* ptr)
    {
        ptr->BuyTicket();
    }

    void TestCovariance()
    {
        Person ps;
        Student st;
        Func(&ps); // 输出:买票-全价
        Func(&st); // 输出:买票-打折
    }
}

⭐ 语法细节与行为机制:

  • 协变约束: 派生类虚函数返回的指针/引用所指向的类(如 B),必须继承自基类虚函数返回的指针/引用所指向的类(如 A),否则无法构成重写,编译直接报错。

2.3 虚析构函数与内存泄漏防护【面试高频👔】

基类的析构函数建议声明为虚函数。

此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。

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

    class B : public A {
    public:
        // 构成重写:编译器将析构函数名统一处理为 destructor()
        ~B()
        {
            cout << "~B()->delete:" << _p << endl;
            delete[] _p; // 释放派生类资源
        }
    protected:
        int* _p = new int[10];
    };

    void TestVirtualDestructor()
    {
        A* p1 = new A;
        A* p2 = new B;

        // 执行 delete 时:先调用析构函数,再调用 operator delete 释放内存
        delete p1;
        delete p2; // 触发多态,正确先调用 ~B() 再调用 ~A()
    }
}

⭐ 语法细节与行为机制:

  • 统一命名处理: 虽然基类与派生类析构函数名字看起来不同,但编译器对析构函数名称做了特殊处理,编译后统一处理成 destructor()。因此只要基类加了 virtual,派生类析构函数就自动构成重写。

  • 内存泄漏风险: 如果 ~A() 不加 virtual,执行 delete p2 时只会根据静态类型 A* 调用 ~A(),不会调用 ~B(),导致派生类中 _p 申请的堆内存无法释放,引发严重内存泄漏。

👔 面试常考:为什么基类的析构函数建议声明为虚函数?

✅️答: 只有把基类的析构函数设计为虚函数,通过基类指针 delete 派生类对象时,才能触发多态,保证先调用派生类的析构函数清理派生类资源,再调用基类的析构函数,防止资源泄露。

2.4 隐藏(重定义)机制与防坑指南

在继承体系中,当派生类定义了与基类同名 的函数,且不构成重写 时,就会触发隐藏(Hide / 重定义)。

示例 1:函数名相同、参数不同触发隐藏

cpp 复制代码
namespace fuyo
{
    class Person {
    public:
        // 基类中的 BuyTicket 带有 int 参数
        void BuyTicket(int a) { cout << "Person:买票-" << a << endl; }
    };

    class Student : public Person {
    public:
        // 派生类中的 BuyTicket 没有参数,且基类函数未加 virtual
        // 触发隐藏!基类的 BuyTicket(int) 被隐藏掉了
        void BuyTicket() { cout << "Student:买票-打折" << endl; }
    };

    void TestHide()
    {
        Student st;
        st.BuyTicket(); // ✅ 默认调用的是 Student 自己的 BuyTicket()
        
        // st.BuyTicket(10); // ❌ 编译报错!报错信息:函数不接受 1 个参数
        // 原因:只要派生类定义了同名函数 BuyTicket,基类的所有同名函数就会全部被隐藏!
        
        st.Person::BuyTicket(10); // ✅ 必须通过显式指定作用域,才能调用到被隐藏的基类函数
    }
}

⭐ 语法细节与行为机制:

  • 隐藏的核心规则: 只要函数名相同,且不构成重写(比如没加 virtual,或者参数列表不同),派生类的函数就会将基类的同名函数隐藏起来。

  • 作用域屏蔽: 编译器在查找函数时是自下而上 (先去派生类 作用域找,再去基类 作用域)查找的。一旦在派生类作用域里找到了名为 BuyTicket 的函数,编译器就会停在该作用域进行参数匹配。此时如果发现形参与传入的实参不匹配(如传入了 int),编译器会直接报错,而不会再去基类作用域里搜寻。

示例 2:函数名与参数均相同,但因未加 virtual 触发隐藏

cpp 复制代码
namespace fuyo
{
    class Person {
    public:
        // 没有加 virtual 关键字
        void func(int x) { cout << "Person func: " << x << endl; }
    };

    class Student : public Person {
    public:
        // 与基类函数"三同",但因为基类没有 virtual,不构成重写,而是隐藏!
        void func(int x) { cout << "Student func: " << x << endl; }
    };

    void TestHideSameParam()
    {
        Student st;
        st.func(10); // 输出:Student func: 10(调用派生类自己的函数)

        Person* ptr = &st;
        ptr->func(10); // 输出:Person func: 10(无法触发多态,静态绑定调用基类函数)
    }
}

⭐ 语法细节与行为机制:

  • 重写 vs 隐藏 的判定分界:

    • 重写(覆盖) :基类必须有 virtual + 函数名相同 + 参数列表相同 + 返回值相同(协变除外)。

    • 隐藏(重定义):只要函数名相同,且不满足重写的条件,就统一属于隐藏!

  • 指针调用的差异:隐藏场景下不具备动态绑定特性。通过基类指针 Person* 调用时,编译器仅根据静态类型 Person* 去寻找 Person 类内部的函数,因此无法达到多态的效果。

2.5 override 与 final 关键字(C++11)

从上面的代码可以看出,C++ 对虚函数重写的要求比较严格。

在传统 C++ 中,由于疏忽(比如函数名写错、参数写错)导致无法构成重写时,编译器在编译期间不会报错,只有在程序运行时没有得到预期结果才去调试,效率极低。

因此 C++11 引入了 override 关键字,专门用于帮助程序员明确表示重写意图,并让编译器帮我们做严谨的语法检查。

示例 1:使用 override 进行强制重写检查

cpp 复制代码
namespace fuyo
{
    class Car {
    public:
        virtual void Drive() {}
    };

    class Benz : public Car {
    public:
        // 如果基类中拼错成了 Dirve(),增加 override 后编译器会直接报 C3668 错误
        // 报错信息:包含重写说明符"override"的方法没有重写任何基类方法
        virtual void Drive() override { cout << "Benz-舒适" << endl; }
    };
}

⭐ 语法细节与行为机制:

  • 重写校验: override 放置在派生类虚函数声明末尾,强制编译器检查该函数是否成功重写了基类的某个虚函数。如果基类中不存在对应的虚函数,编译期报错。

示例 2:使用 final 禁止虚函数重写

如果我们不希望后续的派生类重写某个虚函数,可以用 final 关键字去修饰该虚函数。

cpp 复制代码
namespace fuyo
{
    class Car {
    public:
        // 使用 final 修饰,禁止后续派生类重写该虚函数
        virtual void Drive() final
        {}
    };

    class Benz : public Car {
    public:
        // ❌️编译报错:error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写
        // virtual void Drive() { cout << "Benz-舒适" << endl; }
    };
}

⭐ 语法细节与行为机制:

  • 终止重写: final 放在虚函数声明末尾,封死该虚函数的进一步继承覆盖,派生类若尝试重写将直接报出 C3248 错误。

2.6 函数关系辨析:重载、重写(覆盖)与隐藏(重定义)

3. 纯虚函数与抽象类

在虚函数的后面写上 = 0,这个函数就是纯虚函数 。包含纯虚函数的类叫做抽象类。

纯虚函数不需要定义实现(实现没太大意义,因为要被派生类重写,但是语法上可以实现),只要声明即可。

cpp 复制代码
namespace fuyo
{
    class Car
    {
    public:
        // 纯虚函数:无须具体实现,强制派生类重写
        virtual void Drive() = 0;
    };

    class Benz : public Car
    {
    public:
        // 重写纯虚函数后,Benz 不再是抽象类,可以实例化
        virtual void Drive()
        {
            cout << "Benz-舒适" << endl;
        }
    };

    class BMW : public Car
    {
    public:
        virtual void Drive()
        {
            cout << "BMW-操控" << endl;
        }
    };

    void TestAbstractClass()
    {
        // 编译报错:error C2259: "Car": 无法实例化抽象类
        // Car car;

        // 抽象类指针可以指向具体派生类的对象
        Car* pBenz = new Benz;
        pBenz->Drive(); // 输出:Benz-舒适

        Car* pBMW = new BMW;
        pBMW->Drive();  // 输出:BMW-操控
    }
}

⭐ 语法细节与行为机制:

  • 抽象类限制: 抽象类无法实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类,除非派生类重写了继承来的全部纯虚函数。

  • 接口继承: 纯虚函数在语法上强制了派生类必须重写该虚函数 ,体现了"接口继承"的思想。

    也就是说,可以利用抽象类,让每一个子类都重写一个函数,使同一个函数名字,不同子类表现不同行为。

4. 运行时多态的底层逻辑与虚函数表

4.1 对象内存布局与虚函数表指针(__vfptr)

在理解多态底层是如何运作之前,我们先来看一道极其经典的 C++ 笔试/面试题:

经典题目实测【面试高频👔】

在 32 位系统环境编译下,以下程序的运行输出结果是什么( )

A. 编译报错 B. 运行报错 C. 8 D. 12

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

namespace fuyo
{
    class Base
    {
    public:
        virtual void Func1()
        {
            cout << "Func1()" << endl;
        }
    protected:
        int _b = 1;
        char _ch = 'x';
    };
}

int main()
{
    fuyo::Base b;
    cout << sizeof(b) << endl; // 计算类对象 b 的占用内存大小
    return 0;
}

✅️正确答案: D. 12

⭐ 语法细节与行为机制拆解:

  1. 内存对齐基础计算
  • 成员变量 _b 的类型是 int,在 32 位系统下占 4 字节。

  • 成员变量 _ch 的类型是 char,占 1 字节。

  • 按照结构体/类成员的内存对齐规则,整体按 int(4 字节)对齐,_b 和 _ch 合计占用 8 字节。

  1. 多出来的 4 字节是什么?
    如果类中没有虚函数,sizeof(b) 应该是 8。但运行结果却是 12 bytes,说明对象内部多存了一个 4 字节的指针!

这个指针通常存放在对象内存模型的最前面(某些特定平台或编译器可能会放在最后面,VS 编译器默认放在最首部),它的名字叫做虚函数表指针 (Virtual Function Table Pointer),在调试窗口中通常显示为 __vfptr:

  • v 代表 virtual(虚);

  • f 代表 function(函数);

  • ptr 代表 pointer(指针)。

  1. 虚函数表(vtable)的本质
    只要一个类中包含了虚函数(哪怕只有一个),编译器就会在创建对象时,隐式地在对象首部插入一个 __vfptr 指针。

这个 __vfptr 指向一个函数指针数组 (即虚函数表 ,简称虚表 vtable),里面存放着该类中所有虚函数的入口指令地址,它在编译期 就生成,放在只读常量区,不是运行时新建的。

4.2 多态的底层实现原理

4.2.1 运行时多态的底层调优过程

从底层的角度来看,在 Func 函数中执行 ptr->BuyTicket() 时,编译器是如何做到:

  • 当 ptr 指向 Person 对象时,调用 Person::BuyTicket;

  • 当 ptr 指向 Student 对象时,调用 Student::BuyTicket;

  • 当 ptr 指向 Soldier 对象时,调用 Soldier::BuyTicket ?

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

namespace fuyo
{
    class Person {
    public:
        virtual void BuyTicket() { cout << "买票-全价" << endl; }
    private:
        string _name;
    };

    class Student : public Person {
    public:
        virtual void BuyTicket() { cout << "买票-打折" << endl; }
    private:
        string _id;
    };

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

    void Func(Person* ptr)
    {
        // 核心机制:虽然调用的发起者都是 Person* 类型的 ptr,
        // 但真正调用的函数完全取决于 ptr 运行时所指向对象的真实类型!
        ptr->BuyTicket();
    }

    void TestPolymorphismMechanism()
    {
        Person ps;
        Student st;
        Soldier sr;

        Func(&ps); // 输出:买票-全价
        Func(&st); // 输出:买票-打折
        Func(&sr); // 输出:买票-优先
    }
}

⭐ 底层寻址原理剖析:

  • 静态编译阶段:满足多态条件后,编译器不再在编译期根据指针的声明类型(Person*)写死调用的函数地址。

  • 运行寻址阶段:程序执行到 ptr->BuyTicket() 时,通过以下 3 步在运行期动态确定地址:

  • 找指针:通过指针 ptr 访问指向的对象首部,取出对象头部的 4 个字节,即虚表指针 __vfptr。

  • 查虚表:顺着 __vfptr 找到该对象独有的虚函数表(vtable)。

  • 定位函数:在虚表中取出偏移量对应的虚函数入口地址,执行 call 指令。

这使得指针指向哪个对象,就能精准拿到那个对象虚表中重写后的虚函数地址。

4.2.2 动态绑定与静态绑定的汇编级对比

在 C++ 中,函数调用在底层的绑定方式分为两大类:

  • 静态绑定(静态多态): 对不满足多态条件 的普通函数(或通过对象直接调用),编译器在编译阶段就能直接确定函数的调用地址。

  • 动态绑定(动态多态): 对满足多态条件 (基类指针/引用 + 调用虚函数)的调用,编译器在编译期无法确定地址,必须生成底层指令,在运行期去对象的虚函数表中动态查表定位。

我们将两种场景下的底层 x86 汇编代码进行反汇编对比:

  1. 满足多态场景(动态绑定汇编)
cpp 复制代码
ptr->BuyTicket();

对应反汇编指令:

复制代码
00EF2001 mov eax, dword ptr [ptr]  ; 1. 取出 ptr 指向对象的首地址(即 __vfptr 的地址)
00EF2004 mov edx, dword ptr [eax]  ; 2. 取出 __vfptr 的值,即虚表 vtable 的首地址
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr [ptr]  ; 3. 将 ptr 作为 this 指针存入 ecx 寄存器
00EF200B mov eax, dword ptr [edx]  ; 4. 取出虚表中第 1 项虚函数 (BuyTicket) 的入口地址
00EF200D call eax                  ; 5. 间接调用 eax 寄存器中存放在运行期查到的函数地址

⭐ 关键机制: call eax 是间接寻址指令,调用的地址保存在寄存器 eax 中,而 eax 的值来自于运行时查到的虚表。

  1. 不满足多态场景(静态绑定汇编)
cpp 复制代码
ptr->BuyTicket(); // 假设 BuyTicket 不是虚函数

对应反汇编指令:

复制代码
00EA2C91 mov ecx, dword ptr [ptr]  ; 1. 将 ptr 作为 this 指针压入 ecx 寄存器
00EA2C94 call Person::BuyTicket (0EA153Ch) ; 2. 直接 call 编译器预先写死的绝对地址

⭐ 关键机制: call 0EA153Ch 是直接寻址指令,调用的函数地址在编译链接阶段就已经硬编码固定。

4.2.3 虚函数表(vtable)的构成与存放位置实测

虚函数表的核心生成法则

  1. 共享与独立性:
  • 同一个类的所有对象,共享同一张虚函数表;

  • 不同的类(如 Person 与 Student)各自拥有独立的虚函数表。

  1. 派生类虚表的继承逻辑:
  • 派生类由"继承自基类的部分"与"自己新增的成员"构成。派生类对象中包含继承下来的基类虚表指针,但派生类对象的虚表内容是全新生成且独立保存的。
  1. 派生类虚表的内容组成(三部分):
  • 未被重写的基类虚函数地址;

  • 已经被派生类覆盖重写的虚函数地址;

  • 派生类自己新增的虚函数地址。

  1. 虚表结尾标记:

虚表本质上是一个存储虚函数指针的指针数组(如 void(*)() 数组)。在 VS 系列编译器下,虚表数组的末尾默认以 nullptr(即 0x00000000)作为结束标记(GCC/g++ 编译器不一定有此标记)。

4.3 多重继承的虚表数量【面试题】

题目:假设D类先继承B1,然后继承B2,B1和B2基类均包含虚函数,D类对B1和B2基类的虚函数重写了,并且D类增加了新的虚函数,则:( )

A.D类对象模型中包含了3个虚表指针

B.D类对象有两个虚表,D类新增加的虚函数放在第一张虚表最后

C.D类对象有两个虚表,D类新增加的虚函数放在第二张虚表最后

D.以上全部错误

✅️正确答案:B.D类对象有两个虚表,D类新增加的虚函数放在第一张虚表最后

⭐ 语法细节与行为机制拆解:

  • B1、B2 各自都有虚函数 → D 会生成两张虚表,对应两个父类

    • 第 1 张虚表:对应 B1 子对象
    • 第 2 张虚表:对应 B2 子对象
  • D 自己新增的虚函数,追加到第一张虚表(B1 对应的那张)末尾,不会新开一张虚表,也不会放到第二张。

⭐结论:

多重继承中

  • 多个带虚函数的父类 → 父类数量 = 虚表数量;
  • 子类新增的虚函数,统一挂靠在第一个父类对应的虚表后面。

4.4 内存区域剖析实测【面试高频👔】

🤔疑问:虚函数和虚函数表分别存放在内存的哪个区域?

  • 虚函数:和普通函数一样,编译后是一段二进制 CPU 指令,存放在代码段(代码区)。

  • 虚函数表:C++ 标准未规定虚表的存储区域。我们通过打印对象的虚表首地址,与栈区、堆区、静态区、常量区(代码段)进行地址对比测算:

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

namespace fuyo
{
    class Base {
    public:
        virtual void func1() { cout << "Base::func1" << endl; }
        virtual void func2() { cout << "Base::func2" << endl; }
        void func5()         { cout << "Base::func5" << endl; }
    protected:
        int a = 1;
    };

    class Derive : public Base
    {
    public:
        // 重写基类的 func1
        virtual void func1() { cout << "Derive::func1" << endl; }
        virtual void func3() { cout << "Derive::func3" << endl; }
        void func4()         { cout << "Derive::func4" << endl; }
    protected:
        int b = 2;
    };

    void TestVtableLocation()
    {
        int i = 0;
        static int j = 1;
        int* p1 = new int;
        const char* p2 = "xxxxxxxx";

        printf("栈区地址:   %p\n", &i);
        printf("静态区地址: %p\n", &j);
        printf("堆区地址:   %p\n", p1);
        printf("常量区地址: %p\n", p2);

        Base b;
        Derive d;
        Base* p3 = &b;
        Derive* p4 = &d;

        // 取出对象头部的前 4 个字节,即指针 __vfptr 的内容(虚表首地址)
        printf("Base 虚表地址:   %p\n", *(int*)p3);
        printf("Derive 虚表地址: %p\n", *(int*)p4);

        printf("虚函数地址:     %p\n", &Base::func1);
        printf("普通函数地址:   %p\n", &Base::func5);

        delete p1;
    }
}

int main()
{
    fuyo::TestVtableLocation();
    return 0;
}

⭐ 在 VS (32位) 环境下的运行结果与推导分析:

cpp 复制代码
栈区地址:   010FF954
静态区地址: 0071D000
堆区地址:   0126D740
常量区地址: 0071ABA4
Base 虚表地址:   0071AB44
Derive 虚表地址: 0071AB84
虚函数地址:     00711488
普通函数地址:   007114BF

⭐实测结论:

  • Base 虚表地址 (0071AB44) 与 Derive 虚表地址 (0071AB84) 的数值极度接近 常量区地址 (0071ABA4),这证明在 VS 编译器下,虚函数表存在代码段(常量区)。

  • 虚函数地址 (00711488) 和 普通函数地址 (007114BF) 存放位置非常相近,证明虚函数与普通函数一样存放在代码段中,虚表中保存的仅仅是虚函数的入口地址指针。

5. 章节微总结💡

相关推荐
w_zero_one1 小时前
递归算法(一)
java·开发语言·算法
IT规划师1 小时前
一文讲透五种发布机制:滚动、蓝绿、金丝雀、红黑与 AB
后端·面试
Julien20042 小时前
管理机密(一)
运维·服务器·开发语言·学习方法
Chen_harmony2 小时前
七、STL与string类
开发语言·c++
沐晓时光2 小时前
C语言入门,字符串和字符串函数知识讲解
c语言·开发语言
xxxiugou1232 小时前
C++命名空间与引用全解析
开发语言·c++
用户7624117567053 小时前
第十五章 人工智能基础
c++
yangmu32034 小时前
编程语言发展简史:从机器指令到智能协作
开发语言
爱写Bug的浮游6 小时前
unique_ptr 零开销的真相与边界
c++·facebook