C++11特性对象优化和右值引用
左值、右值引用
- 左值:可以取地址,用的是对象的身份(内存中的位置)。有内存、有名字
- 右值:字面常量、表达式、函数的非引用返回值(int func() )。没内存(临时变量)、没名字
带右值引用参数的拷贝构造和赋值函数不需要额外的内存开辟、内存释放和数据拷贝。
左值引用是对一个左值进行引用的类型,右值引用则是对一个右值进行引用的类型。
左值引用和右值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。
// 左值引用
int &a = 2; // 左值引用绑定到右值,编译失败, err
int b = 2; // 非常量左值
const int &c = b; // 常量左值引用绑定到非常量左值,编译通过, ok
const int d = 2; // 常量左值
const int &e = c; // 常量左值引用绑定到常量左值,编译通过, ok
const int &b = 2; // 常量左值引用绑定到右值,编程通过, ok
// 右值引用
int && r1 = 22;
int x = 5;
int y = 8;
int && r2 = x + y;
T && a = ReturnRvalue();
“const 类型 &”为 “万能”的引用类型,它可以接受非常量左值、常量左值、右值对其进行初始化;
对象优化
- 编译器不同输出结果可能有差异,以vs2019和qt为例,如下代码输出结果:
#include <iostream>
#include <cstring>
using namespace std;
class MyString
{
public:
MyString(const char *tmp = "abc")
{//普通构造函数
len = strlen(tmp); //长度
str = new char[len+1]; //堆区申请空间
strcpy(str, tmp); //拷贝内容
cout << "普通构造函数 str = " << str << endl;
}
MyString(const MyString &tmp)
{//拷贝构造函数
len = tmp.len;
str = new char[len + 1];
strcpy(str, tmp.str);
cout << "拷贝构造函数 tmp.str = " << tmp.str << endl;
}
MyString &operator= (const MyString &tmp)
{//赋值运算符重载函数
if(&tmp == this)
{
return *this;
}
//先释放原来的内存
len = 0;
delete []str;
//重新申请内容
len = tmp.len;
str = new char[len + 1];
strcpy(str, tmp.str);
cout << "赋值运算符重载函数 tmp.str = " << tmp.str << endl;
return *this;
}
~MyString()
{//析构函数
cout << "析构函数: ";
if(str != NULL)
{
cout << "已操作delete, str = " << str;
delete []str;
str = NULL;
len = 0;
}
cout << endl;
}
private:
char *str = NULL;
int len = 0;
};
MyString func() //返回普通对象,不是引用,不能返回局部的或临时对象的指针或引用
{
MyString obj("mike");
cout << "&obj" << (void *)&obj << endl;
//qt, 返回值优化技术
//vs2013, debug模式,没有做返回值优化
return obj;
}
int main()
{
MyString tmp = func();
cout << "&tmp" << (void *)&tmp << endl;
return 0;
}
vs输出结果:
vscode输出结果:
原因分析:vscode进行了一次结果优化,不会生成临时对象,vs会生成临时对象,调用一次拷贝构造函数。原理如下图:
/*
C++编译器对于对象构造的优化,用临时对象生成新对象的时候,临时对象就不产生了,直接构造新对象就可以了
*/
MyString obj = MyString("Mike"); // 相当于MyString obj("Mike"); 二者无区别, 直接构造不生成临时对象
obj = MyString("Mike") // 显示生成临时对象,其生存周期仅为所在的语句 先调用构造、再调用赋值、后调用析构
obj = (MyString) "Mike" // 显示生成临时对象 char->MyString(char) 先调用构造、再调用赋值、后调用析构
obj = "Mike" // 隐式生成临时对象 会自动调用相应构造MyString(char) 先调用构造、再调用赋值、后调用析构
指针指向临时对象不安全,应使用常量引用
指向临时对象
MyString* obj = &MyString("Mike"); // 执行完立马析构,产生野指针
MyString* obj = new MyString("Mike"); // new出来的对象在堆上,delete的时候才释放
const MyString& obj = MyString("Mike"); // 程序整体运行完再析构
/*
MyString tmp = MyString("Mike");
MyString obj = tmp;
*/
对象使用和函数调用–构造析构
- 示例1:对象使用过程中的构造与析构
class Test {
public:
// 有默认值可构造三种, Test(); Test(10);传参给a,b为默认值 Test(10, 10);
Test(int a = 5, int b = 5){} //普通构造
Test(const int& ) {} // 拷贝构造
~Test() {} // 析构
void &operator= (const Test& ) {} // =赋值重载
private:
int ma;
int mb;
};
// 程序运行后,全局变量先构造
Test t1(10, 10); // 1.Test(int, int)
int main() {
Test t2(20, 20); // 3.Test(int, int)
Test t3 = t2; // 4.&operator=
static Test t4 = Test(30, 30); // 5. Test(int, int)
t2 = Test(40, 40); // 6. Test(int, int) -> &operator= -> ~Test()
t2 = (Test) (50, 50); // 7. Test(int, int) -> &operator= -> ~Test()
t2 = 60; // 8. Test(int, int) -> &operator= -> ~Test()
Test *p1 = new Test(70, 70); // 9. Test(int, int)
Test *p2 = new Test[2]; // 10.Test(int, int) Test(int, int)
Test *p3 = &Test(80, 80); // 11. Test(int, int) ~Test()
const Test &p4 = Test(90, 90); // 12. Test(int, int)
delete p1; // 13. ~Test()
delete []p2; // 14. ~Test() ~Test()
return 0;
}
// 程序析构顺序:p1->p2->p4->t3->t2->t4->t5->t1
// new出来的在堆上,需要delete释放,static定义的对象在程序运行完后再析构
Test t5(100, 100); // 2.Test(int, int)
- 示例2:函数调用过程中的构造与析构
class Test {
public:
Test(int val = 10) {}
Test(const Test& ) {}
void operator= (const Test& ) {}
int getDate() const { return val}
private:
int val;
};
// 不能返回局部的或者临时对象的指针或引用,如果使用指针,会间接放回局部对象的地址,但局部变量运行完就被回收
Test GetObject(Test t) {
int i = t.getData();
Test tmp(i);
return tmp;
}
int main() {
Test t1;
Test t2;
// 函数调用, 实参 -> 形参是初始化过程
t2 = GetObject(t1);
return 0;
}
OOP编程对象优化的三条规则:
- 函数参数传递过程中,对象优先按引用传递,不要按值传递。
- 函数返回对象的时候,应该优先返回一个临时对象,而不要返回一个定义过的对象。
返回临时对象Test(val)时,会在main方法中开辟内存构造临时对象,3.Test(int)。而不会产生定义过的变量对临时变量的拷贝构造和析构。
3. 接收返回值是对象的函数调用的时候,优先按初始化的方式接收,不要按赋值的方式接收。
移动语义
为什么需要移动语义?
1. 转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的``临时对象``的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。右值引用是用来支持转移语义的。
2. 转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
3. 通过转移语义,临时对象中的资源能够转移其它的对象里。
移动语义定义
- 在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。
- 如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。
//移动构造函数
//参数是非const的右值引用
MyString(MyString && t)
{
str = t.str; //拷贝地址,没有重新申请内存
len = t.len;
//原来指针置空
t.str = NULL;
cout << "移动构造函数" << endl;
}
MyString &&tmp = func(); //右值引用接收
//移动赋值函数
//参数为非const的右值引用
MyString &operator=(MyString &&tmp)
{
if(&tmp == this){
return *this;
}
//先释放原来的内存
len = 0;
delete []str;
//无需重新申请堆区空间
len = tmp.len;
str = tmp.str; //地址赋值
tmp.str = NULL;
cout << "移动赋值函数\n";
return *this;
}
- 拷贝构造函数类似,有几点需要注意:
- 参数(右值)的符号必须是右值引用符号,即“&&”。
- 参数(右值)不可以是常量,因为我们需要修改右值。
- 参数(右值)的资源链接和标记必须修改,否则,右值的析构函数就会释放资源,转移到新对象的资源也就无效了。
标准库函数move和forward
- std::move()
既然编译器只对右值引用才能调用转移构造函数和转移赋值函数,而所有命名对象都只能是左值引用,如果已知一个命名对象不再被使用而想对它调用转移构造函数和转移赋值函数,也就是把一个左值引用当做右值引用来使用,怎么做呢?标准库提供了函数 std::move,这个函数以非常简单的方式将左值引用转换为右值引用。
int a;
int &&r1 = a; // 编译失败
int &&r2 = std::move(a); // 编译通过
- std::forward
完美转发适用于这样的场景:需要将一组参数原封不动的传递给另一个函数。
“原封不动”不仅仅是参数的值不变,在 C++ 中,除了参数值之外,还有一下两组属性:左值/右值和 const / non-const。完美转发就是在参数传递过程中,所有这些属性和参数值都不能改变,同时,而不产生额外的开销,就好像转发者不存在一样。在泛型函数中,这样的需求非常普遍。
那C++11是如何解决完美转发的问题的呢?实际上,C++11是通过引入一条所谓“引用折叠”(reference collapsing)的新语言规则,并结合新的模板推导规则来完成完美转发。
typedef const int T;
typedef T & TR;
TR &v = 1; //在C++11中,一旦出现了这样的表达式,就会发生引用折叠,即将复杂的未知表达式折叠为已知的简单表达式