第六章 执行期语意学
一、对象的构造与解构
1.全局对象
Matrix identity;
main()
{
//identity必须在此处被初始化
Matrix m1=identity;
...
return 0;
}
C++保证,一定会在main()函数中第一次用到identity之前,把identity构造出来,而在main()函数结束之前把identity摧毁掉。像identity这样的所谓全局对象,如果有构造函数和析构函数的话,就说它需要静态的初始化操作和内存释放操作。
C++程序中所有的全局对象都被放置在程序的data segment中,如果明确指定给它一个值,object将以该值为初值。否则object所配置到的内存内容为0。
2.局部静态对象
const Matrix&
identity(){
static Matrix mat_identity;
//...
return mat_identity;
}
以上代码中,无论函数identity()被调用多少次,局部静态变量mat_identity的构造函数和析构函数都只施行一次。
1)静态局部变量存放在内存的全局数据区。函数结束时,静态局部变量不会消失,每次该函数调用时,也不会为其重新分配空间。它始终驻留在全局数据区,直到程序运行结束。
2)静态局部变量的初始化与全局变量类似.如果不为其显式初始化,则C++自动为其 初始化为0。
4)静态局部变量与全局变量共享全局数据区,但静态局部变量只在定义它的函数中可见。
5)静态局部变量与局部变量在存储位置上不同,使得其存在的时限也不同,导致对这两者操作的运行结果也不同
3.对象数组
数组其实也可以容纳更复杂的数据类型,比如程序员定义的结构或对象。这一切所需的条件就是,每个元素都拥有相同类型的结构或同一类的对象。
比如数组
Point knot[10];
关于对象数组的7个要点:
1)数组的元素可以是对象。
2)如果在创建对象数组时未使用初始化列表,则会为数组中的每个对象调用默认构造函数。
3)没有必要让数组中的所有对象都使用相同的构造函数。
4)如果在创建对象数组时使用初始化列表,则将根据所使用参数的数量和类型为每个对象调用正确的构造函数。
5)如果构造函数需要多个参数,则初始化项必须釆用构造函数调用的形式。
6)如果列表中的初始化项调用少于数组中的对象,则将为所有剩余的对象调用默认构造函数。
7)最好总是提供一个默认的构造函数。如果没有,则必须确保为数组中的每个对象提供一个初始化项。
二、new和delete
此部分参考https://blog.csdn.net/passion_wu128/article/details/38966581
new
new操作针对数据类型的处理,分为两种情况:
int *p=new int;
int *p=new int(4);//指定初值
1.简单数据类型(包括基本数据类型和不需要构造函数的类型)
简单类型直接调用operator new分配内存;
可以通过new_handler来处理new失败的情况;
new分类失败的时候不像malloc那样返回NULL,它直接抛出异常。要判断是否分配成功应该用异常捕获的机制
2.复杂数据类型(需要由构造函数初始化对象)
class Object
{
public:
Object()
{
_val=1;
}
~Object()
{
}
private:
int _val;
}
void main()
{
Object *p= new Object();
}
new复杂数据类型的时候先调用operator new,然后在分配的内存上调用构造函数。
3.new数组
new[]也分为两种情况
简单数据类型(包括基本数据类型和不需要析构函数的类型)
new[]调用的是operator new[],计算出数组总大小之后调用operator new.
可以通过()初始化数组为零值,例如
char *p = new char[32];
等同于
char *p = new char[32];
memset(p,32,0);
针对简单类型,new[]计算好大小后调用operator new.
复杂数据类型(需要由析构函数销毁对象)
class Object
{
public:
Object()
{
_val = 1;
}
~Object()
{
cout << "destroy object" << endl;
}
private:
int _val;
};
void main()
{
Object* p = new Object[3];
}
ew[]先调用operator new[]分配内存,然后在p的前四个字节写入数组大小,最后调用三次构造函数。
实际分配的内存块如下:
这里为什么要写入数组大小呢?因为对象析构时不得不用这个值,举个例子:
当我们在main()函数最后中加上
delete[] p;
释放内存之前会调用每个对象的析构函数。但是编译器并不知道p实际所指对象的大小。如果没有储存数组大小,编译器如何知道该把p所指的内存分为几次来调用析构函数呢?
总结:
针对复杂类型,new[]会额外存储数组大小。
delete
delete也分为两种情况:
简单数据类型( 包括基本数据类型和不需要析构函数的类型)。
int *p = new int(1);
delete p;
delete简单数据类型默认只是调用free函数。
复杂数据类型
class Object
{
public:
Object()
{
_val = 1;
}
~Object()
{
cout << "destroy object" << endl;
}
private:
int _val;
};
void main()
{
Object* p = new Object;
delete p;
}
delete复杂数据类型先调用析构函数再调用operator delete
delete[]也分为两种情况:
简单数据类型( 包括基本数据类型和不需要析构函数的类型)。
delete和delete[]效果一样
比如下面的代码:
int* pint = new int[32];
delete pint;
char* pch = new char[32];
delete pch;
运行后不会有什么问题,内存也能完成的被释放。看下汇编码就知道operator delete[]就是简单的调用operator delete。
总结:
针对简单类型,delete和delete[]等同。
复杂数据类型(需要由析构函数销毁对象)
释放内存之前会先调用每个对象的析构函数。
new[]分配的内存只能由delete[]释放。如果由delete释放会崩溃,为什么会崩溃呢?
假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。
总结:
针对复杂类型,new[]出来的内存只能由delete[]释放。
三、临时性对象
有三种常见的临时对象创建的情况
- 以值的方式给函数传参
- 类型转换
- 函数需要返回对象时
从一个例子出发
下面的代码你能找出几个不必要的临时对象?
string FindAddr(list<Employee> emps, string name)
{
for(list<Employee>::iteraotr i = emps.begin();
i != emps.end(); i++) {
if(*i == name)
return i->addr;
}
return "";
}
无论你是否相信,在上面这个短短的函数中存在着三个明显的,以及两个不太明显的不必要的临时对象,还有两处可能会迷惑你的地方。
以 const 引用传递对象参数
在函数的声明语句中有两个明显的临时对象:
string FindAddr(list<Employee> emps, string name)
这些参数应该通过 const & 的方式来传递,而不应该通过传值方式。传值方式将会使编译器创建这两个参数对象的完全副本,而这种做法非常昂贵,而且完全没有必要。
在传递对象参数时,选择 const & 方式而不是传值方式。
缓存不变量而不是重新构造
第三个临时对象是在 for 循环的条件判断语句中,这个临时对象比前面两个更明显,而且同样是可以避免的:
for(/*..*/; i != emps.end(); /*..*/)
对于大部分的容器(包括链表)而言,调用容器的 end()
函数将返回一个临时对象,这个对象需要被构造和析构。由于这个临时对象的值在循环中是不会改变的,因此如果在每次循环迭代中都重新进行计算(包括重新构造和重新析构),都将会导致不必要的低效,而且代码也不够干净利落。实际上,这个临时对象的值只需计算一次,将其保存在一个局部对象中,之后重复使用即可。
对于程序运行中不会改变的值,应该预先计算并保存起来备用,而不是重复地创建对象,这是没有必要的。
优先选择前缀递增
接下来再考虑一下在for
循环中i
的递增方式:
for(/*...*/; i++)
这个临时对象并不是很明显。通常,后置递增的运算效率要低于前置递增,因为后缀递增必须记录和返回操作数的初始值。通常为了保持一致性,使用前置递增来实现后置递增,看起来像下面这样:
const T T::operator++(int)
{
T old(*this); // 记录初始值
++*this; // 使用前置递增
return old; // 返回记录的初始值
}
现在,就很容易理解为什么后置递增的运算效率要低于前置递增了。在后置递增运算中除了必须完成与前置递增相同的所有工作外,还必须构造和返回一个包含初始值的临时对象。
通常,为了保持一致性,应该使用前置递增来实现后置递增。
优先选择使用前置递增。只有在需要初始值时,才使用后置递增。
在上述问题的代码中,初始值永远都用不到,因此也就没有必要使用后置递增,而应该使用前置递增。
编译器何时优化后置递增
也许你会认为编译器会对上面那种没有使用初始值的后置递增进行优化。然而编译器通常都不会这样做。只有当操作数的类型是内置类型或者标准类型,比如 int 和 complex,编译器才会将后置递增改写为前置递增以进行优化,因为编译器知道这些标准类型的语义。
而对于我们自己创建的类型,编译器不可能知道前置递增和后置递增的实际语义——实际上,这两个运算所执行的操作可能确实不同。不过,如果这两个运算的语义不同,那将是一件非常可怕的事情。
有一种方法可以让编译器知道在一个类中前置递增和后置递增之间的关系:用标准的形式来实现后置递增,即在后置递增函数中调用前置递增,并使用 inline
来声明后置递增函数,这样编译器就能跨越函数边界来检测未被使用的临时对象(这要求编译器支持 inline
指令)。然而 inline
并不是万能的,它有可能会被编译器忽略,并在其他一些情况下带来更为紧密的耦合。
更好的解决方案就是养成一种习惯:如果不需要初始值,那么就使用前置递增,这样就不需要使用上面的优化措施了。
注意隐式转换中的临时对象
再看看 if 条件语句:
if(*i == name)
...
虽然我们没有给出 Employee 类的定义,但还是可以推断出这个类的一些信息。为了使上面的代码能够运行,在 Employee 类中很可能有一个将 Employee 转换为 string 的函数,或者有一个带有 string 参数的类型转换构造函数。即要么转换 *i
为 string 临时对象,要么以name为实参构造一个 Employee 临时对象。
在这两种情况中都会创建一个临时对象,并在这个临时对象上调用 string 的 operator==()
,或者调用 Employee 的 operator==()
。只有当存在一个同时带有 Employee 参数和 string 参数的 operator==
,或者 Employee 能够被转换为引用类型,即 string & 时,才不会生成临时对象。
在进行隐式转换时,要注意在转换过程中创建的临时对象。要避免这个问题,一个好办法就是尽可能地通过显式的方式来构造对象,并避免编写类型转换运算符。
单入/单出更好吗
代码中有两处返回语句:
return i->addr;
...
return "";
这是第一个可能迷糊你的地方。这两条语句确实都创建了临时的 string 对象,但是这些临时对象是无法避免的。你也许会想使用单入/单出(single-entry/single-exit)的编程方式,即在函数中声明一个局部的 string 对象来保存返回值,这样只需一句 return 语句:
string ret;
...
ret = i->addr;
break;
...
return ret;
这种单入/单出的方式通常可以提高代码的可读性(而且有时也能使程序运行得更快),但这种做法的表现很大程度上取决于实际的代码和编译器。因为还附加了 string 的赋值运算符函数的调用开销。具体的表现得在你使用的编译器上做测试。但是一般来说,“两条 return 语句”的函数表现得更加良好。
绝不返回局部对象的句柄
确实有一个方法能够避免返回语句使用的临时对象,那就是声明一个静态局部对象,并返回这个对象的句柄(引用或指针)。但是,将局部对象定义为静态的,将导致函数不可重入,这意味着在多线程环境中它几乎肯定是一个大问题。此外,返回局部对象的引用,不用多说,调用者总是会在该对象已经销毁后还使用它(通过这个函数返回的句柄),而这一般都会引起内存故障,更坏的情况是程序“正常”运行下去······
记住对象的生存期。永远都不要返回指向局部对象的指针或引用,它们没有任何用处,因为主调代码无法跟踪它们的有效性,但却可能会试图这么做。