首页 > 代码库 > 深度探索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 运算符的两个分离步骤
//givenint *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++对象模型》

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    深度探索C++对象模型第6章 执行期语意学