首页 > 代码库 > 深度探索C++对象模型第6章 执行期语意学
深度探索C++对象模型第6章 执行期语意学
(一)对象的构造和析构(Object Construction and Destruction)
一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要的对象产生操作和摧毁操作。
全局对象
如果我们有以下程序片段:
Matrix identity
main()
{
//identity 必须在此处被初始化
Matrix m1=identity;
...
return 0;
}
C++保证,一定会在main()函数中第一次用到identity之前,把identity构造出来,而在main()函数结束之前把identity摧毁掉。像identity这样的所谓global object如果有constructor和destructor的话,我们说它需要静态的初始化和内存释放操作。
C++程序中所有的global objects都被设置在程序的data segment中。如果显示指定给它一个值,此object将以该值为初值。否则object所配置到的内存内容为0。
在C语言中一个global object只能够被一个常量表达式(可在编译时期求其值得那种)设定初值。当然,constructor并不是常量表达式。虽然class object在编译时期可以被放置于data segment中并且内容为0,但constructor一直要到程序启动(startup)时才会实施。必须对一个“放置于program data segment中的object的初始化表达式”做评估(evaluate),这正是为什么一个object需要静态初始化的原因。
局部静态变量(Local Static Objects)
假设我们有以下程序片段:
const Matrix&
identity()
{
static Matrix mat_identity;
//...
return mat_identity;
}
local static class object保证了什么样的语意?
1、mat_identity的constructor必须只能施行一次,虽然上述函数可能会被调用多次。
2、mat_identity的destructor必须只能施行一次,虽然上述函数可能会被调用多次。
编译器的策略之一就是,无条件地在程序起始(startup)时构造出对象来。然而这会导致所有的local static class objects都在程序起始时被初始化,即使它们所在的那个函数从不曾被调用过。因此,只在identity()被调用时才把mat_identity构造起来,是比较好的做法。具体做法如下:
首先,导入一个临时性对象以保护mat_identity的初始化操作。第一次处理identity()时,这个临时对象被评估为false,于是constructor会被调用,然后临时对象被改为true。这样就解决了构造的问题。而在相反的那一端,destructor也需要有条件地执行于mat_identity是否被构造起来,很简单,如果那个临时变量为true,就表示构造好了。困难的是,由于cfront产生C码,mat_identity对函数而言仍然是local,因此没办法在静态的内存释放函数中存取它。
新的规则要求编译单位中的static local class objects必须被摧毁——以构造的相反顺序摧毁。由于这些objects是在需要时才被构造(例如每一个含有static local class objects的函数第一次被进入时),所以编译时期无法预期其集合以及顺序。为了支持新的规则,可能需要对被产生出来的static class object保持一个执行期链表。
对象数组(Array of Objects)
假设我们有下列的数组定义:
Point konts[10];
什么东西需要完成?如果Point既没有定义一个constructor也没有定义一个destructor,那么我们的工作不会比建立一个“内建(build-in)类型所组成的数组”更多,也就是我们只要配置足够内存以存储10个连续的Point元素即可。
然而Point的确定义了一个default destructor,所以这个destructor必须轮流施行于每一个元素之上。一般而言这是经由一个或多个runtime library函数达成的。在c_front中,我们使用一个被命名为vec_new()的函数,产生出以class objects构造而成的数组。比较新的编译器,包括Borland、Microsoft和Sun,则是提供两个函数,一个用来处理“没有virtual base class”的class,另一个用来处理“内含virtual base class”的class。后一个函数通常被称为vec_vnew()。函数类型通常如下(当然在各平台上可能会有些许差异):
void*
vec_new(
void *array, //数组起始地址
size_t elem_size, //每一个class object的大小
int elem_count, //数组中元素个数
void (*constructor)(void*),
void (*destructor)(void*,char)
)
其中的constructor和destructor参数是这一class之default constructor和default destructor的函数指针。参数array持有的若不是具名数组的地址,就是0。如果是0,那么数组将经由应用程序的new运算符,被动态的置于heap中。Sun把“由class objects所组成的具名数组”和“动态配置而来的数组”的处理操作分为两个library函数:_vector_new2和_vector_con,它们各自拥有一个virtual base class函数实例。
参数elem_size表示数组中的元素个数。在vec_new()中,constructor施行于elem_count个元素之上。对于支持exception handling的编译器而言,destructor的提供是必要的。下面是编译器可能针对我们的10个Point元素所做的vec_new()调用操作:
Point konts[10];
vec_new(&knots,sizeof(Point),10,&Point::Point,0);
如果Point也定义了一个destructor,当konts的生命结束时,该destructor也必须施行于那10个Point元素身上。我想你不会惊讶,这是经由一个类似的vec_delete()(或是一个vec_vdelete()——如果classes 拥有virtual base classes的话)的runtime library函数完成的,其函数类型如下:
void*
vec_delete(
void *array, //数组起始地址
size_t elem_size, //每一个class object的大小
int elem_count, //数组中的元素个数
void (*destructor)(void*,char)
)
有些编译器会另外增加一些参数,用以传递其他数值,以便能够有条件地导引vec_delete()的逻辑。在vec_delete()中,destructor被施行于elem_count个元素身上。
如果程序员提供一个或多个明显初值给一个由class objects组成的数组,像下面这样,会如何:
Point konts[10]=
{
Point(),
Point(1.0,1.0,0.5),
-1.0
};
对于那些明显获得初值的元素,vec_new()不再有必要。对于那些尚未初始化的元素,vec_new()的施行方式就像面对“由class elements组成的数组,而该数组没有explicit initialization list”一样。因此上一个定义很可能被转换为:
Point konts[10];
//C++ 代码
//显式地初始化前3个元素
Point::Point(&konts[0]);
Point::Point(&konts[1],1.0,1.0,0.5);
Point::Point(&konts[2],-1.0,0.0,0.0);
//以vec_new初始化后的7个元素
vec_new(&knots+3,sizeof(Point),7,&Point::Point,0);
Default Constructor和数组
如果你想在程序中取出一个constructor的地址,是不可以的。当然啦,这是编译器在支持vec_new()时该做的事情。然而,经由一个指针来启动constructor,将无法(不被允许)存取default argument values。cfront所采用的方法是产生一个内部的stub constructor,没有参数。在其函数内调用由程序员提供的constructor,并将default参数值显示地指定过去(由于constructor的地址已被取得,所以它不能够成为一个inline)。
(二)new和delete运算符
运算符new的使用,看起来似乎是个单一运算,像这样:
int *pi=new int(5);
但事实上它是由两个步骤完成的:
1、通过适当的new运算符函数实例,配置所需的内存:
//调用函数库中的new运算符
int *pi=_new(sizeof(int));
2、将配置得来的对象设立初值:
*pi=5;
更进一步地说,初始化操作应该在内存配置成功(经由new运算符)后才执行:
//new 运算符的两个分离步骤
//given:int *pi=new int(5);
//重写声明
int *pi;
if(pi=_new(sizeof(int)))
*pi=5; //译注:成功了才初始化
delete运算符的情况类似。当程序员写下:
delete pi;
时,如果pi的值是0,C++语言会要求delete运算符不要有操作。因此编译器必须为此调用构造一层保护膜:
if(pi!=0)
_delete(pi);
请注意pi并不会因此被自动清除为0,因此像这样的后继行为:
//没有良好的定义,但是合法
if(pi && *pi==5)...
虽然没有良好的定义,但是可能(也可能不)被评估为真。这是因为对于pi所指向之内存的变更或再使用,可能(也可能不)会发生。
pi所指对象的生命会因delete而结束。所以后继任何对pi的参考操作就不再保证有良好的行为,并因此被视为是一种不好的程序风格。然而,把pi继续当做一个指针来用,仍然是可以(虽然其使用受到限制),例如:
//ok:pi仍然指向合法空间
//甚至即使存储于其中的object已经不再合法
if(pi==sentine1)...
在这里,使用指针pi,和是使用pi所指的对象,其差别在于哪一个的生命已经结束了。虽然该地址上的对象不再合法,地址本身却仍然代表一个合法的程序空间。因此pi能够继续被使用,但只能在受限制的情况下,很像一个void*指针的情况。
以constructor来配置一个class object,情况类型。例如:
Point3d *origin=new Point3d;
被转换为:
Point3d *origin;
//C++伪码
if(origin = _new(sizeof(Point3d)))
origin=Point3d::Point3d(origin);
如果实现出exception handing,那么转换结果可能会更复杂些:
//C++伪码
if(origin = _new(sizeof(Point3d)))
{
try
{
origin=Point3d::Point3d(origin);
}
catch(...)
{
//调用delete library function以
//释放new而配置的内存
_delete(origin);
//将原来的exception上传
throw;
}
}
在这里,如果以new运算符配置object,而其constructor抛出一个exception,配置得来的内存就会被释放掉。然后exception再被抛出去(上传)。
Destructor的应用极为类似。下面的式子:
delete origin;
会变成:
if(origin != 0)
{
//C++伪码
Point3d::~Point3d(origin);
_delete(origin);
}
如果在exception handling的情况下,destructor应该被放在一个try区段中。exception handler会调用delete运算符,然后再一次抛出该exception。
一般的library对于new运算符的实现操作都很直截了当,但有两个精巧之处值得斟酌(请注意,以下版本并未考虑exception handling):
extern void*
operator new(size_t size)
{
if(size==0)
size=1;
void *last_alloc;
while(!(last_alloc=malloc(size)))
{
if(_new_handler)
(*_new_handler)();
else
return 0;
}
return last_alloc;
}
虽然这样写是合法的:
new T[0];
但语言要求每一次对new的调用都必须传回一个独一无二的指针。解决此问题的传统方法是传回一个指针,指向一个默认为1-bytes的内存区域(这就是为什么程序代码中的size被设为1的原因)。这个实现技术的另一个有趣之处是,它允许使用者提供一个属于自己的_new_handler()函数。这正是为什么每一次循环都调用_new_handler()之故。
new运算符实际上总是以标准的C malloc()完成,虽然并没有规定一定得这么做不可。相同情况,delete运算符也总是以标准的C free()完成:
extern void
operator delete(void *ptr)
{
if(ptr)
free((char*)ptr);
}
针对数组的new语意
当我们这没写:
int *p_array=new int[5];
时,vec_new()不会真正被调用,因为它的主要功能是把default constructor施行于class objects所组成的数组的每一个元素身上。倒是new运算符函数会被调用:
int *p_array=(int*) _new(5*sizeof(int));
相同情况,如果我们写:
//struct simple_aggr{float f1,f2;};
simple_aggr *p_aggr=new simple_aggr[5];
vec_new()也不会被调用。为什么?simple_aggr并没有定义一个constructor或destructor,所以配置数组以及清除p_aggr数组的操作,只是单纯地获得内存和释放内存而已。这些操作由new和delete运算符来完成就绰绰有余了。
然而如果class定义了一个default constructor,某些版本的vec_new()就会被调用,配置并构造class objects所组成的数组。例如这个算式:
Point3d *p_array=new Point3d[10];
通常会被编译为:
Point3d *p_array;
p_array=vec_new(0,sizeof(Point3d),10,&Point3d::Point3d,&Point3d::~Point3d);
在个别的数组元素构造过程中,如果发生exception,destructor就会被传递给vec_new()。只有已经构造妥当的元素才需要destructor的施行,因为它们的内存已经被配置出来了,vec_new()有责任在exception发生的时机把那些内存释放掉。
寻找数组维度,对于delete运算符的效率带来极大的冲击,所以才导致这样的妥协:只有在中括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个object要被删除。如果程序员没有提供必须的中括号,那么就只有第一个元素会被析构。其他的元素仍然存在——虽然其相关的内存已经被要求归还了。
施行于数组上的destructor,如我们所见,是根据交给vec_delete()函数的“被删除之指针类型的destructor”。这很明显并非我们所希望。此外,每一个元素的大小也一并被传递过去。这就是vec_delete()如何迭代走过每一个数组元素的方式。
基本上,程序员必须迭代走过整个数组,把delete运算符实施于每一个元素身上。以此方式,调用操作将是virtual。
Placement Operator new的语意
有一个预先定义好的重载的(overloaded)new运算符,称为placement operator new。它需要第二个参数,类型为void*。调用方式如下:
Point2w *ptw = new(arena) Point2w;
其中arena指向内存中的一个区块,用以放置新产生出来的Point2w object。这个预先定义好的placement operator new的实现方式简直是出乎意料的平凡。它只要将“获得的指针(arena)”所指的地址传回即可:
void* operator new(size_t,void* p)
{
return p;
}
如果它的作用只是传回其第二个参数,那么它有什么价值呢?也就是说,为什么不简单地这么写算了:
Point2w *ptw=(Point2w*)arena;
事实上这只是所发生的操作的一半而已。另外一半无法由程序员产生出来。想想这些问题:
1、什么是使placement new operator能够有效运行的另一半扩充(而且是“arena的显式指定操作(explicit assignment)”所没有提供的)?
2、什么是arena指针的真正类型?该类型暗示了什么?
Placement new operator所扩充的另一半是将Point2w constructor自动实施于arena所指的地址上:
//C++伪码
Point2w *ptw=(Point2w*) arena;
if(ptw != 0)
ptw->Point2w::Point2w();
这正是使placement operator new威力如此强大的原因。这一份代码决定objects被放置在哪里;编译系统保证object的constructor会施行于其上。
然而却有一个轻微的不良行为。你能得出来吗?下面是一个有问题的程序片段:
//让arena成为全局性定义
void fooBar()
{
Point2w *p2w=new(arena) Point2w;
//...do it...
//...now manipulate a new object...
p2w=new(arena) Point2w;
}
如果placement operator在原已存在的一个object上构造新的object,而该既存的object有个destructor,这个destructor并不会被调用。调用该destructor的方法之一是将那个指针delete掉。不过在此例中如果你像下面这么做,绝对是个错误:
//以下并不是实施destructor的正确方法
delete p2w;
p2w = new(arena) Point2w;
是的,delete运算符会发生作用,这的确是我们所期待的。但是它也会释放由p2w所指的内存,这却不是我们所希望的,因为下一个指令就要用到p2w了。因此,我们应该显示地调用destructor并保留存储空间以便再使用:
//施行destructor的正确方法
p2w->~Point2w;
p2w = new(arena) Point2w;
剩下的唯一问题是一个设计上的问题:在我们的例子中对placement operator的第一次调用,会将新object构造于原已存在的object之上吗?还是会构造于全新地址上?也就是说,如果我们这样写:
Point2w *p2w = new (arena) Point2w;
我们如何知道arena所指的这块区域是否需要先析构?这个问题在语言层面上并没有解答。一个合理的风俗是令执行new的这一端也要负起destructor的责任。
另一个问题关系到arena所表现的真正指针类型。C++ Standard说它必须指向相同类型的class,要不就是一块“新鲜”内存,足够容纳该类型的object。注意,derived class很明显并不在被支持之列。对于一个derived class,或是其他没有关联的类型,其行为虽然并非不合法,却也未经定义。
“新鲜”的存储空间可以这样配置而来:
char *arena = new char[sizeof(Point2w)];
相同类型的object则可以这样获得:
Point2w *arena = new Point2w;
不论哪一种情况,新的Point2w的存储空间的确是覆盖了arena的位置,而此行为已在良好控制之下。然而,一般而言,placement new operator并不支持多态。被交给new的指针,应该适当地指向一块预先配置好的内存。如果derived class比其base class大,例如:
Point2w *p2w = new (arena) Point3w;
Point3w的constructor将会导致严重的破坏。
(三)临时性对象(Temporary Objects)
临时性对象的被摧毁,应该是对完整表达式求值过程中的最后一个步骤。该完整表达式造成临时对象的产生。
如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference的生命结束,或直到临时对象的生命范畴(scope)结束——视哪一种情况先到达而定。
参考资料:《深度探索C++对象模型》
深度探索C++对象模型第6章 执行期语意学