如果有一个Point3d的指针和对象:
Point3d obj;
Point3d *ptr = &obj;
当这样做:
obj.normalize();
ptr->normalize();
时,会发生什么事?其中的Point3d::normalize()定义如下:
Point3d Point3d::normalize() const
{
register float mag = magnitude();
Point3d normal;
normal._x = _x / mag;
normal._y = _y / mag;
normal._z = _z / mag;
return normal;
}
而其中的Point3d::magnitude()又定义如下:
float Point3d::magnitude() const
{
return sqrt( _x * _x + _y * _y + _z * _z );
}
答案是不明确的。C++支持三种类型的member functions:static、nonstatic和virtual,每一
种类型被调用的方式都不同。不过我们虽不能确定normalize()和magnitude()两函数是否为
virtual或nonvirtual,但可以确定它一定不是static,原因有二:(1)它直接存取nonstatic数据,(2)
它被声明为const。而static member functions不可能做到这两点。
一、Member的各种调用方式
1、非静态成员函数(Nonstatic Member Functions)
C++的设计准则之一就是:nonstatic member function至少必须和一般的nonmember
function有相同的效率。也就是说,如果我们要在以下两个函数之间做选择:
float magnitude3d( const Point3d *_this ){ ... }
float Point3d::magnitude3d() const { ... }
选择member function不应该带来什么额外负担。这是因为编译器内部已将”member 函数实
例“转换为对等的”nonmember函数实例“。
比如下面是magnitude()的一个nonmember定义:
float magnitude3d( const Point3d *_this )
{
return sqrt( _this->_x * _this->_x +
_this->_y * _this->_y +
_this->_z * _this->_z );
}
咋看之下似乎nonmember function比较没有效率,它间接地经由参数取用坐标成员,而
member function却是直接取用坐标成员。然而实际上member function被内化为nonmember的
形式。下面是转化步骤:
1)改写函数的signature(函数原型)以安插一个额外的参数到member function中,用以
提供一个存取管道,使class object得以将此函数调用。该额外参数被称为this指针:
// non-const nonstatic member的扩张过程
Point3d Point3d::magnitude( Point3d *const this )
如果member function 是const,则变为:
// const nonstatic member的扩张过程
Point3d Point3d::magnitude( const Point3d *const this )
2)将每一个”对nonstatic data member的存取操作“改为经由this指针来存取:
{
return sqrt( this->_x * this->_x +
this->_y * this->_y +
this->_z * this->_z );
}
3)将member function重新写成一个外部函数。将函数名称经过”mangling“处理,使它在程
序中成为独一无二的语汇:
extern magnitude_7Point3dFv(
register Point3d *const this );
现在这个函数已经被转换好了,而其每一个调用操作也都必须转换。于是:
obj.magnitude();
// 转换为:
magnitude_7Point3dFv( &obj );
而
ptr->magnitude();
// 转换为:
magnitude_7Point3dFv( ptr );
而normalize()函数会被转化为下面的形式,其中假设已经声明有一个Point3d copy
constructor,而named returned value(NRV)的优化也已经实施:
// 以下描述”named return value函数“的内部转化
// 使用C++代码
void normalize_7Point3dFv( register const Point3d *const this,
Point3d &_result )
{
register float mag = this->magnitude();
// default constructor
_result.Point3d::Point3d();
_result._x = this->_x / mag;
_result._y = this->_y / mag;
_result._z = this->_z / mag;
return;
}
一个有效率的做法是直接构建”normal“值,像这样:
Point3d Point3d::normalize() const
{
register float mag = magnitude();
return Point3d( _x / mag, _y / mag, _z / mag );
}
它会转化为以下的代码(再一次假设Point3d的copy constructor已经声明好了,而NRV
的优化也已实施):
// 以下描述内部转化
// 使用C++伪码
void normalize_7Point3dFv( register const Point3d *const this, Point3d & _result )
{
register float mag = this->magnitude();
// _result用以取代返回值(return value)
_result.Point3d::Point3d( this->_x / mag, this->_y / mag, this->_z / mag );
return;
}
这可以节省default constructor初始化所引起的额外负担。
2、名称的特殊处理(Name Mangling)
一般而言,member的名称前面会被加上class名称,形成独一无二的命令。例如下面的声
明:
class Bar { public: int ival; ... };
其中的ival有可能变成这样:
// member经过name-mangling之后的可能结果之一
ival_3Bar
为什么编译器要这么做?清考虑这样的派生操作(derivation):
class Foo : public Bar { public: int val;... };
Foo对象内部结合了base class和derived class两者:
// C++伪码
// Foo的内部描述
class Foo
{
public:
int ival_3Bar;
int ival_3Foo;
};
不管处理哪个ival,通过”name mangling“,都可以绝对清楚地指出来。由于member
functions可以被重载化(overload),所以需要更广泛的mangling手法,以提供对独一无二的
名称。如果把:
class Point
{
public:
void x( float newX );
float x();
...
};
转换为:
class Point
{
public:
void x_5Point( float newX );
float x_5Point();
...
};
会导致两个被重载化(overloaded)的函数实例拥有相同的名称。为了让它们独一无二,唯
有再加上它们的参数链表(可以从函数原型中参考得到)。如果把参数类型也编码进取,就一
定可以制造出独一无二的结果,使我们的两个x() 函数有良好的转换(如果声明extern ”C“,就会
压抑nonmember functions的”mangling“效果):
class Point
{
public:
void x_5PointFf( float newX );
float x_5PointFv();
...
};
把参数和函数名称编码在一起,编译器于是在不同的编译模块之间达成了一种优先形式的
类型检验。如下print函数被这样定义:
void print( const Point3d& ) { ... }
但意外地被这样声明和调用:
// 以为是const Point3d&
void print( const Point3d );
两个实例如果拥有独一无二的name mangling,那么任何不正确的调用操作在链接时期就
因无法决议(resolved)而失败。但如果是”返回类型“声明错误就没办法检查出来。
3、虚拟成员函数(Virtual Member Functions)
如果normalize()是一个virtual member function,那么以下的调用:
ptr->normalize();
将会被内部转化为:
( *ptr->vptr[ 1 ] )( ptr );
其中:
1)vptr表示由编译器产生的指针,指向virtual table。它被安插在每一个“声明有(或继承
自),一个或多个virtual functions”的class object中。其名称也会被“mangled”,因为在一个复
杂的class派生体系中,可能存在多个vptrs。
2)1是virtual table slot的索引值,关联到normalize()函数。
3)第二个ptr表示this指针。
同样,如果magnitude()也是一个virtual function,它在normalize()之中的调用操作将被转
换如下:
// register float mag = magnitude();
register float mag = ( *this->vptr[ 2 ] )( this );
由于Point3d::magnitude()是在Point3d::normalize()中被调用的,而后者已经由虚拟机制而
决议妥当,所以显示地调用“Point3d实例”会比较有效率,并因此压制由于虚拟机制而产生的不
必要重复调用操作:
// 显示的调用操作(explicitly invocation)会压制虚拟机制
register float mag = Point3d::magnitude();
如果magnitude()声明为inline函数,会更有效率。使用class scope operator显示调用一
个virtual function,其决议方式会和nonstatic member function一样:
register float mag = magnitude_7Point3dFv( this );
对于以下调用:
// Point3d obj;
obj.normalize();
如果编译器把它转换为:
( *obj.vptr[ 1 ] )( &obj );
虽然语意正确,却没有必要。”经由一个class object 调用一个virtual function“,这种操作
应该总是被编译器像对待一般nonstatic member function一样地加以决议:
normalize_7Point3dFv( &obj );
这项优化的一利益是,virtual function的一个inline函数实例可以被扩展(expanded)开
来,因而提供极大的效率利益。
4、静态成员函数(Static Member Functions)
如果Point3d::normalize()是一个static member function,以下两个调用操作:
obj.normalize();
ptr->normalize();
将被转换为一般的nonmember函数调用,如下:
// obj.normalize();
normalize_7Point3dSFv();
// ptr->normalize();
normalize_7Point3dSFv();
在C++引入static member functions之前,很少会看到如下怪异写法:
( ( Point3d* )0 )->object_count();
其中的object_count只是简单传回_object_count这个static data member。
在引入static member functions之前,C++语言要求所有的member functions都必须经由
该class的object来调用。而实际上,只有当一个或多个nonstatic data members在member
function中被直接存取时,才需要class object。Class object提供了this指针给这种形式的函数
调用使用。这个this指针把”在member function中存取的nonstatic class members“绑定于”object
内对应的members“之上。如果没有任何一个members被直接存取,事实上就不需要this指针,
因此也就没必要通过一个class object来调用一个member function。
这么一来就存取static data members时产生了一些不规则性。如果class的设计者把static
data member声明为nonpublic(这一直被视为一种好的习惯),那么他就必须提供一个或多个
member functions来存取该member。因此,虽然你可以不靠class object来存取一个static
member,但其存取函数却得绑定于一个class object之上。
独立于class object之外的存取操作,在某个时候特别重要:当class设计者希望支持”没
有class object存在“的情况时。程序方法上的解决之道是很奇特地把0强制转换为一个class指
针,因而提供出一个this指针实例:
// 函数调用的内部转换
object_count( ( Point3d* )0 );
Static member functions的主要特性就是它没有this指针。以下次要特性统统根源于其主
要特性:
1)它不能直接存取其class中的nonstatic members。
2)它不能够被声明为const、volatile或virtual。
3)它不需要经由class object才被调用——虽然大部分时候它是这样被调用的!
“member selection”语法的使用是一种符号上的便利,它会被转化为一个直接调用操作:
if( Point3d::object_count() > 1 ) ...
如果class object是因为某个表达式而获得的,会如何?例如:
if( foo().object_count() > 1 ) ...
这个表达式仍然需要被评估求值:
// 转化,以保存副作用
( void ) foo();
if( Point3d::object_count() > 1 ) ...
一个static member function,当然会被提出于class声明之外,并给予一个经过
“mangled”的适当名字。例如:
unsigned int Point3d::object_count()
{
return _object_count;
}
会被cfront转化为:
// 在cfront之下的内部转化结果
unsigned int object_count_5Point3dSFv()
{
return _object_count_5Point3d;
}
其中SFv表示它是一个static member function,拥有一个空白(void)的参数链表
(argument list)。
由于static member function没有this指针,所以其地址的类型并不是一个“指向class
member function的指针”,而是一个“nonmember函数指针”。也就是说:
&Point3d::object_count();
会得到一个数值,类型是:
unsigned int (*)();
而不是:
unsigned int ( Point3d::* )( );
Static member function由于缺乏this指针,因此差不多等同于nonmember function。它
提供了一个意想不到的好处:成为一个callback函数,使我们得以将C++和C-base X Window系
统结合。它们也可以成功地应用在线程(threads)函数身上。
二、Virtual Member Function(虚拟成员函数)
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 data { public: int m, d, y; };
但实际上它并不需要那份信息。下面的class声明虽然不符合新规范,却需要那份信息:
struct geom { public: virtual ~geom(); ... };
我们需要一个以class的使用为基础,而不在乎关键词是class或struct的规范。如果class
真正需要那份信息,它就会存在;如果不需要,它就不存在。很明显在必须支持某种形式之“执行
期多态(runtime polymorphism)”的时候需要这份信息。
在C++中,多态(ploymorphism)表示“一个public base class的指针(或reference),
寻找出一个derived class 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 ploymorphism)"的常见例子
ptr->z();
在funtime type identification(RTTI)性质于1993年被引入C++语言之前,C++对“积极
多态(active polymorphism)”的唯一支持,就是对于virtual function call 的决议操作。有了
RTTI,就能够在执行期查询一个多态的pointer或多态的reference了:
// "积极多态(active polymorphism)"的第二个例子
if( Point3d *p3d = dynamic_cast<Point3d*>( ptr ) )
return p3d->_Z;
所以欲鉴定哪些classes展现多态特性,我们需要额外的执行期信息。关键词class和
struct并不能帮助我们。由于没有导入像是polymorphic之类的新关键词,因此识别一个class是
否支持多态,唯一适当的方法就是看看它是否有任何virtual function。只要class拥有一个virtual
function,它就需要这份额外的执行期信息。
下一个明显的问题是,什么样的额外信息是我们需要存储起来的?也就是说,如果有这
样的调用:
ptr->z();
其中z()是一个virtual function,那么什么信息才能让我们在执行期调用正确的z()实例?
需要知道:
1)ptr所指对象的真实类型。这可使我们选择正确的z()实例。
2)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。
一个class只会有一个virtual table。每一个table内含其对应之class object中所有active
virtual function函数实例的地址。包括:
1)这一class所定义的函数实例。它会改写(overriding)一个可能存在的base class
virtual function函数实例。
2)继承自base class的函数实例。这是在derived class决定不改写virtual function时才会
出现的情况。
3)一个pure_virtual_called()函数实例,它既可以扮演pure virtual function的空间保卫者
角色,也可以当做执行期异常处理函数(有时候会用到)。
每一个virtual function都被指派一个固定的索引值,这个索引在整个继承体系中保持与特
定的virtual function的关系。例如我们的Point class体系中:
#include <iostream>
class Point
{
public:
Point( float x = 0.0 ) : _x( x ) { }
virtual ~Point() { }
virtual int mult( float ) = 0;
// ...其他操作
float x() const { return _x; }
virtual float y() const { return 0; }
virtual float z() const { return 0; }
// ...
protected:
float _x;
};
class Point2d : public Point
{
public:
Point2d( float x = 0.0, float y = 0.0 )
: Point( x ), _y( y ) { }
~Point2d() { }
// 改写base class virtual functions
int mult( float y ) { return 1; }
float y() const { return _y; }
// ...其他操作
protected:
float _y;
};
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
int mult( float z ) { return 2; }
float z() const { return _z; }
// ...其他操作
protected:
float _z;
};
int main()
{
Point2d point2d;
Point3d point3d;
std::cout << "sizeof( Point ) = " << sizeof( Point ) << std::endl;
std::cout << "sizeof( point2d ) = " << sizeof( point2d ) << std::endl;
std::cout << "sizeof( point3d ) = " << sizeof( point3d ) << std::endl;
return 0;
}
下面是class Point的虚表:
.section .rodata._ZTV5Point,"aG",@progbits,_ZTV5Point,comdat
.align 8
.type _ZTV5Point, @object
.size _ZTV5Point, 28
_ZTV5Point: # vtable for Point
.long 0
.long _ZTI5Point # typeinfo for Point
.long _ZN5PointD1Ev # Point::~Point()
.long _ZN5PointD0Ev # Point::~Point()
.long __cxa_pure_virtual
.long _ZNK5Point1yEv # Point::y() const
.long _ZNK5Point1zEv # Point::z() const
virtual destructor被指派slot 2,3,而mult()被指派slot 4。此例并没有mult()的函数定义(因
为它是一个pure virtual function),所以pure_virtual_called()的函数地址会被放在slot 4。如果
该函数意外地被调用,通常操作是结束这个程序。y()被指派slot 5,z被指派slot 6。x()不是
virtual function所以不存在虚表中。
下面是class Point2d的虚表:
.section .rodata._ZTV7Point2d,"aG",@progbits,_ZTV7Point2d,comdat
.align 8
.type _ZTV7Point2d, @object
.size _ZTV7Point2d, 28
_ZTV7Point2d: # vtable for Point2d
.long 0
.long _ZTI7Point2d # typeinfo for Point2d
.long _ZN7Point2dD1Ev # Point2d::~Point2d()
.long _ZN7Point2dD0Ev # Point2d::~Point2d()
.long _ZN7Point2d4multEf # Point2d::mult(float)
.long _ZNK7Point2d1yEv # Point2d::y() const
.long _ZNK5Point1zEv # Point::z() const
当一个class派生自Point时,会发生什么事?
一共有三种可能性:
1)它可以继承base class所声明的virtual function的函数实例。正确地说是,该函数实例
的地址会被拷贝到derived class的virtual table的相对应slot之中。
2)它可以使用自己的函数实例。这表示它自己的函数实例地址必须放在对应的slot之中。
3)它可以加入一个新的virtual function。这时候virtual table的尺寸会增大一个slot,而新
的函数实例地址会被放进该slot之中。
Point2d的virtual table在slot 2,3中指出destructor,而slot 4中指出mult()(取代pure
virtual function)。它自己的y()函数实例地址放在slot 5中,继承自Point的z()函数实例地址则放
在slot 6中。
下面是class Point3d的虚表:
.section .rodata._ZTV7Point3d,"aG",@progbits,_ZTV7Point3d,comdat
.align 8
.type _ZTV7Point3d, @object
.size _ZTV7Point3d, 28
_ZTV7Point3d: # vtable for Point3d
.long 0
.long _ZTI7Point3d # typeinfo for Point3d
.long _ZN7Point3dD1Ev # Point3d::~Point3d()
.long _ZN7Point3dD0Ev # Point3d::~Point3d()
.long _ZN7Point3d4multEf # Point2d::mult(float)
.long _ZNK7Point2d1yEv # Point2d::y() const
.long _ZNK7Point3d1zEv # Point3d::z() const
同样对于派生自Point2d的Point3d,其virtual table中的slot 2,3放置Point3d的
destructor,slot 4放置Point3d::mult()函数地址,slot 5放置继承自Point2d的y()函数地址,slot 6
放置自己的z()函数地址。
现在,如果有这样的式子:
ptr->z();
如何有足够的知识在编译时期设定virtual function的调用呢?
1)一般而言,在每次调用z()时,并不知道ptr所指对象的真正类型。然而知道经由ptr可以
存取到该对象的virtual table。
2)虽然不知道哪一个z()函数实例会被调用,但知道每一个z()函数地址都被放在slot 6中。
这些信息使得编译器可以将该调用转化为:
( *ptr->vptr[ 6 ] )( ptr );
这一转化中,vptr表示编译器所安插的指针,指向virtual table;6表示z()被指派的slot编号
(关系到Point体系的virtual table)。唯一一个在执行期才能够知道的东西是:slot 6所指的到
底是哪一个z()函数实例。
在一个单一继承体系中,virtual function 机制的行为十分良好,不但有效率而且很容易塑
造出模型来。但是在多重继承和虚拟继承之中,对virtual functions的支持就没那么美好了。
1、多重继承下的virtual Functions
在多重继承中支持virtual functions,其复杂度围绕在第二个及后继的base classes身上,
以及”必须在执行期调整this指针“这一点。以下面的class体系为例:
#include <iostream>
class Base1
{
public:
Base1( float base1 ) : data_Base1( base1 ) { }
virtual ~Base1() { }
virtual void speakClearly() { data_Base1 += 1000.0; }
virtual Base1 *clone() const { return new Base1( this->data_Base1 ); }
protected:
float data_Base1;
};
class Base2
{
public:
Base2( float base2 ) : data_Base2( base2 ) { }
virtual ~Base2() { }
virtual void mumble() { data_Base2 -= 1.0; }
virtual Base2 *clone() const { return new Base2( this->data_Base2 ); }
protected:
float data_Base2;
};
class Derived : public Base1, public Base2
{
public:
Derived( float data1, float data2, float derived )
: Base1( data1 ), Base2( data2 ), data_Derived( derived ) { }
virtual ~Derived() { }
virtual Derived *clone() const { return new Derived( this->data_Base1, this->data_Base2, this->data_Derived ); }
protected:
float data_Derived;
};
int main()
{
Base1 base1( 1.0 );
Base2 base2( 2.0 );
Derived derived( 1.0, 2.0, 3.0 );
std::cout << "sizeof( base1 ) = " << sizeof( base1 ) << std::endl;
std::cout << "sizeof( base2 ) = " << sizeof( base2 ) << std::endl;
std::cout << "sizeof( derived ) = " << sizeof( derived ) << std::endl;
return 0;
}
"Derived支持virtual functions"的困难度,统统落在Base2 subobject身上。有三个问题需
要解决,以此而言分别是(1)virtual destructor,(2)被继承下来的Base2::mumble(), (3)一组
clone()函数实例。
首先,把一个从heap中配置而得的Derived对象的地址,指定给一个Base2指针:
Base2 *pbase2 = new Drived;
新的derived对象的地址必须调整以指向其Base2 subobject。编译时期会产生以下的代
码:
// 转移以支持第二个base class
Derived *temp = new Drived;
Base2 *pbase2 = temp ? temp + sizeof( Base1 ) : 0;
如果没有这样的调整,指针的任何”非多态运用“都将失败:
// 即使pbase2被指定一个Derived对象,这也应该没有问题
pbase2->data_Base2;
当程序员要删除pbase2所指的对象时:
// 必须首先调用正确的virtual destructor函数实例
// 然后施行delete运算符
// pbase2 可能需要调整,以指出完整对象的起始点
delete pbase2;
指针必须被再一次调整,以求再一次指向Drived对象的起始处。然而上述的offset加法
却不能够在编译时期直接设定,因为pbase2所指的真正对象只有在执行期才能确定。
一般规则是,经由指向”第二或后继之base class“的指针(或reference)来调用derived
class virtual function。
Base2 *pbase2 = new Derived;
...
delete pbase2; // invoke derived class's destructor( virtual )
其所连带的必要的”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的调整。
比较有效率的解决方法是利用所谓的thunk。所谓thunk是一小段assembly代码,用来(1)
以适当的offset值调整this指针,(2)跳到virtual function去。例如,经由一个Base2指针调用
Derived destructor,其相关的thunk可能看起来是这个样子:
// 虚拟C++代码
pbase2_dtor_thunk:
this += sizeof( base1 );
Derived::~Derived( this );
Thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承不要任何空间上
的额外负担。Slots中的地址可以直接指向virtual function,也可以指向一个相关的thunk(如需
调整this指针的话)。
调整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)。对于本例的Derived而
言,会有两个virtual tables被编译器产生出来:
1)一个主要实例,与Base1(最左端base class)共享。
2)一个次要实例,与Base2(第二个base class)有关。
针对每一个virtual tables,Derived对象中有对应的vptr。
class Base1的虚表:
.weak _ZTV5Base1
.section .rodata._ZTV5Base1,"aG",@progbits,_ZTV5Base1,comdat
.align 8
.type _ZTV5Base1, @object
.size _ZTV5Base1, 24
_ZTV5Base1: # vtable for Base1
.long 0
.long _ZTI5Base1 # typeinfo for Base1
.long _ZN5Base1D1Ev # Base1::~Base1()
.long _ZN5Base1D0Ev # Base1::~Base1()
.long _ZN5Base112speakClearlyEv # Base1::speakClearly()
.long _ZNK5Base15cloneEv # Base1::clone() const
class Base2的虚表:
.weak _ZTV5Base2
.section .rodata._ZTV5Base2,"aG",@progbits,_ZTV5Base2,comdat
.align 8
.type _ZTV5Base2, @object
.size _ZTV5Base2, 24
_ZTV5Base2: # vtable for Base2
.long 0
.long _ZTI5Base2 # typeinfo for Base2
.long _ZN5Base2D1Ev # Base2::~Base2()
.long _ZN5Base2D0Ev # Base2::~Base2()
.long _ZN5Base26mumbleEv # Base2::mumble()
.long _ZNK5Base25cloneEv # Base2::clone() const
class Derived的虚表:
.weak _ZTV7Derived
.section .rodata._ZTV7Derived,"aG",@progbits,_ZTV7Derived,comdat
.align 32
.type _ZTV7Derived, @object
.size _ZTV7Derived, 48
_ZTV7Derived: # vtable for Derived
.long 0
.long _ZTI7Derived # typeinfo for Derived
.long _ZN7DerivedD1Ev # Derived::~Derived()
.long _ZN7DerivedD0Ev # Derived::~Derived()
.long _ZN5Base112speakClearlyEv # Base1::speakClearly()
.long _ZNK7Derived5cloneEv # Derived::clone() const
.long -8
.long _ZTI7Derived # typeinfo for Derived
.long _ZThn8_N7DerivedD1Ev # non-virtual thunk to Derived::~Derived()
.long _ZThn8_N7DerivedD0Ev # non-virtual thunk to Derived::~Derived()
.long _ZN5Base26mumbleEv # Base2::mumble()
.long _ZTchn8_h8_NK7Derived5cloneEv # covariant return thunk to Derived::clone() const
于是当你将一个Derived对象地址指定给一个Base1指针或Derived指针时,被处理的
virtual table是主要表格。而当你将一个Derived对象地址指定给一个Base2指针时,被处理的
virtual table是次要表格。
由于执行期链接器(runtime linkers)的降临(可以支持动态共享函数库),符号名称的
链接可能变得非常缓慢。为了调节执行期连接器的效率,Sun编译器将多个virtual tables连锁为
一个:指向次要表格的指针,可由主要表格名称加上一个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。
Microsoft以所谓的“address points“来代替thunk策略。即将用来改写别人的那个函数
(overriding function)期待获得的是”引入该virtual function之class“(而非derived class)的地
址。这就是该函数的“address point”。
2、虚拟继承下的Virtual Functions
考虑下面的virtual base class派生体系,从Point2d派生出Point3d:
#include <iostream>
class Point2d
{
public:
Point2d( float x = 0.0, float y = 0.0 )
: _x( x ), _y( y ) { }
virtual ~Point2d() { }
virtual void mumble( ) { _y += _x; }
virtual float z() { return _x + _y; }
protected:
float _x, _y;
};
class Point3d : public virtual Point2d
{
public:
Point3d( float x = 0.0, float y = 0.0, float z = 0.0 )
: Point2d( x, y ), _z( z ) { }
~Point3d() { }
float z() { return _z; }
protected:
float _z;
};
int main()
{
Point2d point2d;
Point3d point3d;
std::cout << "sizeof( point2d ) = " << sizeof( point2d ) << std::endl;
std::cout << "sizeof( point3d ) = " << sizeof( point3d ) << std::endl;
return 0;
}
class Point2d的虚表:
.weak _ZTV7Point2d
.section .rodata._ZTV7Point2d,"aG",@progbits,_ZTV7Point2d,comdat
.align 8
.type _ZTV7Point2d, @object
.size _ZTV7Point2d, 24
_ZTV7Point2d: # vtable for Point2d
.long 0
.long _ZTI7Point2d # typeinfo for Point2d
.long _ZN7Point2dD1Ev # Point2d::~Point2d()
.long _ZN7Point2dD0Ev # Point2d::~Point2d()
.long _ZN7Point2d6mumbleEv # Point2d::mumble()
.long _ZN7Point2d1zEv # Point2d::z()
class Point3d的虚表:
.weak _ZTV7Point3d
.section .rodata._ZTV7Point3d,"aG",@progbits,_ZTV7Point3d,comdat
.align 32
.type _ZTV7Point3d, @object
.size _ZTV7Point3d, 60
_ZTV7Point3d: # vtable for Point3d
.long 8
.long 0
.long _ZTI7Point3d # typeinfo for Point3d
.long _ZN7Point3dD1Ev # Point3d::~Point3d()
.long _ZN7Point3dD0Ev # Point3d::~Point3d()
.long _ZN7Point3d1zEv # Point3d::z()
.long -8
.long 0
.long -8
.long -8
.long _ZTI7Point3d # typeinfo for Point3d
.long _ZTv0_n12_N7Point3dD1Ev # virtual thunk to Point3d::~Point3d()
.long _ZTv0_n12_N7Point3dD0Ev # virtual thunk to Point3d::~Point3d()
.long _ZN7Point2d6mumbleEv # Point2d::mumble()
.long _ZTv0_n20_N7Point3d1zEv # virtual thunk to Point3d::z()
虽然Point3d有唯一一个(同时也是最左边的)base class,也就是Point2d,单Point3d和
Point2d的起始部分并不像”非虚拟的单一继承“情况那样一致。由于Point2d和Point3d的对象不
再相符,两者之间的转换也就需要调整this指针。至于在虚拟继承的情况下要消除thunks,一般
而言已经被证明是一项该难度技术。
建议是不要在一个virtual base class中声明nonstatic data members。要不然会越来越复
杂。
三、函数的效能
在下面这组测试中,计算两个3D点,其中用到一个nonmember friend function,
一个member function,以及一个virtual member function,并且Virtual member
function分别在单一、虚拟、多重继承三种情况下执行。
对于nonmember function:
未优化:
优化:
对于inline member:
未优化:
优化:
对于static Member:
未优化:
优化:
对于nonstatic Member:
未优化:
优化:
对于Virtual Member:
未优化:
优化:
对于Virtual Member(多重继承):
未优化:
优化:
对于Virtual Member(虚拟继承):
未优化:
优化:
nonmember 、static member或nonstatic member函数都被转化为完全相同的形式。所以三
者效率完全相同。
virtual member的效率相比前三项降低了4%到11%不等。
多重继承中的virtual function的调用利用thunk技术用掉了较多成本。
而虚拟继承花掉了最多的成本。
下面使用两种方法优化:
1)在函数参数中加上一个对象,用以存放加法的结果:
void Point3d::cross_product( Point3d &pC, const Point3d &pA, const Point3d &pB )
{
pC.x = pA.y * pB.z - pA.z * pB.y;
pC.y = pA.z * pB.x - pA.x * pB.z;
pC.z = pA.x * pB.y - pA.y * pB.x;
}
可以看到在未优化情况下,效率优化了50%。
2)直接在this对象中计算结果:
void Point3d::cross_product( const Point3d &pB )
{
x = y * pB.z - z * pB.y;
y = z * pB.x - x * pB.z;
z = x * pB.y - y * pB.x;
}
四、指向Member Function的指针(Pointer-to-Member Functions)
取一个nonstatic data member的地址,得到的结果是该member在class布局中的
bytes位置(再加1)。可以想象它是一个不完整的值,它需要被绑定于某个class
object的地址上,才能够被存取。
取一个nonstatic member function的地址,如果该函数是nonvirtual,得到的结果是它在内存
中真正的地址。然而这个值也是不完全的。它也需要被绑定于某个class object的地址上,才能
够通过它调用该函数。所有的nonstatic member functions都需要对象的地址(以this指出)。
一个指向member function的指针,其声明语法如下:
double // return type
{ Point::* // class the function is member
pmf } // name of pointer to member
(); // argument list
然后我们可以这样定义并初始化该指针:
double( Point::*coord )() = &Point::x;
也可以这样指定其值:
coord = &Point::y;
欲调用它,可以这么做:
( origin.*coord )();
或
( ptr->*coord )();
这些操作会被编译器转化为:
// 虚拟C++码
( coord )( &origin );
和
// 虚拟C++码
( coord )( ptr );
指向member function的指针的声明语法,以及指向”member selection运算符“的指针,其
作用是作为this指针的空间保存者。这也就是为什么static member functions(没有this指针)的
类型是”函数指针”,而不是“指向member function的指针”之故。
使用一个“member function指针”,如果并不用于virtual function、多重继承、virtual base
class等情况的话,并不会比使用一个“nonmember function指针”的成本高。上述三种情况对于
“member function指针”的类型以及调用都太过复杂。事实上,对于那些没有virtual functions、
virtual base class或multiple base classes的classes而言,编译器可以为它们提供相同的效率。
1、支持“指向Virtual Member Functions”的指针
考虑下面的程序片段:
float ( Point::*pmf )() = &Point::z;
Point *ptr = new Point3d;
pmf,一个指向member function的指针,被设置为Point::z()(一个virtual function)的地
址。ptr则被指定以一个Point3d对象。如果我们直接经由ptr调用z():
ptr->z();
被调用的是Point3d::z()。但如果我们从pmf间接调用z()呢?
( ptr->*pmf )();
仍然是Point3d::z()被调用吗,也就是说,虚拟机制仍然能够在使用“指向member
function之指针”的情况运行。
对一个nonstatic member function取其地址,将获得该函数在内存中的地址。然而面对一
个virtual function,起地址在编译时期是未知的,所能知道的仅是virtual function在其相关之
virtual table中的索引值。也就是是说,对一个virtual member function取其地址,所能获得的只
是一个索引值。
例如,假设我们有以下的Point声明:
class Point
{
public:
virtual ~Point();
float x();
float y();
virtual float z();
// ...
};
然后取destructor的地址:
&Point::~Point;
取x()或y()的地址:
&Point::x();
&Point::y();
得到的则是函数在内存中的地址,因为它们不是virtual。取z()的地址:
&Point::z();
得到的结果是2。通过pmf来调用z(),会被内部转化为一个编译时期的式子,一般形式如
下:
( *ptr->vptr[ ( int )pmf ] )( ptr );
对一个“指向member function的指针”评估求值,会因为该值有两种意义而复杂化:其调
用操作也将有别于常规调用操作。pmf的内部定义,也就是:
float ( Point::*pmf )();
必须允许此函数能够寻址出nonvirtual x()和virtual z()两个member functions,而那两个
函数有着相同的原型:
// 两者都可以被指定给pmf
float Point::x() { return _x; }
float Point::z() { return 0; }
只不过其中一个代表内存地址,另一个代表virtual table中的索引值。因此,编译器必
须定义pmf。使它能够(1)持有两种数值,(2)更重要的是其数值可以被区别代表内存地址还
是Virtual table中的索引值。
在cfront2.0非正式版中,这两个值被内含在一个普通的指针内。cfront如何识别该值是
内存地址还是virtual table索引呢?它使用了以下技巧:
( ( ( int )pmf ) & ~127 )
? // non-virtual invocation
( *pmf )( ptr )
: // virtual invocation
( *ptr->vptr[ ( int )pmf ]( ptr ) );
2、在多重继承之下,指向 Member Functions的指针
为了让指向member functions的指针也能支持多重继承和虚拟继承,Stroustrup设计了下面
一个结构体:
// 一般结构,用以支持
// 在多重继承之下指向member functions的指针
struct _mptr
{
int delta;
int index;
union
{
ptrtofunc faddr;
int v_offset;
};
};
index和faddr分别(不同时)持有virtual table索引和nonvirtual member function地址(为
了方便,当index不指向virtual table时,会被设为-1)。在此模型之下,像这样的调用操作:
( ptr->*pmf )();
会变成:
( pmf.index < 0 )
? // non-virtual invocation
( *pmf.faddr )( ptr )
: // virtual invocation
( *ptr->vptr[ pmf.index ]( ptr ) );
此法所受到的批评是,每一个调用操作都得付出上述成本,检查其是否为virtual或
nonvirtual。Microsoft把这项检查拿掉,导入一个它所谓的vcall thunk。在此策略执之下,faddr
被指定的要不就是真正的member function地址(如果函数是nonvirtual的话),要不就是vcall
thunk的地址。于是virtual或nonvirtual函数的调用操作透明化,vcall thunk会选出并调用相关
virtual table中的适当slot。
这个结构体的另一个副作用就是,当传递一个不变值的指针给member function时,它需
要产生一个临时性对象。如下:
extern Point3d foo( const Point3d&, Point3d ( Point3d::* )() );
void bar( const Point3d& p )
{
Point3d pt = foo( p, &Point3d::normal );
// ...
}
其中&Point3d::normal的值类似这样:
{ 0, -1, 10727417 }
将需要产生一个临时性对象,有明确的初值:
// 虚拟C++码
_mpter temp = { 0, -1, 10727417 }
foo( p, temp );
delta字段表示this指针的offset值,而v_offset字段放的是一个virtual(或多重继承中的第
二或后继的)base class的vptr位置。如果ptr被编译器放在class对象的起头处,这个字段就没
有必要了,代价则是C对象兼容性降低。这些字段只在多重继承或虚拟继承的情况下才有其必要
性,有许多编译器在自身内部根据不同的classes特性提供多种指向member functions的指针形
式,例如Microsoft就提供了三种风味:
1)一个单一继承实例(其中持有vcall thunk地址或是函数地址)
2)一个多重继承实例(其中持有faddr和delta两个members)
3)一个虚拟继承实例(其中持有4个members)
3、“指向 Member Functions之指针”的效率
下面一组测试中,cross_product()函数经由以下方式调用:
1)一个指向nonmember function的指针;
2)一个指向class member function的指针;
3)一个指向virtual member function的指针;
4)多重继承下的nonvirtual及virtual member function call;
5)虚拟继承下的nonvirtual及virtual member function call;
五、Inline Functions
下面是一个加法运算符的可能实现内容:
class Point
{
friend Point operator+( const Point&, const Point& );
}
Point operator+( const Point &lhs, const Point &rhs )
{
Point new_pt;
new_pt._x = lhs._x + rhs._x;
new_pt._y = lhs._y + rhs._y;
return new_pt;
}
理论上,一个比较“干净”的做法是使用inline函数来完成set和get函数:
// void Point::x( float new_ ) { _x = new_x; }
// float Point::x() { return _x; }
new_pt.x( lhs.x() + rhs.x() );
由于我们受限只能在上述两个函数中对_x直接存取,因此也就将稍后可能发生的data
members的改变所带来的冲击最小化了。如果把这些存取函数声明为inline,我们就可以继续保
持直接存取members的那种高效率——虽然我们亦兼顾了函数的封装性。此外,加法运算符不
再需要被声明为Point的一个friend。
然而,实际上我们并不能够强迫将任何函数都变为inline。关键词inline只是一项请求。如
果这项请求被接受,编译器就必须认为它可以用一个表达式(expression)合理地将这个函数
扩展开来。
一般而言,处理一个inline函数,有两个阶段:
1)分析函数定义,以决定函数的“intrinsic inline ability”(本质的inline能力)。“instrinsic”
一词在这里指“与编译器相关”。
如果函数因其复杂度,或因其建构问题,被判断不可成为inline,它会被转为一个static
函数,并在编译模块内产生对应的函数定义。
2)真正的inline函数扩展操作是在调用的那一点上。这会带来参数的求值操作以及临时对象
的管理。
1、形式参数(Formal Arguments)
在inline扩展期间,每一个形式参数都会被对应的实际参数取代。一般而言,面对“会带来副
作用的实际参数”,通常都需要引入临时性对象。换句话说,如果实际参数是一个常量表达式
(constant expression),我们可以在替换之前先完成其求值操作;后继的inline替换,就可以把
常量直接“绑”上去。如果既不是常量表达式,也不是个带有副作用的表达式,那么就直接带换
之。
假设有以下的简单inline函数:
inline int
min( int i, int j )
{
return i < j ? i : j;
}
下面是三个调用操作:
inline int
bar()
{
int minval;
int val1 = 1024;
int val2 = 2048;
/* (1) */ minval = min( val1, val2 );
/* (2) */ minval = min( 1024, 2048 );
/* (3) */ minval = min( foo(), bar()+1 );
return minval;
}
标示为(1)的那一行会被扩展为:
// (1)参数直接替换
minval = val1 < val2 ? val1 : val2;
标示为(2)的那一行直接拥抱常量:
// (2) 代换之后,直接拥抱常量
minval = 1024;
表示为(3)的那一行则引发参数的副作用。它需要导入一个临时性对象,以避免重复求
值:
// (3) 有副作用,所以导入临时性对象
int t1;
int t2;
minval = ( t1 = foo() ), ( t2 = bar() + 1 ),
t1 < t2 ? t1 : t2;
2、局部变量(Local Variables)
如果我们轻微地改变定义,在inline定义中加入一个局部变量,会怎样?
inline int
min( int i, int j )
{
int minval = i < j ? i : j;
return minval;
}
这个局部变量需要什么额外的支持或处理吗?如果我们有以下的调用操作:
{
int local_var;
int minval;
// ...
minval = min( val1, val2 );
}
inline被扩展来后,为了维护其局部变量,可能会成为这个样子
{
int local_var;
int minval;
// 将inline函数的局部变量处以“mangling”操作
int _min_lv_minval;
minval =
( _min_lv_minval =
val1 < val2 ? val1 : val2 ),
_min_lv_minval;
}
一般而言,inline函数中的每一个局部变量都必须被放在函数调用的一个封闭区段中,拥有
一个独一无二的名称。如果inline函数以单一表达式扩展多次,则每次扩展都需要自己的一组局
部变量。如果inline函数以分离的多个式子(duscrete statements)被扩展多次,那么只需要一
组局部变量,就可以重复使用。
inline函数中的局部变量,再加上有副作用的参数,可能会导致大量临时性对象的产生。特
别是如果它以单一表达式被扩展多次的话。例如:
minval = min( val1, val2 ) + min( foo(), foo() + 1 );
可能被扩展为:
// 为局部变量产生临时变量
int _min_lv_minval_00;
int _min_lv_minval_01;
// 为放置副作用值而产生临时变量
int t1;
int t2;
minval =
( ( _min_lv_minval_00 =
val1 < val2 ? val1 : val2 ),
_min_lv_minval_00 )
+
( ( _min_lv_minval_01 = ( t1 = foo() ),
( t2 = foo() + 1 ),
t1 < t2 ? t1 : t2 ),
_min_lv_minval_01 );
Inline函数对于封装提供了一种必要的支持,可以有效存取封装于class中的nonpublic数
据。它同时也是C程序中大量使用的#define(前置处理宏)的一个安全代替品——特别是如果
宏中的参数有副作用的话。然而一个inline函数如果被调用太多次的话,会产生大量的扩展码,
使程序大小暴涨。
对于既要安全又要效率的程序码,inline函数提供了一个强而有力的工具。然而,与non-
inline函数比起来,它们需要更加小心地处理。