文章目录
- 一、引言
- 二、左值和右值
- 什么是左值
- 什么是右值
- 三、左值引用和右值引用
- 左值引用
- 右值引用
- 左值引用与右值引用的比较
- 四、右值引用的使用场景和意义
- 左值引用的使用场景
- 左值引用的短板
- 用右值引用和移动语义解决上述问题
- 移动构造
- 移动赋值
- 右值引用引用左值 - 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;
}
//左值引用版本的pushback
void PushBack(const T& x)
{
Insert(_head, x);
}
//右值引用版本的pushback
void 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 pos
prev->_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 pos
prev->_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_back
lt.PushBack("2222"); // 调用右值引用版本的push_back
return 0;
}
只要想保持右值的属性,在每次右值传参时都需要用
std::forward
进行完美转发,实际STL库中也是通过完美转发来保持右值属性的。