文章目录
- 一、引言
- 二、左值和右值
- 什么是左值
- 什么是右值
- 三、左值引用和右值引用
- 左值引用
- 右值引用
- 左值引用与右值引用的比较
- 四、右值引用的使用场景和意义
- 左值引用的使用场景
- 左值引用的短板
- 用右值引用和移动语义解决上述问题
- 移动构造
- 移动赋值
- 右值引用引用左值 - std::move()
- STL容器的接口函数更新了右值引用的版本
- 五、完美转发
- 模板中的“&&”是万能引用
- std::forward()实现完美转发
- 完美转发的使用场景
一、引言
传统的C++语法中就有引用
的语法,而C++11中新增了的右值引用语法特性,所以从现在开始,为了与右值引用(rvalue reference)
区分开来,我们可以称之为左值引用 (lvalue reference)
。无论左值引用还是右值引用,都是给对象取别名。
二、左值和右值
在了解右值引用之前,有必要先区分左值和右值。C++的表达式要么是右值
( rvalue ,读作“ are-value ”),要么就是左值
( lvalue ,读作 "ell-value ” )。
这两个名词是从 C 语言继承过来的,原本是为了帮助记忆:
左值可以位于赋值语句的左侧,右值则不能。
什么是左值
左值是一个表示数据的表达式(如变量名或解引用的指针/迭代器)
- 我们可以获取左值的地址且可以对左值赋值。
- 左值可以出现赋值符号的左边,而右值不能出现在赋值符号左边。
- 定义被
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);// 特例:可以将一个const左值引用绑定到一个右值上const int& cref = 10;// 下面三个编译会报错:error C2106: “=”: 左操作数必须为左值10 = 1;x + y = 1;fmin(x, y) = 1;return 0;
}
左值引用与右值引用的比较
左值引用总结:
- 左值引用只能引用左值,不能引用右值。
int a = 10; int& ra1 = a; // 正确,ra为a的别名 int& ra2 = 10; // 编译失败,因为10是右值
- 但是const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10; // 正确,ra3引用了右值10 const int& ra4 = a; // 正确,ra4引用了左值a
右值引用总结:
- 右值引用只能右值,不能引用左值。
// 右值引用只能右值,不能引用左值。 int&& r1 = 10; // 正确,10绑定到一个右值引用int a = 10; // error C2440: “初始化”: 无法从“int”转换为“int &&” int&& r2 = a; // 错误,无法将左值a绑定到右值引用r2
- 但是右值引用可以引用
std::move()
后的左值。int&& r3 = std::move(a); //正确,右值引用可以引用move以后的左值
四、右值引用的使用场景和意义
前面我们可以看到左值引用既可以引用左值和又可以引用右值:
const int& cref = 10;
那为什么C++11还要提出右值引用呢?是不是化蛇添足呢?下面我们来看看左值引用的短板,右值引用是如何补齐这个短板的!下面是我们模拟实现的 std::string
类:
#pragma once
#include <cstring>
#include <cassert>
#include <iostream>
namespace chen
{class string{public:// 迭代器typedef char* iterator;iterator begin(){return _str;}iterator end(){return _str + _size;}// 默认构造string(const char* str = ""):_size(strlen(str)), _capacity(_size){//std::cout << "string(char* str)" << std::endl;_str = new char[_capacity + 1];strcpy(_str, str);}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){std::cout << "string(const string& s) -- 深拷贝" << std::endl;string tmp(s._str);swap(tmp);}// 赋值重载string& operator=(const string& s){std::cout << "string& operator=(string s) -- 深拷贝" << std::endl;string tmp(s);swap(tmp);return *this;}// 移动构造string(string&& s):_str(nullptr), _size(0), _capacity(0){std::cout << "string(string&& s) -- 移动语义" << std::endl;swap(s);}// 移动赋值string& operator=(string&& s){std::cout << "string& operator=(string&& s) -- 移动语义" << std::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) 传左值引用没有拷贝提高了效率string& operator+=(char ch){push_back(ch);return *this;}const char* c_str() const{return _str;}private:char* _str;size_t _size;size_t _capacity; // 不包含最后做标识的\0};
}
左值引用的使用场景
左值引用做参数和做返回值都可以提高效率,减少了深拷贝:
void func1(chen::string s)
{}void func2(const chen::string& s)
{}int main()
{chen::string s1("hello world");// func1和func2的调用我们可以看到左值引用做参数减少了拷贝,提高效率的使用场景和价值func1(s1);func2(s1);// string operator+=(char ch) 传值返回存在深拷贝// string& operator+=(char ch) 传左值引用没有拷贝提高了效率s1 += '!';return 0;
}
因为我们模拟实现是string类的拷贝构造函数当中打印了提示语句,因此运行代码后通过程序运行结果就知道,值传参时调用了string的拷贝构造函数:
因为string的+=
运算符重载函数是左值引用返回的,因此在返回+=
后的对象时不会调用拷贝构造函数,但如果将+=
运算符重载函数改为传值返回,那么重新运行代码后你就会发现多了一次拷贝构造函数的调用。
我们都知道string的拷贝是深拷贝,深拷贝的代价是比较高的,我们应该尽量避免不必要的深拷贝操作,因此这里左值引用起到的作用还是很明显的。
左值引用的短板
是当函数返回对象是一个函数作用域内的局部变量,它出了函数作用域就会被销毁,就不能使用左值引用返回,只能传值返回。
例如:bit::string to_string(int value)
函数中可以看到,这里只能使用传值返回:
namespace chen
{chen::string to_string(int value){bool flag = true;if (value < 0){flag = false;value = 0 - value;}chen::string str;while (value > 0){int x = value % 10;value /= 10;str += (x + '0');}if (flag == false){str += '-';}std::reverse(str.begin(), str.end());return str;}
}int main()
{chen::string s = chen::to_string(1234);return 0;
}
传值返回会导致至少1次拷贝构造:
如果是一些旧一点的编译器可能是两次拷贝构造:
对于vs2022,有可能只调用一次构造函数,没错,是构造函数,极致的优化:
因为即使编译器支持 C++11,也不能确保一定会调用移动构造函数。具体调用的是拷贝构造函数还是移动构造函数,取决于编译器对于返回对象优化的实现和对移动语义的判断。
用右值引用和移动语义解决上述问题
移动构造
在string
中增加移动构造,移动构造本质是将参数右值的资源窃取过来,占为已有,那么就不
用做深拷贝了,所以它叫做移动构造,就是“窃取”别人的资源来构造自己。
// 移动构造
string(string&& s):_str(nullptr), _size(0), _capacity(0)
{std::cout << "string(string&& s) -- 移动语义" << std::endl;swap(s);
}
可以这样鼓励编译器使用移动构造函数:
// chen::string s1 = chen::to_string(1234); // 由于编译器的优化,这里调用的可能是构造函数
chen::string s2 = std::move(chen::to_string(1234)); // 显式move一下来通知编译器调用移动构造,来构造s2//运行结果:
// string(char* str) -- 构造函数
// string(string&& s) -- 移动语义
移动赋值
在string
类中增加移动赋值函数,再去调用to_string(1234)
,不过这次是将
to_string(1234)返回的右值对象赋值给ret1对象,这时调用的是移动构造。
// 移动赋值
string& operator=(string&& s)
{std::cout << "string& operator=(string&& s) -- 移动语义" << std::endl;swap(s);return *this;
}
chen::string s;
s = chen::to_string(1234);//运行结果:
// string(char* str) -- 构造函数
// string(char* str) -- 构造函数
// string& operator=(string&& s) -- 移动语义
右值引用引用左值 - std::move()
按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过std::move()
函数将左值转化为右值。
C++11中,std::move
的定义位于头文件 <utility>
中,其定义如下:
template <class _Ty>
constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept
{return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
几点说明:
- 这里的
remove_reference_t
是一个辅助模板,用于去除传入类型的引用。move
函数接受一个通用引用T&&
(即右值引用),并返回一个右值引用T&&
。- move函数中_Arg参数的类型不是右值引用,而是万能引用。万能引用跟右值引用的形式一样,但是右值引用需要是确定的类型。
- 一个左值被move以后,它的资源可能就被转移给别人了,因此要避免使用一个被move后的左值。
- move函数的名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。
STL容器的接口函数更新了右值引用的版本
如果list容器当中存储的是string对象,那么在调用push_back向list容器中插入元素时,可能会有如下几种插入方式:
void push_back (value_type&& val);
int main()
{list<chen::string> lt;bit::string s1("1111");// 这里调用的是拷贝构造lt.push_back(s1);// 下面调用都是移动构造lt.push_back("2222");lt.push_back(std::move(s1));return 0;
}
//运行结果:
// string(const string& s) -- 深拷贝
// string(string&& s) -- 移动语义
// string(string&& s) -- 移动语义
五、完美转发
模板中的“&&”是万能引用
模板中的&&不代表右值引用,而是 万能引用 ,其既能接收左值又能接收右值。下面是的T是一个万能引用:
template<class T>
void PerfectForward(T&& t)
{//...
}
右值引用和万能引用的区别是:
- 右值引用需要是确定的类型,而万能引用是根据传入实参的类型进行推导,如果传入的实参是一个左值,那么这里的形参t就是左值引用,如果传入的实参是一个右值,那么这里的形参t就是右值引用。
- 换句话说:右值引用的类型在声明时就已经确定,而通用引用的类型是根据传入的实参类型进行推导的:
int&& rvalue_ref = 42; // 右值引用,类型是 int&&
万能引用因此更加灵活,可以接受各种值类别的参数。
下面重载了四个Func函数,这四个Func函数的参数类型分别是左值引用、const左值引用、右值引用和const右值引用。在主函数中调用PerfectForward函数时分别传入左值、右值、const左值和const右值,在PerfectForward函数中再调用Func函数。如下:
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;
}template<class T>
void PerfectForward(T&& t)
{Func(t);
}int main()
{int a = 10;PerfectForward(a); //左值PerfectForward(move(a)); //右值const int b = 20;PerfectForward(b); //const 左值PerfectForward(move(b)); //const 右值return 0;
}
但实际调用PerfectForward()
函数时传入左值和右值,最终都匹配到了左值引用版本的Func()
函数,调用PerfectForward()
函数时传入const左值
和const右值
,最终都匹配到了const左值
引用版本的Func
函数,如下:
根本原因就是,编译器选择调用左值引用版本的Func()
,通常希望对传递的对象进行修改,而将右值引用看作左值引用可以确保安全的修改,所以在PerfectForward
函数中调用Func()
函数时会将t
识别成左值。
[!Quote] 举个简单的例子:
#include \<iostream>void Func(int&& x) {// 在函数内部,x 被当作左值引用x += 10;std::cout << "Inside Func: " << x << std::endl; }int main() {int a = 5;// 将右值引用传递给函数Func(std::move(a));// 在这里,a 可能被移动了,但在函数外部,a 仍然是左值std::cout << "Outside Func: " << a << std::endl;return 0; }
结果:
在这个例子中,
std::move(a)
将左值a
转换为右值引用,并传递给Func
函数。在函数内部,x
被当作左值引用,但我们仍然可以对它进行修改。在函数外部,a
仍然是左值,但在传递给函数时可能已经发生了移动。
总结:右值经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要用到完美转发。
std::forward()实现完美转发
想要在传参的过程中保留对象原生类型属性,可以使用std::forward()
,比如:
template<class T>
void PerfectForward(T&& t)
{Func(std::forward<T>(t));
}
经过完美转发后,调用PerfectForward函数时传入的是右值就会匹配到右值引用版本的Func函数,传入的是const右值就会匹配到const右值引用版本的Func函数:
完美转发的使用场景
下面模拟实现了一个简化版的
list
类,类当中分别提供了左值引用版本和右值引用版本的push_back
和insert
函数。
namespace chen
{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;}//左值引用版本的pushbackvoid PushBack(const T& x){Insert(_head, x);}//右值引用版本的pushbackvoid PushBack(T&& x){//Insert(_head, x);Insert(_head, std::forward<T>(x)); // 关键位置1}void PushFront(T&& x){//Insert(_head->_next, x);Insert(_head->_next, std::forward<T>(x)); // 关键位置2}void Insert(Node* pos, T&& x){Node* prev = pos->_prev;Node* newnode = new Node;newnode->_data = std::forward<T>(x); // 关键位置3// 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; // 关键位置4// prev newnode posprev->_next = newnode;newnode->_prev = prev;newnode->_next = pos;pos->_prev = newnode;}private:Node* _head;};
}
下面定义一个list对象,list容器中存储的就是之前模拟实现的string类,这里分别传入左值和右值来调用不同版本的push_back。
int main()
{chen::List<chen::string> lt;chen::string s("1111");lt.PushBack(s); // 调用左值引用版本的push_backlt.PushBack("2222"); // 调用右值引用版本的push_backreturn 0;
}
只要想保持右值的属性,在每次右值传参时都需要用
std::forward
进行完美转发,实际STL库中也是通过完美转发来保持右值属性的。