目录
- 前言
- 一、右值引用与移动语义
- 1.1 左值引用和右值引用
- 1.2 右值引用使用场景和意义
- 1.3 右值引用引用左值及其一些更深入的使用场景分析
- 1.3.1 完美转发
- 二、新的类功能
- 三、可变参数模板
前言
本篇文章我们继续来聊聊C++11新增的一些语法——右值引用,我们在之前就已经讲过了左值引用,并且左值引用给我们带来了很多的好处直接减少了拷贝操作提高了效率,那么右值引用到底起什么作用呢?下面我们一起来学习吧!!
一、右值引用与移动语义
1.1 左值引用和右值引用
有关引用我们在之前的文章就讲过,它其实就是给别人取别名,所以无论是左值引用还是右值引用都是给别人取别名,只不过取别名的对象不一样罢了,左值引用就是给左值取别名,右值引用就是给右值取别名!!
什么是左值?什么是左值引用?
左值就是一个表示数据的表达式(如变量名或者解引用的指针),说的再通俗一点它是一个变量,标识一块空间,空间中存储着数据。我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。
int main()
{// 以下的p、b、c、*p都是左值int* p = new int(0);int b = 1;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);// 下面的语句都会编译报错,左操作数必须为左值//10 = 1;//x + y = 1;//fmin(x, y) = 1;return 0;
}
需要注意的是:右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址。也就是说,不能取字面量 10 的地址,但是 rr1 引用后,可以对 rr1 取地址,也可以修改 rr1。如果不想 rr1 被修改,可以用 const int&& rr1 去引用。注:rr1 和 rr2 都是左值。
左值可以引用右值吗?右值可以引用左值吗?
int main()
{// 左值引用可以引用右值吗? const的左值引用可以double x = 1.1, y = 2.2;//double& rr1 = x + y; // 编译报错, x + y是右值, 也是临时对象, 临时对象具有常属性, 需要const保证权限平行const double& rr2 = x + y; // 可以// 右值引用可以引用左值吗?不可以, 可以引用move以后的左值int a = 10;//int&& rr3 = a; // 编译报错, 右值引用不能引用左值int&& rr4 = 10; // 可以, 10是右值, 对右值取别名int&& rr5 = move(a); // move(a)的本质是得到一个右值表达式return 0;
}
左值引用与右值引用总结:左值引用只能引用左值,不能引用右值。但是 const 左值引用既可引用左值,也可引用右值。右值引用只能引用右值,不能引用左值,但是右值引用可以 move 以后的左值。
其实上面并不是右值引用的使用场景,因为const左值引用既能做到对左值引用又能做到对右值引用,那么增加右值引用不就是多余的吗!!下面我们继续来看看右值引用的使用场景。
1.2 右值引用使用场景和意义
左值引用解决的问题:
做参数:a. 减少拷贝,提高效率。b. 做输出型参数
做返回值:a. 减少拷贝,提高效率(不能返回临时对象的引用)。
从上述中我们可以知道左值引用的短板就是不能返回临时对象的引用,那么对于自定义类型对象返回时,是不是一定会进行一次深拷贝的操作,所以右值引用设计出来就是为了解决此类问题的。
在讲右值引用前,我们先来了解一组概念:右值有两类,第一类是纯右值,即内置类型右值;第二类是将亡值,即自定义类型右值。右值将亡值的资源可以转移到指定的对象。
就好比日常生活中,有些重症患者已经到了病入膏肓的情况,它可以将自身的一些器官捐赠给有需要的人身上。右值引用+移动语义最重要的是理解资源转移的过程!
下面我们通过一段代码来进行分析:
namespace bit
{class string{public:typedef char* iterator;iterator begin(){return _str;}iterator end(){return _str + _size;}string(const char* str = ""):_size(strlen(str)), _capacity(_size){_str = new char[_capacity + 1];strcpy(_str, str);}// s1.swap(s2)void swap(string& s){::swap(_str, s._str);::swap(_size, s._size);::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;string tmp(s);swap(tmp);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){push_back(ch);return *this;}string operator+(char ch){string tmp(*this);tmp += ch;return tmp;}const char* c_str() const{return _str;}private:char* _str;size_t _size;size_t _capacity; // 不包含最后做标识的\0};bit::string to_string(int value){bool flag = true;if (value < 0){flag = false;value = 0 - value;}bit::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()
{curry::string ret1 = curry::to_string(1234);return 0;
}
我们先来看看没有C++11之前未新增移动拷贝的情况:
从上图我们也可以看到编译器优化后只调用了一次深拷贝操作,下面当我们在自定义类型中添加了移动构造后,看看编译器会做什么处理:
从上图我们可以看到当新增移动拷贝函数之后,编译器识别为右值类型就会优先去调用移动拷贝函数,相较于深拷贝移动拷贝只是将资源进行了转移——得到那块空间的起始地址就能访问那块空间的所有资源!!避免了拷贝的操作,移动拷贝实际并不是真正的拷贝,其实就可以理解为改变了接收资源的对象!!所以右值引用在这种情况下提供了非常大的价值!!如果返回的对象是一个数组、一棵树呢?是不是极大程度上减少了拷贝,提高了效率!!移动语义包括移动构造与移动赋值两个成员函数!!
注:右值引用并不是直接作为返回值起作用的,右值引用返回临时对象跟左值引用返回对象的情况是一样的,临时对象都是会被销毁的!!不能直接返回临时对象的左右值引用!!
我们通过监视窗口来继续看看移动构造的具象过程:
int main()
{curry::string s1("hello world");curry::string ret1 = s1;curry::string ret2 = (s1 + '1');curry::string ret3 = move(s1); // move操作可以理解为将左值转变为右值返回 -- 是一个表达式return 0;
}
通过上图我们可以发现move操作是有风险的,它能将一个对象的资源转交给另一个对象,转移完之后这个对象就悬空了,我们不能对这个悬空的对象做操作!!当你不想用个资源了之后可以这样做,如果后续你还要用到它就不能这么干了!!
我们来看看下面这种情况:
C++11后,STL 中的容器都是增加了移动构造和移动赋值的。
STL 容器的插入接口函数也增加了右值引用版本
总结:
- 右值引用使用的场景:自定义类型临时对象返回或自定义类型对象作为右值进行传参时,此时编译器会优先进行移动拷贝间接减少拷贝,将右值资源直接进行转移!!
- 左右值引用区别:左值引用是直接减少拷贝,而右值引用是间接减少拷贝,编译器识别出是左值还是右值,如果是右值则不再进行深拷贝,直接进行移动拷贝转移资源提高效率!!
1.3 右值引用引用左值及其一些更深入的使用场景分析
按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。C++11中,std::move()函数位于头文件中,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
// forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
1.3.1 完美转发
模板中的&& 万能引用
// 下面的 Fun都是重载函数, 因为引用也属于类型
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }// 万能引用:既能引用左值,也能引用右值
// 引用折叠
template<typename T>
void PerfectForward(T&& t)
{Fun(t);
}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;
}
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值,这是大佬规定的语法。
我们输出一下结果看看是否已经根据类型实现了万能引用:
我们发现结果并不是我们想象的那样:左值引用就调用左值引用对应的函数,右值引用就调用右值引用的函数??
我们继续进行分析,在之前的例子中我们说过右值是不能取地址的例如10,但是右值引用过后int&& rr1 = 10;这时候你会发现rr1此时成为了一个变量,既然是变量就有空间(地址),所以此时的rr1就充当了左值!!好,我们继续来看一个例子:
结论:模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面学习的完美转发!!
完美转发:保持变量的属性,防止提早转变成左值属性,导致调用的接口函数不一致。
注:完美转发操作在容器中的各种函数中十分常见,因为C++11提供了右值引用+移动语义,并且在某些场景下我们需要一直转发才能防止变量属性提早转变(退化为左值)!!
我们来看看完美转发的场景:
template<class T>
struct ListNode
{ListNode* _next = nullptr;ListNode* _prev = nullptr;T _data;
};template<class T>
class List
{typedef ListNode<T> Node;
public:List(){_head = new Node;_head->_next = _head;_head->_prev = _head;}void push_back(T&& x){//Insert(_head, x);Insert(_head, std::forward<T>(x));}void push_back(const T& x){Insert(_head, x);}void Insert(Node* pos, T&& x){Node* prev = pos->_prev;Node* newnode = new Node;newnode->_data = std::forward<T>(x); // 关键位置// prev newnode posprev->_next = newnode;newnode->_prev = prev;newnode->_next = pos;pos->_prev = newnode;}void Insert(Node* pos, const T& x){Node* prev = pos->_prev;Node* newnode = new Node;newnode->_data = x; // 关键位置// prev newnode posprev->_next = newnode;newnode->_prev = prev;newnode->_next = pos;pos->_prev = newnode;}
private:Node* _head;
};int main()
{List<curry::string> lt;lt.push_back("1111");lt.push_back(curry::string("hello"));return 0;
}
我们来分析一下为什么会是这样的结果:
我们来看看每个接口函数间不一直进行转发会发生什么情况:
我们发现当insert函数中的x未进行转发,导致提前退化为左值故调用赋值重载!!所以我们在某些场景下一定要小心将数据一直转发下去!!
二、新的类功能
原来 C++ 类中,有 6 个默认成员函数:构造函数、析构函数、拷贝构造函数,赋值运算符重载、取地址重载和 const 取地址重载。重要的是前 4 个,后两个用处不大。默认成员函数就是我们不写编译器会生成一个默认的。C++11 新增了两个:移动构造函数和移动赋值运算符重载。拷贝构造函数和赋值运算符重载是针对左值的拷贝,而移动构造和移动赋值时针是右值的拷贝。不需要深拷贝的类,也就不需要自己写移动构造和移动赋值。拷贝对象需要深拷贝时,自己写移动构造和移动赋值。比如:string、vector 和 list 等。
针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:
- 如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行浅拷贝,对于自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。
- 如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行浅拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用赋值运算符重载。
- 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。如果没有移动构造和移动赋值,才会去调用拷贝构造和赋值运算符重载。
类成员变量初始化
C++11允许在类定义时给成员变量初始缺省值,默认生成构造函数会使用这些缺省值初始化。这个在类和对象就讲了,这里就不再细讲了。
强制生成默认函数的关键字 default
C++11 可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原因这个函数没有默认生成。比如:我们提供了拷贝构造,就不会生成移动构造了,那么我们可以使用 default 关键字显示指定移动构造生成。
禁止生成默认函数的关键字 delete
如果能想要限制某些默认函数的生成,在 C++98 中,可以将该函数设置成 private,这样只要其他人想要调用就会报错。在 C++11 中更简单,只需在该函数声明加上 = delete 即可,该语法指示编译器不生成对应函数的默认版本,称 = delete 修饰的函数为删除函数。
继承和多态中的 final 与 override 关键字
final 可以修饰一个类,表示这个类不能被继承;也能修饰一个虚函数,表示这个虚函数不能被重写。override 修饰子类的虚函数,如果子类的虚函数没有完成重写,就会编译报错。
三、可变参数模板
其实在 C 语言中我们就已经接触过可变参数了,只是我们并没有深入的去研究过,上述printf的参数列表中...
就代表任意多个参数,这里我们就不去研究C语言中的可变参数模板到底是如何提取出参数的了,我们来看下C++是如何来操作的!!
C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板,相比
C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改
进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以这块还是比较晦涩的。现
阶段呢,我们掌握一些基础的可变参数模板特性就够我们用了。
下面就是一个基本可变参数的函数模板
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}
上面的参数 args 前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为参数包,它里面包含了 0 到 N(N>=0)个模版参数。我们无法直接获取参数包 args 中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。由于语法不支持使用 args[i] 这样方式获取可变参数,所以我们的用一些奇招来一一获取参数包的值。
计算可变参数的个数
// 可变参数的模板
template <class ...Args>
void ShowList(Args... args)
{cout << sizeof...(args) << endl;
}int main()
{string str("hello");ShowList();ShowList(1);ShowList(1, 'A');ShowList(1, 'A', str);return 0;
}
通过上图我们能准确的知道每次调用showList有多少参数,但是我们该如何展开其中的参数包获取参数包的值呢?
递归函数方式展开参数包
template <class T>
void ShowList(const T& t)
{cout << t << endl;
}template <class T, class ...Args>
void ShowList(T value, Args... args)
{cout << value << " ";ShowList(args...);
}int main()
{ShowList(1);ShowList(1, 'A');ShowList(1, 'A', std::string("sort"));return 0;
}
此时传递了两个模板参数,第一个模板参数接收的是传过来的值,第二个接收的是剩余的参数包,打印完value之后再继续递归展开剩余的参数包,直至参数包的个数为0递归结束!!
逗号表达式展开参数包
这种展开参数包的方式,不需要通过递归终止函数,是直接在expand函数体中展开的,printarg
不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。
这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式。
expand函数中的逗号表达式:(printarg(args), 0),也是按照这个执行顺序,先执行
printarg(args),再得到逗号表达式的结果0。同时还用到了C++11的另外一个特性——初始化列
表,通过初始化列表来初始化一个变长数组, {(printarg(args), 0)…}将会展开成((printarg(arg1),0),
(printarg(arg2),0), (printarg(arg3),0), etc… ),最终会创建一个元素值都为0的数组int arr[sizeof…
(Args)]。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)
打印出参数,也就是说在构造int数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在
数组构造的过程展开参数包。
template <class T>
void PrintArg(T t)
{cout << t << " ";
}template <class ...Args>
void ShowList(Args... args)
{int arr[] = { (PrintArg(args), 0)... };cout << endl;
}int main()
{ShowList(1);ShowList(1, 'A');ShowList(1, 'A', std::string("sort"));return 0;
}
STL 容器中的 empalce 相关接口函数
我们可以看到C++11在很多容器中都新增了emplace相关的接口函数,并且emplace接口函数中使用了可变模板参数的万能引用,那么相对于push_back()、insert等接口emplace有什么优势的地方吗?我们来看一组例子:
int main()
{list<curry::string> list;curry::string s1("1111");// 插入左值 -- 没区别list.push_back(s1);list.emplace_back(s1);cout << endl;// 插入move()curry::string s2("22222");list.push_back(move(s1));list.emplace_back(move(s2));cout << endl;// 直接插入右值 -- 开始有区别//list.push_back("3333");//list.emplace_back("3333");return 0;
}
下面我们来看看区别在哪?
我们可以发现push_back比emplace_back多调用了一次移动构造将右值资源进行了转移,那么为何emplace只会调用一次构造呢?原因是"3333"直接被当做const char*类型的参数包传下去,最终直接去调用const char*的构造函数,而在push_back中"3333"要隐式类型转换为string类型的对象,先去会去调用构造函数,然后它为右值类型会与移动构造进行匹配,所以它比emplace会多调用一次移动构造,但其实理论上俩者的效率都差不大多,因为需要深拷贝的类移动构造并不会消耗太多的时间!!
下面我们来看看浅拷贝类emplace_back与push_back的效率问题:
class Date
{
public:Date(int year = 1, int month = 1, int day = 1):_year(year), _month(month), _day(day){cout << "Date(int year = 1, int month = 1, int day = 1)" << endl;}Date(const Date& d):_year(d._year), _month(d._month), _day(d._day){cout << "Date(const Date& d)" << endl;}Date& operator=(const Date& d){cout << "Date& operator=(const Date& d))" << endl;return *this;}private:int _year;int _month;int _day;
};int main()
{// 没有区别list<Date> list;Date d1(2024, 5, 18);list.push_back(d1);list.emplace_back(d1);cout << "-----------------" << endl;Date d2(2024, 5, 18);list.push_back(move(d1));list.emplace_back(move(d2));cout << "-----------------" << endl;// 有区别list.push_back(Date(2024, 5, 18));list.push_back({ 2024, 5, 18 });cout << "-----------------" << endl;list.emplace_back(Date(2024, 5, 18));list.emplace_back(2024, 5, 18);return 0;
}
注:由于我们实现的是浅拷贝类,所以移动构造与拷贝构造都是浅拷贝并无其他区别,这里我们就不去实现移动拷贝函数了。
通过上图我们知道emplace少调用了一次拷贝构造,当这个浅拷贝的类非常大时是不是就减少了大量的拷贝提高了效率,所以从这个角度新增emplace接口还是有一定价值的!!