C++对象模型——Virtual Member Functions (虚拟成员函数)(第四章)

4.2 Virtual Member Functions (虚拟成员函数)

已经看过了 virtual function的一般实现模型:每一个 class 有一个 virtual table,内含该 class 中有作用的 virtual function的地址,然后每个object有一个vptr,指向 virtual table的所在.

为了支持 virtual function机制,必须首先能够对多态对象有某种形式的"执行期类型判断法(runtime type resolution)".也就是说,以下的函数调用操作将需要ptr在执行期的某些相关信息:

ptr->z();

如此一来才能够找到并调用z()的适当实体.

或许最直接了当但是成本最高的解决办法就是把必要的信息加在ptr上.在这样的策略下,一个指针(或者是一个reference)含有两项信息:

1.
它所参考到的对象的地址(也就是当前它所含有的东西);

2.
对象类型的某种编码,或是某种结构(内含某些信息,用以正确决议出z()函数实例)的地址

这个方法带来两个问题,第一,它明显增加了空间负担,即使程序并不使用多态(polymorphism);第二,它打断了与C程序间的链接兼容性.

如果这份额外信息不能够和指针放在一起,下一个可以考虑的地方就是把它放在对象本身.但是哪一个对象真正需要这些信息呢?应该把这些信息放进可能被继承的每一个聚合体上吗?或许把!但考虑以下这样的C struct 声明:

struct date {
	int m, d, y;
};

严格地说,这符合上述规范,然而事实上它并不需要那些信息,加上那些信息将使C struct 膨胀并且打破链接兼容性,却没有带来任何明显的补偿利益.

或许只有面对那些明确使用了 class 关键字的声明,才应该加上额外的执行期信息,这么做就可以保留语言的兼容性了.不过仍然不是一个聪明的策略.举个例子,下面的 class 符合新规范:

class date {
public:
	int m, d, y;
};

但实际上它并不需要那份信息,下面的 class 声明虽然不符合新规范,却需要那份信息:

struct geom {
public:
	virtual ~geom();
};

因此,需要一个更好的规范,一个"以class的使用为基础,而不在乎关键词是class或struct(1.2节)"的规范.如果 class 真正需要那份信息,它就会存在;如果不需要,它就不存在.那么,到底何时才需要这份信息?很明显是在必须支持某种形式的"执行期多态(runtime polymorphism)"的时候.

在C++中,多态(polymorphism)表示"以一个public base class的指针(或reference),寻址出一个derived object"的意思.例如下面的声明:

Point *ptr;

可以指定ptr以寻址出一个Point2d对象:

ptr = new Point2d;

或是一个Point3d对象:

ptr = new Point3d;

ptr的多态性能主要扮演一个输送机制(transport mechanism)的角色,经由它,可以在程序的任何地方采用一组 public derived类型.这种多态形式被称为是消极的(passive),可以在编译时期完成——virtual base class 的情况除外.

当被指出的对象真正被使用时,多态就变成积极的(active).下面对于 virtual function的调用,就是一例子:

// "积极多态(active polymorphism)"的常见例子
ptr->z();

在runtime type identification(RTTI)性质于1993年被引入C++语言之前,C++对"积极多态(active polymorphism)"的唯一支持,就是对于 virtual function call的决议(resolution)操作.有了RTTI,就能够在执行期间查询一个多态的pointer或多态的reference.

// "积极多态(active polymorphism)"的第二个例子
if (Point3d *p3d = dynamic_cast<Point3d *>(ptr))
	return p3d->_z;

所以,问题已经被区分出来,那就是:欲鉴定哪些 class 展现多态特性,需要额外的执行期信息.关键词 class 和 struct 并不能够帮助这点.由于没有导入如polymorphism之类的新关键词,因此识别一个 class 是否支持多态,唯一适当的方法就是看看它是否有任何
virtual function.只要 class 拥有一个 virtual function,它就需要这份额外的执行期信息.

下一个明显的问题是,什么样的额外信息是需要存储的?也就是说,如果有这样的调用:

ptr->z();

其中z()是一个 virtual function,那么什么信息才能使得在执行期调用正确的z()实体?需要知道:

    ptr所指对象的真实类型,这可使的选择正确的z()实体

z()实体位置,以便能够调用它

在实现上,首先在每一个多态的 class object上增加两个members:

1.一个字符串或数字,表示 class 的类型.

2.
一个指针,指向某表格,表格中带有程序的 virtual functions的执行期地址.

表格中的 virtual functions地址如何被建构起来?在C++中, virtual functions(可经由其 class object被调用)可以在编译时期获知,此外,这一组地址是固定不变的,执行期不可能新增或替换.由于程序执行时,表格的大小和内容都不会改变,所以其建构和存取皆可以由编译器完全掌控,不需要执行期的任何介入.

然而,执行期备妥那些函数地址,只是解答的一半而已,另一半解答是找到那些地址,以下两个步骤可以完成这项任务:

    1.为了找到表格,每一个 class object被插入一个由编译器内部产生的指针,指向该表格.

2.
为了找到函数地址,每一个 virtual function被指派一个表格索引值.

这些工作都由编译器完成,执行期要做的,只是在特定的 virtual table slot(记录着 virtual function的地址)中激活 virtual function.

一个 class 只会有一个 virtual table.每一个table内含其对应的 class object中所有的active virtual functions函数实体的地址,这些active virtual functions包括:

这个 class 所定义的函数实体,它会改写(overriding)一个可能存在的base class virtual function函数实体.

继承自base class 的函数实体,这是在derived class 决定不改写 virtual function时才会出现的情况

一个pure_virtual_called()函数实体,它既可以扮演pure virtual function的空间保卫者角色,也可以当作执行期异常处理函数

每一个 virtual function都被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的 virtual function的关联.例如在Point class 体系中:

class Point {
public:
	virtual ~Point();
	virtual Point &mult(float) = 0;
	float x() const { return _x; }
	virtual float y() const { return 0; }
	virtual float z() const { return 0; }
protected:
	Point(float x = 0.0);
	float _x;
};

virtual destructor被赋值slot 1,而mult()被赋值slot 2,此例并没有mult()的函数定义(因为它是一个pure virtual function),所以pure_virtual_called()的函数地址会被放在slot 2中.如果该函数意外地被调用,通常的操作是结束掉这个程序,y()被赋值slot
3而z()被赋值slot 4,x()的slot是多少?答案是没有,因为x()并非 virtual function.

当一个 class 派生自Point时,会发生什么事情?例如 class Point2d:

class Point2d : public Point {
public:
	Point2d(float x = 0.0, float y = 0.0) : Point(x), _y(y) {}
	~Point2d();
	// 改写base class virtual functions
	Point2d& mult(float);
	float y() const { return _y; }
protected:
	float _y;
};

一共有三种可能性:

1.它可以继承base class 所声明的 virtual functions的函数实体,正确地说,是该函数实体的地址会被拷贝到derived class 的 virtual table相对应的slot中.

 2.它可以使用自己的函数实体,这表示它自己的函数实体地址必须放在对应的slot中

3.它可以加入一个新的 virtual function.这时候 virtual table的尺寸会增大一个slot,新的函数实体地址会放进该slot中.

Point2d的 virtual table在slot 1中指出的destructor,而在slot 2中指出mult()(取代pure virtual function).它自己的y()函数实体地址放在slot 3,继承自Point的z()函数实体地址则放在slot 4.

类似的情况,Point3d派生自Point2d,如下:

class Point3d : public Point2d {
public:
	Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), _z(z) {}
	~Point3d();
	// 改写的base class virtual functions
	Point3d& mult(float);
	float z() const { return _z; }
protected:
	float _z;
};

其中 virtual table中的slot 1放置Point3d的destructor,slot 2放置Point3d::mult()函数地址,slot 3放置继承自Point2的y()函数地址,slot 4放置它自己的z()函数地址.

如下图4.1所示(取自Inside The C++ Object Model的原图):

图4.1 virtual table的布局:单一继承情况

现在,有这样的式子:

ptr->z();

那么,如何有足够的知识在编译时期设定 virtual function的调用呢?

一般而言,并不知道ptr所指对象的真正类型,然而知道,经由ptr可以存取到该对象的 virtual table.

虽然不知道哪一个z()函数实体会被调用,但知道每一个z()函数地址都被放在slot 4.

这些信息使得编译器可以将该调用转化为:

(*ptr->vptr[4])(ptr);

在这个转化中,vptr表示编译器所插入的指针,指向 virtual table;4表示z()被赋值的slot编号(关联到Point体系的 virtual tabl).唯一一个在执行期才能直到的东西是:slot 4所指的到底是哪一个z()函数实体?

    在一个单一继承体系中,virtual function机制的行为十分良好,不但有效率而且很容易被塑造出模型,但在多重继承和虚拟继承中,对 virtual functions的支持就没有那么美好了.

多重继承下的Virtual Functions

多重继承中支持的 virtual functions,其复杂度围绕在第二个以及后继的base classes上,以及"必须在执行期调整this指针"这一点,以下面的 class 体系为例:

// class体系,用来描述多重继承(MI)情况下支持 virtual function时间的复杂度
class Base1 {
public:
	Base1();
	virtual ~Base1();
	virtual void speakClearly();
	virtual Base1 *clone() const;
protected:
	float data_Base1;
};
class Base2 {
public:
	Base2();
	virtual ~Base2();
	virtual void mumble();
	virtual Base2 *clone() const;
protected:
	float data_Base2;
};
class Derived : public Base1, public Base2 {
public:
	Derived();
	virtual ~Derived();
	virtual Derived *clone() const;
protected:
	float data_Derived;
};

"Derived 支持virtual functions"的困难度,统统落在Base2 subobject上,有三个问题需要解决,以此例而言分别是:

    (1)virtual destructor

(2)被继承下来的Base2::mumble()

(3)一组clone()函数实体

首先,把一个从heap中配置而得到的Derived对象的地址,指定给一个Base2指针:

Base2 *pbase2 = new Derived;

新的Derived对象的地址必须调整,以指向其Base2 subobject,编译时期会产生以下的代码:

// 转移以支持第二个base class
Derived *temp = new Derived;
Base2 *pbase = temp ? temp + sizeof(Base1) : 0;

如果没有这样的调整,指针的任何"非多态运用"都将失败:

// 即使pbase2被指定一个Derived对象,这也应该没有问题
pbase2->data_Base2;

当程序员要删除pbase2所指的对象时:

// 必须首先调用正确的virtual destructor函数实体
// 然后施行delete运算符
// pbase2可能需要调整,以指出完整对象的起始点
delete pbase2;

指针必须被再一次调整,以求再一次指向Derived对象的起始处(推测它还指出Derived对象).然而上述的offset加法却不能够在编译时期直接设定,因为pbase2所指向的真正对象只有在执行期才能确定.

一般规则是,经由指向"第二或后继的base class"的指针(或reference)来调用derived class virtual function.

Base2 *pbase2 = new Derived;
// ...
delete pbase2;

该调用操作所连带的"必要的this指针调整"操作,必须在执行期完成.也就是说,offset的大小,以及把offset加到 this 指针上头的那一小段代码,必须由编译器在某个地方插入.问题是,在哪个地方?

Bjarne原先实施于cfront编译器中的方法是将 virtual table加大,使它容纳此处所需的 this 指针,调整相关事物.每一个 virtual table slot,不再只是一个指针,而是一个聚合体,内含可能的offset以及地址,于是 virtual function的调用操作由:

(*pbase2->vptr[1])(pbase2);

改变为:

(*pbase2->vptr[1].faddr)(pbase2 + pbase2->vptr[1].offset);

其中faddr内含 virtual function地址,offset内含 this 指针调整值.

这样做法的缺点是,它相当于连带处罚了所有的 virtual function调用操作,不管它们是否需要offset的调整.所谓的处罚,包括offset的额外存取以及其加法,以及每一个 virtual table slot的大小改变.

调整 this 指针的额外负担是,由于两种不同的可能:(1)经由derived class(或第一个base class)调用,(2)经由第二个(或其后继)base class 调用,同一函数在 virtual table中需要多笔对应的slots.例如:

Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;
delete pbase1;
delete pbase2;

虽然两个 delete 操作导致相同的Derived destructor,但它们需要两个不同的 virtual table slots.

1.
pbase1不需要调整 this 指针(因为Base1是最左端base class 之故,它已经指向Derived对象的起始处),其 virtual table slot需放置真正的destructor地址.

2.
pbase2需要调整 this 指针,其 virtual table slot需要相关的thunk地址

在多重继承下,一个derived class 内含n-1个额外的 virtual tables,n表示其上一层base classes的数目(因此,单一继承将不会有额外的 virtual tables).对于本例的Dervied而言,会有两个 virtual tables被编译器产生出来:

1. 一个主要实体,与Base1(最左端base class)共享.

2.
一个次要实体,与Base2(第二个base class)有关.

针对每一个 virtual tables,Derived对象中有对应的vptr,图4.2(书上的原图4.2)说明了这一点.如下所示,vptrs将在constructor中被设置初值.

图4.2 virtual table的布局:多重继承情况

用以支持"一个class拥有多个virtual table"的传统方法是,将每一个tables以外部对象的形式产生出来,并给及独一无二的名称.例如,Derived所关联的两个tables可能有这样的名称:

vtbl_Derived;		// 主要表格
vtbl_Base2_Derived	// 次要表格

于是将一个Derived对象地址指定给一个Base1指针或Derived指针时,被处理的 virtual table是主要表格vtbl_Derived,而当将一个Derived对象地址指定给一个Base2指针时,被处理的 virtual table是次要表格vtbl_Base2_Derived.

由于执行期链接器(runtime linkers)的降临(可以支持动态共享函数库),符号名称的链接可能变得非常缓慢.为了调节执行期链接器的效率,Sun编译器将多个 virtual tabls连锁为一个:指向次要表格的指针,可由主要表格名称加上一个offset获得.在这样的策略下,每一个 class 只有一个具名的 virtual table.

有三种情况,第二个或后继的base class 会影响到 virtual functions的支持,第一种情况是,通过一个"指向第二个base class"的指针,调用derived class virtual function.例如:

Base2 *ptr = new Derived;
// 调用Derived::~Derived
// ptr必须被向后调整sizeof(Base1)个bytes
delete ptr;

这个调用操作的重点:ptr指向Derived对象中的Base2 subobject:为了能够正确执行,ptr必须调整指向Derived对象的起始处.

第二种情况是第一种情况的变化,通过一个"指向derived class"的指针,调用第二个base class 中一个继承而来的 virtual function.在此情况下,derived class 指针必须再次调整,以指向第二个base subobject.例如:

Derived *pder = new Derived;
// 调用Base2::mumble()
// pder必须被向前调整sizeof(Base1)个bytes
pder->mumble();

第三种情况发生于一个语言扩充性质下:允许一个 virtual function的返回类型有所变化,可能是base type,也可能是publicly derived type,这一点可以通过Derived clone()函数实体来说明.clone()函数的Derived版本传回一个Derived class 指针,默默地改写了它的两个base class 函数实体.当通过"指向第二个base class"的指针来调用clone()时,this
指针的offset问题于是诞生:

Base2 *pb1 = new Derived;
// 调用Derived *Derived::clone()
// 返回值必须被调整,以指向Base2 subobject
Base2 *pb2 = pb1->clone();

当进行pb1->clone()时,pb1会被调整指向Derived对象的起始地址,于是clone()的Derived版会被调用,它会传回一个指针,指向一个新的Derived对象;该对象的地址在被指定给pb2之前,必须先经过调整,以指向Base2 subobject.

当函数被认为"足够小"的时候,Sun编译器会提供一个所谓的"split functions"技术:以相同算法产生出两个函数,其中第二个返回之前,为指针加上必要的offset,于是不论通过Base1指针或Derived指针调用函数,都不需要调整返回值;而通过Base2指针所调用的,是另一个函数.

如果函数并不小,"split function"策略会给予此函数中的多个进入点(entry points)中的一个.每一个进入点需要三个指令,但Mike Ball想办法去除了这项成本,对于OO没有经验的程序员,可能会怀疑这种"split function"的应用性,然而OO程序员都会尽量使用小规模的 virtual function将操作"局部化",通常 virtual function的平均大小是8行.

函数如果支持多重进入点,就可以不必有许多"thunks".如IBM就是把thunk搂抱在真正被调用的 virtual function中.函数一开始先(1)调整 this 指针,然后才(2)执行程序员所写的函数码;至于不需要调整的函数调用操作,就直接进入(2)的部分.

Microsoft以所谓的"address points"来取代thunk策略,即将用来改写别人的那个函数(也就是overriding function)期待获得的是"引入该virtual function的class"(而非derived class)的地址,这就是该函数的"address point".

虚拟继承下的Virtual Functions

考虑下面的 virtual base class 派生体系,从Point2d派生出Point3d:

class Point2d {
public:
	Point2d(float = 0.0, float = 0.0);
	virtual ~Point2d();
	virtual void mumble();
	virtual float z();
protected:
	float _x, _y;
};
class Point3d : public virtual Point2d {
public:
	Point3d(float = 0.0, float = 0.0, float = 0.0);
	~Point3d();
	float z();
protected:
	float _z;
};

虽然Point3d有唯一一个(同时也是最左边)的base class,也就是Point2d,但Point3d和Point2d的起始部分并不像"非虚拟的单一继承"情况那样一致.这种情况显示于图4.3(如下图所示).由于Point2d和Point3d的对象不再相符,两者之间的转换也需要调整 this 指针.至于在虚拟继承的情况下要清除thunks,一般而言已经被证明是一项高难度技术.

图4.3 virtual table布局:虚拟继承情况

当一个 virtual base class 从另一个 virtual base class 派生而来,并且两者都支持 virtual functions和nonstatic data members时,编译器对于 virtual base class 的支持非常复杂.最好,不要在一个
virtual base class 中声明nonstatic data members.

版权声明:本文为博主原创文章,未经博主允许不得转载。

时间: 2024-12-26 04:06:15

C++对象模型——Virtual Member Functions (虚拟成员函数)(第四章)的相关文章

Virtual Member Functions &amp; Static Member Function

如果一个 normalize() 是一个 virtual member function, 那么以下的调用: ptr->normalize(); 将会被内部转化为: (*ptr->vptr[1])(ptr); 其中:vptr 表示由编译器生成的指针, 指向 virtual table, 它被安插在每一个声明有(或继承自) virtual functinos 的 class object 中. 事实上其名称也会被 mangled, 因为在一个复杂的 class 派生体系中, 可能存在多个 vpt

Virtual Member Functions 的语意

看过之前的 virtual function可以知道其实现模型: 每一个 class 有一个 virtual table. 内含该 class 之中 有作用的 virtual function 地址, 然后每个 object 有一个 vptr, 指向 virtual table 的所在. 在这一节中, 需要走访一组可能的设计, 然后根据单一继承, 多重继承和虚拟继承的多种情况, 从细节上来探究这个模型.为了支持 virtual function 机制, 必须首先能够对于多态对象有某种形式的执行期

C++对象模型——Member的各种调用方式(第四章)

第四章 Function语意学 (The Semantics of Function) 如果有一个Point3d的指针和对象: Point3d obj; Point3d *ptr = &obj; 当这样做: obj.normalize(); ptr->normalize(); 时,会发生什么事情呢?其中的Point3d::normalize()定义如下: Point3d Point3d::normalize() const { register float mag = magnitude()

函数的效能 &amp; 指向 Member Functions 的指针与其效能

nonmember friend 或 nonstatic member 或 static member 函数都会被转化为相同的形式, 因此三者的效率完全相同.另外, inline member function 的效率一直是最高的(前提是简单操作), 优化后的效率更是高, 这是因为编译器会将被视为不变的表达式提到循环之外, 因此只计算一次, inline 函数不只能够节省一般函数所调用的负担, 也提供程序额外的优化机会.virtual function 和 多重继承 的效率要低于一般函数, 要再

C++对象模型——指向Member Function的指针 (Pointer-to-Member Functions)(第四章)

4.4 指向Member Function的指针 (Pointer-to-Member Functions) 取一个nonstatic data member的地址,得到的结果是该member在 class 布局中的byte位置(再加1),它是一个不完整的值,须要被绑定于某个 class object的地址上,才可以被存取. 取一个nonstatic member function的地址,假设该函数是nonvirtual,则得到的结果是它在内存中真正的地址.然而这个值也是不全然的,它也须要被绑定

C++ Member Functions的各种调用方式

[1]Nonstatic Member Functions(非静态成员函数) C++的设计准则之一就是:nonstatic member function至少必须和一般的nonmember function有相同的效率.也就是说,如果我们要在以下两个函数之间作选择: float magnitude3d(const Point3d* _this) {...}; float Point3d::magnitude3d() const {...}; 那么选择member function不应该带来什么额

Head First Python 第二章 函数模块&amp;第三章 文件与异常&amp;第四章 持久存储&amp;第五章 处理数据

第三章 1.共享模块 模块和发布工具箱全世界共享模块 编写函数,以.py为文件后缀名,第三方库都在PyPI *注释代码:三引号(单双都可以) 发布过程P40 使用发布工具,函数模块变成了一个“发布”,可以使用import导入其他程序 2.如果函数模块功能不满意 添加参数以供api选择比添加函数工作量小! 首先考虑BIF内置函数 ----------------------------------------------------------- 第四章 1.文件 open()语句,readlin

C++对象模型——Data Member的绑定(第三章)

3.1    Data Member的绑定 (The Binding of a Data Member) 考虑下面这段代码: // 某个foo.h头文件,从某处含入 extern float x; // 程序员的Point3d.h文件 class Point3d { public: Point3d(float, float, float); // 问题:被传回和被设定的x是哪一个x? float X() const { return x; } void X(float new_x) const

C++对象模型——函数的效能(第四章)

4.3 函数的效能 在以下的这组測试中,在不同的编译器上计算两个3D点,当中用到一个nonmember friend function,一个member function,以及一个 virtual member function,而且 virtual member function分别在单一,虚拟,多重继承三种情况下运行.以下就是nonmember function: void cross_product(const Point2d &pA, const Point3d &pB) { Po