C++基础之运行时类型识别RTTI

转载

这篇RTTI实现详解写得很好,转载备份,常温常新!

正文

在使用C++进行面向对象编程时,我们经常用到RTTI(Run Time Type Identification,运行时类型识别)。我们常常使用 typeid 判断某个对象的类型, dynamic_cast 动态转换对象的指针或引用类型。每次使用起来我们都大呼过瘾,这用起来确实非常方便。那么,RTTI到底是如何实现的呢?

本文将从typeid关键字___RTtypeid()运行时函数开始,逐渐衍生到RTTI的实现。已经熟悉typeid和___RTtypeid()的朋友可以跳过一、二部分。

(本文中所有的阐述均针对与MSVC编译器,其他编译器的一些实现会有不同)。

typeid关键字

我们都知道,编译器在生成程序的时候,会为每一个类型都生成一个唯一的 type_info 对象来描述对应的类型(必须启用“运行时类型信息”)。我们在程序中可使用typeid关键字来获取这些对象的常量引用。那么,typeid关键字是如何实现的呢?

通过查看typeid的反汇编代码,我们可以发现编译器对typeid关键字的实现。

设有如下代码:

class A{};
 
class B : public A{
public:
    virtual ~B() {}
};
 
class C : public B{};
    
C obj;
A & ref1 = obj;
B & ref2 = obj;
C & ref3 = obj;

分别对每一个变量,

使用typeid获取type_info时将会有如下几种情况:

(1)consttype_info & ti = typeid(C);

反汇编代码:

mov   dword ptr [ti],0E8D264h

 给typeid传入类型名称,编译器直接将type_info对象的地址赋给ti。

(2)const type_info & ti = typeid(obj);

反汇编代码:

mov    dword ptr [ti],0E8D264h

给typeid传入对象名称时,无论此类有没有继承关系,继承关系是否具有多态性,编译器直接将type_info对象的地址赋给ti。

(3)const type_info & ti = typeid(ref1);

反汇编代码:

mov    dword ptr [ti],0E8D264h

const type_info & ti = typeid(ref2);

反汇编代码:

mov    edx,dword ptr [ref2] 
push   edx 
call   ___RTtypeid (010D2300h) 
add    esp,4 
mov    dword ptr [ti],eax

 const type_info & ti = typeid(ref3);

反汇编代码:

mov    edx,dword ptr [ref3] 
push   edx 
call   ___RTtypeid (010D2300h) 
add    esp,4 
mov    dword ptr [ti],eax

当传入对象引用,编译器会看传入的引用的类型(是引用类型,不是对象的类型),看类和类上层的类,是否有虚函数(是否多态)。若有虚函数,调用运行时函数___RTtypeid(),否则将引用类型的type_info对象的地址赋给ti。

至此,我们已经清楚了编译器对typeid关键字的实现。在上面我们看到,在传入对象引用,并且引用类型或它的上层的类有虚函数时,编译器会将typeid关键字转到调用___RTtype()函数。那么___RTtype()函数如何获取type_info对象地址呢?

 ___RTtypeid()函数

通过上面的叙述,我们可以知道,只有在对象里有vfptr(指向类的虚函数表)的时候,编译器才会将typeid关键字转到___RTtypeid()运行时函数。由此可知,___RTtypeid()函数查找type_info对象地址需要vfptr。___RTtypeid()通过vfptr查找type_info对象的流程如下:

根据传入的对象地址,取到vfptr。MSVC将vfptr放在对象的起始位置,所以这里vfptr就等于对象的地址(补充:若有虚拟继承则不一样,虚拟继承第一个指针指向的是一个虚基类表,该表描述了每一个虚基类数据在对象中的偏移)。

取位于(vfptr-4)地址的值addr1(随便取个名字,便于表述),再取位于(addr1+12)地址的值,此值就是type_info对象的地址。

由此我们便可以确定一个对象对应的type_info的位置,如下图:

看到这里我们会奇怪,为什么图中addr1还要加12才是type_info的地址?看到这种情况我们首先想到的是addr1指向的是一个结构体或者是数组,而type_info对象的地址放在这个结构体或者数组的首地址向后偏移12bytes的位置。那么这到底是个什么东东?其实这是一个叫做RTTICompleteObjectLocator的结构体。它有什么用?接下来详细说明MSVC的RTTI的具体实现。

MSVC对RTTI的实现

通过前面的叙述,我们知道,一个类的虚函数表的前面4个字节(32位程序)保存了一个地址,这个地址指向一个RTTICompleteObjectLocator的实例。这个结构体有什么用?这便涉及到编译器对RTTI的实现。下面介绍编译器实现RTTI必须的几个结构体(PS:这些结构体的定义在WinCE的代码中都有定义,而关于DynamicCast的知识,详见这篇解读)。

RTTICompleteObjectLocator结构体

struct  RTTICompleteObjectLocator {
    DWORD    signature;  // 单词的意思是签名,但实际中似乎总是为0
    DWORD    offset; // 本部分相对于对象开始的偏移量(一般用来取完整对象的指针)
    DWORD    cdOffset; // <下面详细说明>
    TypeDescriptor     *pTypeDescriptor; // 指向一个type_info对象
    _RTTIClassHierarchyDescriptor    *pClassDescriptor; // 描述类的继承信息
};

顾名思义,这个结构体描述某类的一个完整的对象的内存布局情况。这个结构体针对的是类,比如类B派生自类A(A有虚函数),那么,编译器就会为类B专门生成一张针对于类A的虚函数表,与之同时,也会生成一个类B专门针对与继承类A的RTTICompleteObjectLocator的实例。举一反三,若是类B派生自多个类,那么也会有多张虚表,多个RTTICompleteObjectLocator实例,每一个都专门针对于一个基类。

cdOffset主要用于使用虚基类的时候。当子类重写了虚基类的虚函数时(至少一个,不算析构函数),编译器在构造对象的内存布局时会为每一个虚基类部分都生成一个叫做vtordisp的成员。那么这个vtordisp是干什么的呢?vtordisp记载了一个偏移,通过虚基类数据的起始地址和offset取对象的起始地址后,还需要加vtordisp的值,才是正确的对象起始地址。cdOffset指明了从虚基类数据起始地址向前(注意这里是向前)偏移多少位可以取到vtordisp。当cdOffset为0表明没有生成这个东西,此时就不用再加上vtordisp。由于这个字段容易产生混淆,而且难以理解,所以在后面的例子中我们都没有用vtordisp,cdOffset都是0。

(题外话:可以通过编译器选项/vd让编译器不生成这个隐藏字段。那么为什么要这个vtordisp?要他有什么意义?有兴趣的朋友可以在MSDN中搜索vtordisp。)

RTTIClassHierarchyDescriptor结构体

struct  RTTIClassHierarchyDescriptor {
    DWORD    signature;
    DWORD    attributes; // 属性,按位或
    DWORD    numBaseClasses; // 基类的个数
    _RTTIBaseClassArray    *pBaseClassArray; // 指向基类数组结构体的实例
};

这个结构体描述了一个类的派生情况,一个类对应唯一一个,不会根据基类个数而定。其中attribute字段为继承的属性,按位进行或运算,有如下三个位:

  • CHD_MULTINH=0x00000001,表示多继承。
  • CHD_VIRTINH=0x00000002,存在虚拟继承。
  • CHD_AMBIGUOUS=0x00000004,继承情况模糊不清。

值得特别说明一下的是CHD_AMBIGUOUS标志,何为模糊不清的?打个简单的比方,比如说B、C都继承A(不采用虚拟继承,虚拟继承的话此种情况不成立),D同时继承B和C,那么在一个D的对象中,就会包含两份类A的数据拷贝,便不明确到底此对象的类A的指针该取哪一个。此时CHD_AMBIGUOUS就会被设置。

RTTIBaseClassArray结构体

struct  RTTIBaseClassArray {
        _RTTIBaseClassDescriptor  *arrayOfBaseClassDescriptors[];
};

这个结构体包含一个成员,这个成员为一个RTTIBaseClassDescriptor指针数组,数组的长度由RTTIClassHierarchyDescriptor结构体的numBaseClasses字段指出。该数组的第一个元素描述的是该类自身

struct  RTTIBaseClassDescriptor {
    TypeDescriptor    *pTypeDescriptor; // 指向type_info对象
    DWORD    numContainedBases; // 该基类包含的其他基类的个数
    PMD    where; // 描述该基类的成员在对象中的位置
    DWORD    attributes; // 属性
};

numContainedBase为当前的基类包含的其他基类的个数(在数组中,从当前位置后的第一个位置开始)。打个比方,有类A,B,C,其中B继承A,C继承B,那么在类C的arrayOfBaseClassDescriptors[]数组长度为3。其中,第一个元素描述类C,numContainedBases为2,这就说明了C的对象的数据包含了从数组第二个元素开始往后的2个类的数据。以此类推,第二个元素描述类B,numContainedBases为1,第三个元素描述类A,numContainedBases为0。

attributes成员是一个按位或运算的值,表示属性,主要有6个位:

  • BCD_NOTVISIBLE=0x00000001,一般private和protected继承时设置此位。
  • BCD_AMBIGUOUS=0x00000002,与CHD_AMBIGUOUS一样。
  • BCD_PRIVORPROTINCOMPOBJ=0x00000004,一般private和protected继承时设置此位。
  • BCD_PRIVORPROTBASE=0x00000008,一般private和protected继承时设置此位。
  • BCD_VBOFCONTOBJ=0x00000010,为虚基类。
  • BCD_NONPOLYMORPHIC=0x00000020,非多态。

(补充:在WinCE的代码中只有这6位的定义,但是我在测试的时候发现,无论何种情况下,attributes的第7位都会备设置,这个位表示什么?难道其实它没有什么实际意义?或则设置一个这个东西有其他什么用途?希望知道的朋友告知在下一下,多谢。)

struct  PMD {
    ptrdiff_t mdisp;
    ptrdiff_t pdisp;
    ptrdiff_t vdisp;
};

这个结构体被包含在RTTIBaseClassDescriptor结构体中,用于描述对应的基类的数据在一个完整的对象中的位置。

  • mdisp,基类的数据开始位置相对于对象开始位置的偏移。据观察,虚基类的此字段一般为0,采用另外两个字段可取到准确的偏移。非虚基类采用此字段取偏移。
  • pdisp,虚基类表相对于对象开始位置的偏移。若类没有采用虚拟继承,则该类的对象也没有指向该类的虚基类表的指针,此时,pdisp一般为-1。
  • vdisp,若pdisp为非负,则说明该类有虚基类表。虚基类表记录了每一个虚基类的数据相对于对象开始位置的偏移。vdisp便记录了这一个虚基类对应虚基类表的那一项,其值为一个相对于虚基类表起始位置的偏移。我们通过对象的起始位置和pdsip找到虚基类表的位置,再通过虚基类表起始位置和vdisp找到该基类的数据的位置。

RTTI实例分析

上面讲了MSVC对于RTTI的实现。由于我对自己的写作水平确实不咋自信,可能有些地方表述的不是很明确。到目前位置,可能一些朋友还是云里雾里的。没关系,本节我们再来看一些实际情况,以加深我们的理解。(图中的箭头表示指针指向。拼图可花了不少功夫,自我感觉图还是能看懂,线条指向有点复杂混乱,需要仔细看。)

最基本的继承关系

class Base{
public:
    virtual ~Base() {}
};
 
class Derive : public Base {};
 
Base obj_base;
Derive obj_derive;

obj_base和obj_derive的内存情况图

此种情况,我分别定义了obj_base和obj_derive两个对象。目的在于说明以下几点:

        (1)每个类的每个虚表对应一个COL实例,每个类对应一个CHD。

        (2)两个拥有相同基类A的类,若它们继承A的情况是相同的(相同的继承类型、A的数据在它们的对象中的布局相同),则它们描述A的BCD是同一个实例。

多继承

class Base1{
public:
    virtual ~Base1() {}
};
 
class Base2{
public:
    virtual ~Base2() {}
};
 
class Derive : public Base1, public Base2{};
 
Derive obj_derive;

obj_derive的内存情况图

大家可以看到,在多继承的情况下,CHD结构的attributes的值为0x01,CHD_MULTINH标志位备设置,表明了此类的继承关系为多继承。

若有Base1* p1,Base2* p2,并将它们都指向obj_derive。假设p1=x,则p2=x+4。p1指向的地址为obj_derive对象的起始地址,p2指向的是obj_derive的起始地址向后偏移4bytes的地址。

若要根据p2找到obj_derive的起始地址,我们可以根据p2找到COL2,获取offset=4,p2减offset便是obj_derive的起始地址了。

可能有的朋友会奇怪COL2中有offset=4,Base2的基类描述结构BCD2中也有指明where.mdisp=4,这不是重复了吗?看一下WinCE的RTTI部分的DynamicCast的实现,我们就会明白。其实COL中的offset的用处一般为,根据传入的指针,找到完整的对象的起始地址。而BCD中的where的作用一般是,根据对象的起始地址,和要转换的目标类型的BCD中的where,求出目标的位置。这样说,相信应该更容易理解一些。

虚拟继承

class Base1{
public:
    virtual ~Base1() {}
};
class Base2{
public:
    virtual ~Base2() {}
};
class Derive : virtual public Base1, virtual public Base2{};
 
Derive obj_derive;

obj_derive的内存情况图

这种情况与情况2大体上相同,但我们仔细观看的话,有几个细节的地方不同,下面我们来逐一对比。

(1)情况2中的obj_derive对象比情况1中的obj_derive对象多了4个字节。情况2的obj_derive中多了一个vbptlPtr指针,该指针指向Derive类的虚基类表。

(2)情况2的COL2的offset变成了8,多了4个字节。

(3)情况2的CHD结构的attributes为0x03,CHD_MULTINH和CHD_VIRTINH标志位都被设置,表示Derive类为多继承,并且存在虚拟继承。

(4)情况2的BCD1的attributes为0x50,BCD_VBOFCONTOBJ被设置,表明Base1为Derive类的虚基类。BCD1的where.pdisp为0,表明,obj_derive中有虚基类表指针,并且该指针位于对象起始地址+0的地址。where.vdisp为4,说明在obj_derive中Base1部分的偏移值(相对于Base1起始地址),位于虚基类表起始地址+4的位置,在这里也就是4。所以obj_derive中Base1部分位于obj_derive起始地址+4的位置。

(5)和BCD1的描述一样,我们可以找到obj_derive中,Base2部分位于obj_derive起始地址+8的位置。

后记

大佬对照着代码讲解得非常清晰细致了,尤其是最后这几张图,对于初步了解RTTI的实现机制非常有帮助!感谢!

后续的心得体会待持续补充。。。

posted @ 2021-04-15 13:35  箐茗  阅读(423)  评论(1编辑  收藏  举报