《平沙落雁》又名《雁落平沙》,是一首汉族古琴名曲,其意在借大雁之远志,写逸士之心胸。当然,这些都是“文人雅士”的说法,其实我就是看了《笑傲江湖》,对令狐冲的“屁股向后,平沙落雁式”印象比较深罢了
(~ ̄▽ ̄)~
C++编译器有时会在我们不知不觉的情况下为我们做一些事情,有时候运行时的代码也许会和我们编写的代码有较大的出入。说实在的,要完全掌握C++的运行时难度颇大,所以我在本式中写的是“浅剖析”。这个“浅”字,相信大家都懂得。
本式中和大家一起学习C++用于运行时的一些技术和机制,那我们就开始吧。
一、typeid操作符的使用
typeid操作符使程序能够获得一个表达式的类型,其使用方式如下:
typeid(e); //其中e是任意表达式或者是类型名。
如果操作数不是类类型或者是没有虚函数的类类型,则typeid操作符指出操作数的静态类型;如果操作数是定义了至少一个虚函数的类类型,则其类型将在运行时计算。
typeid操作符的结果是名为type_info的标准库类型的对象引用。如果想使用type_info类,必须包括头文件typeinfo。当然仅使用typeid是不用包含该头文件的啦。
举个例子,我们有如下类定义:
class A
{
virtual void func(){}//虚函数,激活typeid的运行时计算
};
class B :public A{};
如下调用:
B b;
A* pa = &b;
cout << typeid(*pa).name() << endl;//输出类型为class B
也可以对两个类型值进行比较,如下:
typeid(*pa) == typeid(B)
测试时要使用指针指向的对象,如果测试的是指针,则返回的是该指针静态的、编译时的类型。
对于指针p为0时的处理:如果指针是带虚函数的类型,则typeid(*p)将抛出一个bad_typeid异常;如果p的类型没有定义任何虚函数,则结果是p的静态类型,与p的值无关。
二、dynamic_cast操作符
可以使用dynamic_cast操作符将基类类型对象的引用或指针转换为同一继承层次中其它类型的引用或指针。与dynamic_cast一起使用的指针必须是有效的——它必须为0或者指向一个对象。
与其它强制类型转换不同,dynamic_cast涉及运行时类型检查。如果绑定到引用或指针的对象不是目标类型的对象,则dynamic_cast失败。如果转换到指针类型的dynamic_cast失败,则dynamic_cast的结果是0;如果转换到引用类型的dynamic_cast失败,则抛出一个bad_cast类型的异常。
继续使用上面的类定义,我们可以如下使用dynamic_cast来转换指针:
B b;
A* pa = &b;
if (B* pb = dynamic_cast(pa))//如果转换失败,pb将为0
{
cout << "change completed!" << endl;
}
也可以用类似的手法进行引用的类型转换:
B b;
A &ya = b;
try{
B &yb = dynamic_cast(ya);
}catch (bad_cast){
cout << "My heart is broken! " << endl;
}
由上可见,转换引用的语法格式如下:
dynamic_cast
只有当val实际引用一个type类型对象,或者val是一个type派生类型的对象的时候,dynamic_cast操作才将操作数val转换为想要的type&类型。当转换失败时,将抛出一个std::bad_cast异常。
三、new和delete运算符
其实前面几式中,我们已经对构造与析构的运行时操作有所涉及了,所以这里就不再单独讨论这些了,而是把它们与new和delete操作符结合起来再探讨一番。
如果有如下的代码:
int *pi = new int(9);
将被分为两步来完成。
1. 通过适当的new运算符函数实例,配置所需的内存空间:
int *pi = (int *)operator new(sizeof(int));
operatornew内部调用malloc分配内存,返回类型为(void *)。
2. 为配置的对象空间设置初值:
*pi = 9;
也就是说,初始化操作应该在内存配置成功后才执行:
int *pi;
if(pi = operator new(sizeof(int)))
*pi = 9;
执行delete时,如下:
delete pi;
如果pi的值为0,C++语言要求delete操作符不要有操作。所以,编译器会这样处理:
if(pi != 0)
operator delete(pi);
注意:pi被delete之后并不会被自动置0。pi所指的对象生命会因delete而结束。所以后面任何对pi的参考操作就不再保证有良好的行为,并且这是一种不好的编程风格(最好是delete之后就把对应指针置为空,再次用它指向新对象时再重新赋值)。
前面讲的是内置数据类型,其实对于类类型也差不多,只是初始化时是调用构造函数(如果有的话),在执行operator delete之前会执行析构函数(有需要的话)。
如果要new一个数组又会怎样?例如:
int *pa = new int[3];
此时将会被转换为如下操作:
int *pa = operator new(3 * sizeof(int));
假设我们定义了A类,但是其中没有构造或析构函数(包括编译器合成的情况),此时如果:
A * pA = new A[3];
处理情况与上面相同,即
A *pa = operator new(3 * sizeof(A));
如果类A中有默认构造函数的定义(包括合成情况),则某些版本的vec_new()就会被调用,配置并构造类对象所组成的数组。还是前面的操作:
A * pA = new A[3];
此时会被编译为:
A * pA ;
pA = vec_new(0,sizeof(A),3,&A::A,&A::~A);
其中vec_new函数的声明如下:
void * vec_new(
void *array,//数组起始地址,为0表示从堆中分配内存
size_t elem_size,//每个类对象的大小
int elem_count,//数组元素个数
void (*constructor)(void *),//默认构造函数地址
void (*destructor)(void *,char)//析构函数地址
)
在个别的数组元素构造过程中,如果发生异常,析构函数就会被传递给vec_new()。只有已经构造好的元素才需要析构操作,因为它的内存已经配置出来了,vec_new()有责任在异常出现的时候把那些内存释放掉。
对数组执行delete操作:
delete [] pA;
只有当delete后出现[],delete运算符才会去查询数组的维度,因为该操作会带来执行效率上的冲击。如果没出现中括号,编译器会假设只有一个对象需要被删除,此时只有第一个元素会被析构。其它的元素依然存在——虽然其相关的内存已经被要求归还了。那么delete操作符如何知道数组的维度呢?编译器维护了所需的数据结构,其中保存着指针、数组大小、构造析构等。
需要特别注意的是:在继承情况下,使用父类指针删除指向子类对象的数组。如下定义:
class A
{
public:
A(){ cout << "In A constructor" << endl; }
~A(){ cout << "In A destructor" << endl; }
};
class B:public A
{
public:
B(){ cout << "In B constructor" << endl; }
~B(){ cout << "In B destructor" << endl; }
};
调用:
{
A *pa_array = new B[5];
delete[] pa_array;
}
执行结果如下:
即在delete操作中调用的是父类的析构操作(被delete的指针类型的析构操作)。如果子类的对象大小与父类对象不同,则除了第一个之外,析构操作都被实施于不正当的内存之上。避免这种情况,最好就是不要以基类指针指向一个由派生类对象组成的数组。
也可以通过程序自身来解决这里问题,例如:
for(int ix = 0; ix < elem_count; ++ix)
{
B* p = &((B*)pa_array)[ix];
delete p;
}
手动以迭代的方式遍历整个数组,把delete运算符实施于每一个元素身上。
对于placement operator new,调用方式如下:
A *pA = new(area) A;
其中area指向内存中的一个区块,用以放置产生出来的A类型对象。
该操作的定义类似于如下:
void * operator new(size_t,void* p)
{
A* pA = (A*)area;
if(pA)
pA->A::A();
return p;
}
即有,在指定的内存中产生相应的对象,然后再返回原本传入的指针。
需要注意的是:如果placementoperator在原来已存在的一个对象上构造新的对象,即使原本的对象存在析构函数,该函数也不会被调用。我们不能通过使用delete运算符来进行该操作,因为delete会释放该内存。所以说,只能在对应的位置显示调用析构操作来达到目的。Standard C++以一个placement operator delete矫正了该错误,它会对存在的对象实施析构操作,但并不释放内存,这样就无需直接调用析构操作了。
四、临时性对象
我们知道在程序运行时,有时需要产生一些临时对象来暂存中间的计算结果,这就带来了如何管理这些临时对象的话题。临时性对象的产生和消亡会给执行效率带来负担,所以在设计程序时也应该考虑到相关的问题。
如果有如下的定义:
T a, b;
则,T c = a + b;总是会比后面的操作更有效率地被转换:T c;c = a + b;另一种情况:a + b;即使没有目标对象,此时还是会产生一个临时对象用以放置运算后的结果。
临时对象被摧毁,应该是完整表达式求值过程中的最后一个步骤。任何一个子表达式产生的任何一个对象,都应该在完整表达式被求值完成后,才可以毁去。如下:
String s(“Hello”),t(“world”);
printf(“%s\n”, s + t);
其中String类有类型转换操作:
String::operator const char*(){return _str;}
_str是String类对象成员变量存储地址。
此时的转换可能如下:
String temp1 = operator+(s,t);
const char *temp2 = temp1.operator const char*();
temp1.~String();//合法,但是摧毁地过早了
printf(“%s\n”,temp2);//此时temp2指向的对象已被析构
与我们的表达式有关的临时性对象需要被摧毁,但是显然的,只有在临时性对象被产生出来的情况下,我们才去摧毁它。
如下式:
if(s + t || u + v)
其中,u + v只有在s + t被评估为false时,才会被计算。
一种解决办法是,把临时性对象的析构操作放在每一个子算式的求值过程中,这样可以免除对“第二个子算式是否需要被评估”的追踪。但是上面说到,临时性对象在表达式尚未完成计算之前不能被摧毁,所以行不通。第二种方法就是在计算的过程中插入一些条件测试,根据相关的测试来决定是否需要摧毁和第二个子算式相关的临时性对象。
临时性对象的生命规则有两个例外。第一个例外发生在表达式被用来初始化一个对象时。此时,持有表达式结果的临时性对象应该保留到对象的初始化操作完成为止。第二个例外发生在临时性对象被一个引用绑定时。如果临时性对象被绑定于一个引用,该对象将残留,直到被初始化的引用生命结束,或直到临时性对象的生命周期结束——视哪种情况先到达而定。