考虑以下程序:
#include <iostream>
#include <string_view>
class Base
{
public:
std::string_view getName() const { return "Base"; } // 非虚函数
virtual std::string_view getNameVirtual() const { return "Base"; } // 虚函数
};
class Derived: public Base
{
public:
std::string_view getName() const { return "Derived"; }
virtual std::string_view getNameVirtual() const override { return "Derived"; }
};
int main()
{
Derived derived {};
Base& base { derived };
std::cout << "base has static type " << base.getName() << '\n';
std::cout << "base has dynamic type " << base.getNameVirtual() << '\n';
return 0;
}
首先,我们来看对 base.getName()
的调用。由于这是一个非虚函数,编译器可以使用 base
的实际类型(Base
)来确定(在编译时)这应该解析为 Base::getName()
。
尽管它看起来几乎相同,但对 base.getNameVirtual()
的调用必须以不同的方式解析。由于这是一个虚函数调用,编译器必须使用 base
的动态类型来解析调用,而 base
的动态类型直到运行时才可知。因此,只有在运行时才会确定这个特定的 base.getNameVirtual()
调用解析为 Derived::getNameVirtual()
,而不是 Base::getNameVirtual()
。
那么虚函数到底是如何工作的呢?
虚表
C++ 标准没有指定虚函数应该如何实现(这一细节留给实现者自行决定)。
然而,C++ 实现通常使用一种称为 虚表 的晚期绑定形式来实现虚函数。
虚表是一个用于以动态/晚期绑定方式解析函数调用的查找表。虚表有时也被称为“vtable”、“虚函数表”、“虚方法表”或“调度表”。在 C++ 中,虚函数解析有时被称为动态调度。
术语
这里有一个更简单的方法来理解 C++ 中的这些概念:
- 早期绑定/静态调度 = 直接函数调用重载解析
- 晚期绑定 = 间接函数调用解析
- 动态调度 = 虚函数重写解析
由于了解虚表的工作原理并非使用虚函数的必要条件,因此可以将本节视为选读内容。
虚表实际上相当简单,尽管用文字描述它有点复杂。首先,每个使用虚函数的类(或从使用虚函数的类派生的类)都有一个对应的虚表。这个表只是一个编译器在编译时设置的静态数组。虚表为类对象可以调用的每个虚函数包含一个条目。表中的每个条目只是一个函数指针,指向该类可以访问的最派生函数。
其次,编译器还会在使用虚函数的最基类中添加一个隐藏的指针成员,我们将其称为 __vptr
。__vptr
在类对象创建时(自动)设置,以便它指向该类的虚表。与 this
指针不同(this
指针实际上是编译器用于解析自引用的函数参数),__vptr
是一个真正的指针成员。因此,它会使每个类对象分配的大小增加一个指针的大小。这也意味着 __vptr
会被派生类继承,这是很重要的。
到目前为止,你可能对这些内容如何组合在一起感到困惑,因此我们来看一个简单的例子:
class Base
{
public:
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
void function1() override {};
};
class D2: public Base
{
public:
void function2() override {};
};
由于这里有 3 个类,编译器将设置 3 个虚表:一个用于 Base
,一个用于 D1
,一个用于 D2
。
编译器还会在使用虚函数的最基类中添加一个隐藏的指针成员。尽管编译器会自动完成这一操作,但我们在下一个例子中将其加入,只是为了展示它被添加的位置:
class Base
{
public:
VirtualTable* __vptr;
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
void function1() override {};
};
class D2: public Base
{
public:
void function2() override {};
};
当创建类对象时,__vptr
被设置为指向该类的虚表。例如,当创建一个 Base
类型的对象时,__vptr
被设置为指向 Base
的虚表。当创建 D1
或 D2
类型的对象时,__vptr
被设置为分别指向 D1
或 D2
的虚表。
现在,我们来谈谈这些虚表是如何填充的。由于这里只有两个虚函数,每个虚表将有两个条目(一个用于 function1()
,一个用于 function2()
)。记住,当这些虚表被填充时,每个条目都用该类类型的对象可以调用的最派生函数填充。
Base
对象的虚表很简单。Base
类型的对象只能访问 Base
的成员。Base
无法访问 D1
或 D2
的函数。因此,function1
的条目指向 Base::function1()
,function2
的条目指向 Base::function2()
。
D1
的虚表稍微复杂一些。D1
类型的对象可以访问 D1
和 Base
的成员。然而,D1
重写了 function1()
,使 D1::function1()
比 Base::function1()
更派生。因此,function1
的条目指向 D1::function1()
。D1
没有重写 function2()
,因此 function2
的条目将指向 Base::function2()
。
D2
的虚表与 D1
类似,只是 function1
的条目指向 Base::function1()
,function2
的条目指向 D2::function2()
。
图形化表示:
虽然这个图表看起来有点复杂,但实际上它非常简单:每个类的 __vptr
指向该类的虚表。虚表中的条目指向该类的对象可以调用的最派生版本的函数。
那么,当我们创建一个 D1
类型的对象时会发生什么:
int main()
{
D1 d1 {};
}
由于 d1
是一个 D1
类型的对象,d1
的 __vptr
被设置为指向 D1
的虚表。
现在,我们用基类指针指向 D1
:
int main()
{
D1 d1 {};
Base* dPtr = &d1;
return 0;
}
请注意,由于 dPtr
是一个基类指针,它只指向 d1
的 Base
部分。然而,还要注意 __vptr
在类的 Base
部分中,因此 dPtr
可以访问这个指针。最后,要注意 dPtr->__vptr
指向 D1
的虚表!因此,即使 dPtr
是 Base*
类型,它仍然可以通过 __vptr
访问 D1
的虚表。
那么,当我们尝试调用 dPtr->function1()
时会发生什么?
int main()
{
D1 d1 {};
Base* dPtr = &d1;
dPtr->function1();
return 0;
}
首先,程序识别出 function1()
是一个虚函数。其次,程序使用 dPtr->__vptr
来获取 D1
的虚表。第三,在 D1
的虚表中查找要调用的 function1()
的版本。这被设置为 D1::function1()
。因此,dPtr->function1()
解析为 D1::function1()
!
现在,你可能会问:“但如果 dPtr
真的指向一个 Base
对象而不是 D1
对象呢?它还会调用 D1::function1()
吗?”答案是不会。
int main()
{
Base b {};
Base* bPtr = &b;
bPtr->function1();
return 0;
}
在这种情况下,当创建 b
时,b.__vptr
指向 Base
的虚表,而不是 D1
的虚表。由于 bPtr
指向 b
,bPtr->__vptr
也指向 Base
的虚表。Base
的虚表条目 function1()
指向 Base::function1()
。因此,bPtr->function1()
解析为 Base::function1()
,这是 Base
对象可以调用的 function1()
的最派生版本。
通过使用这些表,编译器和程序能够确保函数调用解析到适当的虚函数,即使你只使用基类的指针或引用!
调用虚函数比调用非虚函数慢,原因有几点:首先,我们需要使用 __vptr
来获取适当的虚表。其次,我们需要索引虚表以找到要调用的正确函数。只有这样,我们才能调用该函数。因此,我们需要执行 3 个操作来找到要调用的函数,而正常的间接函数调用需要 2 个操作,直接函数调用只需要 1 个操作。然而,对于现代计算机来说,这种额外的时间通常是微不足道的。
另外,需要提醒的是,使用虚函数的任何类都有一个 *__vptr
,因此该类的每个对象都会因一个指针而变大。虚函数功能强大,但它们确实有性能成本。