虚拟表
为了实现虚函数,C ++使用了一种称为虚表的特殊形式的后期绑定。该虚拟表是用于解决在动态/后期绑定方式的函数调用函数的查找表。虚拟表格有时会以其他名称,如“vtable”,“虚拟功能表”,“虚拟方法表”或“调度表”。
因为了解虚拟表的工作原理并不需要使用虚拟功能,所以这部分可以被认为是可选的阅读。
虚拟表格其实很简单,尽管用文字描述有点复杂。首先,每个使用虚函数的类(或者从使用虚函数的类派生)都被赋予它自己的虚表。这个表只是编译器在编译时设置的一个静态数组。一个虚拟表包含一个可以被类的对象调用的虚拟函数的条目。这个表中的每个条目都只是一个函数指针,指向那个类可以访问的派生最多的函数。
其次,编译器还向基类添加了一个隐藏指针,我们将调用* __ vptr。* __ vptr在创建类实例时自动设置,以便指向该类的虚拟表。与* this指针不同,它实际上是编译器用来解析自引用的函数参数,* __ vptr是一个真正的指针。因此,它使每个类对象的大小分配一个指针的大小。这也意味着* __ vptr被派生类继承,这很重要。
到目前为止,您可能对这些东西如何融合在一起感到困惑,所以我们来看一个简单的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class Base
{
public:
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
virtual void function1() {};
};
class D2: public Base
{
public:
virtual void function2() {};
};
|
因为这里有3个类,所以编译器会设置3个虚拟表:一个用于Base,一个用于D1,另一个用于D2。
编译器还为使用虚函数的最基类添加了一个隐藏的指针。虽然编译器会自动执行此操作,但我们将把它放在下一个示例中,以显示它的添加位置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
class Base
{
public:
FunctionPointer *__vptr;
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
virtual void function1() {};
};
class D2: public Base
{
public:
virtual void function2() {};
};
|
创建类对象时,* __ vptr被设置为指向该类的虚拟表。例如,当创建Base类型的对象时,* __ vptr被设置为指向Base的虚拟表格。当构造D1或D2类型的对象时,* __ vptr被设置为分别指向D1或D2的虚拟表。
现在,我们来讨论一下这些虚拟表格是如何填充的。因为这里只有两个虚拟函数,每个虚拟表将有两个入口(一个用于函数1(),另一个用于函数2())。请记住,填写这些虚拟表时,每个条目都填写了该类类型的对象可以调用的派生最多的函数。
基础对象的虚拟表格很简单。Base类型的对象只能访问Base的成员。基地不能访问D1或D2功能。因此,函数1的条目指向Base :: function1(),函数2的条目指向Base :: function2()。
D1的虚拟表格稍微复杂一些。D1类型的对象可以访问D1和Base的成员。但是,D1重写了function1(),使得D1 :: function1()比Base :: function1()更加派生。因此,函数1的条目指向D1 :: function1()。D1没有重写function2(),所以函数2的入口将指向Base :: function2()。
D2的虚拟表与D1类似,除了函数1的条目指向Base :: function1(),并且函数2的条目指向D2 :: function2()。
这是一张图片:
虽然这张图看起来很疯狂,但实际上很简单:每个类中的* __ vptr都指向该类的虚拟表。虚拟表中的条目指向允许调用该类的函数对象的派生最多的版本。
所以考虑一下当我们创建一个D1类型的对象时会发生什么:
1
2
3
4
|
int main()
{
D1 d1;
}
|
因为d1是D1对象,所以d1将其* __ vptr设置为D1虚拟表。
现在,我们设置一个基地指针到D1:
1
2
3
4
5
|
int main()
{
D1 d1;
Base *dPtr = &d1;
}
|
请注意,因为dPtr是一个基址指针,所以它只指向d1的基本部分。但是,还要注意* __ vptr是在类的基本部分,所以dPtr有权访问这个指针。最后请注意,dPtr - > __ vptr指向D1虚拟表!因此,即使dPtr是Base类型,它仍然可以访问D1的虚拟表(通过__vptr)。
那么当我们尝试调用dPtr-> function1()时会发生什么呢?
1
2
3
4
5
6
|
int main()
{
D1 d1;
Base *dPtr = &d1;
dPtr->function1();
}
|
首先,程序认识到function1()是一个虚函数。其次,程序使用dPtr - > __ vptr去D1的虚拟表。第三,它查找在D1的虚拟表中调用哪个版本的function1()。这已被设置为D1 :: function1()。因此,dPtr-> function1()解析为D1 :: function1()!
现在,你可能会说:“但是如果Base真的指向一个Base对象而不是一个D1对象。它仍然会打电话给D1 :: function1()?“。答案是不。
1
2
3
4
5
6
|
int main()
{
Base b;
Base *bPtr = &b;
bPtr->function1();
}
|
在这种情况下,当创建b时,__vptr指向Base的虚拟表,而不是D1的虚拟表。因此,bPtr - > __ vptr也将指向Base的虚拟表。function1()的基本虚拟表项指向Base :: function1()。因此,bPtr-> function1()解析为Base :: function1(),它是Base1对象应该能够调用的function1()的派生版本。
通过使用这些表,编译器和程序能够确保函数调用解析到适当的虚拟函数,即使您只使用指针或对基类的引用!
调用虚拟函数比调用非虚函数要慢,原因如下:首先,我们必须使用* __ vptr来获取适当的虚拟表。其次,我们必须索引虚拟表来找到正确的调用函数。只有这样我们才能调用这个函数。因此,我们必须执行3个操作才能找到要调用的函数,而不是针对正常的间接函数调用的2个操作,或者针对直接函数调用的一个操作。但是,用现代计算机,这个额外的时间通常是微不足道的。
另外值得一提的是,任何使用虚拟函数的类都有一个__vptr,因此该类的每个对象都会被一个指针放大。虚拟功能是强大的,但他们确实有一个性能成本。