文章目录
前言
这篇文章,小编会和大家一起探讨C++类对象多态的实现原理和其扩展问题。
- 注:本文章环境vs2022 x86环境下。每个编译器可能实现有所差异,但是殊途同归!
1. 现象及剖析
1.1 现象
例1:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func()
{
cout << "Base::func" << endl;
}
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
}
上面代码的运行结果是多少呢?
出乎意料的是:作为一个没有成员属性的类,这个类占有4个字节的空间。
下面我们打算来看看监视窗口:
现象:
声明了虚函数的类,其中多了一个字段,一个名为
_vfptr
的字段!
1.2 虚函数指针和虚函数表
_vfptr:
全称为:Virtual Function Pointer(虚函数指针)
其含义也不言而喻:指向虚函数(…)的一个指针。
来看例2:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
virtual void func3()
{
cout << "Base::func3" << endl;
}
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
上面代码在例1的基础上扩展了
func2
,func3
仍然打开监视窗口:
我们从监视窗口可以得到以下结论:
_vfptr
是一个指针。这个指针指向了一个指针数组。这个数组中的元素都是函数指针,每一个指针都指向的是被声明的虚函数。
这个数组的元素个数:当前被声明为虚函数的函数个数 + 1。在vs下最后一个元素是
nullptr
来标记结束。但是在g++编译器下并不是!!来看内存中:
下面正式给出定义:
_vfptr
:被称为虚函数表指针。这个指针是一个函数指针数组指针,指向的是虚函数表的首地址!指向的那个数组被称为:虚函数表。实际上里面存放就是:作为该类的虚函数方法的指针。如果是普通函数肯定不会进入该表。
通过以上的了解我们可以大致得到调用一个虚函数的过程:
(0. 指针或者引用。如果是对象调用:那么就采用会在编译期决定调用,后面验证)
- 找到虚函数表指针
_vfptr
。 - 通过虚函数指针找到虚函数表
_vftable
。 - 通过对应的信息,找到调用函数的指针,再调用函数!
至此,我们已经了解了多态的底层原理的前置知识,接下来让我们一起探讨多态到底是如何进行的:
1.3 多态实现
了解上面虚函数的调用过程后,我们是否可以设想一下,当完成重写的虚函数是如何完成多态的呢?
是否只需要将对应位置的函数指针的值修改为子类的虚函数是否就可以了?
考虑下面继承场景:
例3:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
virtual void func3()
{
cout << "Base::func3" << endl;
}
};
class Derived : public Base
{
public:
virtual void func1() //完成重写
{
cout << "Derived::func1" << endl;
}
virtual void func2()
{
cout << "Derived::func2" << endl;
}
};
int main()
{
Base b;
Derived d;
return 0;
}
注意:上面代码的派生类只重写了函数func1,和func2.
我们仍然通过监视窗口观察:
通过对比相同部分和不同部分,我们可以得到以下结论:- 基类和派生类的虚函数表不是同一张表。
- 派生类中重写的虚函数都替换了原来基类中的虚函数。
- 派生类中没有被重写的虚函数仍然是原来基类中的虚函数。
1.3.1 多态的两个条件剖析
至此我们再来看,类对象实现多态的两个条件:
基类的指针或者引用,指向派生类。
被调用的函数一定是虚函数,派生类必须对虚函数进行重写。
下面进行解析:
条件一剖析:
为什么需要基类的指针或者引用呢?根据上面的现象,我们得知:不同的类是有自己的虚函数表的!而指针和引用可以做到直接指向实体!
例4:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
virtual void func3()
{
cout << "Base::func3" << endl;
}
};
class Derived : public Base
{
public:
virtual void func1() //完成重写
{
cout << "Derived::func1" << endl;
}
virtual void func2()
{
cout << "Derived::func2" << endl;
}
};
int main()
{
Derived d;
Base* ptr = &d; //指针
//Base& ref = d; //引用
ptr->func1();
ptr->func2();
ptr->func3();
return 0;
}
上面代码给出了完整的示例
虚函数下的继承模型
来看存储模型:
Base:
Derived:
当发生基类指针或者引用指向派生类时:
那么很显然地:当我们使用Base
的指针(引用)的时候,由于赋值兼容的规则存在,我们的指针指向的那片实体中的_vfptr
仍然是我们Derived
的虚函数指针,所以采用虚函数调用的时候看见的虚函数就是Derived
的虚函数!!!
同理:当不同派生类重写虚函数的方法不同,虚函数表的内容就不同,那么同类型的指针看到的虚函数的方法就不同。这就产生了多态!!为什么不能是基类的对象呢?
- 如果采用基类的对象,那么就会导致:得到的对象中的虚函数表仍然是基类对象的。因为在发生这样的赋值兼容的时候,我们的基类会调用自己的构造函数,而虚函数表的初始化给对象是在构造时期完成。
- 我们不可能让编译器识别到我们是在用一个派生类赋值基类从而将派生类的虚函数表交给基类,这样会破坏本来的继承体系结构。
条件二剖析:
为什么要虚函数?
- 需要进入虚函数表!
为什么需要满足重写三同条件?
- 编译器需要一个标准来将对应基类中的虚函数表对应的函数指针替换为派生类的函数指针,这个标准就是由三同来决定的!
1.3.2 多态调用的消耗
实际上多态的调用不会在任何时候发生!
多态的调用是会影响运行时间的。消耗来源于:
- 查找虚函数指针
- 虚函数表
这些操作在汇编层面都是有开销的。所以编译期不会将任何的调用都会采用类似于多态的调用方式,这对性能是有负担的!
- 事实:编译器在采用类对象调用的时候,即使调用虚函数也不会采用类似于多态的调用方式。
不同调用方式的汇编对比
例5:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
void func3() //让func3成为一个普通函数
{
cout << "Base::func3" << endl;
}
};
class Derived : public Base
{
public:
virtual void func1() //完成重写
{
cout << "Derived::func1" << endl;
}
virtual void func2()
{
cout << "Derived::func2" << endl;
}
};
int main()
{
Derived d;
Base b = d;
Derived *ptr_d = &d; //d的指针
Base* ptr = &d; //指针
//对比一:对象调用虚函数
d.func1(); //d对象调用
b.func1(); //b对象调用
//对比二:指针调用虚函数
ptr_d->func1();
ptr->func1();
//对比三:指针调用普通函数
ptr_d->func3();
ptr->func3();
return 0;
}
来看如下的汇编代码:
事实:不管是子类还是父类的指针/引用调用虚函数的代价远远大于对象调用虚函数或者指针调用普通函数。
现象:能直接确定调用的函数编译器绝对不会采用多态的方式进行调用!
2. 虚函数表的存放位置
小编直接告诉大家:在VS下存放在常量区
下面我们将写一段代码进行验证!
我们采用的方式是直接打印虚函数指针指向的地址,然后打印该地址和各个区域的变量进行比较!
例6:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
};
class Derived : public Base
{
public:
virtual void func1() //完成重写
{
cout << "Derived::func1" << endl;
}
};
int global = 0;
int main()
{
Base b;
Derived d;
int _vftable_b = *(int*)&b; //拿到b对象的低四个字节的值,作为整型拿到
int _vftable_d = *(int*)&d;
printf("Base _vftable: %p\n", (void*)_vftable_b);
printf("Derived _vftable: %p\n\n", (void*)_vftable_d);
//从上往下
int stack = 0;
printf("Stack address: %p\n", &stack); //栈区
int *heap = new int(0);
printf("Heap address: %p\n", heap); //堆区
printf("Global address: %p\n", &global); //全局数据区
static int _static = 0;
printf("Static address: %p\n", &_static); //静态数据区
const char* str = "hello";
printf("char const address: %p\n", str); //字符常量区
printf("code address: %p\n", main); //代码区
delete heap;
return 0;
}
上面也是相当于介绍了如何查看每个区域的地址。
上面图片的结果告诉我们这个虚函数表的地址比较接近字符常量区。
我们便有理由相信:虚函数表被存放在字符常量区。
同时:上面的堆区地址空间貌似大于栈区地址空间,这个我们不必关系!
3. 拓展问题
3.1 派生类自己虚函数的存放位置
我们一定会好奇,如果派生类自己声明了一个基类没有的虚函数,那么这个虚函数被存放在哪里呢?
例7:
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
};
class Derived : public Base
{
public:
virtual void func1() //完成重写
{
cout << "Derived::func1" << endl;
}
virtual void func2()
{
cout << "Derived::func2" << endl;
}
virtual void func3() //func3是派生类自己声明的虚函数。
{
cout << "Derived::func3" << endl;
}
};
上面的func3会被存储到哪里呢?
下面我们进行验证:
续例7代码
typedef void(*fptr)(); //类型重命名—>利用虚函数都是同种类型
void print(fptr* table, int n)
{
for (int i = 0; i < n; ++i)
{
printf("func[%d]: %p say: ", i + 1, (void*)table[i]); //打印的地址为函数的地址
table[i](); //调用该函数
}
}
int main()
{
Base b;
Derived d;
//打印基类表:
printf("Base _vftable: %p\n", (void*)*(int*)&b);
print((fptr*)*(int*)&b, 2);
//(fptr*)*(int*)&b —> (int*)&b强转为int*,解引用拿到第四个字节,再强转为(fptr*)
//打印派生类表
printf("Derived _vftable: %p\n", (void*)*(int*)&d);
print((fptr*)*(int*)&d, 3);
return 0;
}
小编在测试的时候这个虚函数表出现了一些问题,所以小编采用直接传入个数。
运行结果:
结论:
- 基类和派生用不同的虚函数表。
- 派生类自己的虚函数会依序放在自己的虚函数表后面。
3.2 多继承下的虚函数的细节问题
接下来我们会探讨多继承下的虚函数问题,小编会根据现象抛出几个问题!
例8:
#include<iostream>
using namespace std;
class Base1
{
public:
virtual void func1()
{
cout << "Base1::func1" << endl;
}
virtual void func2()
{
cout << "Base1::func2" << endl;
}
};
class Base2
{
public:
virtual void func1()
{
cout << "Base2::func1" << endl;
}
virtual void func2()
{
cout << "Base2::func2" << endl;
}
};
class Derived : public Base1, public Base2
{
public:
virtual void func1() //完成重写
{
cout << "Derived::func1" << endl;
}
virtual void func3() //func3是派生类自己声明的虚函数。
{
cout << "Derived::func3" << endl;
}
};
int main()
{
Base1 b1;
Base2 b2;
Derived d;
return 0;
}
上面代码:
Derived
继承Base1
,Base2
,同时重写了函数func1
和声明定义自己的虚函数func3()
- 现象:
不再验证:多继承下派生类的虚函数表有多个且每个都是独立于基类的。
问题:
Derived::func1
为什么在Base1::_vftable
和Base2::_vftable
中的地址不同?难道这是两个函数吗?Derived::func3
函数没有在任何一个_vftable
中出现,它应该在哪一个_vftable
中呢?
验证:
我们仍然可以通过上面例7的方式进行验证
例9:
//前置命名继承例8
typedef void(*fptr)();
void print(fptr* table)
{
for (int i = 0; table[i] != nullptr; ++i)
{
printf("func[%d]: %p say: ", i + 1, (void*)table[i]);
table[i](); //调用该函数
}
}
int main()
{
Base1 b1;
Base2 b2;
Derived d;
//打印基类表1:
printf("Base _vftable: %p\n", (void*)*(int*)&b1);
print((fptr*)*(int*)&b1);
cout << endl;
//打印基类表2:
printf("Base _vftable: %p\n", (void*)*(int*)&b2);
print((fptr*)*(int*)&b2);
cout << endl;
//打印派生类表1:
printf("Derived _vftable1: %p\n", (void*)*(int*)&d);
print((fptr*)*(int*)&d);
//打印派生类表2:
printf("Derived _vftable2: %p\n", (void*)*(int*)(Base2*)&d);
print((fptr*)*(int*)(Base2*)&d);
return 0;
}
编译器(VS)个性化行为,小编在测试这里的时候,每一个虚函数表的最后一个位置都被设置为了
nullptr
方便了测试
结果:
结论:
Derived::func1
是一个函数并且只能是一个函数。为什么_vftable中的地址不同呢?(本来小编打算和大家看汇编的,但是编译期封装太厉害了,看不了一点,小编就口头叙述)是这样的:我们都是应该了解到
Base2
在Derived
中是有偏移量的。所以当使用Base2
指向一个Derived
对象的时候,实际上的调用该函数的时候采用的传入的this
指针是不恰当的,此时this
指针的地址是指向Base2
的,所以编译器会利用汇编调整Base2
的this
指针到正确的位置,这就导致了地址不同但是经过调整过后的地址是相同的。func3
在第一个虚函数表后面。因为这样不用找偏移量减少开销。
完。
- 希望这篇文章能够帮助到正在学习多态的你!!