目录
1. 左值引用不是一定不能引用右值,const 左值引用可以引用右值。
2. 右值引用不是一定不能引用左值,右值引用可以引用move(左值)。
前言:
C++向来以效率著称,提高效率最典型的方式非指针和引用不可。而之前我们用到的引用都是左值引用(&),左值引用可以通过传参和传返回值来减少拷贝以提高效率,也可以由此来修改实参和返回对象的值。不过,左值引用的功能还是比较有限,只能用于左值(后文会介绍)资源利用的问题,却不能解决右值(临时变量、匿名对象、字面量常量等,后文会介绍)的资源利用问题。C++11中的右值引用(&&)和移动语义新特性就很好地解决了这个问题。
一、左值和右值
左值引用和右值引用,说白了都是引用,也就是取别名,只不过是取别名的对象不同。
所以要想学习右值引用,我们首先来认识一下何为左值和右值
何为左值和右值,说白了,左值可以取地址,右值不可以取地址,这是左值和右值的根本区别。
下面我来举一些例子帮助大家理解:
左值:可以取地址,一般就是我们常用的有名变量,其次就是一些函数调用比如s[0],我们可以取到数组某个下标对应元素的地址,那这些就是左值。
// 左值:可以取地址
// 以下的p、b、c、*p、s、s[0]就是常见的左值
int* p = new int(0);
int b = 1;
const int 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;
二、左值引用和右值引用
区分好左值和右值后,左值引用和右值引用就很好理解了。
概念解释
引用的本质就是取别名,左值引用和右值引用都是如此,只不过是表示的符号不同(左值引用为 & ,右值引用为 && )
我给大家看一些例子,很容易就可以理解:
左值引用给左值取别名
// 左值:可以取地址
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0] = 'x';
// 左值引用给左值取别名
int& r1 = b;
int*& r2 = p;
int& r3 = *p;
string& r4 = s;
char& r5 = s[0];
右值引用给右值取别名
// 右值:不能取地址
double x = 1.1, y = 2.2;
10;
x + y;
fmin(x, y);
string("11111");
// 右值引用给左值取别名
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
string&& rr4 = string("11111");
需要注意的细节:
1. 左值引用不是一定不能引用右值,const 左值引用可以引用右值。
举个例子:
// const左值引用可以引用右值
double x = 1.1, y = 2.2;
const double& rx2 = x + y;
// 对于我们之前的认识而言,赋值运算符会在x + y计算得到结果后,产生一个临时变量,由临时变量赋给左边的变量
// 而这里这个临时变量具有常性(只读),所以引用需要加const,这是为了保证权限不放大(普通的左值引用读写都可)
// 所以我们可以得到这个结论,const 左值引用可以引用右值
2. 右值引用不是一定不能引用左值,右值引用可以引用move(左值)。
举个例子:
// 右值引用不能直接引用左值,但是右值引用可以引用move(左值)
int b = 1;
int&& rrx1 = move(b);
// 这里的move()是一个模板函数,传入b会实例化出int类型的move
// 后面会讲解move的具体原理,大家现在只需要知道有这回事
3. 右值引用/const左值引用可以延长右值的生命周期
不难理解,右值被引用绑定之后,在引用变量没有出其作用域销毁之前,右值是不会销毁的,因为后面可能仍然需要使用。
对于右值引用,一旦给右值加了右值引用,右值就暂时不会销毁,而右值也有了别名,我们可以通过这个别名对原右值进行修改。
对于左值引用,const左值引用确实引用了右值,但是由于const的限制,我们并不能通过这个引用来修改原右值。
一句话总结:右值引用可以修改,const左值引用不能修改,两者都可以延长右值的生命周期。
int main()
{
std::string s1 = "Test";
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';
return 0;
}
4. 右值引用的结果是左值。
当我们将一个右值绑定到右值引用时,这个引用本身其实是一个左值。因为一旦这个右值被绑定到一个具名的右值引用,它就具有了持久性,可以在后续的代码中被多次使用,所以它必须是一个左值。例如:
int&& rr = 42;
这里rr是一个左值,类型是int的右值引用。也就是说,虽然rr是对右值的引用,但它本身作为具名变量,是一个左值表达式。
// 右值:不能取地址
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);
string&& rr4 = string("11111");
cout << &rr1 << endl;
cout << &rr2 << endl;
cout << &rr3 << endl;
cout << &rr4 << endl;
三、右值引用可以用来做什么?
我们之前使用左值引用,左值引用作为返回值类型、参数类型,避免了深拷贝类型拷贝所需要的消耗,也可以用来修改实参和返回值。
而右值引用引用的是右值,右值是注定要销毁的,那么如果右值在销毁之前,把自己所有的资源(如果有)贡献出来给其它变量,那么也会减少其它变量的拷贝消耗(转移指针,一步到位,不需要拷贝资源)。
文字乍一下看不懂没有关系,我们结合一个实例看看:
1. 实例
这份代码中用到的string为手搓版本,方便观察构造函数等等的行为。
这份代码是字符串相加的代码,由于str是在函数内部创建的,所以出函数是一定会销毁的,不能返回引用,否则得到的引用就是无效的,只能传值返回。
namespace muss
{
string addStrings(string num1, string num2)
{
string str;
int end1 = num1.size() - 1, end2 = num2.size() - 1;
int next = 0;
while (end1 >= 0 || end2 >= 0)
{
int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
int ret = val1 + val2 + next;
next = ret / 10;
ret = ret % 10;
str += ('0' + ret);
}
if (next == 1)
str += '1';
reverse(str.begin(), str.end());
cout << "******************************" << endl;
return str;
}
}
// 场景1
int main()
{
muss::string ret = muss::addStrings("11111", "2222");
cout << ret.c_str() << endl;
return 0;
}
这种情况下如果编译器没有进行优化的前提下,就会发生两次拷贝构造,对于一些携带资源比较多的情况下, 拷贝构造的代价不可谓不大。
C++11之前的解决方案:输出型参数,也就是先把main函数里的ret创建好,然后addStrings函数里面多加一个输出型参数,调用的时候输出型参数就传ret,这样也就不会出现拷贝构造的情况了。
但是这样子写代码真的一点也不优雅,C++委员会和其它的大佬不能容忍......
这时大佬们想:既然addStrings里的str都要销毁了,里面就有ret需要的资源,但是ret得不到,str自己出函数栈帧释放了。那么有没有办法把str的资源转移给ret呢?
(有的兄弟,有的 ´•̥ו̥` )
对于主函数中,str的出现只有一行噢,编译器会把str当作右值,我们要做的就是把str的资源在它销毁前先转移走。
怎么转移呢:只要有一个变量prt让它指向str的资源,再让str指向nullptr,str的析构就不会带走原指向的那部分资源了,那部分资源也就保留下来使这个ptr指向了,省得拷贝了。
其实这就是移动语义,官方一点的说法是:在C++中,移动语义是一种优化资源管理的机制,允许资源(如动态内存、文件句柄等)从一个对象“移动”到另一个对象,而不是进行深拷贝。移动语义通过避免不必要的复制操作,提升了性能。
大家只需要明白移动语义就是需要资源不走拷贝构造的路子,而是走将别的对象(通常是临时对象)的资源移动(“掠夺”)给自己使用的路子
文字难解,如图:
两次移动构造只会进行简单的指针拷贝,而不会拷贝资源, 几乎没有消耗,比两次拷贝构造好了十万八千里。
2. 移动构造和移动赋值
如上,为了如上的那种情况的效率问题,C++11为类增加了移动构造和移动赋值(和移动构造原理一样)。它们的实现方式类似于拷贝构造和拷贝赋值,只是获取资源的方式从拷贝改为了“掠夺”。
下面我们来实现一下移动构造和移动赋值,理解了底层原理,很容易的。
namespace muss
{
class string
{
public:
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 移动构造
string(string&& s)
{
cout << "string(string&& s) -- 移动构造" << endl;
// 把s的资源掠夺过来,(默认)使s指向空并销毁
swap(s);
}
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
// 把s的资源掠夺过来,(默认)使s指向不再需要的空间并带走销毁
swap(s);
return *this;
}
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
}
什么时候需要移动构造和移动赋值?
原类型变量需要释放且其资源仍有利用价值(转移资源)。
移动构造和移动赋值与拷贝构造和拷贝赋值的写法类似,只是引用的类型需要是右值引用。
四、引用折叠
经过前面的学习,我们已经知道了什么是左值引用,什么是右值引用。那么如果我给你一个表达式
int &&& r = a; 你知道它的含义吗?这可以解释为三层左值引用,也可以解释为一层左值引用和一层右值引用的结合......所以这样的表达式就会造成歧义,编译器不支持我们直接这样去写引用类型。
而实践中我们有时候又不可避免地去进行引用右值引用的引用等的操作,这时我们就需要用到typedef来构成引用的引用。
int main()
{
// 通过typedef的方式,我们才可以区分引用是通过什么和什么叠加到一起的
typedef int& lref;
typedef int&& rref;
int n = 0;
// 左值引用 + 任意引用 = 左值引用
lref& r1 = n; // r1 的类型是 int&
lref&& r2 = n; // r2 的类型是 int&
// 右值引用 + 左值引用 = 左值引用
// 右值引用 + 右值引用 = 右值引用
rref& r3 = n; // r3 的类型是 int&
rref&& r4 = 1; // r4 的类型是 int&&
return 0;
}
这种通过typedef结合两种引用,最后使两种引用叠加到一起变成一种引用的过程,就是引用折叠。
引用折叠的规则:有左值引用,结果就是左值引用;没左值引用,结果就是右值引用
所以我们实际使用当中经常用到类似T&& x的模板,传左值x就是左值引用,传右值x就是右值引用
帮助理解的小例子:
// 由于引用折叠限定,f1实例化以后总是⼀个左值引⽤
template<class T>
void f1(T& x)
{}
// 由于引用折叠限定,f2实例化后可以是左值引⽤,也可以是右值引⽤
template<class T>
void f2(T&& x)
{}
int main()
{
int n = 0;
// 没有折叠->实例化为void f1(int& x)
f1<int>(n);
//f1<int>(0); // 报错
// 折叠->实例化为void f1(int& x)
f1<int&>(n);
//f1<int&>(0); // 报错
// 折叠->实例化为void f1(int& x)
f1<int&&>(n);
//f1<int&&>(0); // 报错
// 折叠->实例化为void f1(const int& x)
f1<const int&>(n);
f1<const int&>(0); // const左值引用可以引用右值、
// 折叠->实例化为void f1(const int& x)
f1<const int&&>(n);
f1<const int&&>(0);
// 没有折叠->实例化为void f2(int&& x)
//f2<int>(n); // 报错 n 是左值,右值引用只能引用move(左值)
f2<int>(0);
// 折叠->实例化为void f2(int& x)
f2<int&>(n);
//f2<int&>(0); // 报错 右值不能直接被左值引用引用
// 折叠->实例化为void f2(int&& x)
//f2<int&&>(n); // 报错
f2<int&&>(0);
return 0;
}
五、完美转发
前面我们有提到,右值引用本身是一个左值。
不过这会在右值多层传递的情况下出现一个问题:
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<class T>
void Function(T&& t)
{
Fun(t);
}
// 模板T的推导规则:传左值,T推导为对应的左值引用类型,经过引用折叠,最后t是左值引用类型的变量
// 传右值,T推到为对应的原类型,不发生引用折叠,最后t是右值引用类型的变量
int main()
{
// 10是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(10); // 右值
int a;
// a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)
Function(a); // 左值
// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(std::move(a)); // 右值
const int b = 8;
// a是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int&t)
Function(b); // const 左值
// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&&t)
Function(std::move(b)); // const 右值
return 0;
}
运行结果如图,我们发现:
Function中的t无论被推导为左值引用还是右值引用,再传入Fun中的时候,都会被当作左值看待,结果都走了Fun中左值引用的逻辑。这与我们在实际使用中的需求是很不一样的。
实际使用中,我们肯定希望:右值引用走一个逻辑,左值引用走一个逻辑,最后结果就是右值的资源被转移,右值释放;左值的资源被拷贝,继续利用。
而上述情况不论是左值引用还是右值引用都走了同一个逻辑,所以我们需要解决传参过程中右值引用继续往下传参退化为左值引用的问题。
解决方法:使用一个函数模板forward来保持右值引用的右值特性,使右值引用往下传递性质为右值,左值引用往下传递性质为左值(依靠返回值,当然不是直接改变右值引用的性质)。这就是完美转发。
要做到这一点,我们只需要把Fun(t);改为Fun(forward<T>(t));
template<class T>
void Function(T&& t)
{
// Fun(t);
// T int& 返回左值表达式
// T int 返回右值表达式
// 实现了保持原始值类别(左值/右值)传递给目标函数
Fun(forward<T>(t));
}
这样,完美转发就实现了保持原始值类别(左值/右值)传递给目标函数。
右值引用和移动语义使C++的代码美观程度和运行效率再攀高峰~
这篇博客就到这里了,完结撒花~