【C++】C++11新特性(一)

【C++】C++11新特性(一)



🎬 个人主页MSTcheng · ZEEKLOG
🌱 代码仓库MSTcheng · Gitee
🔥 精选专栏: 《C语言
数据结构
《算法学习》
C++由浅入深

💬座右铭:路虽远行则将至,事虽难做则必成!


前言:在前面的文章中我们花费了较长的时间终于结束了C++STL部分的学习,翻过这一座大山之后接下来我们就来学一下C++11的新特性。

文章目录

一、C++11的发展历史

C++11 是 C++ 语言的第二个主要版本,也是自 C++98以来最重要的更新。这次更新不仅引入了大量新特性,还标准化了现有实践,并显著提升了 C++ 程序员的开发效率。在 2011 年 8 月 12日被 ISO 正式采纳前,它曾被称为"C++0x",因为原本预计会在 2010 年前发布。从 C++03 到 C++11 历经了 8年时间,这是迄今为止最长的版本间隔。此后,C++ 开始保持每三年一次的规律性更新。
在这里插入图片描述

那么传统C++说的就是C++98及之前的版本,而C++11之后的C++则称之为现代C++。

二,列表初始化{ }

2.1C++98中的列表初始化

structPoint{int _x;int _y;};//C++98传统的花括号{} 可以支持数组和结构体初始化intmain(){int array1[]={1,2,3,4,5};int array2[5]={0}; Point p ={1,2};return0;}

传统的C++98只支持数组,和结构体在初始化的时候使用花括号去初始化,而C++11则不同。

2.2C++11中的{}

#include<vector>structPoint{int _x;int _y;};classDate{public:Date(int year =1,int month =1,int day =1):_year(year),_month(month),_day(day){ cout <<"Date(int year, int month, int day)"<< endl;}Date(const Date& d):_year(d._year),_month(d._month),_day(d._day){ cout <<"Date(const Date& d)"<< endl;}private:int _year;int _month;int _day;};// ⼀切皆可⽤列表初始化,且可以不加=intmain(){//==============C++98支持的==================int a1[]={1,2,3,4,5};int a2[5]={0}; Point p ={1,2};//==============C++11支持的===================// 内置类型⽀持int x1 ={2};// ⾃定义类型⽀持// 这⾥本质是⽤{ 2025, 1, 1}构造⼀个Date临时对象// 临时对象再去拷⻉构造d1,编译器优化后合⼆为⼀变成{ 2025, 1, 1}直接构造初始化d1// 运⾏⼀下,我们可以验证上⾯的理论,发现是没调⽤拷⻉构造的 Date d1 ={2025,1,1};// 这⾥d2引⽤的是{ 2024, 7, 25 }构造的临时对象const Date& d2 ={2024,7,25};// 需要注意的是C++98⽀持单参数时类型转换,也可以不⽤{} Date d3 ={2025}; Date d4 =2025;//在使用花括号初始化时可以省略掉= Point p1{1,2};int x2{2}; Date d6{2024,7,25};const Date& d7{2024,7,25};// 不⽀持,只有{}初始化,才能省略=// Date d8 2025; vector<Date> v; v.push_back(d1); v.push_back(Date(2025,1,1));// 比起有名对象和匿名对象传参,这里{}更有性价比 v.push_back({2025,1,1});return0;}

说明:

  • C++11使用花括号{}初始化的方式叫做列表初始化,而C++11想尽量做到让一切对象都使用初始化列表来初始化,所以初始化列表初始化不仅支持内置类型,自定义类型也支持,而自定义类型使用花括号的时候走的就是一个隐式类型转换,中间会产生临时对象,最后优化成直接构造。
  • 在C++11中使用花括号{}初始化的时候,可以省略=赋值符号.

2.3C++11中的std::initializer_list(初始化列表)

对于初始化列表相信大家都还是很熟悉的,在前面模拟实现vectorlist的文章中我们也介绍果相关初始化列表的内容,甚至还模拟实现了初始化列表的功能,底层调用insert来完成的。

那么既然C++11已经给内置类型,自定义类型的对象都安排了初始化列表初始化,那如果不给STL容器对象安排初始化列表初始化就有点说不过去了,所以C++11的库中就提出了⼀个std::initializer_list的类, auto il = { 10, 20, 30 };这个类的本质是底层开⼀个数组,将数据拷贝过来,std::initializer_list内部有两个指针分别指向数组的开始和结束。

#include<vector>#include<string>#include<map>usingnamespace std;intmain(){ std::initializer_list<int> list ={1,2,3,4,5,6,7,8,9,10};//list计算出来的大小是16的原因是 initializer_list 定义的类对象中//有两个指针构成的 一个begin_ptr() 一个end_ptr() //在64位系统上,每个指针占用8字节,因此两个指针共占用16字节(8 + 8 = 16)。//这与代码中输出的 sizeof(list) 结果一致。 cout <<sizeof(list)<< endl;// 这⾥begin和end返回的值initializer_list对象中存的两个指针// 这两个指针的值跟i的地址跟接近,说明数组存在栈上int i =0; cout << list.begin()<< endl; cout << list.end()<< endl; cout <<&i << endl;//{}列表中可以有任意多个值 vector<int>v1({1,2,3,4,5});//{}中的内容隐式类型转化为v对象然后拷贝构造 优化后变直接构造 vector<int> v2 ={5,6,7,8,9};//构造临时对象+临时对象拷⻉v2+优化为直接构造const vector<int> v3 ={2,4,6,8,10}; v1 ={11,12,13};//initializer_list版本的赋值// 这⾥是pair对象的{}初始化和map的initializer_list构造结合到⼀起⽤了 map<string, string> dict ={{"sort","排序"},{"string","字符串"}};return0;}

三、右值和移动语义

3.1左值和右值

在之前写代码的时候也经常会碰到什么无法被赋值的左值,以及右值的报错,今天我们就来看看到底什么是左值,什么是右值?

  1. 左值:是一个数据的表达式(比如变量名,或解引用的指针),一般是有持久的状态,存储在内存中。所以我们可以获取它的地址。左值既可以出现在赋值符号的左边也可以出现在赋值符号的右边。在定义const修饰的左值后,就不能给他赋值了因为此时的左值具有常量属性,但是我们依然能够获取它的地址。
  2. 右值:右值也是一个数据表达式,要么是字面值常量,要么是表达式求值所产生的临时变量,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边。 这其实很好理解,因为右值都是一些常量值,临时变量具有常属性是不能被修改的因此不能放到赋值符号的左边。另外右值是不能取地址的,因为临时变量都快要销毁了取地址是毫无意义的。

总结:所以区分到底是左值还是右值,用肉眼看是区分不出来的,所以要看他们是否能取地址。能取地址的一般都是左值,而不能取地址的一般都是右值。注意:不能直接通过看赋值符号的左右来区分左值和右值!

#include<iostream>usingnamespace std;intmain(){// 左值:可以取地址// 以下的p、b、c、*p、s、s[0]就是常⻅的左值int* p =newint(0);int b =1;constint c = b;*p =10; string s("111111"); s[0]='x'; cout <<&c << endl; cout <<(void*)&s[0]<< endl;// 右值:不能取地址double x =1.1, y =2.2;// 以下⼏个10、x + y、fmin(x, y)、string("11111")都是常⻅的右值10; x + y;fmin(x, y);string("11111");//cout << &10 << endl; //cout << &(x+y) << endl;//cout << &(fmin(x, y)) << endl;//cout << &string("11111") << endl;return0;}

3.2左值引用和右值引用

首先左值引用我们是熟悉的,在直接C++入门的时候就介绍过引用,我们知道引用就是取别名,像int a=0; int& b=a int* p=a; int*& pp1=p;这些都是左值引用。那有没有右值引用呢?答案是有的,譬如像int&&x=10;10是一个右值,在类型前面加上两个&&就是右值引用,同样右值引用就是给右值取别名。
#include<iostream>usingnamespace std;intmain(){// 左值:可以取地址// 以下的p、b、c、*p、s、s[0]就是常⻅的左值int* p =newint(0);int b =1;constint c = b;*p =10; string s("111111"); s[0]='x';double x =1.1, y =2.2;// 左值引⽤给左值取别名int& r1 = b;int*& r2 = p;int& r3 =*p; string& r4 = s;char& r5 = s[0];// 右值引用给右值取别名int&& rr1 =10;double&& rr2 = x + y;double&& rr3 =fmin(x, y); string&& rr4 =string("11111");// 左值引用不能直接引用右值,但是const左值引用可以引用右值constint& rx1 =10;constdouble& rx2 = x + y;constdouble& rx3 =fmin(x, y);const string& rx4 =string("11111");// 右值引用不能直接引用左值,但是右值引用可以引用move(左值)int&& rrx1 =move(b);int*&& rrx2 =move(p);int&& rrx3 =move(*p); string&& rrx4 =move(s); string&& rrx5 =(string&&)s;//move就是相当于讲s的类型强转成string&&// b、r1、rr1都是变量表达式,都是左值 cout <<&b << endl; cout <<&r1 << endl; cout <<&rr1 << endl;// 这里要注意的是,rr1的属性是左值,所以不能再被右值引用绑定,除非move⼀下int& r6 = r1;// int&& rrx6 = rr1;int&& rrx6 =move(rr1);return0;}

注意事项:

  1. 左值引用不能直接引用右值,但是const左值引用可以引用右值。很好理解,const修饰了左值就具有常属性了,所以可以引用右值(绑定到右值),但是是不允许修改的!!!
  2. 右值引用不能直接引用左值,但是右值引用可以引用move(左值)move函数实际上就是库里面的一个函数模板内部本质就是在进行强制类型转换。
  3. 一个右值被右值引用绑定后,就具有了左值的属性,因为右值引用是为了修改右值。
  4. 值得注意的是:语法层面看,左值引用和右值引用都是取别名,不开空间。从汇编底层的角度看上面代码中r1rr1汇编层实现,底层都是⽤指针实现的,没什么区别。

3.3右值引用的作用——延长生命周期

可能有人会问:右值的引用设计出来有什么用?除了上面所说的可以更改,还有一种作用就是延长生命周期。
intmain(){ std::string s1 ="Test";// std::string&& r1 = s1; // 错误:不能绑定到左值const std::string& r2 = s1 + s1;// OK:到 const 的左值引用延长生命周期// r2 += "Test"; // 错误:不能通过到 const 的引用修改 std::string&& r3 = s1 + s1;// OK:右值引用延长生命周期 r3 +="Test";// OK:能通过非 const 的引用修改 std::cout << r3 <<'\n';return0;}

3.4左值右值的作用——参数匹配

对于左值右值,它的核心作用就体现在了函数传参上,我们可能传的是一个变量,传一个左值的引用,这些都是左值。但是我们还有可能直接传一个值,这个值可能是一个整数,这就是右值。
intmain(){int i =1;constint ci =2;f(i);// 调用 f(int&) 左值引用版本f(ci);// 调用 f(const int&) const左值引用右值 调用const左值版本f(3);// 调用 f(int&&) 右值引用版本,如果没有 f(int&&) 重载则会调用 f(const int&)f(std::move(i));//move之后就相当于强转成右值 调用 f(int&&)右值引用版本// 右值引用了右值后其本身就具有了左值属性 因为它可以取地址了 且可以修改了int&& x =1;f(x);//所以f(x)调用 f(int& x)左值的版本f(std::move(x));// 调⽤ f(int&& x)return0;}

3.5右值引用和移动语义的使用场景

3.5.1右值引用的使用场景

左值引用的主要使用场景是在函数中左值引用传参或左值引用传返回值时减少拷贝,同时还有能修改实参或修改返回对象的价值。左值引用以及解决了大部分的问题了,那C++设计右值引用的意义是什么呢?且看下面两个场景:
在这里插入图片描述
对于以上的问题,C++就使用右值引用搞出了移动构造和移动赋值,让函数外面的对象在接收返回值时尽量减少拷贝,提高效率。下面就来看看移动构造和移动赋值。

3.5.2移动构造和移动赋值

  • 移动赋值:是⼀个赋值运算符的重载,他跟拷贝赋值构成函数重载,类似拷贝赋值函数。移动赋值函数要求第⼀个参数是该类类型的引⽤,但是不同的是要求这个参数是右值,移动赋值其实也是通过抢夺资源,直接交换指针来达到目的。

移动构造:是⼀种构造函数,类似拷贝构造函数,移动构造函数要求第⼀个参数是该类类型的引用,但是不同的是要求这个参数是右值引用,如果还有其他参数,额外的参数必须有缺省值。下面用一张图来说清楚移动构造和拷贝构造的区别:

在这里插入图片描述

下面就来看看string里面的移动构造和移动赋值:

namespace my_str {classstring{public://迭代器部分省略string(constchar* str ="")//默认构造:_size(strlen(str)),_capacity(_size){ cout <<"string(char* str)-构造"<< endl; _str =newchar[_capacity +1];strcpy(_str, str);}// 拷贝构造string(const string& s):_str(nullptr){ cout <<"string(const string& s) -- 拷贝构造"<< endl;reserve(s._capacity);for(auto ch : s){push_back(ch);}}voidswap(string& tmp){ std::swap(_str, tmp._str); std::swap(_size, tmp._size); std::swap(_capacity, tmp._capacity);}// 移动构造string(string&& s){ cout <<"string(string&& s) -- 移动构造"<< endl;swap(s);}//普通赋值重载 string&operator=(const string& s){ cout <<"string& operator=(const string& s) -- 拷贝赋值"<< endl;if(this!=&s){ _str[0]='\0'; _size =0;reserve(s._capacity);for(auto ch : s){push_back(ch);}}return*this;}//移动赋值重载// s4 = bit::string("yyyyy"); string&operator=(string&& s){ cout <<"string& operator=(string&& s) -- 移动赋值"<< endl;swap(s);return*this;}voidreserve(size_t n){if(n > _capacity){char* tmp =newchar[n +1];if(_str){strcpy(tmp, _str);delete[] _str;} _str = tmp; _capacity = n;}}//其他的一些接口略......private:char* _str =nullptr; size_t _size =0; size_t _capacity =0;};intmain(){ my_str::string s1("xxxxx");// 拷⻉构造 my_str::string s2 = s1;// 构造+移动构造,优化后直接构造 my_str::string s3 = bit::string("yyyyy");// 移动赋值->移动构造 my_str::string s4 =move(s1); cout <<"******************************"<< endl;return0;}
从上面string移动构造和移动赋值的代码实现中我们看到,移动构造和移动赋值调用的是string内部的swap函数,swap函数直接交换两个对象(一个是string对象,一个是string临时对象)的指针的指向,从而让string对象指针临时对象的资源,而临时对象指向空,销毁的时候也不用释放空间直接删除变量即可。

总结:
对于像string/vector这样的深拷贝的类或者包含深拷贝的成员变量的类,移动构造和移动赋值才有意义
。因为移动构造和移动赋值的第⼀个参数都是右值引用的类型,他的本质是要“窃取”引用的右值对象的资源,而不是像拷贝构造和拷贝赋值那样去拷贝资源,从提高效率。

3.6右值引用和移动语义解决传值返回的问题

有了上面的知识,下面我们就可以来优化上面我们所提出的传值返回的问题了,但首先要明确一点:对于传值返回我们并不是在返回值上下功夫并不是改成右值引用!因为strvv本质是⼀个局部对象,函数结束这个对象就析构销毁了,右值引⽤返回也⽆法概念对象已经析构销毁的事实。我们优化的是返回对象所产生的临时对象与接收对象之间的指针指向,从而避免拷贝!

1、右值对象构造,只有拷贝构造,没有移动构造的场景

上图展示了vs2019 debug环境下编译器对拷贝的优化,左边为不优化的情况下,两次拷贝构造,右边为编译器优化的场景下连续步骤中的拷贝合二为⼀变为⼀次拷贝构造。编译器优化后直接将str对象的构造,str拷贝构造临时对象,临时对象拷贝构造ret对象,合三为⼀,变为直接构造。 变为直接构造。要理解这个优化要结合局部对象⽣命周期和栈帧的⻆度理解

2、右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值的场景



上图左边展示了vs2019 debug关闭优化环境下编译器的处理,⼀次拷贝构造,⼀次拷贝赋值。
需要注意的是在vs2019的release和vs2022的debug和release,下⾯代码会进⼀步优化,直接构造要返回的临时对象,str本质是临时对象的引⽤,底层⻆度⽤指针实现。运⾏结果的⻆度,我们可以看到str的析构是在赋值以后,说明str就是临时对象的别名。

3、右值对象构造,有拷贝构造,也有移动构造的场景

上图展示了vs2019 debug环境下编译器对拷贝的优化,左边为不优化的情况下,两次移动构造,右边为编译器优化的场景下连续步骤中的拷贝合二为一变为一次移动构造。注意上图的右边编译器优化后,ret对象直接变成str对象的引用,底层用指针的方式实现,打印strret的地址会发现是一样的,这样不用创建任何的临时对象也不会浪费空间,极大的提高了效率。

4、右值对象赋值,既有拷贝构造和拷贝赋值,也有移动构造和移动赋值的场景



上图左边展示了vs2019 debug关闭优化环境下编译器的处理,一次移动构造,一次移动赋值。
需要注意的是在vs2019releasevs2022debug和release,右侧代码会进⼀步优化,直接构造要返回的临时对象,str本质是临时对象的引⽤,底层⻆度⽤指针实现。运行结果的角度,我们可以看到str的析构是在赋值以后,说明str就是临时对象的别名。

四、总结

对于一些编译器,比如直接将接收对象变成返回对象的引用的编译器来说,右值引用的移动构造是没有什么价值了,反正编译器会优化,但是这是否就意味着移动构造一点价值没有呢?

答案是否定的,编译器的优化一般都是走在C++标准的前,所有上面的优化都是标准规定,编译器可实现可不实现具体取决于编译器,而移动构造以后传值返回的代价一定很低,且不依赖编译器的优化。就是如果是比较老的编译器优化没有那么好,那么移动构造的价值还是非常的高的。 撇开优化,从另一个角度来看,传值返回的移动构造一定比之前的多次拷贝构造,直接构造效率要高所以不能完全否定其价值。
MSTcheng 始终坚持用直观图解 + 实战代码,把复杂技术拆解得明明白白! 👁️ 【关注】 看普通程序员如何用实用派思路搞定复杂需求 👍 【点赞】 给 “不搞虚的” 技术分享多份认可 🔖 【收藏】 把这些 “好用又好懂” 的干货技巧存进你的知识库 💬 【评论】 来唠唠 —— 你踩过最 “离谱” 的技术坑是啥? 🔄 【转发】把实用技术干货分享给身边有需要的程序员伙伴 技术从无唯一解,让我们一起用最接地气的方式,写出最扎实的代码! 🚀💻 
能够看到这里的小伙伴已经打败95%的人了超棒的,为你点赞,休息一下吧!