产生临时对象的情况
我们定义一个类进行测试
class tempVal {
public:
int v1, v2;
tempVal(int v1 = 0, int v2 = 0);
tempVal(const tempVal& t) :v1(t.v1), v2(t.v2)
{
cout << "调用拷贝构造函数" << endl;
}
virtual ~tempVal()
{
cout << "调用析构函数" << endl;
}
};
tempVal::tempVal(int v1, int v2) :v1(v1), v2(v2)
{
cout << "调用了构造函数" << endl;
cout << "v1:" << v1 << endl;
cout << "v2:" << v2 << endl;
}
以值传递的方式给函数传递参数
在文件中添加以下函数,其中函数的参数是我们定义的tempVal类,且使用的是值传递方式传参
int add(tempVal t)
{
int tmp = t.v1 + t.v2;
t.v1 = 1000;
return tmp;
}
在main函数中添加代码
int main()
{
tempVal tm(10, 20);
int sum = add(tm);
cout << "sum=" << sum << endl;
cout << tm.v1 << endl;
system("pause");
return 0;
}
运行结果如下:
观察上述输出结果,可以看到代码中输出了拷贝构造函数,这是因为调用Add成员函数时把对象tm传递给了Add函数,此时,系统会调用拷贝构造函数创建一个副本t(成员函数Add的形参),把tm对象复制给形参t,因为这是一个副本(复制),所以可以注意到,修改副本的val1的值为1000,并不会影响到外界tm对象的val1值(tm对象的val1值仍旧为10)
代码行中的形参t是一个局部对象(局部变量),从程序功能的角度来讲,函数体内需要临时使用它一下,来完成求和运算,严格意义上来讲,它不能称为一个临时对象,因为真正的临时对象往往指的是真实存在,但又感觉不到的对象(至少从代码上是不能直接看到的对象)。
但是代码生成了t对象,调用了tempVal类的拷贝构造函数,有了复制的动作,就会影响程序执行效率。修改代码以提升性能的方式也很简单,把传参方式修改为引用传参即可,即:
int add(tempVal& t)
再次运行观察结果:
观察上面的结果可以发现,少了一次调用拷贝构造函数和析构函数,提升了效率,如果对象很大,并且还从其他父类继承(继承会导致父类的拷贝构造函数也执行),那效率也许会提升很大,但是tm.value1的值在函数内部修改,直接被带到了函数外部,影响了函数外部tm对象的值,这就是引用的能力
类型转换生成的临时对象
现在修改main函数内的代码:
tempVal t1;
t1=1000;
运行观察结果
观察上述结果发现,系统调用了两次构造函数,其中第一次是声明对象t1时调用的构造函数,而第二次构造函数的调用则是因为类型转换生成临时对象造成的。
具体而言,系统会在此时生成一个临时对象(我们无法知道这个临时对象的名字和地址),之后调用构造函数把1000赋给这个临时对象的v1,而v2使用默认参数进行初始化,再把这个临时对象赋值给t1,之后再调用析构函数是否掉生成的临时对象 (注意析构函数的调用销毁的是生成的临时对象,而不是t1,因为代码system("pause")的作用,此时对象t1还没有离开main函数的作用域)
为了观察方便,我们在tempVal的类中添加一个拷贝赋值运算符,如下:
tempVal& tempVal::operator=(const tempVal& tmp)
{
cout<<"调用了拷贝赋值运算符"<<endl;
this->v1=tmp.v1;
this->v2=tmp.v2;
return *this;
}
再次运行代码,观察运行结果:
现在,总结一下以上代码的运行过程:
- 调用构造函数构造对象t1
- 调用构造函数,将1000作为参数传递给构造函数,生成一个临时对象
- 调用拷贝赋值运算符,将生成的临时对象赋值给对象t1
- 是否掉临时对象
上述代码的优化也很简单,只需要将以上代码合并一下即可,如下:
tempVal t1=1000;
此时,代码便少调用了一次构造函数、一次拷贝赋值运算符和一次析构函数
注意,针对“tempVal=1000;这行代码,这里的“=”不是赋值运算符,而是“定义时初始化”的概念。可以这样理解:在这里定义了t1对象,系统就为t1对象创建了预留空间,然后用1000调用构造函数来构造临时对象的时候,这种构造是在为t1对象创建的预留空间里进行的,所以并没有真的产生临时对象。
如果不想要隐式类型转换,将构造函数声明为explicit即可
再次观察一个由于隐式类型转换而造成生成不必要的临时对象的例子
我们在文件中定义一个新的函数,注意函数中参数的类型
void calc(const string& str)
{
const char* p=str.c_str();
return;
}
接下来,我们在main函数中调用这个函数,注意传给calc函数参数的类型
char mystr[100]="I love China";
calc(mystr);
运行代码,我们可以看到代码会正常运行成功。
但是我们看到,calc函数的形参类型为string,而我们传进去的参数类型为char数组,显然编译器为我们做了隐式类型转换,解决了代码运行过程中类型不匹配的问题,那么编译器是如何做的呢?
事实上,编译器首先调用string的构造函数生成了一个string类型的临时对象(通过我们的char数组实参mtstr进行初始化),之后形参str通过引用绑定到这个临时对象上,等函数调用结束之后,这个临时对象就被销毁了
显然,尽管我们在函数中对形参使用了左值引用,但是由于类型隐式转换的原因,我们仍旧在调用函数的过程中生成了不必要的临时对象,浪费了程序性能。
另外需要注意的是,假如我们将上述calc函数的形参中的const去掉,再次运行程序时就会报错。
造成代码报错的原因是,编译器在类型转换的过程中生成了临时对象,而形参str绑定在这个临时对象上,但是我们现在没有对形参str加const限制, 因此编译器就会认为我们有可能对str做出修改,而str绑定在临时对象上,就等于编译器认为我们可能对一个临时对象做修改,这是不被允许的。
因此,C++只会为const引用,而不会为非const引用产生临时对象
函数返回对象生成的临时对象
继续以本文最开始定义的tempVal类来说明问题
重新定义一个函数如下:
tempVal cal(tempVal& tmp)
{
tempVal t;
t.v1=tmp.v1*2;
t.v2=tmp.v2*2;
return t;
}
在main函数中调用该函数
int main()
{
tempVal t1(10,20);
cal(t1);
system("pause");
return 0;
}
使用g++重新编译代码,并启用关闭编译优化选项
g++ main.cpp -o main.exe -fno-elide-constructors
运行可执行文件,观察终端输出结果
发现终端多输出了一次拷贝构造函数和两次析构函数的调用,这是因为
- cal函数调用结束时返回局部变量t的时候,会生成一个临时对象,把t的值拷贝给这个临时对象
- 这个临时对象就可以在main函数中获取,从而获取到返回的值
- 第一次析构函数的调用是cal函数结束时释放掉函数中的临时变量t
- 第二次的析构函数的调用是main函数中(代码行55)调用cal函数结束后,返回的临时对象被释放掉了
注意,现代编译器会自动对返回值做优化,因此需要关闭优化选项才能看到返回的临时对象的生成
重新修改main函数的代码
int main()
{
tempVal t1(10,20);
tempVal t3=cal(t1);
system("pause");
return 0;
}
再次运行观察结果
与上次相比,这次调用了两次拷贝构造函数,分析可知
- 第一次是cal返回时产生临时对象调用的,作用是将cal函数中计算得到的变量t的值拷贝给这个临时对象返回出去
- 第二次拷贝构造函数的调用则是将这个临时对象拷贝给t3从而构造对象t3时调用的
右值引用在临时对象中的作用
我们继续修改代码以说明问题,将main函数的代码修改如下,也就是将t3修改为右值引用
int main()
{
tempVal t1(10,20);
tempVal&& t3=cal(t1);
system("pause");
return 0;
}
观察运行结果发现,这次函数又只进行了一个拷贝构造函数的调用,并且少调用了一次析构函数
- 显然该拷贝构造函数是由于返回值的临时对象产生的,但是在将这个临时对象赋值给t3的时候却没有调用拷贝构造函数
- 这是因为使用了右值引用,将t3绑定到了cal函数返回的临时对象身上,从而避免了一次拷贝构造函数的调用
- 并且从t3开始绑定这个返回的临时对象开始,它的生存周期将与t3的生存周期一样,这就是为什么与上次实验相比少了一次析构函数调用的原因
至此,我们已经通过代码可以“看到”右值引用的作用了