首先需要注意的是免责声明:标准并不保证这一切。标准规定了代码应该长什么样子,应该如何工作,但并没有具体说明编译器需要如何实现它。
话虽如此,所有的C++编译器在这方面都非常相似。
所以,让我们从非虚函数开始。它们分为两类:静态和非静态。
其中简单的是静态成员函数。静态成员函数几乎就像是一个类的友元全局函数,只不过还需要类名作为函数名的前缀。
非静态成员函数稍微复杂一些。它们仍然是直接调用的普通函数,但是会传递一个指向调用它们的对象实例的隐藏指针。在函数内部,您可以使用关键字“this”来引用该实例数据。因此,当您调用像a.func(b)这样的东西时,生成的代码与您为func(a, b)获得的代码非常相似。
现在让我们考虑虚函数。这里涉及到虚表和虚表指针。我们有足够多的间接性,可能最好画一些图来看看它们是如何布局的。这里几乎是最简单的情况:一个类的一个实例有两个虚函数。
![enter image description here](https://istack.dev59.com/liUDr.webp)
因此,对象包含其数据和指向vtable的指针。vtable包含该类定义的每个虚函数的指针。然而,为什么我们需要这么多间接性可能不会立即显而易见。为了理解这一点,让我们看一下稍微复杂一些的情况:该类的两个实例:
![enter image description here](https://istack.dev59.com/AJsbt.webp)
请注意每个类的实例都有自己的数据,但它们共享同一个虚表和相同的代码 - 如果我们有更多的实例,它们仍然会在所有同一类实例之间共享同一个虚表。
现在,让我们考虑派生/继承。例如,让我们将现有的类重命名为“Base”,并添加一个派生类。由于我感到有创意,我会将其命名为“Derived”。与上面一样,基类定义了两个虚函数。派生类覆盖其中一个(但不是另一个):
![enter image description here](https://istack.dev59.com/jjQ23.webp)
当然,我们可以将两者结合起来,拥有多个基类和/或派生类的实例:
![enter image description here](https://istack.dev59.com/Sp6hZ.webp)
现在让我们更深入地探讨一下。派生的有趣之处在于,我们可以将指向派生类对象的指针/引用传递给一个函数,该函数被编写为接收指向基类的指针/引用--但如果调用虚函数,则会得到实际类的版本,而不是基类的版本。那么,这是如何工作的呢?我们如何将派生类的实例视为基类的实例,并使其正常工作?为了做到这一点,每个派生对象都有一个“基类子对象”。例如,让我们考虑以下代码:
struct simple_base {
int a;
};
struct simple_derived : public simple_base {
int b;
};
在这种情况下,当您创建一个
simple_derived
的实例时,您将获得一个包含两个
int
的对象:
a
和
b
。
a
(基类部分)位于内存中的对象开头,而
b
(派生类部分)紧随其后。因此,如果将对象的地址传递给期望基类实例的函数,则该函数仅使用存在于基类中的部分,编译器将它们放置在与基类对象中相同的偏移量处,因此函数可以操纵它们,甚至不知道它正在处理派生类的对象。同样,如果调用虚函数,所有它需要知道的就是虚表指针的位置。就它而言,像
Base::func1
这样的东西基本上只意味着它跟随虚表指针,然后使用从那里的某个指定偏移量(例如第四个函数指针)的函数指针。
至少目前为止,我将忽略多重继承。它给图景增加了相当多的复杂性(特别是涉及到虚继承时),而且您根本没有提到它,所以我怀疑您真的关心吗。
至于访问任何这些内容或以任何方式使用它们而不仅仅是调用虚函数:您可能能够为特定编译器想出一些东西,但不要指望它具有可移植性。尽管像调试器这样的东西经常需要查看这些东西,但涉及的代码往往非常脆弱且特定于编译器。