深度探索c++对象模型之临时对象的探讨

      如果我们有一个自定义类类型T,里面有一个int变量x,再在里面定义了一个operator+【T operator+( const T& _a, const T& _b)】,然后我们声明了3个T对象a、b、c,当我们写【c = a+b;】时,编译器会不会为我们这个表达式产生一个临时性T对象呢?

      答案是未必。对于现在的大多数编译器来说,要看我们怎么写,如果我们写成【T c = a+b;】,那么编译器并不会产生临时对象,而是利用NRV(name return valuie)优化内部代码:

//T a,b;
//......
//T c = a+b;
//以下是可能的优化【对于operator+】

void T::operator+( T& _c, const T& _a, const T& _b)
{
  _c.x = _a.x+_b.x;
  return;
}

     而如果我们写成【c = a+b; 】的形式,那么情况就会有些不同了,它很可能会导致下面的结果:

//c++伪码
T temp;
temp.operator+(a, b);//......

c.operator=(temp);
temp.T::~T();//在这里析构掉临时对象
注释为省略号的那一行,未构造的临时对象被赋值给operator+。这意思要么是“表达式的结果被拷贝构造至临时对象中”,要么是“用临时对象取代NRV”,在后者中,原本要施行于NRV的constructor,现在将施行于该临时对象。不管是哪一种情况,直接把c传递到operator+中是有问题的,因为operator+希望的是一块新鲜的内存,而且它并不打算为它的外加参数调用destructor,所以必须在operator+的调用之前先调用destructor,于是,转换语意将会把下面的assignment操作:
c = a+b; //c.operator+(a, b);
替换为它的copy assignment运算符的隐含调用操作,以及一系列的destructor和copy construction:

//c++伪码
c.T::~T();
c.T::T(a + b); 

      copy constructor、destructor以及copy assignment constructor都可以由用户提供,所以不能保证上述两个操作会导致相同的语意,因此用一连串的destructor和copy constructor来替换assignment,一般而言是不安全的,而且会产生临时性对象,所以对于我们用户来说,写成【T c =a+b;】这种形式要比【c = a+b;】更好。

      接下来让我们来考虑第三种形式,一个单纯的【a+b】,像这种形式肯定会产生一个临时对象,用来存储表达式运算结果。比如我们定义了类String,然后写【String s("hello"), t("world!");】,这样,当我们【printf("%s\n", s+t);】,就会产生一个与s+t相关联的临时对象。像这样的情况就会带来一个值得探讨的话题,那就是“临时对象的生命周期”。在c++ standard之前,临时对象的生命周期并没有明确规定,而是由各家的编译器厂商自行决定。  换句话说,上面的printf并不保证安全,它的正确性与产生的那个临时对象何时被摧毁有关。

      在我们的String定义中,有一个conversion定义如下:

String::operator const char*()
{
  ...
  return _str;
}
其中的_str是一个private member addressing storage【私有的字符串地址指针】,在String object构造时配置,在其destructor时被释放。

      因此,上面的例子中,如果那个产生的临时对象在进入printf函数之前就被destructor了,那么经由conversion运算符函数交给printf的参数地址就是不合法的。真正的结果视底部的delete在释放相关内存时的具体动作行为而定:也需要某些编译器在delete时可能会把那个临时对象的内存标志为free,不会改变其中的内存,在这块内存在其它地方被宣称主权之前,只要它还没有被delete掉,它就可以继续被使用。但这对于软件工程而言不足作为模范,事实上,像这样的某块内存被释放之后又被使用并非罕见!所以许多编译器提供一个特殊的malloc()调用操作:

malloc(0);
而它就是用来保证上述行为的。

      例如,下面是对于该算式的一个可能的pre-standard转换,虽然在pre-standard语言定义中是合法的,但却可能带来重大灾难:

//c++伪码:pre-standard的合法转换,但临时对象被摧毁的太早了!
String temp1 = operator+(s,t);
const char* temp2 = temp1.operator const char*();

//嗯,这句话是合法的!但也是带来灾难的
temp1.~String();

//这时的temp2指向哪里??
printf("%s\n",temp2);
另一种正确的转换方式是在printf调用完成之后在进行String的destructor,在c++ standard标准下,这是该表达式的必须转换方式,标准上这么说:临时对象的被摧毁,应该是对完整表达式求值过程中的最后一个步骤。该完整表达式造成临时对象的产生。

      那么什么是一个完整表达式?它是被涵括在表达式中最外围的那个,比如下面这个表达式:

((objA > 1024) && (objB > 1024)) ? objA+objB : foo(objA, objB);
在上面的表达式中,一共五个子表达式,其中任何一个sub-expressions所产生出来的临时对象,都应该在完整表达式真个执行完毕后,才应该被摧毁。

      
      仔细看上面那个表达式,由条件测试那里让我们来联想一个更复杂的情况:那就是当临时对象是根据程序的执行期语意有条件地产生出来时,它的生命周期规则就变得更难以捉摸了。比如【if( s+t || s+v )...】,里面的“s+v”是否执行是根据前面的“s+t”结果值评估而来的,当“s+t”评估为假时,才会为“s+v”产生一个临时对象,所以当我们最后要摧毁这个临时对象时,情况要变得棘手一点了,因为我们首先得确定这个临时对象是否真的产生了,只有当临时对象实实切切产生的时候,我们才应该去摧毁它。

      所以,让我们用下面这个例子,来讲解以前的标准编译器是如何应对上面的问题的:

class X
{
public:
X(){...};
~X(){...};
operator int(){...};
X foo(){...};

private:
int val;
}
这是我们的类定义,然后在下面的代码中对两个X object做条件测试:

int main()
{
X xx;
X yy;

if( xx.foo() || yy.foo() )
...;

return 0;
}
其中main中的代码,经过cfront的编译,会转换成如下的代码(伪代码):

int main(void)
{
	struct X _1xx;
	struct X _1yy;
	_ct_1xFv(&_1xx);//这个是X的构造函数
	_ct_1xFv(&_1yy);//同上

	/****上面代码中的if语句块,会被转换成下列形式:***/
	{
		//首先产生两个临时对象
		struct X _t1;
		struct X _t2;

		int n;//这个n用来临时记录if判断括号里面的两个表达式的值


		/***********************************
		接下来请注意,下面的_opi_1xFv是我们代码
		中的X::operator int函数,而_foo_1xFv则是
		X::foo函数,_dt_1xFv则是X::~X()
		***********************************/
		if (
			(n = _opi_1xFv((_t1 = _foo_1xFv(&_1xx), &_t1)), _dt_1xFv(&_t1, 2), n)
			||
			(n = _opi_1xFv((_t2 = _foo_1xFv(&_1yy), &_t2)), _dt_1xFv(&_t2, 2), n)
			)
		{
			...
		}
	}
	return 0;
}
      把临时对象的destructor放在每一个子算式的求值过程中,可以免除“努力追踪第二个子算式是否真的需要被评估”。然而在c++ standard的临时对象生命规则中,这样的策略不再被允许:现在的临时对象在完整表达式尚未执行完毕之前,不能被摧毁!所以某些形式的条件测试必须被插入进来,用来决定是否摧毁我们那个和第二个子算式有关的临时对象。

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

bool verbose;
...
String progNameVersion = !verbose ? 0 : progName+progVersion;
其中的progName和progVersion都是我们自定义类类型String的对象,这时候会生成一个临时对象,放置progName和progVersion的相加结果:

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

临时对象必须根据verbose的测试结果有条件的析构,在临时对象的生命规则下,它应该在【 ?: 】完整表达式结束运行之后尽快被摧毁。然而,如果progNameVersion的拷贝赋值需要调用一个copy constructor:

//c++伪码
progNameVersion.String::String(temp);
那么就算我们在【 ?: 】表达式完整求值过后,也不应该对临时对象temp解构,这显然不是我们希望的。c++ standard的要求是【。。。。。。但凡含有表达式执行结果的临时性对象,应该留到object对象的初始化操作完整为止。】

      c++中的临时对象的生命规则坑实在是太隐蔽,即使我们再努力谨慎,还是难以提防。比如有时候当我们的行为有着明确的定义,就像下面这个初始化操作:

//这是一个注定失败的初始化操作!
const char *progNameVersion = progName+progVersion;
上面的progName和progVersion都是String类型,它们经过编译器的处理会变成这样:

String temp = new String;
temp.String::operator+(temp, progName, progVersion);
progNameVersion = temp.String::const char*();
temp.String::~String(); //在这里编译器析构了我们的临时对象,但其实我们的progNameVersion和temp是处于同一块地皮,但此时我们的progNameVersion已经指向未定义的heap内存!

      再比如下面这个例外:

//当一个临时对象被一个reference绑定时。。。
const String &space = "aaa";

/*上面的代码经过编译器处理,变成这种形式*/
//c++伪码
String temp;
temp.String::String("aaa");
const String &space = temp;
temp.String::~String(); //......
很明显,如果临时对象temp被摧毁,那我们那个space还能靠得住吗?

      最后让我们来看一下c++ standard的要求:【如果一个临时对象被绑定在一个reference上,对象将残留,要么残留到相关的那个reference引用声明周期结束,要么残留到临时对象的生命范畴(scope)结束,看哪一种情况先到达而定!】











  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值