C++基类指针调用子虚函数,为什么基类指针可以看到子类成员

C++ base class pointer calls child virtual function, why could base class pointer see child class member

本文关键字:基类 指针 成员 子类 调用 函数 C++ 为什么      更新时间:2023-10-16

我想我可能会让自己感到困惑。我知道C++中具有虚函数的类有一个 vtable(每个类类型一个 vtable),所以Base类的 vtable 将有一个元素&Base::print(),而Child类的 vtable 将有一个元素&Child::print()

当我声明我的两个类对象basechild时,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的对象。

如果DX的主要基数,则 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();

调用Baseprint()实现,因为指向的对象是类型Base

而这一行:

base_array[1]->print();

调用Childprint()实现,因为(是的,你猜对了)指向的对象是Child类型。 您不需要任何花哨的类型演员来实现这一目标。 无论如何,只要该方法被声明为virtual,它就会发生。

现在,在Base::print()体内,编译器不知道(或关心)this指向类型Base的对象还是类型Child的对象(或一般情况下从Base派生的任何其他类)。 因此,它只能访问由Base(或任何Base的父类,如果有的话)声明的数据成员。 一旦你理解了这一点,一切都很简单。

但是在Child::print()的主体中,编译器确实知道this指向什么 - 它必须是类Child(或从Child派生的其他类)的实例。 所以现在,编译器可以安全地访问value2- 在Child::print()体内- 因此您的示例可以正确编译。

我认为真的是这样。 仅当您通过编译时类型未知的指针调用该方法时,vtable 才会调度到正确的虚拟方法,就像您的示例代码确实在做的那样。(*)


(*) 嗯,差不多。 如今,优化编译器变得越来越时髦,实际上有足够的信息供您直接调用相关方法,但请不要以任何方式混淆问题。