UML软件工程组织

深度探索C++对象模型(二)
作者:雷神

深度探索C++对象模型(6)

这是这个系列笔记的第7篇了,我们还在和构造函数打交道,以前写程序时怎么根本没有考虑过构造函数的事情呢?原来编译器为我们做了这么多的事情,我们都不知道.,要想完全搞明白,看来还需要一段时间.我们继续向下走,进入一个新的章节.在第三章一开始,我就吃了一惊..书上给出了一个例子:
class X{};
class Y:public virtual class X{};
class Z:public virtual class X{};
class A:public Y,public Z{};

下面的结果会因为机器,以及编译有关,不同的情况会产生不同的结果.(怎么会是这样?)

sizeof X; //结果为1
sizeof Y; //结果为8
sizeof Z; //结果为8
sizeof A; //结果为12

一个没有任何成员的类,大小居然不是0.

为什么?

首先一个没有明显的含有成员的类,它的大小不是0,因为实际上它不是空的,它被编译器安插了一个char,为的是使这个类的两个对象能够在内存中被分配独一无二的地址.至于两个派生的类Y和Z,因为语言本身造成的负担,还有编译器对于特殊情况进行的优化处理,再有Alignment的限制,因此结果变成了8.这个8是怎么组成的?

4个bytes用来存放指针,什么指针?指向virtual base class subobject的指针呀.

一个同class X一样的char.它占了1 个bytes.然后受到Alignment的限制,所以填补了3个bytes.

4+1+3=8

不过需要注意的是不同的编译器Y和Z大小的结果也会不同.因为新的编译器会将一个空的virtual base class看做是派生类对象的开头部分,因此派生类有了member,因此也就不必分配char的那一个bytes.也就用不到填补的3个bytes,因此有可能在某些编译器中,class Y和class Z的大小为4.

最后看看A.根据我们对class Y的分析可以得出以下算式:

4+4+1+3=12;

不是我们想象的16,而是12.如果换成我们上面说的新的编译器来编译,结果很有可能是8.

我们来看Data Member 的Binding,现在我们对数据成员的绑定只需要记住一个防御性风格:始终把嵌套类型的声明放在class的开始部分,这样做可以确保非直觉绑定的正确性。看下面的一个例子:

typedef int length; //zai
class point3d
{
public:
//length被决议成global typedef 也就是int
//_val被决议成Point3d::_val
void mumble(length val){_val=val;}
length mumble(){return _val;}
//……
private:
//length必须在这个class对它的第一个参考操作之前被看见
//这样声明将使先前的参考操作不合法
typedef float length;
length _val;
//……
};

怎么成了抄书了,雷神也不知不觉,可能是在这章的理解上比较容易些吧,不用去想个看的见摸的着的东西比划。好象小朋友学算术,一位数的计算不用掰手指头,可是两位数或者三位数的计算,手指头加上脚指头还是不够。学习就是这么回事。理解力和抽象能力很重要。回来继续学习。

通过这一章我还知道了。数据成员的布局。数据成员的存取。并且对Static data members有了进一步的了解,在class的生命周期中,静态成员被看作是全局变量,每一个member的存取不会导致任何空间或效率上的额外负担。不论是从一个复杂的继承关系中继承还是直接声明的,Static data member都只会有一个实体。并且有着非常直接的存取路径。另外如果两个类都声明了一个相同名字的静态成员变量,那么编译器会通过一种算法,为我们解决名字冲突的问题。而非静态的成员变量的存去实际上是通过implicit class object(this指针)来完成的。例如
Point3d

Point3d::translate(const Point3d &pt)
{
x+=pt.x;
y+=pt.y;
z+=pt.z;
}

被编译器经过内部转换成为了下面这个样子:

Point3d
Point3d::translate(Point3d *const this,const Point3d &pt)
{
this->x+=pt.x;
this->y+=pt.y;
this->z+=pt.z;
}

如果要对一个非静态的成员变量进行存取,编译器会把类对象的起始地址加上数据成员的偏移量。例如:

Point3d origin;
origin._y=0.0;
//地址&origin._y将等于
&origin+(&Point3d::_y-1);
目的是使编译系统能够区分出以下两种情况:
一个指向数据成员的指针,用来指出类的第一个成员。
一个指向数据成员的指针,没有指出任何成员。
这是什么意思?什么是指向数据成员的指针。书上的例子:
class Point3d
{
public:
virtual ~Point3d();
//……
protected:
static Point3d origin;//静态的数据成员,位置在class object之外
float x,y,z;//每个float是4bytes
}
&Point3d::z; //这个值是什么?

我们在这篇文章开始的时候已经知道了还有一个vptr,不过vptr的位置也许在对象的开始,也许在对象的结尾部。所以上面的操作的值应该是8或者12(如果vptr在前面的话)。但实际上取会的值被加上了1。原因是必须要区别一个不指向任何成员的指针,和一个指向第一个成员的指针。又有点不好理解了,举个例子:

想象你和你的另外两个朋友合住一个三室一厅的房子,你住在第一间。如果你给一个你们三个人共同的朋友的地址你可以给房号就行了。不用给出你们的任意一个人的那间房子号(不指向任何成员)。但如果你给你的一个私人朋友地址,你会给出房间号和你的那个房间号。为了使这个地址有区别,你必须有一个厅来作为偏移量(offset)。不知道大家明白这个例子吗,也许这个例子会影响你的正确思维。那就太糟糕了。不过我还是喜欢这样想问题,也许不太准确,但可以帮助我,因为想象一个内存空间比想象一个三居室要难好几点儿。

深度探索C++对象模型(7)

在单一继承的体系中,虚函数机制是一种很有效率的机制。我们判断一个类是否支持多态,只需要看它有没有虚函数便可以了。 第四章,函数的语意学。先做个复习C++支持三种成员函数:静态、虚、和非静态。每一种函数的调用方式都不同,当然他们的作用也会有区别,一般来说我们只要掌握根据我们的需要正确的使用这三种类型的成员函数便可以了,至于内部是如何运做的我们可以不知。

我们的在设计和使用类时最常用的便是非静态成员函数,使用成员函数是为了封装和隐藏我们的数据,我想这是成员函数和外部函数的最明显的区别。但是他们的效率是否有不同呢?我们不会想为了保护我们的数据而使用成员函数,最后确导致效率降低的结果。让我们看看非静态成员函数在实际的执行时被编译器搞成了什么样子。

float magnitude3d(const Point3d *_this){…}
//这是一个外部函数,它有参数。表示它间接的取得坐标(Point3d)成员。
float Point3d::mangnitude3d() const {…}
//这是一个成员函数,它直接取得坐标(Point3d)的成员。

表面上看,似乎成员函数的效率高很多,但实际上他们的效率真的想我们想象的那样吗?非也。实际上一个成员函数被内部转化成了外部函数。
1、 一个this指针被加入到成员函数的参数中,为的是能够使类的对象调用这个函数。
2、 将对所有非静态数据成员的存取操作改为由this来存取。
3、 对函数的名称进行重新的处理,使它成为程序中独一无二的。

这时后,经过以上的转换,成员函数已经成为了非成员函数。
float Point3d::mangnitude3d() const {…}//成员函数将被变成下面的样子
//伪码
mangnitude3d__7Point3dFv(register Point3d * const this)
{
return sqrt(this->_x * this->x+
this->_y * this->y+
this->_z * this->z);
}

调用此函数的操作也被转换
obj. mangnitude3d()
被转换成:
mangnitude3d__7Point3dFv(*obj);
怎么样看出来了吧,和我们开始声明的非成员函数没有区别了。因此得出结论:两个铁球同时落地。

一般来说,一个成员的名称前面会被加上类的名称,形成唯一的命名。实际上在对成员名称做处理时,除了加上了类名,还会将参数的链表一并加上,这样才能保证结果是独一无二的。

我们在来看看静态成员函数。我们有这样的概念,成员函数的调用必须是用类的对象,象这样obj.fun();或者这样ptr->fun().但实际上,只有一个或多个静态数据成员被成员函数存取时才需要类的对象。类的对象提供一个指针this,用来将用到的非静态数据成员绑定到类对象对应的成员上。如果没有用到任何一个成员数据,就不需要用到this指针,也就没有必要通过类的对象来调用一个成员函数。而且我们还知道静态数据成员是在类之外的,可以被视做全局变量的,只不过它只在一个类的生命范围内可见。(参考前面的笔记)。而且一般来说我们会将静态的数据成员声明为一个非Public。这样我们便必须提供一个或多个成员函数用来存取这个成员。虽然我们可以不依靠类的对象存取静态数据成员,但是这个可以用来存取静态成员的函数确实必须绑定在类的对象上的。为了更加好的解决这个问题,cfront2.0引入了静态成员函数的概念。

静态成员函数是没有this指针的。因为它不需要通过类的对象来调用。而且它不能直接存取类中的非静态成员。并且不能够被声明为virtual,const,volatile.如果取得一个静态成员函数的地址,那么我们获得的是这个函数在内存中的位置。(非静态成员函数的地址我们获得的是一个指向这个类成员函数的指针,函数指针)。可以看到由于静态成员函数没有this指针,和非成员函数非常的相似。

有了前面几章的基础,好象这些描述理解起来也不很费劲,而且我们的思路可以跟着书上所说的一路倾泻下来,这便是读书的乐趣所在了,如果一本书读起来都想读第一章时那样费劲,我想我读不下去的可能性会很高。

继续我们的学习,下面书上开始将虚函数了。我们知道虚函数是C++的一个很重要的特性,面向对象的多态便是由虚函数实现的。多态的概念是一个用一个public base class的指针(或者引用),寻址出一个派生类对象。虚函数实现的模型是这样。每一个类都有一个虚函数表,它包含类中有作用的虚函数的地址,当类产生对象时会有一个指针,指向虚函数表。为了支持虚函数的机制,便有了“执行期多态”的形式。

下面这样。
我们可以定义一个基类的指针。
Point *ptr;
然后在执行期使他寻址出我们需要的对象。可以是
ptr =new Point2d;
还可以是
ptr=new Pont3d;
ptr这个指针负责使程序在任何地方都可以采用一组由基类派生的类型。这种多态形式是消极的,因为它必须在编译时期完成。与之对应的是一种多态的积极形式,即在执行期完成用指针或引用查找我们的一个派生类的对象。
象下面这样:
ptr->z();
要想达到我们目的,这个函数z()应该是虚函数,并且还应该知道ptr所指的对象的真实类型,以便我们选择z()的实体。以及z()实体的位置,以便我们能够调用它。这些工作编译器都会为我们做好,编译器是如何做的呢?
我们已知每一个类会有一个虚函数表,这个表中含有对应类的对象的所有虚函数实体的地址,并且可能会改写一个基类的虚函数实体。如果没有改写基类存在的虚函数实体,则会继承基类的函数实体,这还没完,还会有一个pure_virtual_called()的函数实体。每一个虚函数不论是继承的还是改写的,都会被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的虚函数关联。
说明:当没有改写基类的虚函数时,该函数的实体地址是被拷贝到派生类的虚函数表中的。

这样我们便实现了执行期的积极多态。这种形式的特点是,我们从头到尾都不知道ptr指针指向了那一个对象类型,基类?派生类1?派生类2?我们不知道,也不需要知道。我们只需要知道ptr指向的虚函数表。而且我们也不知道z()函数的实体会被调用,我们只知道z()函数的函数地址被放在虚函数表中的位置。

总结:在单一继承的体系中,虚函数机制是一种很有效率的机制。我们判断一个类是否支持多态,只需要看它有没有虚函数便可以了。

但是构造函数和析构函数和new和delete不同,他们并非必须成对的出现。决定是否为一个类写构造函数或者析构函数,是取决于这个类对象的生命在哪里结束(或开始)。需要什么操作才能保证对象的完整。

深度探索C++对象模型(8)

书的第四章后半部分详细的讲解内联函数,由于比较容易理解,我做一个简单总结便过去吧。

内联函数和其他的函数相比是一种效率很高的函数,未优化的情况下效率可以提高25%,优化以后简直是数量级的变化,书上的给出的数据是0.08比4.43。简直没法比了。内联函数对于封装提供了一种必要的支持,可以有效的存去类中的非共有数据成员,同时可以替代#define(前置处理宏)。但是它也有缺点,程序会随着调用内联函数次数的增多,而产生大量的扩展码。

在内联函数的扩展时每一个形式参数被对应的实参取代,因此会有副作用。通常需要引入临时对象解决多次对实际参数求值的操作产生的副作用。

第五章的开始给出了一个不恰当的抽象类的声明:
class Abstract_base
{
public:
virtual ~Abstract_base()=0;//纯虚析构函数
virtual void interface() const=0; //纯虚函数
virtual const char* mumble() const{return _mumble;}
protected:
char *_mumble;
};

这是一个不能产生实体的抽象类,因为它有纯虚函数。为什么说它存在不合适的地方呢?以下逐一进行说明。

1、 它没有一个明确的构造函数,因为没有构造函数来初始化数据成员则它的派生类无法决定数据成员的初值。类的成员数据应该在构造函数或成员函数中被指定初值,否则将破坏封装性质。

2、 每一个派生类的析构函数会被编译器进行扩展以静态调用方式调用其上层基类的析构,哪怕是纯虚函数。但是编译器并不能在链接时找到纯虚的析构函数,然后合成一个必要的函数实体,因此最好不要把虚的析构函数声明成纯虚的。

3、 除非必要,不要把所有的成员函数都声明为虚函数。这不是一个好设计观念。

4、 除非必要,不要使用const声明函数,因为很多派生的实体需要修改数据成员。

有了以上的观点上面的抽象类应该改为下面这种样子:
class Abstract_base
{
public:
virtual ~Absteact_base(); //不在是纯虚
virtual void interface()=0; //不在是const
const char * mumble() const{return _mumble;} //不在是虚函数
protected:
Abstract_base(char *pc=0); //增加了唯一参数的构造
Char *_mumble;
};

下一个问题,对象的构造。构造一个对象出来很简单,这是我们在编程时经常要做的事情。我理解书上的意思是为我们分析了各种不同的类,例如一个没有Copy constructor,Copy operator的类,或者有私有变量但是没有定义虚函数的类等等,当他们构造对象时也有多种情况,global,local,还有在new时,编译器都做了什么,内存的分配情况如何。搞清楚它们也很有意思。另外这好象是前面几章学到的东西的一个进一步的研究。我们找出最复杂的虚拟继承来进行一下研究。当一个类对象被构造时,实际上这个类的构造函数被调用,不论是我们自己写的,还是由编译器为我们合成的。并且编译器会背着我们做很多的扩充工作,将记录在成员初始化列表中的数据成员的初始化工作放进构造函数,如果一个数据成员没有在成员初始化列表中出现,则会调用默认的构造函数,这个类的所有基类的构造都会被调用,以基类的声明顺序。所有的虚拟基类的构造也会被调用。还要为virtual table pointers设定初始值,指向适当的virtual tables。好家伙,编译器还真累。好象说的不是很清楚,抄一段书上的代码。

已知一个类的层次结构和派生关系如下图:

见书上P211。
这是程序员给出的PVertex的构造函数:
PVertex::PVertex(float x,float y,float z):_next(0),Vertex3d(x,y,z),Point(x,y)
{
if(spyOn)
cerr<<”within PVertex::PVertex()”<<”size:”<<size()<<endl;
}

它可能被扩展成为:
//C++伪码
// PVertex构造函数的扩展结果
PVertex *
PVertex::PVertex(PVertex * this,bool most_derived,float x,float y,float z)
{
//条件式的调用虚基类的构造函数
if(_most_derived!=false)
this->Point::Point(x,y);
//无条件的调用上层基类的构造函数
this->Vertex3d::Vertex3d(x,y,z);

//将相关的vptr初始化
this->_vptr_PVertex=_vtbl_PVertex;
this->_vptr_Point_PVertex=_vtbl_Point_PVertex;

//原来构造函数中的代码
if(spyOn)
cerr<<”within PVertex::PVertex()”<<”size:”
//经虚拟机制调用
<<(*this->_vptr_PVertex[3].faddr )(this)<<endl;
//返回被构造的对象
return this;
}

通过上面的代码我们可以比较清晰的了解在有多重继承+虚拟继承的时候构造一个对象时,编译会将构造函数扩充成一个什么样子。以及扩充的顺序。知道了这个相对于无继承,或者不是虚拟继承时对象的构造应该也可以理解了。与构造对象相对应的是析构。但是构造函数和析构函数和new和delete不同,他们并非必须成对的出现。决定是否为一个类写构造函数或者析构函数,是取决于这个类对象的生命在哪里结束(或开始)。需要什么操作才能保证对象的完整。象构造函数一样析构函数的最佳实现策略是维护两份destructor实体。一个complete object实体,总是设定好vptrs,并调用虚拟基类的析构函数。一个base class subobject实体。除非在析构函数中调用一个虚函数,否则绝不会调用虚拟基类的析构函数,并设定vptrs。

一个对象生命结束于析构函数开始执行的时候。它的扩展形式和构造函数的扩展顺序相反。

当编译一个C++程序时,计算机的内存被分成了4个区域,一个包括程序的代码,一个包括所有的全局变量,一个是堆栈,还有一个是堆(heap),我们称堆是自由的内存区域,我们可以通过new和delete把对象放在这个区域。你可以在任何地方分配和释放自由存储区。但是要注意因为分配在堆中的对象没有作用域的限制,因此一旦new了它,必须delete它,否则程序将崩溃,这便是内存泄漏。(C#已经通过内存托管解决了这一令人头疼的问题)。C++通过new来分配内存,new的参数是一个表达式,该表达式返回需要分配的内存字节数,这是我以前掌握的关于new的知识,下面看看通过这本书,使我们能够更进一步的了解到些什么。

深度探索C++对象模型(9)

这一章主要是说Runtime Semantics执行期语义学。

这是我们平时写的程序片段:
Matrix identity; //一个全局对象
Main()
{
Matrix m1=identity;
……
return 0;
}

很常见的一个代码片段,雷神从来没有考虑过identity如何被构造,或者如何被销毁。因为它肯定在Matrix m1=identity之前就被构造出来了,并且在main函数结束前被销毁了。我们不用考虑这些问题,好象C++就应该这样。但这本书是研究C++底层机制的。既然我们在看这本书,说明我们希望了解C++的编译器又做了那些大量的工作,使得我们可以这样使用对象。

在C++程序中所有的全局对象都被放在data segment中,如果明确赋值,则对象以该值为初值,否则所配置到内存内容为0。也就是说,如果我们有以下定义
Int v1=1024;
Int v2;
则v1和v2都被配置于data segment,v1值为1024,v2值为0。(雷神在VC6环境用MFC编程时中发现如果int v2;v2的值不为0,而是-8,不知为什么?编译器造成的?)。

如果有一个全局对象,并且这个对象有构造函数和析构函数的话,它需要静态的初始化操作和内存释放工作,C++是一种跨平台的编程语言,因此它的编译器需要一种可以移植的静态初始化和内存释放的方法。下面便是它的策略。

1、 为每一个需要静态初始化的档案产生一个_sit()函数,内带构造函数或内联的扩展。

2、 为每一个需要静态的内存释放操作的文件中,产生一个_std()函数,内带析构函数或内联的扩展。

3、 提供一个_main()函数,用来调用所有的_sti()函数,还有一个exit()函数调用所有的_std()函数。

侯先生说:

Sit可以理解成static initialization的缩写。

Std可以理解成static deallocation的缩写。

那么main函数会被编译器变成这样:

Matrix identity; //一个全局对象
Main()
{
_main();//对所有的全局对象做static initialization动作。
Matrix m1=identity;
……
exit();//对所有的全局对象做static deallocation动作。
}
其中_main()会有一个对identity对象的静态初始化的_sti函数,象下面伪码这样:
// matrix_c是文件名编码_identity表示静态对象,这样能够保证向执行文件提供唯一的识别符号
_sti__matrix_c_identity()
{
identity.Matrix:: Matrix(); //这就是静态初始化
}

相应的在exit()函数也会有一个_std_matrix_c_identity(),来进行static deallocation动作。

但是被静态初始化的对象有一些缺点,在使用异常时,对象不能被放置在try区段内。还有对象的相依顺序引出的复杂度,因此不建议使用需要静态初始化的全局对象。

局部静态对象在C++底层机制是如何构造和在内存中销毁的呢?

1、 导入一个临时对象用来保护局部静态对象的初始化操作。

2、 第一次处理时,临时对象为false,于是构造函数被调用,然后临时对象被改为true.

3、 临时对象的true或者false便成为了判断对象是否被构造的标准。

4、 根据判断的结果决定对象的析构函数是否执行。

如果一个类定义了构造函数或者析构函数,则当你定义了一个对象数组时,编译器会通过运行库将你的定义进行加工,例如:
point knots[10]; //我们的定义
vec_new(&knots,sizeof(point),10,&point::point,0); //编译器调用vec_new()操作。

下面给出vec_new()原型,不同的编译器会有差别。

void * vec_new(
void *array, //数组的起始地址
size_t elem_size, //每个对象的大小
int elem_count, //数组元素个数
void(*constructor)(void*),
void(*destructor)(void* ,char)
)
对于明显获得初值的元素,vec_new()不再有必要,例如:
point knots[10]={
Point(), //knots[0]
Point(1.0,1.0,0.5), //knots[1]
-1.0 //knots[2]
};
会被编译器转换成:
//C++伪码
Point::Point(&knots[0]);
Point::Point(&knots[1],1.0,1.0,0.5);
Point::Point(&knots[2],-1.0,0.0,0.0);
vec_new(&knots,sizeof(point),10,&point::point,0); //剩下的元素,编译器调用vec_new()操作。
怎么样,很神奇吧。

当编译一个C++程序时,计算机的内存被分成了4个区域,一个包括程序的代码,一个包括所有的全局变量,一个是堆栈,还有一个是堆(heap),我们称堆是自由的内存区域,我们可以通过new和delete把对象放在这个区域。你可以在任何地方分配和释放自由存储区。但是要注意因为分配在堆中的对象没有作用域的限制,因此一旦new了它,必须delete它,否则程序将崩溃,这便是内存泄漏。(C#已经通过内存托管解决了这一令人头疼的问题)。C++通过new来分配内存,new的参数是一个表达式,该表达式返回需要分配的内存字节数,这是我以前掌握的关于new的知识,下面看看通过这本书,使我们能够更进一步的了解到些什么。
Point3d *origin=new Point3d; //我们new 了一个Point3d对象
编译器开始工作,上面的一行代码被转换成为下面的伪码:
Point3d * origin;
If(origin=_new(sizeof(Point3d)))
{
try{
origin=Point3d::Point3d(origin);
}
catch(…){
_delete(origin);
throw;
}
}
而delete origin;
会被转换成(我将书上的代码改为exception handling情况):
if(origin!=0){
try{
Point3d::~Point3d(origin);
_delete(origin);
catch(…){
_delete(origin); //不知对否?
throw;
}
}

一般来说对于new的操作都直截了当,但语言要求每一次对new的调用都必须传回一个唯一的指针,解决这个问题的办法是,传回一个指针指向一个默认为size=1的内存区块,实际上是以标准的C的malloc()来完成。同样delete也是由标准C的free()来完成。原来如此。

最后这篇笔记再说说临时对象的问题。
T operator+(const T&,const T&); //如果我们有一个函数
T a,b,c; //以及三个对象:
c=a+b;
//可能会导致临时对象产生。用来放置a+b的返回值。然后再由 T的copy constructor把临时对象当作c的初值。也有可能直接由拷贝构造将a+b的值放到c中,这时便不需要临时对象。另外还有一种可能通过操作符的重载定义,经named return value优化也可以获得c对象。这三种方法结果一样,区别在于初始化的成本。对临时对象书上有很好的总结:
在某些环境下,有processor产生的临时对象是有必要的,也是比较方便的,这样的临时对象由编译器决定。
临时对象的销毁应该是对完整表达式求值过程的最后一个步骤。
因为临时对象是根据执行期语义有条件的产生,因此它的生命规则就显得很复杂。C++标准要求凡含有表达式执行结果的临时对象,应该保留到对象的初始化操作完成为止。当然这样也会有例外,当一个临时对象被一个引用绑定时,对象将残留,直到被初始化的引用的生命结束,或者超出临时对象的作用域。

 

版权所有:UML软件工程组织