深入探索C++对象模型(六)

时间:2020-11-27 13:41:00

执行期语意学(Runtime Semantics)

对象的构造和析构(Object Constructor and Destructor)

一般而言,constructor和destructor的安插都如你所预期:

{
Point point;
//point.Point::Point() 一般而言会被安插在这里
...
//point.Point::~Point() 一般而言会被安插在这里
}

如果一个区段(译注:以{}括起来的区域)或函数中有一个以上的离开点,情况会稍微混乱一些。Destructor必须被放在每一个离开点(当时object还存活)之前。例如:

{
Point point;
//constructor在这里行动
switch(int(point.x())){
case -1 :
//mumble;
//destructor在这里行动
return;
case 0:
//mumble
//destructor在这里行动
return;
default:
//mumble
//destructor在这里行动
return;
}
//destructor在这里行动
}

一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要的对象产生操作和摧毁操作。

全局对象(Global Objects)

如果我们有以下程序片段:

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。如下:

int v1 = 1024;
int v2;

v1和v2都被配置于程序的data segment,v1值为1024,v2值为0(这和C略有不同,C并不设定初值)。在C语言中一个global object只能够被一个常量表达式(可在编译期求其值的那种)设定初值。当然,constructor并不是常量表达式。虽然class object在编译时期可以被放置于data segment中并且内容为0,但constructor一直要到程序启动(startup)时才会实施。必须对一个“放置于program data segment中的object的初始化表达式”做评估(evaluate),这正是为什么一个object需要静态初始化的原因。

当cfront还是唯一的C++编译器,而且跨平台移植性比效率的考虑更重要的时候,有一个可移植但成本颇高的静态初始化(以及内存释放)方法,我把它成为munch。其策略如下:

  1. 为每一个需要静态初始化的档案产生一个_ sti()函数,内带必要的constructor调用操作或inline expansions。例如前面所说的identity对象会在matrix.c中产生出下面的_sti()函数(sti就是static initialization的缩写):
    _sti_matrix_c_identity(){
    identity.Matrix::Matrix();
    initialization;
    }
  2. 类似情况,在每一个需要静态的内存释放操作(static deallocation)的文件,产生一个_std()函数(static deallocation),内带必要的destructor操作,或是其inline expansions。在我们的例子中会有一个_ std()函数被产生出来,针对identity对象调用Matrix destructor。
  3. 提供一组runtime library "munch"函数:一个_main()函数(用以调用可执行文件中的所有 _sti()函数),以及一个exit(函数)(以类似方法调用所有的 _std()函数)。

深入探索C++对象模型(六)

以一个derived class的pointer或reference来存取virtual base class subobject,是一种nonconstant expression,必须在执行期才能加以评估求值。例如,尽管下列程序片段在编译时期可知:

//constant expression
Vertex3d *pv = new PVertex;
Point3d *p3d = pv;

深入探索C++对象模型(六)

其virtual base class Point的subobject在每一个derived class中的位置却可能会变动,因此不能够在编译时期设定下来,下面的初始化操作:

//Point是Point3d的一个virtual base class
//pt的初始化操作需要某种形式的执行期评估(runtime evaluation)
Point *pt = p3d;

需要编译器提供内部扩充,以支持class object的静态初始化(至少涵盖class objects的指针和references),例如:

//Initial support of virtual base class conversion requires
//non-constant initialization support
Point *pt = p3d->vbcPoint;

使用被静态初始化的objects有一些缺点,例如,如果exception handling被支持,那些objects将不能够被放置于try区段之内。这对于被静态调用的constructors可能是特别无法接受的,因为任何的throw操作将必然触发exception handling library默认的terminate()函数。另一个缺点是为了控制"需要跨越模块做静态初始化"objects的相依顺序而扯出来的复杂度。

局部静态对象(Local Static Objects)

假设有如下程序片段:

const Matrix& identity(){
static Matrix mat_identity;
//...
return mat_identity;
}

Local static class objects保证了什么样的语意?

  • mat_identity的constructor必须只能施行一次,虽然上述函数可能会被调用多次。
  • mat_identity的destructor必须只能施行一次,虽然上述函数可能会被调用多次。

编译器的策略之一就是,无条件地在程序起始(startup)时构造出对象来。然而这会导致所有的local static class objects都在程序起始时被初始化,即使它们所在的那个函数从不曾被调用过。因此,只在identity()函数被调用时才把mat_identity构造起来,是比较好的做法(现在的C++ Standard已经强制要求这一点)。那我们应该怎么做呢?

cfront之中的做法是:首先,导入一个临时性对象以保护mat_identity的初始化操作。第一次处理identity时,这个临时性对象被评估为false,于是constructor会被调用,然后临时性对象被改写为true。这样就解决了构造的问题。而在相反的一端,destructor也需要有条件施行于 mat_identity是否被构造起来,很简单,如果那个临时性对象为true,就表示构造好了。困难的是,由于cfront产生C码,mat_identity对函数而言仍然是local,因此我没办法在静态的内存释放函数(static deallocation function)中存取它。解决办法是:取出local object的地址。(当然,由于object是static,其地址再downstream compoent中被转换到程序内用来放置global object的data segment中)。下面是cfront的输出:

//被产生出来的临时对象,作为戒护之用
static struct Matrix *_0_F3 = 0; //C++的reference在C中是以pointer来代替identity()的名称会被mangled
struct Matrix* identity_Fv(){
static struct Matrix _1mat_identity; //如果临时性的保护对象已被设立,那就什么也别做,否则
//调用constructor: _ct_6MatrixFv
//设定保护对象,使它指向目标对象
_0_F3
? 0 : (_ct_6MatrixFv(& _1mat_identity),
(_0_F3 = (&_lmat_identity)));
...
}

最后,destructor必须在"与text program file(也就是本例中的stat_0.c)有关了的静态内存释放函数(static deallocation function)"中被有条件的调用:

char _std_stat_0_c(){
_0_F3 ? _dt_6MatrixFv(_0_F3, 2) : 0 ;
...
}

请记住,指针的使用时cfront所特有的;然而条件式解构则是所有编译器都需要的。

对象数组(Array of Objects)

假设有以下的数组定义:

Point knots[10];

需要完成什么东西呢? 如果Point既没有定义一个constructor也没有定义一个destructor,那么我们的工作不会比建立一个"内建(build-in)类型所组成的数组"更多,也就是说,我们只需配置足够的内存以存储10个连续的Point元素。

然而Point的确定义了一个default destructor,所以这个destructor必须轮流施行于每一个元素之上。一般而言这是经由一个或多个runtime library函数达成。在cfront中,我们使用一个被命名为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带有的若不是具名数组(本例为knots)的地址,就是0。如果是0,那么数组将经由应用程序的new运算符,被动态配置于heap中。

在vec_new()中,constructor施行于elem_count个元素之上。对于支持exception handling的编译器而言,destructor的提供是必要的。下面是编译器可能针对我们的10个Point元素所做的vec_new()调用操作:

Point knots[10];
vec_new(&knots, sizeof(Point), 10, &Point::Point, 0);

如果Point也定义了一个destructor,当knots的生命结束时,该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个元素身上。

如下例子:

Point knots[10] = {
Point(),
Point(1.0, 1.0, 0.5),
-1.0
};

对于那些明显获得初值的元素,vec_new()不再有必要。对于那些尚未被初始化的元素,vec_new()的施行方式就像面对“由class elements组成的数组,而该数组没有explicit initialization list”一样,因此上一个定义很可能被转换为:

Point knots[10];

//明确初始化前3个元素
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初始化后7个元素
vec_new(&knots + 3, sizeof(Point), 7, &Point::Point, 0);

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。所以后续的pi的任何操作是没有定义的。这是因为对于pi所指向之内存的变更或再使用,可能发生也可能不发生。

以constructor来配置一个class object,情况类似。例如:

Point3d *origin = new Point3d;

被转换为:

Point3d *origin;
if(origin = _new(sizeof(Point3d)))
origin = Point3d::Point3d(origin);

一般的library对于new运算符的实现操作都很直接了当,但有两个精巧之处值得斟酌。

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-byte的内存区块(这就是为什么吧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 constructo,某些版本的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运算符的效率带来极大的冲击,所以才导致这样的妥协:只有在中括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个objects要被删除。如果程序员没有提供必须的中括号,像这样:

delete p_array;

那么就只有第一个元素会被解析,其他的元素仍然存在——虽然相关的内存已经被要求归还了。

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;
}

当然,以上只是真正所发生的操作的一半而已。另外一半无法由程序员产生出来。想想这些问题:

  1. 什么是使placement new operator能够有效运行的另一半部扩充(而且是"arena的明确指定操作"所没有提供的)
  2. 什么是arena的真正类型?该类型暗示了什么?

Placement new operator所扩充的另一半边是将Point2w constructor自动实施于arena所指的地址上:

Point2w *ptw = (Point2w*)arena;
if(pt2 != 0)
ptw->Point2w::Point2w();

这正是使placement operator new威力如此强大的原因。这一份码决定objects被配置在哪里;编译系统保证object的constructor会施行于其上。

另一个问题关系到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并不支持多态(polymorphism)。被交给new的指针,应该适当地指向一块预先配置好的内存。如果derived class比其base class大,例如:

Point2w *p2w = new(arena) Point3w;

Point3w的constructor将会导致严重的破坏。

临时性对象(Temporary Objects)

如果我们有一个函数,形式如下:

T operator+(const T&, const T&);

以及两个T objects,a和b,那么:

a + b;

可能会导致一个临时性对象,以放置传回的对象。是否会导致一个临时性对象,视编译器的进取性(aggressiveness)以及上述操作发生时的程序上下关系而定。例如:

T a, b;
T c = a + b;

编译器会产生一个临时性对象,放置 a+b的结果,然后再使用T的copy constructor,把该临时性对象当作 c 的初值。然而比较更可能的转换是直接以拷贝构造的方式,将 a+b的值放到c中。于是就不需要临时性对象,以及对其constructor和destructor的调用了。

临时性对象的被摧毁,应该是对完整表达式(full-expression)求值过程中的最后一个步骤。该完整表达式造成临时对象的产生。

什么是一个完整表达式(full-expression)?非正式地说,它是被涵括的表达式中最外围的那个。下面这个式子:

//tertiary full expression with 5 sub-expressions
((objA > 1024) && (objB > 1024) ? objA + objB : foo(objA, objB));

一个有五个子算式,内带一个"? : 完整表达式"中。任何一个子表达式所产生的任何一个临时对象,都应该在完整表达式被求值完成后,才可以毁去。

当临时性对象是根据程序的执行期语意有条件地产生出来时,临时性对象的生命规则就显得有些复杂了。举个例子,像这样的表达式:

if (s + t || u + v)

其中u+v 子算式只有在s+t 被评估为false时,才会开始被评估。与第二个子算式有关的临时性对象必须被摧毁。但是,很明显地,不可以无条件地摧毁,也就是说,我们希望只有在临时性对象被产生出来的情况下才去催毁它。

临时性对象的生命规则有两个例外。第一个例外发生在表达式被用来初始化一个object时,例如:

bool verbase;
...
String progNameVersion = !verbase ? 0 : progName + progVersion;

其中progName和progVersion都是String objects。这时候会生出一个临时对象,放置加法运算符的运算结果:

String operator+(const String&, const String&);

临时对象必须根据对verbase的测试结果有条件地解构。在临时对象的生命规则之下,它应该在完整表达的"? : 表达式"结束评估之后尽快地被摧毁。然而,如果progNameVersion的初始化需要调用一个copy constructor:

progNameVersion.String::String(tmp);

那么临时性对象的解构(在"? : 完整表达式"之后)当然就不是我们所期望的。C++ Standard要求说:凡含有表达式执行结果的临时性对象,应该存留到object的初始化操作完成为止。

临时性对象的生命规则的第二个例外是"当一个临时性对象被一个reference绑定"时,例如:

const String &space = " ";

产生出这样的程序代码:

String tmp;
tmp.String::String(" ");
const String &space = tmp;

如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference的生命结束,或直到临时对象的生命范畴(scope)结束——视哪一种情况先到达而定。