0.简介
多态作为面向对象三大核心特性(封装、继承、多态)之一,其可以分为静态多态(模板,重载,重定义)和动态多态。理解动态多态的底层实现原理对于掌握C++对象模型有重要意义。本文将从虚函数表、内存布局、调用的汇编代码等底层结构出发,深入解析动态多态的实现机制。
1.C++对象内存结构
要理解C++的动态多态实现,需要从对象模型(Object Model)的内存结构入手。让我们先通过设计者的视角来分析这个机制:
1)核心问题:在复杂的继承体系中,对象如何动态确定要调用的函数地址?如果使用地址记录应该怎么记录?如果每个对象都存储所有虚函数的地址,会导致严重的空间浪费(每个对象都保存一份完整的函数指针表)。
2)解决方案设计:C++采用的是一种间接访问的方案,我们分别在类和对象角度来看:
- 类级别:每个包含虚函数的类会维护一个虚函数表(vtable),这是一个函数指针数组。
- 对象级别:每个对象只需存储一个指向对应vtable的指针(vptr)。
|----------------| |------------------|
| 对象实例 | | 类的虚函数表 |
|----------------| |------------------|
| vptr ----------------> | 虚函数1的地址 |
| 成员变量 | | 虚函数2的地址 |
|----------------| | ... |
|------------------|
3)关键优势:
空间效率:所有同类对象共享同一个vtable。
动态绑定:通过vptr在运行时确定实际调用的函数。
继承扩展:派生类可以创建新的vtable,只需修改差异部分
这种设计完美平衡了空间效率(每个对象只需一个指针开销)和时间效率(通过一次指针间接访问实现动态绑定),是C++多态机制的基石。
2.虚函数表基本结构
虚表是以指针数组存在的,即内部又指向不同的函数地址。我们通过一个例子来看。
#include <iostream>
using namespace std;
// 基类
class Base {
public:
virtual void f() { cout << "Base::f()" << endl; }
virtual void g() { cout << "Base::g()" << endl; }
int a;
};
// 派生类
class Derived : public Base {
public:
void f() override { cout << "Derived::f()" << endl; } // 重写f()
virtual void h() { cout << "Derived::h()" << endl; } // 新增虚函数h()
int b;
};
// 定义函数指针类型(匹配虚函数签名)
typedef void (*VFuncPtr)();
int main() {
Derived d;
// 获取对象的虚表指针(vptr):对象首地址即为vptr
void**vtable =*(void*** )&d;
cout << "虚表地址: " << vtable << endl;
// 打印虚表中的函数
cout << "虚表内容(函数地址及调用结果):" << endl;
for (int i = 0; i < 3; ++i) { // Derived有3个虚函数:f、g、h
VFuncPtr func = (VFuncPtr)vtable[i];
cout << " 索引" << i << ":地址=" << (void*)func << ",调用:";
func(); // 调用虚函数
}
return 0;
}
运行结果如下:
所以我们可以得到其层级结构如下:
我们也可以使用如下命令去查看,可以看到其前两个是记录一些别的信息,所以虚表指针是从16偏移开始。
g++ -fdump-lang-class your_code.cpp
3.不同继承下的虚函数表
3.1 单继承下的虚函数表
单继承情况我们在第2节中已经描述了,其简单来说就是将自己的函数替换到虚表中,其余继续使用基类的,这种继承无论多少层都是先继承上一层的虚表,然后把自己实现的替换进去。
3.2 多继承下的虚函数表
多继承是通过多个虚表指针来实现的,其中如果函数同名在调用时会报错,比如基类Base1和基类Base2都定义了虚函数f(),如果调用是直接调用会报错,需要d.Base1::f(), 我们通过下面这个代码来看其结构:
#include<iostream>
using namespace std;
class Base1 {
public:
virtual void f(){cout<<"a"<<endl;};
};
class Base2 {
public:
virtual void g(){cout<<"a"<<endl;};
};
class Derived : public Base1, public Base2 {};
int main()
{
Derived d;
d.Base1::f();
return 0;
}
其虚表结构如下:
依旧可以使用g++ -fdump-lang-class your_code.cpp命令验证:
3.3 菱形继承下的虚函数表
菱形继承即多继承有着共同的父类,我们以下面例子来看,如果不是虚继承且f()在Bottom中没有实现,其直接使用b.f()就会报错,因为其两个父类中都有(来源于Top类),需要指定调用那个的,加上作用域;当然如果Bottom实现了的话就可以直接调用:
#include<iostream>
using namespace std;
class Top { // 顶层基类
public:
virtual void f() { cout << "Top::f()" << endl; }
int x;
};
class Middle1 : public Top { // 中间基类1
public:
virtual void g1() { cout << "Middle1::g1()" << endl; }
};
class Middle2 : public Top { // 中间基类2
public:
virtual void g2() { cout << "Middle2::g2()" << endl; }
};
class Bottom : public Middle1, public Middle2 { // 菱形顶点
public:
//void f() override { cout << "Bottom::f()" << endl; } // 重写Top::f
};
int main()
{
Bottom b;
b.Middle1::f();
return 0;
}
这个的结构如下,其同样是两个虚函数指针:
我们依旧使用g++ -fdump-lang-class your_code.cpp命令验证。
可以看到其中Top的存储存在重复且调用需要指定类名,这个可以通过虚继承解决,代码如下:
#include<iostream>
using namespace std;
class Top { // 顶层基类
public:
virtual void f() { cout << "Top::f()" << endl; }
int x;
};
class Middle1 : virtual public Top { // 中间基类1
public:
virtual void g1() { cout << "Middle1::g1()" << endl; }
};
class Middle2 : virtual public Top { // 中间基类2
public:
virtual void g2() { cout << "Middle2::g2()" << endl; }
};
class Bottom : public Middle1, public Middle2 { // 菱形顶点
public:
//void f() override { cout << "Bottom::f()" << endl; } // 重写Top::f
};
int main()
{
Bottom b;
b.f();
return 0;
}
这个我们直接通过g++ -fdump-lang-class your_code.cpp来看其效果。
4.虚函数调用流程
虚函数调用流程我们从编译期和运行期分别来看,使用简单代码来做分析:
#include<iostream>
using namespace std;
class Base {
public:
virtual void f() { cout << "Base::f()" << endl; } // 虚函数
int a;
};
class Derived : public Base {
public:
void f() override { cout << "Derived::f()" << endl; } // 重写虚函数
int b;
};
int main()
{
Base* b = new Derived();
b->f();
return 0;
}
1)编译期:编译期在处理Base类时,会给虚函数f()分配一个在虚表中的固定索引,派生类重写f()时会在自己的虚表中同一索引位置替换成自己的函数地址,也就是说,虚表在编译期就生成完成。
2)运行期:在创建对象时,其头部存放虚表指针,指向类对应的虚表,调用时通过指针找到虚表,根据索引找到函数地址,然后调用,我们来看一下其汇编代码:
g++ -S -fverbose-asm -g hhh.cpp -o test.s
# hhh.cpp:18: b->f();
.loc 1 18 10 # 源代码位置标记(第18行)
# 第一步:获取对象指针
movq -24(%rbp), %rax # 从栈帧偏移-24处加载对象指针b到rax寄存器
# 这里-24(%rbp)是局部变量b在栈上的存储位置
# rax现在持有Base*类型的对象地址
# 第二步:获取虚函数表指针(vptr)
movq (%rax), %rax # 解引用对象指针,获取虚表指针(vptr)
# 在C++对象内存布局中,vptr通常位于对象起始位置
# 现在rax保存的是该对象对应的虚函数表地址
# 第三步:获取虚函数地址
movq (%rax), %rdx # 解引用虚表指针,获取第一个虚函数地址
# 虚函数表是函数指针数组,首元素对应第一个虚函数f()
# rdx现在保存的是Base::f()的实际函数地址
# hhh.cpp:18: b->f();
.loc 1 18 9 # 源代码位置标记(同一条语句的另一种位置表示)
# 第四步:准备this指针参数
movq -24(%rbp), %rax # 再次加载对象指针到rax(可能因优化被重用)
movq %rax, %rdi # 将对象指针移动到rdi寄存器(x64调用约定的第一个参数)
# 在成员函数调用时,this指针作为隐式参数传递
# 第五步:执行虚函数调用
call *%rdx # 通过函数指针间接调用
# 实际会跳转到rdx存储的地址执行Base::f()
# 这是动态多态的核心机制
5.总结
本文对于动态多态机制进行了详细的分析,从对象模型到虚函数布局再到其调用流程,可以看到,其既通过中间层提供了良好的扩展机制,但也带来了部分的性能开销,这也是一些对性能要求极高的应用选择静态多态(比如模板)的原因。