我想我可能会让自己感到困惑。我知道C++中具有虚函数的类有一个 vtable(每个类类型一个 vtable),所以Base
类的 vtable 将有一个元素&Base::print()
,而Child
类的 vtable 将有一个元素&Child::print()
。
当我声明我的两个类对象base
和child
时,base
的vtable_ptr将指向Base
类的 vtable,而child
的vtable_ptr将指向Child
类的 vtable。在我将 base 和 child 的地址分配给 Base 类型指针数组之后。我打电话给base_array[0]->print()
和base_array[1]->print()
.我的问题是,base_array[0]
和base_array[1]
都是Base*
类型,在运行时,尽管逆表查找将给出正确的函数指针,但Base*
类型如何看到类Child
中的元素?(基本上值 2?当我调用base_array[1]->print()
时,base_array[1]
是Base*
类型,但在运行时它发现它将使用类print()
Child
。但是,我很困惑为什么在此期间可以访问value2
,因为我正在使用类型Base*
.....我想我一定在某个地方错过了什么。
#include "iostream"
#include <string>
using namespace std;
class Base {
public:
int value;
string name;
Base(int _value, string _name) : value(_value),name(_name) {
}
virtual void print() {
cout << "name is " << name << " value is " << value << endl;
}
};
class Child : public Base{
public:
int value2;
Child(int _value, string _name, int _value2): Base(_value,_name), value2(_value2) {
}
virtual void print() {
cout << "name is " << name << " value is " << value << " value2 is " << value2 << endl;
}
};
int main()
{
Base base = Base(10,"base");
Child child = Child(11,"child",22);
Base* base_array[2];
base_array[0] = &base;
base_array[1] = &child;
base_array[0]->print();
base_array[1]->print();
return 0;
}
通过指针对print
的调用会执行 vtable 查找以确定要调用的实际函数。
该函数知道"this"参数的实际类型。
编译器还将插入代码以调整到参数的实际类型(假设您有类子级:
public base1, public base2 { void print(); };
其中print
是从base2
继承的虚拟成员。在这种情况下,相关的 vtable 将不会位于子项中的偏移量 0,因此需要进行调整以从存储的指针值转换为正确的对象位置)。
该修复所需的数据通常存储为隐藏运行时类型信息 (RTTI) 块的一部分。
我想我一定在某个地方错过了一些东西
是的,直到最后你都得到了大部分东西。
这里提醒我们C/C++中真正基本的东西(C和C++:相同的概念遗产,所以许多基本概念是共享的,即使细节在某些时候有很大的不同)。(这可能非常明显和简单,但值得大声说出来感受它。
表达式是编译程序的一部分,它们存在于编译时;对象存在于运行时。对象(事物)由表达式(单词)指定;它们在概念上是不同的。
在传统的 C/C++ 中,左值(左值的缩写)是一个表达式,其运行时计算指定了一个对象;取消引用指针会给出一个左值,(f.ex.*this
)。它被称为"左值",因为左侧的赋值运算符需要一个要赋值的对象。(但并非所有左值都可以位于赋值运算符的左侧:指定常量对象的表达式是左值,通常不能赋值。左值始终具有明确定义的标识,并且其中大多数具有地址(只有声明为位字段的结构的成员才能获取其地址,但底层存储对象仍然具有地址)。
(在现代C++中,左值概念被重新命名为glvalue,并发明了一个新的左值概念(而不是为新概念创建一个新术语,并保留对象概念的旧术语,其身份可能是可修改的,也可能是不可修改的。在我看来,这是一个严重的错误。
多态对象(具有至少一个虚函数的类类型的对象)的行为取决于其动态类型,即其开始构造的类型(开始构造数据成员或进入构造函数主体的对象的构造函数的名称)。在执行Child
构造函数的主体期间,Child
*this
设计的对象的动态类型(在执行基类构造函数的主体期间,动态类型是运行的基类构造函数的动态类型)。
动态多态意味着您可以使用具有左值的多态对象,该左值声明的类型(在编译时从语言规则推导出的类型)不完全相同的类型,而是相关类型(通过继承相关)。这就是 C++ 中虚拟关键字的全部意义,没有它,它将完全无用!
如果base_array[i]
包含对象的地址(因此其值定义良好,而不是 null),则可以取消引用它。这给了你一个左值,其声明的类型总是根据定义Base *
:这就是声明的类型,base_array
的声明是:
Base (*(base_array[2])); // extra, redundant parentheses
当然可以写
Base* base_array[2];
如果你想这样写,但解析树,编译器分解声明的方式不是
{Base*
}{base_array[2]
}
(使用粗体大括号象征性地表示解析)
而是相反
Base {* { {base_array
}[2]
}}
我希望你明白,这里的大括号是我对元语言的选择,而不是语言语法中用来定义类和函数的大括号(我不知道如何在这里的文本周围画框)。
作为一个初学者,重要的是你正确地"编程"你的直觉,总是像编译器一样阅读声明;如果你曾经在同一声明上声明两个标识符,区别很重要int * a, b;
意味着int (*a), b;
而不是int (*a), (*b);
(注意:即使OP可能很清楚,因为这显然是C++初学者感兴趣的问题,C/C++声明语法的提醒可能是其他人使用的。
因此,回到多态性问题:派生类型的对象(最近输入的构造函数的名称)可以由基类声明类型的左值指定。虚拟函数调用的行为由表达式指定的对象的动态类型(也称为实类型)决定,这与非虚函数调用的行为不同;这是C++标准定义的语义。
编译器获取语言标准定义的语义的方式是它自己的问题,而不是在语言标准中描述,但是当只有一个有效的简单方法时,所有编译器基本上都以相同的方式进行操作(细节是特定于编译器的)
- 每个多态类一个虚函数表 (">vtable")
- 每个多态对象一个指向 vtable (">VPTR") 的指针
(vtable和vptr显然都是实现概念,而不是语言概念,但它们是如此普遍,以至于每个C++程序员都知道它们。
vtable 是对类的多态方面的描述:对给定声明类型的表达式的运行时操作,其行为取决于动态类型。每个运行时操作都有一个条目。vtable 就像一个结构(记录),每个操作都有一个成员(条目)(所有条目通常是相同大小的指针,所以很多人将 vtable 描述为指针数组,但我没有,我将其描述为结构)。
vptr 是一个隐藏的数据成员(没有名称的数据成员,C++代码无法访问),它在对象中的位置像任何其他数据成员一样固定,当计算多态类类型的左值(为"声明类型"调用D)时,运行时代码可以读取该成员。取消引用 D 中的 vptr 会为您提供一个描述 D 左值的 vtable,其中包含D类型左值的每个运行时方面的条目。根据定义,vptr 的位置和 vtable 的解释(其条目的布局和使用)完全由声明的类型D决定。(显然,使用和解释 vptr 所需的任何信息都不能是对象的运行时类型的函数:当该类型未知时,将使用 vptr。
vptr 的语义是 vptr 上一组保证有效的运行时操作:如何取消引用 vptr(现有对象的 vptr 始终指向有效的 vtable)。它是表单的属性集:通过将偏移量添加到 vptr 值,您可以获得一个可以"以这种方式"使用的值。这些保证形成运行时协定。
多态对象最明显的运行时方面是调用虚函数,因此在vtable中有一个条目,用于 D lvalue 的每个虚拟函数,可以在D类型的左值上调用,即在该类或基类中声明的每个虚拟函数的条目(不计算覆盖器,因为它们是相同的)。所有非静态成员函数都有一个"隐藏"或"隐式"参数,即this
参数;编译时,它成为普通指针。
任何从 D 派生的类 X都将具有D左值的 vtable。为了在普通(非虚拟)单一继承的简单情况下提高效率,基类(然后我们称之为主基类)的 vptr 的语义将用新属性进行增强,因此X的 vtable 将得到增强:D的 vtable 的布局和语义将得到增强:用于 D的 vtable 的任何属性也是X的 vtable 的属性,语义将被"继承":Vtables 的"继承"与类中的继承并行
。从逻辑上讲,保证增加了:派生类对象的 vptr 的保证比基类对象的 vptr 的保证更强。因为它是更强的协定,所以为基本左值生成的所有代码仍然有效。
[在更复杂的继承中,要么是虚拟继承,要么是非虚拟继承 二次继承(在多重继承中,从二级基继承,即任何未定义为"主基"的基),基类的 vtable 语义的增强并不是那么简单。
[解释类实现C++一种方法是转换为 C(实际上,编译器的第一个C++编译是编译为 C,而不是汇编)。C++成员函数的转换只是一个 C 函数,其中隐式this
参数是显式的,一个普通的指针参数。
Dlvalue 的虚函数的 vtable 条目只是一个指向函数的指针,该函数的参数是现在显式的this
参数:该参数是指向 D 的指针,它实际上指向派生自 D 的类对象的 D 基子对象,或实际动态类型D的对象。
如果D是X的主要基数,则 V 表从与派生类相同的地址开始,并且 vtable 从同一地址开始,因此 vptr 值相同,并且 vptr 在主基和派生类之间共享。这意味着对X中以相同方式替换(使用相同的返回类型覆盖)的虚拟调用(通过 vtable 对 lvalue 的调用)只遵循相同的协议。
(虚拟覆盖程序可以具有不同的协变返回类型,在这种情况下可能会使用不同的调用约定。
还有其他特殊的 vtable 条目:
- 给定虚拟函数签名的多个虚拟调用条目(如果覆盖程序具有需要和调整的协变返回类型(不是主要基础)。
- 对于特殊虚函数:当
delete operator
在具有虚拟析构函数的多态基础上使用时,通过删除虚拟析构函数来完成,以调用正确的operator delete
(如果有,则替换删除)。 - 还有一个用于显式析构函数调用的非删除虚拟析构函数:
l.~D();
- vtables 存储每个虚拟基子对象的偏移量,以便隐式转换为虚拟基指针或访问其数据成员。
- 对于
dynamic_cast<void*>
,存在派生最多的对象的偏移量。 - 应用于多态对象(尤其是类的
name()
)的typeid
运算符的条目。 - 应用于多态对象的指针的
dynamic_cast<X*>
运算符有足够的信息,以便在运行时导航类层次结构,定位给定的基类或派生子对象(除非X
不仅仅是强制转换类型的基类,因为它没有动态导航层次结构)。
这只是对 vtable 中存在的信息和 vtable 种类的概述,还有其他微妙之处。(在实现级别,虚拟基础明显比非虚拟基础复杂。
我认为您可能会将指针的声明方式与它恰好指向的对象类型混淆。
暂时忘记 vtables 吧。 它们是实现细节。 它们只是达到目的的一种手段。 让我们看看你的代码实际在做什么。
因此,参考您发布的代码,此行:
base_array[0]->print();
调用Base
的print()
实现,因为指向的对象是类型Base
。
而这一行:
base_array[1]->print();
调用Child
的print()
实现,因为(是的,你猜对了)指向的对象是Child
类型。 您不需要任何花哨的类型演员来实现这一目标。 无论如何,只要该方法被声明为virtual
,它就会发生。
现在,在Base::print()
体内,编译器不知道(或关心)this
指向类型Base
的对象还是类型Child
的对象(或一般情况下从Base
派生的任何其他类)。 因此,它只能访问由Base
(或任何Base
的父类,如果有的话)声明的数据成员。 一旦你理解了这一点,一切都很简单。
但是在Child::print()
的主体中,编译器确实知道this
指向什么 - 它必须是类Child
(或从Child
派生的其他类)的实例。 所以现在,编译器可以安全地访问value2
- 在Child::print()
体内- 因此您的示例可以正确编译。
我认为真的是这样。 仅当您通过编译时类型未知的指针调用该方法时,vtable 才会调度到正确的虚拟方法,就像您的示例代码确实在做的那样。(*)
(*) 嗯,差不多。 如今,优化编译器变得越来越时髦,实际上有足够的信息供您直接调用相关方法,但请不要以任何方式混淆问题。