原来 C++ 虚函数是这样实现的!

开发
本文让我们抛开枯燥的概念讲解,通过一个有趣的故事,一步步揭开 C++ 对象模型的神秘面纱。

"为什么我的程序这么占内存?" 

"虚函数到底是怎么实现的?" 

"多态背后的原理是什么?" 

如果你也有这些疑问,那么这篇文章正是为你准备的。让我们抛开枯燥的概念讲解,通过一个有趣的故事,一步步揭开 C++ 对象模型的神秘面纱。

C++ 对象模型

"诶,小王啊!" 老张端着冒着热气的咖啡杯走进办公室,眼睛里闪着神秘的光 ☕️ "今天咱们来聊个有意思的历史故事~"

小王从键盘上抬起头,一脸困惑:"历史?这和代码有什么关系?" 🤔

老张神秘地笑了笑:"你猜猜看,咱们天天用的 C++,最开始是怎么实现的?"

"这还用猜吗?" 小王信心满满,"肯定是直接编译成机器码啊!" 💻

"嘿嘿,不对哦~" 老张喝了口咖啡,露出高深莫测的笑容,"C++ 最初其实是个'翻译官',叫 Cfront,它的工作就是把 C++ 代码翻译成 C 代码!" 🔄

"不会吧!" 小王瞪大了眼睛 😮 "这不是多此一举吗?"

老张摇摇手指:"聪明着呢!Stroustrup 大师当年在贝尔实验室可是深谋远虑啊。你想啊,C 编译器都已经很成熟了,何必重复造轮子呢?而且这样一来,C++ 代码还能和 C 代码愉快地玩耍,简直是一箭双雕!" 🎯

"来来来," 老张站起身,走到白板前,"让我给你变个魔术,看看 C++ 代码是怎么'变身'的~" ✨

老张挥舞着马克笔,在白板上画出一段优雅的 C++ 代码:

// 这是一个典型的 C++ 类定义 🏗️
class Rectangle {
public:
    // 构造函数,初始化宽和高 ✨
    Rectangle(int w, int h) : width(w), height(h) {}
    
    // 计算面积的成员函数 📐
    int area() { return width * height; }
    
private:
    int width;   // 矩形的宽 📏
    int height;  // 矩形的高 📏
};

"这段 C++ 代码经过编译器处理后,本质上会变成这样的 C 代码" 老张微笑着说

// C 语言结构体定义 🔨
struct Rectangle {
    int width;   // 存储宽度
    int height;  // 存储高度
};

// 构造函数被转换为普通的 C 函数 🛠️
void Rectangle_Rectangle(
    struct Rectangle* this, 
    int w, 
    int h
) {
    this->width = w;
    this->height = h;
}

// 成员函数也变成普通的 C 函数 🔧
int Rectangle_area(struct Rectangle* this) {
    returnthis->width * this->height;
}

小王恍然大悟:"原来如此!C++ 的类其实就是在 C 的基础上做了语法糖啊!"

老张点点头:"是的!所有的成员函数都会被转换成普通的 C 函数,只是多了一个 this 指针参数。这就是为什么我们说,要真正理解 C++,必须先理解 C 语言的基础。" 

"那继承是怎么实现的呢?" 小王充满好奇地问道 🤔

老张笑着说 😄:"这个问题问得好!让我们继续深入了解 C++ 对象模型的奥秘..."

简单对象模型

"来,小王," 老张放下热腾腾的咖啡 ☕️,拿起马克笔 ✍️,"让我们来了解一下 C++ 最初的对象模型是什么样的。这个模型虽然简单,但对理解继承特别有帮助哦!" 🎯

小王立刻坐直了身体,眼睛闪闪发亮

"知道为什么现在的 C++ 类写起来这么优雅吗?" 老张神秘地笑着说 🧙♂️,"这还得从上古时代说起..."

老张在白板上写下一段代码 👨🏫:

// 一个简单的学生类 👨🎓
class Student {
    std::string name;     // 存储学生姓名 📝
    int score;           // 记录考试分数 💯
    void study();       // 学习方法 📚
    void doHomework();  // 写作业功能 ✏️
};

"猜猜看,在 C++ 刚诞生的年代,Stroustrup 大师是怎么实现这个类的?" 老张眨眨眼 🤔

小王摇摇头 🤷:"难道...和现在不一样吗?"

"哈哈,那时候为了简化编译器的实现,他们设计了'简单对象模型'" 📐

老张画出了内部实现示意图 🎨:

// 在内存中的实际表示
struct Student_Internal {
    void* name_ptr;      // 指向实际的 string 数据
    void* score_ptr;     // 指向实际的 int 数据
    void* study_ptr;     // 指向 study 函数
    void* homework_ptr;  // 指向 homework 函数
};

"看到没?" 老张指着图说 👀,"所有成员,不管是数据还是函数,统统变成指针!就像一个巨大的导航表 🗺️ 📍"

小王瞪大了眼睛 👀:"等等...那岂不是一个简单的 int 也要用指针来存?" 🤔

"没错!" 老张笑着说 😄,"想象一下,你点外卖 🥡,每个菜都要先送到隔壁小区,然后给你一张地址条 📝,告诉你:'你的红烧肉在A栋3层 🏢,炒青菜在B栋5层...' 🥬 🏃♂️"

"这...这不是很浪费吗?" 小王忍不住笑了 😅

"所以啊!" 老张喝了口咖啡 ☕️,"假设我们创建一个学生对象 👨🎓:"

// 创建一个学生对象
Student student;  

// 在内存中占用 32 字节
// 因为有 4 个指针,每个指针 8 字节
// 4 × 8 = 32 字节 📏

"原本一个 int 只需要 4 字节 📊,现在却要用 8 字节的指针去指向它。就像点个炒青菜 🥬,还得配个专职导游 👨🦯!" 老张摇头晃脑地说 😅

"而且啊," 老张拿起笔又在白板上写道 ✍️,"你想想访问成员时会有多麻烦 😫:"

// 简单对象模型下访问成员的复杂过程 🔍
void useStudent(Student* s) {
    // 第一步:定位 score 指针的地址 📍
    void** score_ptr_addr = (void**)((char*)s + sizeof(void*));
    
    // 第二步:通过指针找到实际分数 🎯
    int* real_score = (int*)(*score_ptr_addr);
    
    // 第三步:终于可以修改分数了 ✏️
    *real_score = 100;

    // 调用方法更复杂 😱
    // 第一步:找到函数指针的地址 🔍
    void** study_ptr_addr = (void**)((char*)s + 2 * sizeof(void*));
    
    // 第二步:获取实际的函数指针 🎯
    void (*study_func)(Student*) = 
        (void (*)(Student*))*study_ptr_addr;
    
    // 第三步:调用函数 🚀
    study_func(s);
}

"天哪!" 小王惊呼 😱,"就改个分数要这么多步骤?"

"是啊!" 老张点点头 😌,"现在我们写student.score = 100 或student.study() 这么简单 ✨,但在简单对象模型下,编译器要做的工作可复杂了 🔧。每次访问成员都要进行两次内存寻址:一次找到指针 🔍,一次通过指针找到实际数据 🎯。"

"而且这还不是全部问题 ⚠️," 老张继续说,"想象一下,如果要实现虚函数,还得再加一层间接寻址。性能损失就更大了 📉。"

"那后来呢?" 小王来了兴趣 🤓

"后来当然是改进啦!这就像餐厅最后想通了 💡:'与其把菜放在各个地方,还不如直接送到客人桌上呢!'" 老张眨眨眼 😉,"这就是我们现在用的内存模型,数据直接存在对象里,该多大就多大。"

"不过呢," 老张神秘地补充道 🤫,"这个看似笨拙的想法,后来却启发了'成员指针'的设计。这又是另一个有趣的故事了..." ✨

小王托着下巴 🤔:"老张,您这故事讲得,把我都听饿了..." 🍜 😋

"哈哈,那正好!" 老张站起身 🚶,"我请你吃饭 🍚,路上再给你讲讲成员指针的故事!" 🗣️

表格驱动对象模型

"吃饱了吧?" 老张笑眯眯地问道 😊,"现在让我们继续讲讲 C++ 对象模型的演进。" 🔄

小王满足地点点头 😌:"刚才您说到简单对象模型的缺点,后来是怎么改进的呢?" 🤔

"在简单对象模型之后,出现了一个叫'表格驱动对象模型'的设计。" 老张兴致勃勃地在白板上画起了新的示意图 ✍️:

// 学生类的原始定义 👨🎓
class Student {
    std::string name;    // 学生姓名
    int score;          // 学生分数
    void study();      // 学习方法
    void doHomework(); // 做作业方法
};

// ===== 表格驱动模型的内部实现 =====

// 数据部分:专门存储成员变量 📝
struct Student_Data {
    std::string name;  // 直接存储姓名
    int score;        // 直接存储分数
};

// 表格部分:管理数据和函数 📋
struct Student_Table {
    // 指向实际数据的指针
    Student_Data* data;     
    
    // 指向函数表的指针(存储所有成员函数)
    void** function_table;   
};

"看出区别了吗?" 老张指着图说 👉,"这个模型把数据和函数分开存储。数据直接存在对象里,函数则通过一个表来管理。" 🗂️

"这样做有什么好处呢?" 小王充满好奇地问道 🧐

"首先,数据访问变快了!" 老张兴奋地解释道 🚀,"因为数据直接存储,不用再通过指针间接访问。其次,这种设计为后来的虚函数表铺平了道路。" ✨

"啊,原来虚函数表是从这里来的!" 小王恍然大悟 💡

"没错," 老张欣慰地点头 😌,"这就是为什么我们说要理解 C++ 的历史演进。每个设计都不是凭空出现的,都是在解决实际问题中逐步优化的结果。" 📈

"不过啊," 老张喝了口咖啡继续说 ☕️,"表格驱动模型虽然比简单对象模型好,但还是存在一些问题。" 🤔

小王认真地听着 👂

"让我给你列举几个主要问题:" 老张掰着手指数道 ✌️

// 问题演示 1: 内存占用问题 💾
struct Student_Table {
    Student_Data* data;      // 8字节
    void** function_table;   // 8字节
};  // 总共需要 16 字节!

// 问题演示 2: 访问效率问题 🐌
void example() {
    Student s;
    
    // 访问数据要两次解引用 
    int score = s.table->data->score;  
    
    // 调用函数更复杂
    void (*func)() = s.table->function_table[0];
    func();
}

"你看," 老张指着代码说 👉,"首先是内存问题。即使一个空类,也要占用至少 16 个字节来存储两个指针!" 😱

"其次是性能问题," 老张继续解释 🏃,"每次访问数据都要经过两次指针跳转,调用函数更是要先找表,再找函数..."

"这不就和简单对象模型一样慢吗?" 小王皱眉道 🤨

"没错!" 老张点点头 😌,"所以后来就有了现代 C++ 对象模型,它采用了一种更聪明的方式。" ✨

"那现代的对象模型又是什么样的呢?" 小王继续追问 🤓

"这个嘛..." 老张神秘地笑了笑 😏,"我们下次再讲。" 📚 ✨

现代 C++ 对象模型

"说到现代 C++ 对象模型," 老张站起来走到白板前,"这可是 Stroustrup 大师经过反复权衡后的杰作。"

"它有什么特别之处呢?" 小王问道。

"我们用一个经典的例子来说明。" 老张在白板上写道:

// 现代 C++ 对象模型示例 🎯
class ModernStudent {
    // 数据直接存储在对象中 📝
    std::string name;   
    int score;         
    
    // 普通函数直接编译为独立函数 🔨
    void study() { 
        // 直接访问数据成员
        score += 10;  
    }
    
    // 虚函数才使用虚表 🌟
    virtual void doHomework() {
        // 通过虚表调用
    }
    
private:
    // 只有需要多态的类才有虚表指针
    void* vptr;  // 虚函数表指针 📍
};

"现代对象模型的特点是:" 老张总结道 📋

  • 数据成员直接存储 💾
  • 普通成员函数独立存储 🔧
  • 只在需要多态时才使用虚表 🎯
  • 最大限度减少间接访问 🚀

"这样既保证了性能,又支持了 C++ 的所有特性!" 老张笑着说 😊

"原来是这样!" 小王恍然大悟 💡,"那这就解释了为什么有些类比其他类占用内存更多 - 因为它们需要虚表指针!"

"聪明!" 老张赞许地点点头 👍,"这就是为什么我们说 - 要理解 C++ 的性能特性,必须先理解它的对象模型。" 🎓

"那虚表具体是怎么工作的呢?" 小王继续追问 🤔

"这个问题问得好!" 老张眼睛一亮 ✨,"不过这是另一个精彩的话题了,我们下次再聊..."

小王若有所思地点点头 🤝,"感觉 C++ 每个特性背后都有这么多故事啊!"

"是啊," 老张笑道 😄,"这就是 C++ 的魅力所在。它的每个设计决策,都是在实践中不断优化的结果。" 🌟

虚函数表工作原理

"说到虚函数表啊..." 老张放下咖啡杯,眼睛里闪着光 ✨,"这可是 C++ 里最精妙的设计之一。"

"为什么这么说?" 小王好奇地问道 🤔

"想啊," 老张拿起马克笔走向白板,"C++ 需要在运行时才能确定调用哪个函数,但又要保证性能不受太大影响。这就像餐厅里的点菜系统,既要让客人能随时换菜,又不能让服务员跑来跑去问厨师 - 这可怎么办呢?" 🏃♂️

"啊!就像餐厅的电子菜单?" 小王眼前一亮 💡

"没错! 🎯" 老张开心地笑着说 😊, "虚函数表就像是每个类的'专属菜单' 📋。来,让我给你画个生动的例子..." ✨

// 动物基类 🦁
class Animal {
public:
    // 构造函数,初始化动物名字 🏗️
    Animal(conststd::string& name) : _name(name) {}
    
    // 虚析构函数,确保正确释放内存 🗑️
    virtual ~Animal() = default;
    
    // 纯虚函数,所有动物都要实现发声 🔊
    virtual void makeSound() = 0;  
    
protected:
    std::string _name;    // 动物的名字 📝
    
private:
    staticint population;  // 所有动物的数量统计 📊
};

// 猫咪类 - 继承自动物 🐱
class Cat :public Animal {
public:
    // 构造小猫咪 🐱
    Cat(conststd::string& name) : Animal(name) {}
    
    // 实现猫咪的叫声 🔊
    virtual void makeSound() override { 
        std::cout << "喵喵喵~" << std::endl; 
    }
    
private:
    int _lives = 9;  // 猫有9条命 ✨
};

"在现代 C++ 中 🌟," 老张拿起马克笔继续画图 ✏️, "这些类在内存中的布局是这样的:"

// Animal 在内存中的实际布局 📐
struct Animal_Layout {
    void* vptr;         // 虚函数表指针 🔍
    std::string _name;  // 名字成员变量 📝
};

// Cat 在内存中的实际布局 📏
struct Cat_Layout {
    Animal_Layout base;  // 继承的基类部分 👆
    int _lives;         // Cat独有的成员 🎯
};

// 类外部的静态成员 📦
static int Animal::population;  // 存储在数据段中

"这个设计展示了几个超级重要的特点 🌟:" 老张指着白板兴奋地说 😊:

  • 虚表指针总是在最前面 🎯
  • 基类成员排在前面 ⬆️
  • 派生类成员在后面 ⬇️
  • 虚函数通过表格查找调用 🔍

"这样的设计既高效又灵活 🚀,是 C++ 智慧的结晶呢!" 老张总结道 ✨

如何访问虚函数表? 

"让我们一步步看看虚函数调用背后发生了什么," 老张拿起马克笔 ✏️, "首先创建一个猫咪对象:"

Cat kitty("咪咪");  // 创建猫咪对象

"当我们创建这个对象时," 老张解释道 👨🏫, "编译器会自动初始化虚表指针(vptr),指向 Cat 类的虚函数表。"

"接下来,当我们调用虚函数时:"

kitty.makeSound();  // 看起来很简单的一行代码

"但在底层,编译器会生成一系列复杂的操作。首先是获取虚表指针:"

// 第一步:获取对象的虚表指针
void** vptr = *(void***)(&kitty);  // 从对象内存布局的开始位置读取虚表指针

// 指针层次分析:
// === 第1步:获取对象地址 ===
Cat* cat_ptr = &kitty;
// cat_ptr 现在指向对象的起始位置
// 因为虚表指针总是在对象的最开始位置,所以这个地址
// 实际上就指向了虚表指针的存储位置

// === 第2步:转换为 void*** 类型 ===
void*** triple_ptr = (void***)cat_ptr;
// 为什么要转换成 void***?
// - 因为我们要通过这个指针去读取虚表指针
// - 虚表指针本身的类型是 void**
// - 所以指向虚表指针的指针就是 void***

// === 第3步:解引用获取虚表指针 ===
void** vptr = *triple_ptr;
// 现在 vptr 就是真正的虚表指针了
// - 它指向了函数指针数组(虚函数表)
// - 类型是 void**,因为它指向的是函数指针数组

// 内存布局示意:
/*
内存地址     内容                  类型
0x1000   [ 虚表指针 ]            void**    <-- cat_ptr/triple_ptr 指向这里
         [ name成员 ]            string
         [ lives成员 ]           int

0x2000   [ 析构函数指针 ]        void*     <-- vptr 指向这里(虚函数表的开始)
         [ makeSound指针 ]       void*
         [ eat指针 ]            void*
         [ purr指针 ]           void*
*/

"让我们详细解释一下这个指针转换过程:" 老张拿起马克笔画起示意图 ✏️


// 假设有一个 Cat 对象
Cat kitty("咪咪");

// 获取虚表指针的详细步骤分解
void** vptr = *(void***)(&kitty);

/* 让我们一步步解析这行代码:

1. &kitty 得到 Cat* 类型
   - 这是对象的起始地址
   - 因为虚表指针在对象的开头,所以这个地址就是虚表指针的位置

2. (void***) 转换
   - 为什么需要 void***?
   - 因为我们要:
     a) 首先通过指针访问对象 (第一个*)
     b) 对象开头存储的是虚表指针 (第二个*)
     c) 虚表本身是函数指针数组 (第三个*)
   - 这就像一个三层的包装盒:
     最外层: 对象地址
     中间层: 虚表指针
     最内层: 函数指针数组

3. *(void***) 解引用
   - 这一步实际获取了虚表指针
   - 结果类型是 void**,正好是函数指针数组的类型
*/

"这样理解起来容易多了吧?" 老张问道 🤔

"哦~" 小王恍然大悟 💡,"原来这些指针操作是为了层层剥开对象的结构,最终找到虚函数表!"

"这里用了两次指针转换," 老张指着代码说 🔍, "&kitty 得到对象地址,然后通过指针转换和解引用,找到虚表指针。"

"让我用一个三维数组的类比来帮大家更好地理解这个指针结构," 老张补充道 👨🏫

// 想象一个三维数组结构
Class[N][M][K] objects;

/*
三个维度分别代表:
第一维 [N]: 不同的类
    - 每个类都有自己的虚函数表
    - 比如 Animal、Cat、Dog 等类

第二维 [M]: 虚函数表
    - 存储了该类所有的虚函数指针
    - 包括继承的、覆写的和新增的函数

第三维 [K]: 具体的函数指针
    - 指向实际的函数实现
    - 比如 makeSound、eat 等方法

访问过程就像在这个三维空间中导航:
1. 通过对象找到对应的类 (第一维)
2. 获取该类的虚函数表 (第二维)
3. 在表中找到具体的函数指针 (第三维)
*/

// 用代码表示这个访问过程
void callVirtualFunction(Cat* obj, int funcIndex) {
    // 第一维:通过对象找到类的虚表指针
    void*** classPtr = (void***)obj;
    
    // 第二维:获取虚函数表
    void** vtable = *classPtr;
    
    // 第三维:获取具体函数指针
    void* funcPtr = vtable[funcIndex];
    
    // 调用函数
    ((void(*)(Cat*))funcPtr)(obj);
}

"看到了吗?" 老张指着图说 👉 "就像在一个三维空间中导航:"

  • "第一维就像一个类的博物馆 🏛️,每个展厅都是一个不同的类"
  • "第二维就像每个展厅里的展示柜 📚,里面陈列着该类的所有虚函数"
  • "第三维就是展示柜中的具体展品 🎯,也就是实际的函数实现"

"当我们通过对象调用虚函数时,就像是在这个三维空间中找到正确的'展品'。" 老张解释道 🗺️

"啊!这么一说就清楚多了!" 小王眼睛一亮 💡 "每次解引用就是在不同维度间穿梭!"

如何通过虚函数表获取函数地址?

"接着是在虚表中查找函数地址:"

// 第二步:在虚表中查找函数地址
void (*makeSound)(Cat*) = vptr[1];  // 虚表中查找 makeSound 函数指针

"虚表就像一个函数指针数组," 老张继续解释 📚, "每个虚函数在表中都有固定的位置。这里的 [1] 表示 makeSound 在虚表中的偏移位置。"

"等等," 小王突然举手问道 🤚, "这个 [1] 是怎么来的?为什么是 1 而不是其他数字?"

"啊!问得好!" 老张笑着说 😊, "虚表中的函数位置是在编译时就确定好的。让我详细解释一下..."

老张在白板上画起了新的示意图:

// Animal 类的虚函数表布局 📋
struct Animal_VTable {
    // [0] 析构函数永远在第一个位置
    void (*destructor)(Animal*);     
    // [1] makeSound 在第二个位置
    void (*makeSound)(Animal*);      
    // 如果还有其他虚函数,继续往后排...
};

// Cat 类继承并覆写了这些函数
struct Cat_VTable {
    // [0] Cat 的析构函数
    void (*destructor)(Cat*);        
    // [1] Cat 的 makeSound 实现
    void (*makeSound)(Cat*);         
};

"编译器遵循以下规则来安排虚函数的位置 📝:" 老张解释道:

  • "虚析构函数总是位于索引 [0] 的位置 🎯"
  • "其他虚函数按照它们在基类中首次声明的顺序排列 📊"
  • "派生类如果覆写了基类的虚函数,就使用相同的位置 🔄"
  • "派生类新增的虚函数放在表的末尾 ⬇️"

"比如说,如果我们扩展一下这个例子:" 老张继续写道:

class Animal {
public:
    virtual ~Animal();                // 位置 [0]
    virtual void makeSound() = 0;     // 位置 [1]
    virtual void eat();               // 位置 [2]
};

class Cat : public Animal {
public:
    virtual ~Cat();                   // 位置 [0]
    virtual void makeSound() override;// 位置 [1]
    virtual void eat() override;      // 位置 [2]
    virtual void purr();              // 位置 [3] - Cat特有
};

"所以当我们调用kitty.makeSound() 时,编译器知道 makeSound 在位置 [1],这是在编译时就确定好的,运行时直接用这个固定位置去查找,非常高效!" 老张总结道 🚀

"原来如此!" 小王恍然大悟 💡, "这就像图书馆的分类系统,每本书都有固定的位置编号!"

"没错!" 老张点头赞许 👍, "而且这种设计还保证了即使基类添加了新的虚函数,已有的函数位置也不会改变,这对二进制兼容性非常重要。"

"最后,才是实际调用函数:"

// 第三步:调用找到的函数
makeSound(&kitty);  // 传入 this 指针调用函数

"看到了吗?" 老张总结道 🎯, "一个简单的虚函数调用,背后其实包含了三个关键步骤:

  • 获取虚表指针
  • 查找函数地址
  • 调用目标函数

这就是为什么虚函数调用会比普通函数调用慢一点 - 它需要额外的间接寻址操作。"

小王恍然大悟 💡: "原来如此!这就解释了为什么有些性能敏感的代码会避免使用虚函数。"

"没错!" 老张点点头 👍, "不过现代 CPU 的分支预测已经很强大了,所以除非在特别关键的性能热点,否则虚函数的开销通常不会造成明显影响。"

"这比之前的简单对象模型和表格驱动模型高明多了!" 小王惊叹道。

"是的," 老张点头,"这个设计既保证了性能,又支持了多态。"

同类对象的虚函数表共享机制

"对了老张!" 小王突然想到一个问题 🤔,"每个对象都有一个虚表指针,那虚函数表本身是每个对象都有一份吗?"

老张笑着摇摇头 😊:"这个问题问得好!虚函数表是由编译器为每个类创建的,而不是每个对象。所有同类型的对象共享同一个虚函数表!"

"让我画个图解释一下:" 老张走向白板 ✍️:

// 内存布局示意图 📝

// 代码段(只读)中存储的虚函数表
const Cat_VTable {              // 📋 所有 Cat 对象共享这个表
    &Cat::destructor,          // [0]
    &Cat::makeSound,          // [1]
    &Cat::eat,               // [2]
    &Cat::purr              // [3]
};

// 堆/栈中的对象
Cat cat1("咪咪");   // 🐱 对象1
// {
//     vptr -> Cat_VTable    // 指向共享的虚函数表
//     name: "咪咪"
//     lives: 9
// }

Cat cat2("花花");   // 🐱 对象2
// {
//     vptr -> Cat_VTable    // 指向相同的虚函数表
//     name: "花花"
//     lives: 9
// }

"你看," 老张指着图解释道 👉,"虚函数表存储在程序的只读数据段(.rodata)中,是只读的。每个 Cat 对象的 vptr 都指向这同一个表。这样设计有几个重要好处:"

  • "节省内存 💾 - 不需要为每个对象都存储一份完整的函数表"
  • "提高缓存效率 🚀 - 因为所有对象共享同一份表,增加了缓存命中率"
  • "保证一致性 ✨ - 所有对象调用的都是同一份虚函数实现"

"哇!这设计真是太巧妙了!" 小王赞叹道 👏,"那是不是说,一个程序里面,每个类只会有一份虚函数表?"

"基本上是这样。" 老张点点头 😌,"不过要注意,如果你的程序使用了动态库,同一个类可能在不同的动态库中各有一份虚函数表。但在同一个编译单元内,确实是共享同一份表的。"

"这就像一个大餐厅的菜单系统," 老张举例说 🏪,"每个服务员(对象)手里都有一个平板(vptr),但他们都连接到同一个中央点餐系统(虚函数表)。这样不管哪个服务员接单,都能保证点到一样的菜!" 🍽️

小王若有所思地点点头 🤔:"所以虚函数的内存开销主要是每个对象都要多存一个指针,而不是虚函数表本身?"

"聪明!" 老张赞许地说 👍,"这就是为什么在 C++ 中,一个带有虚函数的类的对象,至少要比没有虚函数的类多占用一个指针的大小。在 64 位系统上就是 8 字节。"

派生类和基类的虚函数表关系

"等等," 小王突然想到什么 🤔, "那派生类和基类的虚函数表是怎么回事?它们也是共享一个表吗?"

"啊,这个问题问得好!" 老张眼睛一亮 ✨, "派生类会有自己独立的虚函数表,而不是和基类共享。让我画个图解释一下:"

// 基类 Animal 的虚函数表
const Animal_VTable {           // 🦁 基类表
    &Animal::destructor,       // [0]
    &Animal::makeSound,       // [1]
    &Animal::eat             // [2]
};

// 派生类 Cat 的虚函数表
const Cat_VTable {             // 🐱 派生类有自己的表
    &Cat::destructor,         // [0] 覆写的析构函数
    &Cat::makeSound,         // [1] 覆写的 makeSound
    &Cat::eat,              // [2] 覆写的 eat
    &Cat::purr             // [3] Cat 特有的函数
};

// 内存中的对象
Animal* p1 = new Animal();  // 基类对象
// {
//     vptr -> Animal_VTable   // 指向 Animal 的表
//     name: "动物"
// }

Animal* p2 = new Cat();     // 通过基类指针指向派生类对象
// {
//     vptr -> Cat_VTable      // 指向 Cat 的表!
//     name: "咪咪"
//     lives: 9
// }

"看到了吗?" 老张指着图说 👉, "每个类都有自己的虚函数表,这样做有几个重要原因:"

  • "多态的实现 🔄 - 当通过基类指针调用虚函数时,实际会根据对象的真实类型找到正确的函数版本"
  • "函数覆写的支持 ✏️ - 派生类可以替换掉继承来的虚函数实现"
  • "扩展的灵活性 🎯 - 派生类可以添加新的虚函数"

"所以说," 老张继续解释道 👨🏫, "虽然每个类型都有自己的虚函数表,但同一个类型的所有对象还是共享同一个表。这就是 C++ 多态的精妙之处!"

"原来如此!" 小王恍然大悟 💡, "这就像每个餐厅分店(类)都有自己的菜单(虚函数表),但同一个分店的所有服务员(对象)都用同一份菜单!"

"没错!" 老张笑着说 😊, "而且你注意到了吗?即使用基类指针指向派生类对象,对象的 vptr 也是指向派生类的虚函数表。这就是为什么我们能通过基类指针正确调用派生类的函数实现!"

"这设计真是太巧妙了!" 小王赞叹道 👏, "每个类一份虚函数表,每个对象一个指针,就实现了如此强大的多态机制!"

老张喝了口咖啡,笑着说道 😊: "今天讲的这些只是虚函数表的基础知识。要完全理解 C++ 的对象模型,还有很多有趣的话题要探讨呢!"

"比如说?" 小王来了兴趣 🤔

"比如..." 老张神秘地眨眨眼 😉:

  • 虚函数表是在什么时候、怎么创建的? 🏗️
  • 多重继承时的虚函数表是什么样的? 🌳
  • 虚继承又会带来哪些特殊的内存布局? 🔄
  • 构造和析构过程中的虚函数调用又是怎么处理的? ✨

"这些都是非常有趣的话题," 老张站起身来 🚶♂️, "不过这些精彩的内容,我们下次再聊..."

小王若有所思地点点头 🤝, "感觉 C++ 的每个特性背后都藏着这么多精妙的设计啊!"

"是啊," 老张笑着说 😄, "这就是为什么即使到今天,研究 C++ 的底层实现依然是那么有趣。" 

责任编辑:赵宁宁 来源: everystep
相关推荐

2010-01-18 17:38:54

C++虚函数表

2010-02-01 11:22:09

C++虚函数

2022-07-18 15:32:37

C++虚函数表

2010-01-27 10:36:54

C++虚函数

2024-04-22 13:22:00

虚函数象编程C++

2024-01-23 10:13:57

C++虚函数

2011-05-24 16:20:27

虚函数

2022-05-09 08:37:43

IO模型Java

2010-01-20 18:06:06

C++虚基类

2020-12-28 08:36:30

C语言编程泛型

2020-06-08 17:35:27

Redis集群互联网

2011-05-24 16:30:35

虚函数

2010-02-05 13:35:19

C++虚析构函数

2022-12-14 07:32:40

InnoDBMySQL引擎

2022-01-12 19:59:19

Netty 核心启动

2011-07-20 17:04:55

C++虚函数动态联编

2022-02-14 11:25:53

C++lambda函数

2021-11-10 09:45:06

Lambda表达式语言

2009-03-11 14:42:57

面试求职案例

2023-05-08 07:52:29

JSXReactHooks
点赞
收藏

51CTO技术栈公众号