执行期语意学
一个简单的例子
class Y { public: bool operator==(const Y&) const; }; class X { public: operator Y() const; X getValue(); }; X xx; Y yy; if(yy==xx.getValue()) //会发生下列转换 X temp1=xx.getValue(); Y temp2=temp1.operator Y(); int temp3=yy.operator==(temp2); if(temp3) //... temp2.Y::~Y(); temp1.X::~X();
一、对象的构造和析构
一般而言,我们将object尽可能放置在使用它的那个程序区段附近,这样可以节省非必要的对象的构造和析构成本。
destructor必须被放在每一个离开点(当时object还活着)之前。
全局对象
已经初始化全局对象均存储在data segment(数据段),未初始化的全局变量存储在BSS(block started by symbol),C++中如果全局对象没有显式初始化,那么该对象所配置到的内存内容为0,但是构造函数一直到程序启动才会实施。
全局对象如果有constructor和destructor的话,那么他需要静态的初始化操作和释放内存操作。
C语言中的一个全局对象只能够被一个常量表达式(可在编译时期求值的那种)设定初始值。
munch方法:全局变量静态初始化的方法
- 为每一个需要静态初始化的文件产生一个__sti()函数(sti: static initialization),内含必要的constructor调用操作或者inline expansions。
- 为每一个需要静态的内存释放操作产生__std()函数(std: static deallocation),内含必要的constructor调用操作或者inline expansions。
- 提供一组running library”munch”函数:调用所有的__sti()一个_main()函数以及一个调用__std()函数的exit()函数。
Matrix identity1, identity2; main() { Matrix m1 = identity, m2 = identity2; ... return 0; } //对应much策略如下 main() { _main(); //_main()调用__sti_identity1()以及__sti_identity2();对所有的global做static initialization操作 ... exit(); //exit()调用__std_identity1()以及__std_identity2();对所有的global做static deallocation操作 return 0; }
使用静态初始化object会有一些缺点,比如如果支持exception handling,那些objects将不能被置于try区间之内,这对于静态被调用的constructor可能是无法接受的。
静态局部对象
const Matrix& identity() { static Matrix mat_identity; return mat_identity; }
- mat_identity的构造函数只执行一次,即使identity函数调用多次。
- mat_identity的析构函数也只执行一次,即使identity函数调用多次。
编译器的策略是无条件在程序起始处构造对象,这会导致所有的静态对象在程序起始时都初始化,即使调用它们的函数从来没有被调用过。
解决办法是:取出local object地址(由于object是static,其地址在downstream component中将会被转到程序用来放置global object的data segment中)
新的规则要求编译单位中的局部静态对象必须被摧毁,以构造相反的顺序摧毁。由于这些object都是第一次需要时才被构造(如每个含有static local class objects的函数第一次被进入时)所以编译器无法预期集合以及顺序,为了支持新规则,可能需要被产生出来的static class object保持一个执行期链表。
对象数组
Point knots[10];
假如Point定义了一个默认构造函数,在从cfront的策略中,产生一个vec_new
函数(如果含有virtual base class,产生vec_vnew()
函数)(有构造函数时才会生效),用来构造数组:
void vec_new( void* array, //持有的若不是具名数组的地址,就是0,如果是0数组将由应用程序的new运算符被动态配置于heap中 size_t elem_size, //数组中每一个元素的大小 int elem_count, //数组中元素的个数 void (*constructor)(void*), //形参个数为0的默认构造函数指针 void (*destructor)(void*, char) //析构函数指针 )
上树的转化为
Point knots[10]; vec_new(&knots, sizeof(Point), 10, Point::Point, 0);
同样地,cfront的策略中,产生一个vec_delete
函数(或是一个vec_vdelete(),如果class含有virtual base class的话
),用来析构数组
void vec_delete( void* array, //数组的起始地址 size_t elem_size, //数组中每一个元素的大小 int elem_count, //数组中元素的个数 void (*destructor)(void*, char) //析构函数指针 )
如果程序提供一个或多个明显初始值给一个由class objects组成的数组,vec_new()不再有必要。
Default Constructor和数组
如果一个类的构造函数有一个或一个以上的默认参数值,例如:
class complex{ complex(double = 0.0, double = 0.0); }
那么当我们写下complex array[10];
时,编译器最终需要调用
vec_new(&array, sizeof(complex), 10, &complex::complex, 0);
这里的&complex::complex
需要是无参的构造函数,那么应该怎么做?做法是:在&complex::complex
中调用我们自己提供的的constructor,并将default参数值显式指定过去,例如:
complex::complex() { complex(0.0, 0.0); //调用我们自己的构造函数 }
二、new和delete运算符
int *pi = new int(5);
1. 通过__new配置所需要的内存:
int *pi = __new(sizeof(int));
2.将配置来的对象设定初值
*pi = 5; //或者更确切地说,初始化操作因该在配置成功(经由new运算符)后才执行 int *pi; if(pi = __new(sizeof(int))) *pi = 5;
delete时
delete pi; //下面是编译器的实现 if(pi != 0) //如果pi为0,c++会要求delete运算符不会有操作 __delete(pi);//pi不会自动被清除为0
pi所指的对象生命结束,所以后继任何对pi的参考操作就不再保证有良好的行为;虽然该地址上的对象不再合法,但是地址本身却代表一个合法的程序空间,此时pi像个void*指针。
如果是一个class
Point3d *origin = new Point3d;
转换为
Point3d *origin; if(origin = __new(sizeof(Point3d))) origin->Point3d::Point3d(origin);
再加上异常处理
Point3d *origin; if(origin = __new(sizeof(Point3d))) { try { origin->Point3d::Point3d(origin); } catch(...) { __delete(origin) throw; } }
在这里,如果constructor抛出异常,配置的内存就会释放掉,然后异常在被抛上去。Derstructor的应用也是类似的:
delete origin;//下面是编译器的实现 if(origin != 0) { Point3d::~Point3d(origin); __delete(origin); }
总结:
- 操作符new的过程就是:先配置内存,再调用构造函数(内置类型直接赋值)。(如果配置内存失败,内存还是需要释放的)
- 操作符delete的过程就是: 先调用析构函数(内置类型没有这一步),再释放内存()
下面给出不考虑异常的new的实现(实际是以malloc()函数完成):
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; }
令size = 1的原因是每一次对new的调用都必须传回一个都以无二的指针,传回一个指针指向默认为1byte(所以设为1)的内存区块。delete函数也是以free()函数为基础完成的:
extern void operator delete(void* ptr) { if(ptr) free((char*) ptr); }
针对数组的new语意
int* p_array = new int[5];
由于int是内置类型,并没有默认构造函数,所以vec_new()
不会被调用,倒是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没有定义构造函数或者析构函数。
针对数组的delete,通常的写法是delete [] p_array;或者delete [5] p_array;第一种是最常见的。但是如果使用这样的写法:delete p_array;那么只有第一个元素被析构,其它的元素依然存在,虽然相关的内存已经被归还了。
为了记录元素个数,一个明显的方法就是为vec_new()传回每个内存区配置的一个额外的word,然后把元素个数包藏在那个word中。
class Point { public: Point(); virtual ~Point(); }; class Point3d:public Point { public: Point3d(); virtual ~Point3d(); }; Point *ptr=new Point3d[10];
在delete []ptr时,实行于数组上的destructor,是根据交给vec_delete()函数的“被删除之指针类型的destructor”,但这并不是我们希望的。此过程的失败原因不只是执行了错误的destructor,而且从第一个元素之后,该destructor即被施行与不正确的内存区块中。
应该避免以一个base class指针指向一个derived class objects所组成的数组——如果derived class比base class大的话。
for(int ix=0;ix<elem_count;++ix) { Point3d *p=&((Point*)ptr)[ix]; delete p; }
Plancement Operator new的语意
Placement Operator new是一个预先定义好的重载的new运算符,原型如下:
void* operator new(size_t, void* p) { return p; }
Placement Operator new的用法:
Point2w* ptw2 = new(arena) Point2w;
其中arena是指向内存中的一个区块,用来放置新产生的Point2w object,而
Point2w* ptw1 = (Point2w*) arena;//只是做了强制隐式类型转换
Placement Operator new还将Point2w的constructor自动施行于arena的地址上,Placement Operator new的语句等价于
Point2w* ptw2 = (Point2w*) arena; ptw2->~Point2w(); if(ptw2 != 0) ptw2->Point2w::Point2w();
如果placement operator在原已存在的一个object上构造新的object,而该既存在的object有个destructor,这个destructor并不会被调用,调用该destructor的方法之一是将那个指针delete掉,但是delete运算符并不会释放所指的内存。
该块内存的类型必须是指向相同类型的class,要么就是一块“新鲜”内存,足够容纳该类型的object。
一般而言,Placement Operator new不支持多态,被交给new的指针,应该指向一块先配置好的内存,如果derived class比其base class大,则derived constructor将会导致严重破坏。
struct Base{int j;virtual void f();}; struct Derived:Base{void f();}; void fooBar() { Base b; b.f(); b.~Base(); new(&b) Derived; b.f(); }
上述两个class大小相同,把derived object放在base class而配置的内存是安全的。但是必须放弃对于“经由objects静态调用所有的virtual functions(x向b.f())”;所以我们不能确定那个f()被调用。
三、临时性对象
加入有一个函数,形式如下
//如果表达式有这种形式 T c = a + b; //加法操作被定义为 T operator+(const T&, const T&); //或 T T::operator(const T&);
则不会产生临时对象,如果像下列陈述语句
c = a + b;
则会产生临时对象,如下
T temp; temp.operator+(a, b);//未构造的临时对象被赋值给operator+(),也就是“表达式的结果被copy constructed至临时对象中”,就是“以临时对象取代NRV” c.operator=(temp); temp.T::~T();
但是由于运算符函数并不为其外加参数调用一个destructor(它期望一块“新鲜的”内存),所以必须在此调用之前先调用destructor,然而“转换”语意将被用来下面的assignment
c = a + b;//c.operator(a+b); //取代为其copy assignment运算符的隐式调用操作,所以一系列的destructor和copy constructor c.T::~T(); c.T::T(a+b);
因此T c = a + b;
总比T c;c = a + b;
更有效率。第三种运算形式:a + b;
没有出现目标对象,也有必要产生一个临时对象存储运算后的结果。
C++允许编译器对临时对象的产生有完全的自由度
string s,t; printf("%s\n",s+t); String::operator const char*() { return _str; }
s+t产生的临时对象如果在调用printf之前就被摧毁,那么经由conversion运算符交给他的地址就是不合法的,真正的结果视底部的delete运算符在释放内存时的进取性而定。某些编译器把这块内存标为free,不以任何方式改变其内容,在这块内存被其他地方宣称“主权”前,只要他还没被delete,他就可以被使用。
临时对象的被摧毁,应该是对完整表达式(full-expression)求值过程中的最后一个步骤。该表达式造成临时对象的产生
什么是完整表达式?例如:
((objA > 1024) && (objB > 1024)) ? objA + objB : foo(objA, objB);
一共五个式子objA > 1024
,objB > 1024
,(objA > 1024) && (objB > 1024)
,objA + objB
,foo(objA, objB)
包含在?:
完整表达式中,每一个式子产生的临时对象都应该在这个完整表达式被求值之后才能销毁。
临时对象的生命规则有两个意外:
1. 凡是有表达式执行结果的临时性对象,应该保存到object的初始化操作完成为止。
string proNameVersion = !verbose ? 0 : proName + progVersion; //proName和progVersion均是string类型
proName + progVersion
产生的临时对象本应该在?:
表达式求值之后立即销毁,但是proNameVersion
objecrt初始化需要用到该临时对象,因此应该保存到object的初始化操作完成为止。
2.如果一个临时性对象绑定于一个reference,对象将残留,知道被初始化之reference的生命结束,或者知道临时对象的生命范畴结束,视哪种情况先到达。
const String& space = " "; //会产生这样的代码: string temp; temp.String::String(" "); const String& sapce = temp;
如果临时对象现在就被销毁,那么引用也就没有什么用处了。
临时性对象的迷思
例如,复数类complex定义了如下操作符重载:
friend complex operator+(complex, complex);
测试程序是这样的:
void func(complex* a, const complex* b, const complex* c, int N) { for(int i = 0; i < N; ++i) { a[i] = b[i] + c[i] - b[i] * c[i]; } }
上述测试程序会产生5个临时对象:
- 一个临时对象,放置
b[i] + c[i]
; - 一个临时对象,放置
b[i] * c[i]
; - 一个临时对象,放置上述两个临时对象的相减的结果;
- 两个临时对象,为了放置上述第一个和第二个临时对象,为的是完成第三个临时对象。