C++11:右值引用


传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

右值与左值

在讲解右值引用之前,我们就需要先辨析一下左值右值的区别。

左值

左值是一个表示数据的表达式,我们可以获取它的地址并且对其赋值,左值可以出现在赋值操作符=的左边,但是右值不能。

比如以下代码中:

int i = 0;
int* p = &i;
double d = 3.14;

变量ipd都是左值,一方面来说,它们出现在了=的左边,另一方面来说,我们可以对其取地址,并修改它的值。

当然,我们也有const变量:

const int ci = 0;
int const* cp = &i;
const double cd = 3.14;

变量cicpcd都是左值,它们出现在了=的左边,我们可以对其取地址。但是由于具有const属性,我们不能修改它。

因此, 左值最显著的特征是可以取地址,但是不一定可以被修改

右值

右值也是一个表达数据的表达式,比如字面常量表达式返回值函数返回值等等,右值可以出现在赋值操作符=的右边,但是不能出现在=的左边,右值不能取地址

比如以下代码中:

double func()
{
	return 3.14;
}

int x = 10;
int y = 20;
int z = x + y;
double d = func();

以上代码中,1020x + yfunc()都是右值,它们出现在=的右边。1020对应了字面常量x + y对应了表达式返回值func()对应函数返回值。这些都是右值,它们最显著的特点就是无法取地址

简单辨析了什么是左值,什么是右值,现在我们知道左值与右值的最大区别在于可不可以取地址,接下来我们就要讲解右值引用这个语法了。


右值引用语法

先回顾一下左值引用的语法:

int i = 0;
int* p = nullptr;

int& ri = i;
int*& rp = p;

左值引用只需要在原本变量的类型后面,加一个&,就是一个左值引用的类型。左值引用后,新的变量相当于原先变量的别名,我们可以传引用传参,传引用返回等操作,来减少拷贝。

但是我们不能左值引用一个右值,比如这样:

int& ri = 0;
int*& rp = nullptr;
double& rd = 3.14;

以上代码中,=右侧都是字面常量,也就都是右值,而我们变量rirppd都是左值引用。我们不能拿左值引用来引用右值

左值引用的语法是:type&;右值引用的语法是:type&&

接下来我们尝试对刚刚的值进行右值引用:

int&& ri = 0;
int*&& rp = nullptr;
double&& rd = 3.14;

这样我们就完成了对右值的引用。

现在我们有了右值引用语法,那么再来考虑两个问题:

  1. 左值引用可以引用右值吗?
  2. 右值引用可以引用左值吗?

也许你会感到疑问,我刚刚已经证明过了无法直接通过左值引用引用右值,为什么我还要提出这个问题。这是因为刚刚的测试不全面,没有考虑特殊情况。

  1. 左值引用不能直接引用右值
  2. const左值引用 可以引用右值

在刚刚的测试用例中,我们尝试用左值引用直接引用右值:

int& i = 5; // 非法

这是不允许的,不然就没必要再推出右值引用语法了。

但是如果我们以const引用的形式,那么就可以引用右值:

const int& i = 5; // 合法

一个常量具有常性,也就是不能修改,如果我们直接把一个常量交给引用,那么我们就可能通过引用来修改这个常量,这就违背了常性。因此不能直接引用一个右值常量,但是当我们使用const引用,那么就可以引用了。

  1. 右值引用不能直接引用左值
  2. 右值引用可以引用move后的左值

右值引用不能直接引用左值:

int i = 5;
int&& rri = i; // 非法

但是C++11后,提供了一个函数move,其可以把一个左值强制转化为一个右值

就像这样:

int i = 5;
int&& rri = move(i); // 合法

要注意的是:  move并不会改变参数本身的左值属性,这一点可以参考强制类型转化:

double d = 3.14;
int i = (int)d;

在以上代码中,(int)d这个强制转化过程,并没有改变d是一个double类型的数据,只是在这个表达式中,(int)d返回了一个int类型的d

同理move(i)之后,i依然是左值,但是move(i)这个表达式返回了一个右值的i


右值引用底层

既然存在右值引用这个语法,那么我们来看看右值引用到底干了些啥。

右值引用的工作主要有两种情况,一种是右值引用了常量,另外一种是右值引用了move后的左值

右值引用了常量

当右值引用了常量,引用会把常量区中的数据拷贝一份到栈区,然后该引用指向栈区中拷贝后的数据

看到一段代码:

int&& r = 5;
r = 10;

以上过程中,我们先用r右值引用了常量5,然后通过右值引用把5改为了10
这个过程中,右值常量5存储在常量区,r右值引用后如果r指向常量区的5,会发生什么?此时我们的r = 10操作,就相当于把常量区的5修改为了10,从此以后整个程序中只要去常量区拷贝5都会变成拷贝10,这可就完蛋了。因此我们的右值引用常量,绝对不能直接引用常量区的数据!!

因此,右值引用常量时的真实操作是把常量区的数据拷贝到栈区中,然后这个引用指向这一块栈区内存。

别忘了,我们的const左值引用也可以引用常量,那么这个引用又是如何进行的:

const int& r = 5;

你可能会想,反正const左值引用都不会修改数据,就算让r真的指向常量区的5也没啥问题。但是其实const左值引用常量时和右值引用是一样的,都是先把数据拷贝到栈区,再进行引用

总结如下:

  1. 当右值引用了常量,引用会把常量区中的数据拷贝一份到栈区,然后该引用指向栈区中拷贝后的数据,该数据可以修改
  2. const左值引用了常量,引用会把常量区中的数据拷贝一份到栈区,然后该引用指向栈区中拷贝后的数据,但是该数据是常量,不能修改

右值引用了move后的左值

  1. 当右值引用了move后的左值,右值引用直接指向该左值

看到以下代码:

int i = 5;
int&& rri = move(i);

rri = 10;

cout << i << endl;
cout << rri << endl;

程序输出结果为:

10
10

也就是说,我们可以通过修改右值引用来修改左值,或者说以通俗点的说法,此时右值引用就是这个左值的别名。

这一幕好像似曾相识,是不是左值引用也可以做到这个事情,甚至是一模一样的事情?

确实是这样的,当右值引用了move后的左值,其实和直接左值引用这个左值没有任何区别。那么为什么我们还需要右值引用?

为了搞清右值引用存在的意义,我们先来看看左值引用出现后,解决了那些问题,又没解决哪些问题:

  1. 左值引用解决了传参时存在的拷贝问题
string add_string(string& s1, string& s2)
{
	string s = s1 + s2;

	return s;
}

int main()
{
	string str;
	string hello = "Hello";
	string world = "world";
	
	str = add_string(hello, world);

	return 0;
}

以上代码中,add_string函数需要接收两个string类型的参数,此时我们使用传引用传参,就可以避免两个string的拷贝消耗。

  1. 左值引用解决了一部分返回值的拷贝问题
string& say_hello()
{
	static string s = "hello world";

	return s;
}

int main()
{
	string str1;
	string str2;

	str1 = say_hello();
	
	return 0;
}

以上代码中,函数say_hello生成了一个string,并把它返回给外部,如果我们直接返回,那么str1接收参数时,就会先拷贝构造出一个临时变量,然后临时变量再拷贝构造str1。这个过程发生了两次拷贝构造。但是返回值s指向的string是全局的,其出了函数依然存在,因此我们传引用返回,可以不用拷贝构造一个临时变量,直接拿返回值s去拷贝构造,节省了一次拷贝构造。

也就是说,左值引用通过传引用传参传引用返回节省了拷贝。

但是我们再看到以下情况:

string say_hello()
{
	string s = "hello world";

	return s;
}

int main()
{
	string str;
	str = say_hello();

	return 0;
}

以上代码中,say_hello依然返回hello world这个字符串,但是s是一个局部变量,因为出了函数就会被销毁,如果str想要接收到s,那么就会先拷贝构造一个临时变量,然后临时变量再拷贝构造出str

但我们已经通过s创建好了一个字符串,我们为了得到一个字符串hello world,中间经过了这么多次拷贝。就因为这是一个局部变量,s不能出作用域。我们有没有办法直接把局部变量创建好的hello world移交给作用域外部的str,免去临时变量的拷贝构造

因此,右值引用应运而生。

我们先前说过,右值引用当引用一个被move左值的时候,其本质和左值引用没有区别。右值引用,其实更多的是一种标记

我们先来看看什么情况下会产生可以被右值引用的左值:

  1. 当一个左值被move后,可以被右值引用
  2. C++会把即将离开作用域的非引用类型的返回值当成右值,这种类型的右值也称为将亡值 

回顾我们刚刚的情况:函数内部的局部变量s已经创建好了字符串hello world,但是s马上就要出函数作用域销毁了,于是把hello world拷贝一份给外部临时变量,s被销毁后,临时变量再拿拷贝到的hello world去拷贝构造str

这个过程中,变量s已经快要离开作用域了,马上就要被销毁,s被销毁没有问题,但是s内部的hello world是我们需要的。这种情况可以理解为:一个富翁快要死亡了,于是他在死前立遗嘱,把自己的金钱继承给谁。

同理,一旦左值得到了右值属性,相当于立好了遗嘱,不希望自己的资源被系统释放,而是被合适的对象继承走。

由于C++会把即将离开作用域的非引用类型的返回值当成右值,这种类型的右值也称为将亡值 s即将被销毁,此时s就是一个右值了,右值的意思就是:这个变量的资源可以被迁移走。这句话非常非常重要!!!

右值的意思就是:这个变量的资源可以被迁移走

我们再看到另外一种情况:

  1. 当一个左值被move后,可以被右值引用

C++之所以要给出一个move属性,是因为有一些变量,其生命周期还很长,C++不敢擅自把这个变量的资源迁移走。但是一旦程序员把这个变量move了,就得到了一个有右值属性的左值,此时相当于程序员亲自许可把这个变量的资源迁移走。

那么右值是如何把资源迁移走的呢?这就涉及到右值引用的移动语义了:


移动语义

为了讲解移动语义,我先写一个简单的mystring类:

class mystring
{
public:
	//构造函数
    mystring(const char* str = "")
    {
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    
    //析构函数
    ~mystring()
    {
        delete[] _str;
    }

    // 赋值重载
    mystring& operator=(const mystring& s)
    {
        cout << "赋值重载" << endl;
        return *this;
    }

    // 拷贝构造
    mystring(const mystring& s)
    {
        cout << "拷贝构造" << endl;
    }

private:
    char* _str = nullptr;
};

这个mystring类中,我没有具体实现每一个接口,因为移动语义中,更重要的是函数的调用关系,而不是函数的具体实现。在mystring类中,有一个成员_str,类型为char*指针,指向一块空间,内部存储了字符串的字符。

现在我们有如下过程:

mystring get_string()
{
    mystring str("hello");

    return str;
}

int main()
{
    mystring s2 = get_string();

	return 0;
}

s2通过函数get_string来获得字符串,并构造自己。这个过程中,由于str是局部变量,会发生拷贝构造临时变量,临时变量再拷贝构造s2的过程。但是由于str是一个将亡值,具有右值属性,我们可以写一个函数直接把它的资源转移走

class mystring
{
public:
    // 移动构造
    mystring(mystring&& s)
    {
        cout << "移动构造" << endl;
        std::swap(_str, s._str);
    }
};

这个移动构造函数的参数是一个mystring&&类型,也就是一个右值引用。函数主体部分,通过一个swap函数把参数s_str指针成员与自己的_str成员进行交换。由于指针指向字符串数组,此时相当于把s的字符串数组交换给自己,这样就完成了对右值引用的数据转移

除了移动构造,我们还有原先的拷贝构造:

class mystring
{
public:
    // 移动构造
    mystring(mystring&& s)
    {
        cout << "移动构造" << endl;
        std::swap(_str, s._str);
    }
    
    // 拷贝构造
    mystring(const mystring& s)
    {
        cout << "拷贝构造" << endl;
    }
};

那么为什么get_string要去调用移动构造而不是调用拷贝构造呢?

  • 因为get_string的返回值是一个mystringstr,但是由于str要出作用域了,被判断为将亡值,因此str具有右值属性。那么str在出生命周期,构造临时变量的时候,就会去调用临时变量构造函数,由于str是构造函数的参数,具有右值属性,而不是左值属性,因此调用的是mystring&&的移动构造,而不是调用const mystring&的拷贝构造
  • 而我们的临时变量的生命周期,只在get_string这一行,马上就要被销毁了,因此临时变量也是一个将亡值,具有右值属性。当拿临时变量构造s2的时候,又会调用一次移动构造

流程如下:

get_string返回值str = =移动构造 = => 临时变量
临时变量 = =移动构造= => s2

可以看到,原先是进行两次拷贝构造,如果我们字符串有一亿个字符,那么总共要拷贝两亿个字符
但是移动构造出现后,我们只需要进行两次移动构造,一次移动构造只交换一个指针,共交换两个指针

现在可以看出,右值引用带来的移动构造有多么强悍。

虽然说我们的左值引用,也可以达到这样的移动构造,但是有一个问题,并不是所有的对象,资源都是可以被转移走的移动构造之所以这么叫,就是因为移走了别人的资源这部分资源之所以会被移走,就是因为它有右值属性而它之所以有右值属性,要么就是这个变量是个将亡值,资源不转移就浪费了;要么就是被程序员亲自move了,程序员许可把这个对象的资源转移走

就是这样的一个逻辑闭环,右值引用以一个既安全,又高效的方式,完成了局部变量的资源拷贝问题。而这个过程,也叫做右值引用的移动语义

移动:改语法实现了通过移走别人的资源,实现高效的创建对象,避免大量拷贝
语义:在这个过程中,右值引用只提供语义层面的功能,即许可一个对象资源被转移的右值语义

因为右值引用的出现,C++11后,类的默认成员函数从6个变成了8个。新增两个成员函数:移动构造移动赋值重载

比如我刚刚的mystring类的移动构造移动赋值

//移动赋值重载
mystring& operator=(mystring&& s)
{
    std::swap(_str, s._str);
    return *this;
}

// 移动构造
mystring(mystring&& s)
{
    std::swap(_str, s._str);
}

它们的特点是:参数为右值引用,函数体内部通过交换别人的指针到自己手上,实现高效的资源转移

当然,STL库内部的所有容器,也都更新了移动构造移动赋值重载

这是C++11的vector构造函数:
在这里插入图片描述

这里多出来了一个move系列的构造函数,参数类型为vector&&右值引用,这就是vector的移动构造。

这是C++11的vectoroperator=
在这里插入图片描述
一样的,多出来一个系列的operator=,参数类型为vector&&右值引用,这是vector移动赋值重载。


引用折叠

看到以下代码:

template <class T>
void func(T&& t)
{
    cout << "T&& 右值引用" << endl;
}

template <class T>
void func(const T& t)
{
    cout << "const T& const左值引用" << endl;
}

int main()
{
    int a = 5;
    func(a);//左值
    func(move(a));//右值

    return 0;
}

以上代码中,有两个模板函数的特化,分别是func的右值引用特化T&&和const左值引用特化const T&&。请问:

向函数func传入一个左值a,会调用哪一个函数;像函数func传入一个右值move(a),会调用哪一个函数?

程序输出结果如下:

T&& 右值引用
T&& 右值引用

可以看到,不论是左值还是右值,都调用了这个右值的模板,这是为什么?/按理来说,虽然const T&int&类型不符,但是从一个一般的引用int&转为const int&是完全合理的,所以应该调用const T&版本才对。但是最后调用了T&&版本,是不是说明在模板中,T&左值引用可以转化为T&&右值引用?

这听起来太扯了,其根本原因在于,C++希望通过统一的方式来处理引用的模板:

因此C++在模板中推出了引用折叠,也叫做万能引用,规则如下:

T& && 推演为 T&
T&& && 推演为 T&&

如果你希望当参数为左值引用和右值引用的时候,函数的功能是一样的,你就可以只写一个函数:

template <class T>
void func(T&& t)
{
}

此时,参数T&&就已经是一个引用折叠了。现在我们来调用这个函数:

int a = 5;
func(a);
func(move(a));

我们共调用了两次函数,分别是左值引用传参和右值引用传参。

第一次传参,func(a);,模板参数T的类型为int&,但是参数类型为int& &&,此时根据折叠引用规则:int& &&等于int&
第二次传参,func(move(a));,模板参数T的类型为int&&,但是参数类型为int&& &&,此时根据折叠引用规则:int&& &&等于int&&

可见,其实这就是一个统一处理左值引用和右值引用的语法,你传入的参数是什么引用,最后T&&就是什么引用。当然,这套规则也对const引用生效。

因此我们刚才的模板,如果作用于int类型,就可以推演出四套函数重载:

void func(int&){};
void func(const int&){};
void func(int&&){};
void func(const int&){};

我们可以用一套模板,生成原先两套模板才能做的事情(前提是左值引用右值引用对函数的要求相同)。


完美转发

看到以下代码:

void fuc1(int& rri)
{
    cout << "func1 左值引用" << endl;
}

void fuc1(int&& rri)
{
    cout << "func1 右值引用" << endl;
}

int main()
{
    int i = 5;
    int&& rri = move(i);
    fuc1(rri);

    return 0;
}

请问输出结果是什么?
输出结果:

func1 左值引用

是不是有点出乎意料?

明明我们的rri是一个右值引用,却调用了左值引用的函数重载,这又是为啥?
这涉及到一个重要知识点:

右值引用后,右值引用指向的对象是右值属性,但是引用本身是左值属性

比如说:int&& r = 5;这个代码,5的属性是右值,但是r的属性是左值。

因此我们在调用函数fuc1(rri);的时候,rri是一个左值,自然就以左值的形式来调用函数了。这该怎么办?

聪明的人就会想到,调用之前move一下不就好了,比如这样:

fuc1(move(rri));

这样确实没有问题,可以解决我们刚才的困境。那么我们再来看到一个案例:

void func2(int& x)
{
    cout << "func2 左值引用" << endl;
}

void func2(int&& x)
{
    cout << "func2 右值引用" << endl;
}

template <class T>
void fuc1(T&& t)
{
    func2(t);
}

int main()
{
    int i = 5;

	fuc1(i);//左值
    fuc1(move(i));//右值

    return 0;
}

func1是一个引用折叠的函数模板,随后在func1中调用了func2,请问如何调用funx2参数的最开始的引用类型?
由于在func1中,我们经过了折叠引用这一步,T&&这个参数类型是不确定的。

如果T&&是右值的话,传参后t会变成左值,那么我们可以对其进行move操作
如果T&&是左值的话,传参后t还是左值,我们无需对其进行操作

这个地方就不能粗暴的进行move了,不然会把原本就是左值的参数,给move成右值。为了解决这个情况,C++提供了一个函数模板forward,称为完美转发,其可以识别到参数的左右值类型,从而将其转化为原来的值。

我们只需要在引用折叠中这样进行调用:

template <class T>
void fuc1(T&& t)
{
    func2(forward<T>(t));
}

forward的模板参数中传入引用折叠的模板参数T,那么forward<T>就可以根据t的类型自动返回其原始的左右值属性了。


相关推荐

  1. C++11_引用

    2024-04-04 01:48:01       22 阅读
  2. C/C++ 15C++11引用

    2024-04-04 01:48:01       18 阅读
  3. 引用引用

    2024-04-04 01:48:01       1 阅读

最近更新

  1. opencv的高斯滤波函数

    2024-04-04 01:48:01       0 阅读
  2. 4.15 day6 ARM

    2024-04-04 01:48:01       0 阅读
  3. pytorch 多进程数据加载 - 序列化数据/serialize_data

    2024-04-04 01:48:01       0 阅读

热门阅读

  1. C# OAuth单点登录的实现

    2024-04-04 01:48:01       2 阅读
  2. [ RV1108_LINUX] 关于如何调整cpu中vdd_core的电压

    2024-04-04 01:48:01       4 阅读
  3. uniapp 打开抖音小程序

    2024-04-04 01:48:01       3 阅读
  4. 使用的sql

    2024-04-04 01:48:01       5 阅读
  5. 网络安全:筑牢防线,抵御恶意攻击

    2024-04-04 01:48:01       5 阅读
  6. springboot

    2024-04-04 01:48:01       3 阅读
  7. Oracle控制文件管理

    2024-04-04 01:48:01       2 阅读
  8. Oracle联机日志文件管理

    2024-04-04 01:48:01       2 阅读
  9. dlib中rectangle与opencv的rect的区别

    2024-04-04 01:48:01       5 阅读
  10. 0基础如何进入IT行业?

    2024-04-04 01:48:01       2 阅读
  11. os模块篇(十一)

    2024-04-04 01:48:01       3 阅读
  12. 借助ChatGPT写作:打造学术论文中的亮点与互动

    2024-04-04 01:48:01       5 阅读