C++11(上)(右值引用、移动构造)
文章目录
- 一、C++11简介
- 二、统一的列表初始化
- 2.1 { } 初始化
- 2.2 initializer_list
- 三、声明
- 3.1 auto
- 3.2 decltype
- 3.3 nullptr
- 3.4 STL中的一些变化
- 四、右值引用和移动语义★★★
- 4.1 左值引用和右值引用
- ● 右值引用与左值引用的比较
- 4.2 右值引用的使用场景及意义
- ● 左值引用的短板
- ● 右值引用和移动语义弥补左值引用的短板
- ● 移动语义的意义★★
- 4.3 右值引用引用左值及其一些更深入的使用场景分析
- ● STL容器的插入接口也增加了右值引用版本
- 4.4 完美转发
- ● 万能引用(引用折叠)
一、C++11简介
在2003年C++标准委员会曾经提交了一份技术勘误表(简称TC1),使得C++03这个名字已经取代了C++98称为C++11之前的最新C++标准名称。不过由于C++03(TC1)主要是对C++98标准中的漏洞进行修复,语言的核心部分并没有改动,因此人们习惯性的把两个标准合并称为C++98/03标准。从C++0x到C++11,C++标准10年磨一剑,第二个真正意义上的标准珊珊来迟。相比于C++98/03,C++11则带来了数量可观的变化,其中包含了约140个新特性,以及对C++03标准中约600个缺陷的修正,这使得C++11更像是从C++98/03中孕育出的一种新语言。相比较而言,C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个重点去学习。C++11增加的语法特性篇幅非常多,这里没办法一一讲解,所以本章主要讲解实际中比较实用的语法。
链接:C++文档介绍
😳小故事:
1998年是C++标准委员会成立的第一年,本来计划以后每5年时需要更新一次标准,C++国际标准委员会在研究C++03的下一个版本的时候,一开始计划是2007年发布,所以最初这个标准叫C++07。但是到06年的时候,官方觉得2007年肯定完不成C++07,而且官方觉得2008年可能也完不成。最后干脆叫C++0x。x的意思就是不知道到底能在07还是08还是09年完成。结果2010年的时候也没完成,最后在2011年终于完成了C++标准。所以最终定名为C++11。
二、统一的列表初始化
2.1 { } 初始化
在C++98中,允许使用 花括号{ } 对数组或者结构体进行统一的列表初始值设定。比如:
struct Point
{int _x;int _y;
};int main()
{//数组初始化int array1[] = { 1, 2, 3, 4, 5 };int array2[5] = { 0 };//结构体初始化(C语言中的结构体初始化)Point p = { 1, 2 };return 0;
}
C++11扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用列表初始化时,可添加等号(=),也可不添加。
//一切都可以用列表初始化
//并且可以省略赋值符号(=)
int main()
{//变量的初始化int i = 0;int j = { 1 };int k{ 2 };//数组和结构体的初始化(省略了=)int array1[]{ 1, 2, 3, 4, 5 };int array2[5]{ 0 };Point p{ 1, 2 };return 0;
}
创建对象时也可以使用列表初始化方式调用构造函数初始化。
class Date
{
public://构造函数Date(int year, int month, int day):_year(year), _month(month), _day(day){cout << "Date(int year, int month, int day)" << endl;}
private:int _year;int _month;int _day;
};int main()
{//调构造函数初始化Date d1(2025, 7, 22);//多参数的(隐式)类型转换 构造+拷贝构造->优化为直接构造Date d2 = { 2025,6,13 };Date d3{ 2025,3,24 };//单参数的(隐式)类型转换string str = "xionger";//临时对象具有常性const Date& d4 = { 2024,9,15 };Date* p1 = new Date[3]{ d1,d2,d3 };//C++11中列表初始化也可以适用于new表达式中Date* p2 = new Date[3]{ {2025,7,14},{2025,7,15},{2025,7,16} };return 0;
}
注意:列表初始化跟初始化列表是不一样的。初始化列表是在构造函数的地方用的!要注意区分。
2.2 initializer_list
std中的initializer_list的介绍文档:initializer_list
std::initializer_list是什么类型:
int main()
{auto il = { 10, 20, 30 };cout << typeid(il).name() << endl;return 0;
}
程序运行结果:
initializer_list其实是C++11库中新增加的一个类模版:
initializer_list类的接口:
initializer_list类可以用来初始化vector、list等容器:
int main()
{//下面一行等价于initializer_list<int> il1 = { 10,20,30,40,50 };//即这里的{10,20,30,40,50}会被强行识别成initializer_list<int>auto il1 = { 10,20,30,40,50 };//用initializer_list<T>类型去初始化vector、listvector<int> v = { 30,40,50 };list<int> lt = { 60,70,80,90 };return 0;
}
简单介绍一下initializer_list:它就相当于是一个底层为数组的类,这个数组的元素都存在常量区的一段空间里,然后有一个start指针指向数组的第一个元素,有一个finish指针指向数组的最后一个元素的下一个位置:
所以initializer_list类的接口支持迭代器,这里的迭代器其实就是原生指针,所以这里用initializer_list类去初始化vector/lis容器,就相当于遍历initializer_list类里的元素去插入到vector/list容器中,就这么简单。
initializer_list<int>::iterator it = il1.begin();
while (it != il1.end())
{cout << *it << " ";++it;
}
cout << endl;for (auto e : lt)
{cout << e << " ";
}
cout << endl;
迭代器的遍历结果:
注意:initializer_list跟链表没有关系,他只是取名叫initializer_list而已。要能区分是否是initializer_list类型。例如:
int main()
{//这里的{2025,3,12}不会被识别成initializer_list<int>//因为{2025,3,12}与日期类的构造函数的参数个数相匹配Date d = { 2025,3,12 };//这里的{10,20,30}会被识别成initializer_list<int>类型vector<int> v = { 10,20,30 };return 0;
}
能用initializer_list去初始化vector/list容器,是因为vector/list容器的构造函数里支持用initializer_list去构造:
那自己实现的vector中要如何实现一个用initializer_list初始化的构造函数呢?很简单:
vector(initializer_list<T> il)
{this->reserve(il.size());for (auto& e : il){this->push_back(e);}
}
上面就是自己实现的vector支持initializer_list初始化的构造函数。当然map或set类也支持用initializer_list去初始化构造:
int main()
{//(隐式)类型转换pair<string, string> kv = { "tree","树" };//显式调用构造函数pair<string, string> kv("left", "左边");//下面的{"sort", "排序"}会先初始化构造一个pair对象//然后再去走map的initializer_list构造函数map<string, string> dict = { kv, {"sort", "排序"}, {"insert", "插入"} };return 0;
}
这里就像是一种嵌套列表初始化:大括号内的每个{“key”, “value”}会先构造一个临时的pair<string, string>对象(隐式类型转换)。然后map的initializer_list构造函数会自动处理这些pair,将其插入到map中。
而且initializer_list也可以支持赋值:
int main()
{//initializer_list的构造vector<int> v = { 10,20,30 };//initializer_list的赋值v = { 20,30,40 };return 0;
}
总结initializer_list的意义:其实就是用来给容器进行列表初始化的。
三、声明
C++11提供了多种简化声明的方式,尤其是在使用模板时。
3.1 auto
在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以auto就没什么价值了。
C++11中废弃auto原来的用法,将其用于实现自动类型推导。这样要求变量/对象必须进行显示初始化,让编译器将定义的变量/对象的类型设置为初始化值的类型。范围for也会用到auto。
int main()
{int i = 1;double j = 2.2;Date d = { 2025,7,23 };//typeid(变量/对象).name可以用来获取变量或对象的类型,且以字符串的形式获取到cout << typeid(i).name() << endl;cout << typeid(j).name() << endl;cout << typeid(d).name() << endl;auto k = i;auto m = i * j;cout << typeid(k).name() << endl;cout << typeid(m).name() << endl;return 0;
}
容器的模版参数不能用auto来实例化,因为编译器并不知道你这里的auto到底是什么类型!比如:
int i = 0;
//typdeid(变量).name()不能用来声明变量,下面定义变量的方式是错的
typeid(i).name() j;//以下两种声明对象的方式都是错的!auto不能用来实例化类模版
vector<auto> v;
list<auto> lt = { 10,20,30 };
typeid(变量/对象).name()也不能用来定义变量/对象,因为其只是获取到变量/对象的类型,且这个类型是字符串的形式获取到的,所以不能用来定义变量/对象。
3.2 decltype
关键字decltype可以将变量的类型声明为表达式指定的类型。
int main()
{int i = 1;double j = 2.2;auto ret = i * j;//decltype可以用来推导变量/对象的类型,且这个类型是可以拿来用的//比如:decltype可以用来做模版实参,或者再定义变量/对象都可以vector<decltype(ret)> v;decltype(ret) d;cout << typeid(v).name() << endl;cout << typeid(d).name() << endl;return 0;
}
3.3 nullptr
由于C++中NULL被定义成字面量0,这样就可能会带来一些问题,因为0既能指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中又新增了nullptr,用于表示空指针。
3.4 STL中的一些变化
新容器
用绿色圈起来的就是C++11中新增的几个容器,但是实际最有用的是unordered_map和unordered set。这两个之前已经进行了非常详细的讲解了,其中forward_list就是单链表,而array本质就是一个数组的容器,最好的就是unordered_map和unordered_set这两个类。
array也是底层为数组的容器,那它与vector有什么区别吗?
int main()
{//开辟10个整型的空间,不初始化array<int, 10> a;//开辟10个整型的空间,并都初始化为0vector<int> v(10, 0);return 0;
}
所以array这个容器没有什么技术含量,唯一的一个优点就是:array有重载[ ],普通的数组检查越界没有那么的严格,但是array的[ ]在读或写数组的元素时会非常严格的检查数组是否越界!
int main()
{array<int, 10> a;a[10];a[10] = 1;return 0;
}
还有就是array这个容器的底层是在栈上开辟的空间,而vector是在堆上开辟的空间,因为vector底层实际管理的是指向这块空间的指针。这也是两者的区别:
int main()
{array<int, 10> a;cout << sizeof(a) << endl;vector<int> v(10, 0);cout << sizeof(v) << endl;return 0;
}
可以看到v比a小,则array底层确实就是在栈上开辟的数组。所以相比起array,我们更喜欢用vector。因为vector容器已经将顺序表的增删查改操作封装的很完美了。
四、右值引用和移动语义★★★
4.1 左值引用和右值引用
传统的C++语法中就有引用的语法,而C++11中新增了右值引用的语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
🧐什么是左值? 什么是左值引用?
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边。定义时如果有const修饰的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取的别名。只要能取地址的值,都叫左值。
int main()
{int i = 1;int j = i; //这里i就是左值,且它在赋值符号的右边//以下的p、b、c、*p都是左值int* p = new int(0);int b = 1;//c叫作常变量,意思是不能直接去修改c的值,但可以间接去修改const int c = 2; //以下几个是对上面左值的左值引用int*& rp = p;int& rb = b;const int& rc = c;int& pvalue = *p;return 0;
}
🔥什么是右值? 什么是右值引用?
右值也是一个表示数据的表达式,如: 字面常量、表达式返回值,函数返回值(不能是左值引用的返回值)等等,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边;右值不能取地址。右值引用就是对右值的引用,给右值取别名。
int main()
{double x = 1.1, y = 2.2;//以下几个都是常见的右值10;x + y;fmin(x, y); //函数返回值(传值返回)//以下几个都是对右值的右值引用int&& rr1 = 10;double&& rr2 = x + y;double&& rr3 = fmin(x, y);//下面编译会报错:error C2106: “=”:左操作数必须为左值10 = 1;x + y = 1;fmin(x, y) = 1;return 0;
}
需要注意:右值引用类型是两个&符号,右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址。也就是说右值引用(右值的别名)是一个左值,可以取地址。例如: 不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,是不是感觉很神奇!这个了解一下即可。实际中右值引用的使用场景并不在于此,这个特性也不重要。
int main()
{double x = 1.1, y = 2.2;//rr1是对右值10的右值引用int&& rr1 = 10;//rr2是对右值x+y的右值引用,且rr2不能直接修改const double&& rr2 = x + y;rr1 = 20;rr2 = 5.5; //报错return 0;
}
● 右值引用与左值引用的比较
🥦左值引用总结:
- 左值引用只能引用左值,不能引用右值。
- 但是const左值引用既可引用左值,也可引用右值。
int main()
{//左值引用只能引用左值,不能引用右值。int a = 10;int& ra1 = a; //ra1为a的别名//int& ra2 = 10; // 编译失败,因为10是右值; 本质这里是一种权限的放大//const左值引用既可引用左值,也可引用右值const int& ra3 = 10; const int& ra4 = a;return 0;
}
🍓右值引用总结:
- 右值引用只能引用右值,不能引用左值。
- 但右值引用可以给move(左值)取别名。
int main()
{//右值引用只能右值,不能引用左值int&& r1 = 10;//error C2440: “初始化”: 无法从“int”转换为“int &&”//message: 无法将左值绑定到右值引用int a = 10;int&& r2 = a;//右值引用可以给move以后的左值取别名int&& r3 = move(a);return 0;
}
注:右值引用可以给move(左值)取别名。
🍐记住:引用的作用就是减少拷贝。
4.2 右值引用的使用场景及意义
前面我们可以看到左值引用既可以引用左值和也可以引用右值,那为什么C++11还要提出右值引用呢? 是不是画蛇添足呢? 下面我们来看看左值引用的短板,以及右值引用是如何补齐这个短板的!
//自主实现的string类
namespace MyStr
{class string{public:typedef char* iterator;iterator begin(){return _str;}iterator end(){return _str + _size;}string(const char* str = ""):_size(strlen(str)), _capacity(_size){//cout << "string(char* str)" << endl;_str = new char[_capacity + 1];strcpy(_str, str);}// s1.swap(s2)void swap(string& s){std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);}// 拷贝构造string(const string& s):_str(nullptr){cout << "string(const string& s) -- 深拷贝" << endl;string tmp(s._str);swap(tmp);}// 赋值重载string& operator=(const string& s){cout << "string& operator=(string s) -- 深拷贝" << endl;//传统写法if (this != &s){char* tmp = new char[s._capacity + 1];strcpy(tmp, s._str);delete[] _str;_str = tmp;_size = s._size;_capacity = s._capacity;}return *this;}// 移动构造string(string&& s):_str(nullptr), _size(0), _capacity(0){cout << "string(string&& s) -- 移动构造" << endl;swap(s);}// 移动赋值string& operator=(string&& s){cout << "string& operator=(string&& s) -- 移动赋值" << endl;swap(s);return *this;}~string(){delete[] _str;_str = nullptr;}char& operator[](size_t pos){assert(pos < _size);return _str[pos];}void reserve(size_t n){if (n > _capacity){char* tmp = new char[n + 1];strcpy(tmp, _str);delete[] _str;_str = tmp;_capacity = n;}}void push_back(char ch){if (_size >= _capacity){size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;reserve(newcapacity);}_str[_size] = ch;++_size;_str[_size] = '\0';}//string operator+=(char ch)string& operator+=(char ch){push_back(ch);return *this;}const char* c_str() const{return _str;}private:char* _str = nullptr;size_t _size;size_t _capacity; // 不包含最后做标识的'\0'};
}
左值引用的使用场景:
做参数和做返回值时都可以提高效率。
void func1(MyStr::string s)
{ }void func2(const MyStr::string& s)
{ }int main()
{MyStr::string s1("hello world");// func1和func2的调用可以看到左值引用做参数减少了拷贝,提高了效率func1(s1);func2(s1);// string operator+=(char ch) 传值返回存在深拷贝// string& operator+=(char ch) 传左值引用返回没有拷贝提高了效率s1 += '!';return 0;
}
因为func1函数是值传参,所以会调用一次拷贝构造函数:
● 左值引用的短板
当函数的返回对象是一个局部对象,出了函数作用域就不存在了,那就不能使用左值引用返回只能传值返回。例如下面: MyStr:string to_string(int value)函数中可以看到,这里只能使用传值返回,传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造)。
namespace MyStr
{string to_string(int value){bool flag = true;if (value < 0){flag = false;value = 0 - value;}string str;while (value > 0){int x = value % 10;value /= 10;str += ('0' + x);}if (flag == false){str += '-';}std::reverse(str.begin(), str.end());return str;}
}int main()
{// 在MyStr::string to_string(int value)函数中可以看到,这里只能使用传值返回// 传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造)MyStr::string ret1 = MyStr::to_string(1234);MyStr::string ret2 = MyStr::to_string(-1234);return 0;
}
上面讲过,函数的返回值(不是左值引用返回)也是一种右值,因为to_string函数中的str是一个局部对象,这个局部对象严格来说不算是右值(“将亡值”),因为它可以取地址,但是因为它在出了to_string函数的作用域后就会被销毁,所以编译器会强行的把str识别成一个右值。在有移动构造的情况下,返回值就是用str去移动构造的一个临时对象。而返回的临时对象也是一个右值,它也是一个 “将亡值”:即用这个临时对象去移动构造完ret2以后,就会被销毁。以前我们学习的右值叫做纯右值,即内置类型的右值。而"将亡值"是指自定义类型的右值。所以右值也指临时对象或者即将被销毁的对象,因为这些右值的生命周期短,而且没有名称,你也取不到他们的地址。
● 右值引用和移动语义弥补左值引用的短板
在MyStr::string中增加移动构造,移动构造本质是将参数右值的资源窃取过来,占为已有,那么就不用做深拷贝了,所以把它叫做移动构造。就是窃取别人的资源来构造自己。
// 移动构造
string(string&& s):_str(nullptr), _size(0), _capacity(0)
{cout << "string(string&& s) -- 移动拷贝" << endl;swap(s);
}
可以看到移动构造函数的参数就是右值引用的形式(注意: 引用就是取别名),因为返回值是临时对象嘛,临时对象就是右值。
那有小伙伴就要问了:定义了移动构造函数,编译器就一定会调用移动构造而不是拷贝构造吗?
答:只要提前定义了移动构造函数,那to_string函数的返回值一定会去调用它,而不是去调用拷贝构造;你就想原来没有移动构造时,to_string的返回值只能去调用拷贝构造,但编译器只要检测到你定义了移动构造,那to_string的返回值(右值)就会优先去调用移动构造函数。这是编译器的默认行为,即会优先匹配最适合的移动构造;当没有实现移动构造时,才会去将就调用拷贝构造。即: 是左值时就去匹配调用左值的拷贝构造,是右值时就去匹配调用右值的移动构造。
不仅仅有移动构造,还有移动赋值:
在MyStr::string类中还有移动赋值函数,在去调用MyStr::to_string(1234)时,是将MyStr::to_string(1234)返回的临时对象赋值给s对象,这时调用的就是移动赋值,移动赋值也是在转移资源。
// 移动赋值
string& operator=(string&& s)
{cout << "string& operator=(string&& s) -- 移动赋值" << endl;swap(s);return *this;
}
int main()
{MyStr::string s;s = MyStr::to_string(1234); //会调用MyStr::string的赋值重载return 0;
}
可以看到,移动构造/移动赋值就是把(临时或局部)对象指向的堆上申请的空间给窃取了过来,占为己有。而局部对象str和临时对象在移动构造完s对象后,该销毁就销毁;但是他们在堆上申请的资源已经转移到了s中。
当然如果是连续的移动构造,那新一点的vs编译器会优化成1次移动构造。比如:
int main()
{MyStr::string s = MyStr::to_string(1234);return 0;
}
● 移动语义的意义★★
通过上面的学习可知,移动语义(Move Semantics)是C++11引入的核心特性,其核心意义在于通过资源所有权转移而非深拷贝来提升程序性能和资源管理效率。
🍎性能优化🍎:
▲ 避免深拷贝开销: 对动态分配的资源(如std::vector、std::string )直接转移所有权,将原本O(n)的拷贝操作降为O(1)的指针操作。
▲ 临时对象处理: 在函数返回或传递右值(如临时对象)时,通过移动而非拷贝减少重复分配/释放内存的开销。
STL里的容器在C++11以后,都增加了移动构造和移动赋值:
…
4.3 右值引用引用左值及其一些更深入的使用场景分析
按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗? 因为: 有些场景下,可能真的需要用右值引用去引用左值实现移动语义。当需要用右值引用去引用一个左值时,可以通过move函数将左值强转为一个右值。C++11中,std::move()函数位于头文件utility中,该函数的名字具有迷惑性它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,从而允许通过移动语义来高效地转移资源所有权。
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{// forward _Arg as movablereturn ((typename remove_reference<_Ty>::type&&)_Arg);
}
这就有点像:
int main()
{int i = 1;//将i强制类型转换成double类型后赋给j//但i本身还是int类型,强制类型转换并没有改变i的属性double j = (double)i;return 0;
}
关于std::move函数:
1.编译器会将std::move函数的返回值视为一个未命名的右值引用(因为它是函数调用的临时结果),其返回值类型是T&&类型;但不改变原左值的属性。
move仅执行类型转换(将左值强制转换为右值),但不改变原对象的实际状态。原对象仍保持左值属性,但可能处于有效但未定义的状态。例如:
int main()
{MyStr::string s1("hello world");// 这里s1是左值,调用的是拷贝构造MyStr::string s2(s1);// 下面把s1 move处理以后,会被编译器视为一个右值,会调用移动构造// 但需要注意,一般不要这样用,因为我们发现s1的资源被转移给了s3,即s1被置空了MyStr::string s3(std::move(s1));return 0;
}
s1 move之前:
s1 move之后:
当然MyStr::string是自己实现的类,移动构造将s1的资源转移给了s3。但s1是main函数里的一个局部对象,还没有释放,它还是左值,只是资源被转移,变成了未定义的状态。所以没有特殊情况,尽量不要把一个左值通过move强转成右值。
🎯2.资源是否被转移?
资源的转移与否取决于你移动构造函数/移动赋值的具体实现。STL标准库容器(如string、vector)的移动构造/移动赋值会转移资源并置空原对象,而某些自定义类可能仅拷贝数据(未实现移动语义)。
到底要怎么理解std::move不改变原左值的属性,而其返回值是一个右值引用呢?
🍊 你只要记住:std::move的本质是类型转换即可。
▲ std::move的作用就是将左值强制转换为右值引用,但不改变原对象的内存状态,允许编译器将其视为可移动的资源。
▲ std::move的返回值“绑定”了原左值的资源,原左值的资源(如动态分配的内存)是否被转移,取决于后续的操作(如移动构造函数/移动赋值是否被调用)
std::move的核心意义是高效地转移资源所有权,而非改变对象的底层属性。你可以将move(左值)理解为为这个左值贴上了一个右值的"标签"。
● STL容器的插入接口也增加了右值引用版本
比如list的push_back接口和set的inset接口都增加了右值引用版本:
int main()
{list<MyStr::string> lt = { "hello","world","C++" };MyStr::string s("language"); //由于s是一个左值,则下面是拷贝构造lt.push_back(s); //MyStr::to_string函数的返回值是一个右值,所以下面是移动构造lt.push_back(MyStr::to_string(1234)); return 0;
}
不包含上面"hello",“world”,"C++"这个三个字符串的(拷贝)构造输出,单看上面两个push_back函数尾插的输出结果:
当编译器识别到对象是一个"将亡值"时,就会去调用移动构造函数,这样能减少深拷贝的次数,提升程序的性能和资源管理效率。
我们可以自己模拟实现一个list容器的右值引用版本的push_back接口:
//list容器里的成员函数
void push_back(T&& x)
{insert(--begin(), move(x));
}
iterator insert(iterator pos, T&& x)
{Node* cur = pos._node;Node* newnode = new Node(move(x));Node* prev = cur->_prev;//prev newnode curnewnode->_next = cur;newnode->_prev = prev;prev->_next = newnode;cur->_prev = newnode;++_size;return newnode;
}
//链表的节点结构
template<class T>
struct list_node
{T _data;list_node<T>* _next;list_node<T>* _prev;//构造函数list_node(const T& x = T()):_data(x), _next(nullptr), _prev(nullptr){ }list_node(T&& x):_data(move(x)), _next(nullptr), _prev(nullptr){ }
};
当用右值引用去引用一个右值以后,这个右值引用的属性就变成了左值,因为它有名字了嘛!所以可以看到上面的接口中:只要是右值引用类型的参数在往下传递的过程中,都要用move去把该参数(对象或变量)强转成右值,这样才能去匹配参数为右值引用类型的接口;如果不把参数强转成右值类型,编译器会把形参识别成左值,去匹配参数为左值引用类型的接口。
记住:右值不能被修改,但是右值被右值引用以后,要能被修改!
因为移动构造/移动赋值需要支持右值引用能被修改,所以右值引用的属性是左值才能被修改。
像上面这种右值引用会退化成左值的情况,就没办法再实现移动构造和移动赋值了!所以每个传递参数的地方都要用move将其强转成右值,但这样太麻烦了!有没有什么办法可以解决一下呢?看下面。
4.4 完美转发
完美转发(Perfect Forwarding)是C++11引入的一项关键技术,旨在解决泛型编程中参数传递时的类型和值类别(左值/右值)丢失的问题。其核心目标是通过模板和引用机制,将函数参数原封不动地转发给其他函数,包括保留其参数的以下属性:
(1) 值的类别(左值或右值)
(2) 类型修饰符(如const)
(3) 避免不必要的拷贝,提升性能
● 万能引用(引用折叠)
当函数模板参数声明为 T&& 时,可根据传入的实参自动推导为左值引用或右值引用:
template<typename T>
void PerfactForward(T&& t) //万能引用
{ Func(t);
}
万能引用(Universal Reference)的使用范围并不局限于函数模板,但其核心规则是必须涉及模板参数的自动推导且声明形式为T&&
意思是: 像有函数模版的地方,出现有T&&这种需要自动推导类型的就是万能引用。这里的&&不代表右值引用,而是代表其既能接收左值又能接收右值。当你传左值时,它就推导为左值,传右值时,它就推导为右值;如果是const修饰的左右值,它一样也能自动推导。
void Func(int& x) { cout << "左值引用" << endl; }
void Func(const int& x) { cout << "const 左值引用" << endl; }
void Func(int&& x) { cout << "右值引用" << endl; }
void Func(const int&& x) { cout << "const 右值引用" << endl; }int main()
{PerfectForward(10); //右值int a;PerfectForward(a); //左值PerfectForward(std::move(a)); //右值const int b = 8;PerfectForward(b); // const左值 PerfectForward(std::move(b)); // const右值return 0;
}
虽然有了万能引用,但是又出现了新的问题:万能引用的引用类型唯一的作用就是限制了接收的类型,但在后续的使用中都退化成了左值。就像上面的函数模版PerfectForward中的参数t,虽是一个万能引用,但t此时已经是具有左值属性的具名变量了,后续在传给函数体内的其他函数时,就退化成了左值。而我们希望的是参数在传递过程中能够保持它的左值或右值的属性,这就需要用到上面所讲的完美转发:
template<typename T>
void PerfectForward(T&& t) //万能引用
{ //std::forward<T>(t)在传参的过程中保持了t的原生类型属性Fun(forward<T>(t)); //完美转发
}
forward<T>的作用就是:根据模板参数T的类型决定转发为左值还是右值引用,避免参数退化为左值。若T为右值引用,则转为右值,否则保留左值。这样在传参的过程中就能保留对象的原生类型属性。
此时再来看上面程序的运行结果:
有了完美转发,上面自己实现的list容器中的push_back接口也可以用完美转发forward<T>(x)替代move(x)也是一样的效果。