zl程序教程

您现在的位置是:首页 >  其他

当前栏目

C++|对象模型|对象模型综述

2023-03-15 22:02:35 时间

作为C++的核心单元,对象模型在编译器眼中是如何实现的?本文从几个基本理论模型出发,剖析实际。

深度探索C++对象模型


简单对象模型

对象存放若干slots,由slot指向实际成员。members按声明顺序

简单对象模型

优点:对象大小等于指针数*成员数,并且取出成员时,只需要根据slot索引获取指针即可。编译器实现简单。

缺点:存在间接层,时空效率降低。

Extension:

尽管简单对象模型没有用于编译器中,但其slot思想应用在指向类成员的指针中。

简单地说,简单对象模型将地址slot的获取定位于object level,而成员指针对地址slot的获取则定位于class level。

Pointer to Data Member

实际使用中,指向类数据成员的指针用法如下。取static data的地址会是其真实地址,而non-static data的地址则会是offset,需要绑定到具体object上才会得到真实地址。

在实际对象模型中,正如常识一样,对象只存有non-static data/vptr,而对non-static data的存取,则通过object起始地址+成员偏移量来获取。由于成员偏移量编译器已知,优化后实际上和直接存取同类型变量效率相同。

假设存在三维Point3D类,其中依次存在x,y,z三个float成员,那么

Point3D::* pmd = & Point3D::z

会是多少呢?答案是z坐标在对象中的偏移量。如果假设vptr不在类首,前面应该有至少8个byte。

最初的实现中,为了区分指向第一个data member的指针和没有指向data member的指针(均为0),真正的data member指针的偏移量都被加上1,用于区分。侯捷在VC++中进行了测试,偏移量并没有增加,很有可能VC++编译器对于其采用了另一种特殊处理。

实际使用中,指向类数据成员的指针用法如下。取static data的地址会是其真实地址,而non-static data的地址则会是offset,需要绑定到具体object上才会得到真实地址。

Point3D point;
point.*pmd == point.z

多重继承情况下,编译器还需要对offset进行调整,填补上BaseClass导致的额外offset量。

Pointer to Function Member

实际使用中,指向类函数成员的指针用法如下。取non-virtual function的地址会是其真实地址,而virtual function的地址则会是其在虚表中的索引,需要绑定到具体object上才能通过虚表查询其真实地址。

对于函数成员来说,情况有所不同。

对于nonvirtual函数而言,函数地址显然是确定的。而对于virtual函数而言,其地址编译器未知。

void (Point::*pmf)() = & Point3D::virtualfunc;

这又会是多少呢,答案是,虚表索引。编译期间

Point*ptr=new Point3d;
(ptr->*pmf)();
//将会被转换为(*ptr->vptr[int(pmf)])(ptr);

然而,问题在于,pmf并不一定只能指向虚函数,也可以指向非虚函数,如何寻址?

Bjarne Stoustrup设计了几种方案[LIPP88],第一种存在普通指针内,限定了虚表大小,规定值小于128即为虚函数索引。第二种,他设计了一个结构体,其中index在不存在时会设置为-1作为哨兵,此时则会直接查询faddr:

struct _mptr{
int delta;//this指针的offset值(应对多重继承情况)
int index;//虚表索引
union{
ptrtofunc faddr;//非虚函数地址
int v_offset;//虚基类虚表指针地址(应对虚拟继承情况)
}

代价很明显:每次调用必须付出检查成本。每次传递这个指针,都必须产生较大的临时对象。

为了减少上述调用成本,VC++引入了vcall thunk,thunk将非虚函数地址改为存放指向一小段代码的指针,这一段代码用来选出并调用虚函数。faddr引入的是成员函数地址或者vcall thunk的地址。

此外,由于部分字段只在多重继承或虚继承的情况下有用,不同情况下,指向成员函数的指针可以有着不同实例,VC++提供了

  1. 单一继承实例(vcall thunk or faddr)
  2. 多重继承实例(vcall thunk or faddr+delta)
  3. 虚拟继承实例(上述四个member)

表驱动对象模型

为了对class的所有object有一致表示方式,另一种对象模型是将所有member相关信息抽离。数据成员表存储数据本身,而函数成员表是一系列slots,指向成员函数。object本身仅仅含有两个指针,指向成员表。

表驱动对象模型

这个模型的function部分可以看做在上面的简单对象模型基础上再增加了一层间接性,因此被称作双表格模型。IBM的系统对象模型SOM也依赖于这种模型。

优点:函数表作为整体被分离出类,而内部的指针又提供了灵活性,为其成为虚表打下基础

缺点:过度设计导致间接层暴增。

Extension:

尽管表驱动对象模型没有用于编译器中,但其member function table思想应用在virtual function中,并成为了主流实现。

事实上,函数和数据均可以通过表驱动进行实现,前者为虚函数,后者为虚基类。

Virtual Table with Virtual Base Class:

下列方法都只是实现模型,而不是标准。归根到底是为了解决虚基类位置变化导致的问题。 1.为object的每个虚基类加上指针 2.导入virtual base class table 3.扩充virtual table

虚基类,表现为菱形式的继承体系,其中菱形的顶部需要被实现为共享。

一个古老的实现方法是,在每一个派生类对象中存放一个虚基类指针而非传统对象模型中的基类对象本身,对虚基类的访问通过指针间接实现,以此实现共享。

然而,这种实现方法有很多缺陷:

  1. 对象为每个虚基类背负一个额外的指针,而理想上空间负担应该不随虚基类数目变化。
  2. 虚拟继承链的延伸导致间接层的增加,而理想上时间负担应该不随虚拟派生深度变化。

对于问题二:

编译器使用拷贝操作获得所有嵌套虚基类的指针存至派生类,以空间换时间,从而解决了固定存取时间的问题。

而对于问题一:

VC++引入virtual base class table,当一个类出现多个虚基类,将会引入一层额外的间接层,由指针指向该表,而该表的slot指向真正的虚基类。

Bjarne Stoustrup采用的方式是,在virtual function table中存储虚基类的offset。使得虚基类的内存结构依然和传统的继承一致。如下图所示:派生类都可以通过vptr获取offset,从而正确地指向虚基类的地址。

作者的实现策略是:虚表正值索引至virtual function entry,而负值索引至virtual base class offset。代价则是对于member的存取较为昂贵。但是,如果是通过对象本身进行访问,而不通过指针多态机制,则可以在编译期间决议,无需代价,这一点和虚函数类似。

然而,上述方法都只是实现模型,而不是标准。归根到底是为了解决虚基类位置变化导致的问题。为语法苦恼的应该是C++编译器作者,而不是程序员。

一般而言,为了避免上述困扰,推荐的方法是面向接口(类型)编程,即一个没有data member的虚基类。

Virtual Table with Virtual Function:

一般的虚函数实现模型为,每一个类有一个虚表,虚表中存放虚函数地址,每一个对象有一个虚表指针,指向虚表。然而单一继承,多重继承,虚拟继承细节上又有不同。

为了支持虚函数机制,必须有执行期的类型判断方法,因此,必须存在两个信息:

  1. 对象地址
  2. 对象的typeinfo或是某个结构(其存储信息,用以正确决议虚函数实例)的地址

如果把额外信息存放在指针中,会导致与C不兼容,并且明显增加了空间负担。因此应当存在对象中,而且仅当class真正需要时才存在。

多态:以public base class的指针或引用,寻址出一个derived class object

问题1:什么样的类需求此信息

答:定义虚函数的类

问题2:这样的类需求什么样的信息

答:对象的类型/虚函数实例的地址

实现:typeinfo/虚表指针

虚表作为class的一员不参与object级的多态(不懂请把OOP抄一万遍),因此编译期即可决议虚函数的地址。然而这只是解答的一半。另一半是object如何在执行期找到地址。因此,每一个object被安插vptr指向虚表,每一个virtual function被指派一个slot,而虚表的第一个slot处存储typeinfo。

执行期所做的,只是在virtual table slot中激活virtual function,它们包括

  • class定义的实例(可能override)->override:覆盖原slot/new:虚表增大slot
  • 继承自base class的实例(不override时的情况)->拷贝函数实例地址
  • pure virtual called函数实例(占用纯虚函数空间/执行期处理纯虚函数调用的异常)

单一继承下:虚表机制十分友好,效率高,并且模型容易塑造。

多重继承下:复杂度的问题在于this指针必须在执行期间调整,以正确获取vptr。

例如,如果Derived继承自Base1和Base2基类,temp为已知Derived指针。

  • 在派生类指针赋值给基类指针时编译器需要调整 Base2 * pbase2 =temp ? temp + sizeof (Base1):0; 目的是防止temp==nullptr时,仍然出现偏移。
  • 而在基类指针调用派生类重写的虚函数时,则需要反过来调整this指针(由编译器插入或者thunk引入),从而正确指向对应的虚表。
  • 函数较小时,产生两个函数,根据调用的指针类别判断是否需要调用有调整的函数
  • 函数较大时,产生多重进入点,函数体分为(1)调整this (2)执行自定义函数码,根据是否需要调整,通过thunks跳转至对应的进入点

虚拟继承下:在虚继承体系单层时,通过上文提及的虚基类寻址处理,还是可以正确地调整this指针,然而涉及虚基类继承虚基类时,并且都支持virtual function和nonstatic data member时(共同特点:Object Level,如果你记忆好的话,上一节提到的成员指针中,这两者的共同之处在于必须绑定至对象才可决议),Stanley表示

我有一个柜子的答案,有一个以上的算法可以决定offset和各种调整,然而这些素材太过迷离,不适合在此讨论。(费马既视感)我的建议是,virtual base class坚决不要有nonstatic data member。


C++对象模型

上述模型的Extension部分其实已经涵盖了部分对象模型的静态结构,而对象模型的生成与维护则更多见原书中的一系列章节。

Bjarne Stroustrup设计的C++对象模型从简单对象模型派生而来,对内存空间和存取时间做了优化。

Class Level

  • static data member
  • static non-virtual function member
  • non-static non-virtual function member
  • virtual table(virtual base class offset+ virtual function entry+type info)

Object Level

  • virtual pointer to virtual table
  • non-static data member

继承体系

虚继承体系间接存储,普通继承体系直接存储。