humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C++ 2023 年 4 月 26 日

[C++] C++11新特性--右值引用的深入分析: 右值引用、万能引用、引用折叠、完美转发、移动语义...

本篇文章是关于C++11右值引用相关内容的深入分析介绍

右值引用

左值 与 左值引用

&这个符号, 在C语言中表示取地址
在C++中则多了一个功能, 即 引用, 用来给变量起别名
但是, &引用在C++11之后 完整的叫法是左值引用
那么问题来了, 什么是左值?
左值, 它可以出现在=的左边, 是一个表示数据的表达式, 比如: 变量名解引用的指针等, 可以对它取地址, 也可以给它赋值, 它在内存中有一块持久维护的地址空间, 不是临时的
左值可以出现赋值符号的左右两边
定义时被const修饰的左值, 不能给它赋值, 但是可以取它的地址
左值引用, 就是左值的引用, 给左值取别名:
int a = 10;
int* b = &a;
int c = *b;
const int d = *b;

int &e = a;
int &f = d;
上面例子中, abcd都是左值, 都可以对其取地址
int &eint &f都为左值引用
另外, 左值引用并只能引用左值, const引用就可以引用右值:
#include <iostream>

int main() {
    // 这个是编译不通过的
    //int &g = 10;
    
    // 这个是可以编译通过的
    const int &g = 10;
    
    return 0;
}
  1. int &g = 10; 无法编译通过

  2. const int &g = 10; 可以编译通过

明确理解对象的 类型和“值型” ***

在正式开始了解右值引用之前, 先思考一个问题:
int a = 10;
int& b = a;
提问:
a的类型是什么? a本身是一个什么值?
b的类型是什么? b本身是一个什么值?
答:
a的类型是int, a本身是一个左值
b的类型是int&, b本身是一个左值
对象的类型 与 对象本身是什么值, 是两个没有关系的概念

右值 与 右值引用

而 在C++11之后, 出现了另一种引用: 右值引用
那么, 什么是右值? 什么是右值引用?
右值, 只能出现在=的右边, 也是一个表示数据的表达式, 但它通常是一个常量或临时数据: 字面常量、表达式返回值、函数返回值等等, 这些表达式 无法对它取地址, 也无法给它赋值 的, 即 无法被修改
虽然, 这些临时表达式存在自己的地址, 但它的地址也是临时的, 无法被普通指针指向
当表达式失效时 地址会相应的失效, 空间资源会被释放, 即 右值没有一块持久维护的地址空间
右值只能出现在赋值符号的右边
右值引用就是对右值的引用, 给 右值取别名
int x = 1, y = 2;
1;
2;
x + y;
min(x, y);

int&& rr1 = 1;
int&& rr2 = x + y;
int&& rr3 = fmin(x, y);
上面的例子中, 12x + ymin(x, y) 都是右值, 无法对其取地址, 也无法给其赋值
并且生命周期只在其所在行, 所在行执行完毕, 右值的地址空间资源就会被释放, 所以这部分地址, 在原则上是禁止被获取并操作的
int&& rr1int&& rr2int&& rr3, 则都是右值引用对象, 右值引用的符号是&&
需要注意的是, 右值引用并不是将右值变成一个变量存储起来, 而是起了别名
可以看作用别名将右值绑定了起来, 将右值的地址生命周期延长了, 并不是新建一块空间将右值存储起来
其临时地址空间不会在所在行结束时释放, 进而右值引用对象可以直接被当作对象使用
并且, 右值引用无法引用左值:
#include <iostream>

int main() {
    int m = 1;
   	int&& n = m;
    
    return 0;
}
右值本身是不能取地址的, 这样是错误的: &10
但是, 当对右值取别名(右值引用)之后, 就会使右值数据的地址的生命周期延长
此时, 对右值引用对象取地址, 所得地址就是所引用右值实际的地址
此时, 可以给右值引用对象赋值, 与普通对象无二
这其实说明: 右值引用对象实际是一个左值
即, 如果存在int&& n = 1;n被看作一个左值, 可以给n赋值!

区分左右值最关键的点是: 看表达式能否取地址
  • 可以取地址的, 有名字的, 非临时的就是左值
  • 不能取地址的, 没有名字的, 临时的就是右值

移动语义 **

介绍了什么是右值引用, 那么 右值引用有什么用呢? 它的使用场景什么呢?
实际上, 当前右值引用的作用场景是移动拷贝移动赋值
这两种用法, 可以在 一定程度上解决深拷贝消耗大的问题
它是如何解决的呢?

代码中的深拷贝

先来回顾一下, 在很久之前 模拟实现的July::string部分代码(下面称呼以string代替):
string_default
string_default
展示的部分代码中, 只包含了几个类的默认函数、自定义的intstringto_string()函数
使用string对象, 拷贝构造string对象、给string对象赋值时, 会自动的调用拷贝构造函数和赋值重载函数
拷贝构造和赋值重载的参数类型都是左值引用, 然后都要复制传入参数的数据赋给对象, 都要针对要存储到string的数据进行深拷贝
还有, 将int类型转换为string类型的to_string()函数
此函数的返回值是string类型的, 是不能使用左值引用的, 因为返回数据是一个临时对象
所以, 为了正确的将转换结果返回, 在返回的过程中一定会发生深拷贝
这里只是一个简单的string的例子, 如果是更复杂的类, 深拷贝消耗的资源可能是非常巨大的
原因当然是 深拷贝涉及到复制对象的所有数据, 包括动态分配的资源
虽然, 左值引用在传参或者某些情况做返回值类型时, 可以节省资源, 但还是存在一些可能会发生深拷贝的地方

左值引用做返回值节省资源的例子:

string+=重载实现时, 要实现连续+=就要将+=之后的string作为返回值返回

如果直接传值返回, 还是会造成深拷贝

所以, 可以使用左值引用返回

右值引用实现的移动语义, 则可以很好的解决上面的这类发生深拷贝的问题

右值引用优化深拷贝

在使用左值引用之后, 很大程度上解决了传参时深拷贝的问题
但深拷贝还可能会发生在拷贝构造赋值重载临时对象返回
而右值引用出现之后, 实现了新的临时对象返回的方式两个新类的默认成员函数
  1. 新的临时对象返回方式

    这里的新的返回方式并不是指编写方式发生了改变, 即 返回值类型不做变化的, 依旧是传值返回

    而是指, C++11之后 当一个函数返回的数据是一个临时对象 或 直接返回一个右值时(出了函数就要销毁的数据做返回值), 编译器会将返回值类型 识别为右值引用类型

  2. 两个新的 类的默认成员函数

    1. 移动构造函数

      什么是移动构造函数?

      string为例, 要实现移动拷贝构造, 它的函数名是这样的:

      string(string &&str) 
      	: _str(nullptr)
      	, _size(0)
      	, _capacity(0) {
          ...
      }

      并且, 移动拷贝构造函数体的实现方法, 通常是直接将传入的参数拥有的数据 与 对象的成员数据进行交换

      那么, string之中, 实现应该是这样的:

      void swap(string& str) {
          std::swap(_str, str._str);
          std::swap(_size, str._size);
          std::swap(_capacity, str._capacity);
      }
      
      string(string &&str) 
      	: _str(nullptr)
      	, _size(0)
      	, _capacity(0) {
          swap(str);
      }

      这样传入右值引用参数, 并直接交换传参数据对象成员数据来实例化对象的函数

      就叫移动构造函数

    2. 移动赋值重载函数

      根据移动构造函数的实现, 可以很快的推断出 移动赋值重载函数的实现:

      void swap(string& str) {
          std::swap(_str, str._str);
          std::swap(_size, str._size);
          std::swap(_capacity, str._capacity);
      }
      
      string& operator=(string &&str) {
          swap(str);
          
          return *this;
      }

      这样传入右值引用参数, 并直接交换传参数据 对象成员数据来给对象赋值的函数

      就叫移动赋值重载函数

    这两个默认成员函数, 在完成对象实例化或对象赋值时, 没有发生数据拷贝

C++11之后, 类添加了这两个默认成员函数之后, 可以解决很大一部分的深拷贝问题
因为, 当使用右值来给对象赋值或实例化对象时, 类会直接调用 移动构造函数移动赋值重载函数
这两个函数不会发生数据拷贝, 而是直接交换数据资源
即, 移动构造移动赋值重载的思想是: 将传入对象的数据与目标对象数据做交换, 从而避免因数据拷贝消耗资源
并且, C++11之后, 当一个函数的返回值类型为传值返回, 且返回的是一个函数内的临时变量其他类型的右值
编译器会默认将返回类型方式识别为 右值引用返回, 让临时变量或者右值, 不会在出函数作用域时被销毁, 从而避免深拷贝的发生

编译器优化构造函数(移动语义后)

之前已经介绍过, 针对类的各种构造函数 一些编译器进行一些优化
相关文章:
C++11引入了右值引用, 引入了移动构造之后, 编译器又会做什么优化呢?
to_string()为例:
July::string to_string(int value) {
	bool flag = true;
	if (value < 0) {
  		flag = false;
  		value = 0 - value;
	}

	July::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;
}
C++11之前
当使用to_string()的返回值, 去实例化新的string对象时
如果编译器不优化:
  1. 传值返回, 需要生成临时对象, 会发生一次拷贝构造
  2. 使用string对象 实例化string又会发生一次拷贝构造
由于生成临时对象这一步, 非常的多余且消耗资源
且, 函数传值返回, 已经是临时对象了, 还要再拷贝构造一个临时对象, 太多余了
所以, 编译器会优化掉第一次的拷贝构造, 直接使用函数内调用return时的临时对象做返回值, 去调用拷贝构造实例化新的string对象
而在C++11之后
编译器会将传值返回 识别为传右值引用返回, 所以去调用移动构造函数
如果编译器不优化:
  1. 传值返回, 需要生成临时对象, 但编译器会识别出右值引用返回, 所以 发生一次移动构造
  2. 使用 一个右值string对象实例化string, 又会发生一次移动构造
还是有一步多余的临时对象的移动构造, 所以编译器会优化掉
编译器会 直接使用函数内调用return时的临时对象做返回值, 去移动构造实例化string对象
C++11之后, 所有的STL容器也增加了这两个成员函数:
vector:
string:
所有的STL容器都增加了这两个成员函数

move()

实际的使用中, 右值引用只能右值, 不能引用左值
不过, C++11不仅提出了右值引用, 还增添了一个新的函数std::move()
这个函数的功能很简单: 将传入的左值以右值引用类型返回
右值引用类型做返回值, 返回值会被编译器认为是将亡值, 将亡值是右值的一种
也就是说, 某些情况需要将左值作为右值使用时
可以使用move()将左值对象转换为对象的右值引用返回
即, 对象作为参数传入move(), 返回值是传入对象的右值引用, 一个将亡值
move()的使用场景在哪呢?
move()实际上是为了更好的支持移动语义

**究竟什么是移动语义? **
支持移动语义要简单理解, 可以这样理解:
支持移动语义的对象, 就可以使用此对象通过移动构造移动赋值重载, 实例化新对象 或 给其他对象赋值
即, 支持移动语义, 就表示此对象数据允许被置换走, 置换走之后对象原数据会失效
举个简单的例子, 如果存在普通左值对象object
直接使用object, 实例化新对象 或 给其他对象赋值, 编译器会调用普通拷贝构造普通赋值重载, object不会失去它的原数据
但是, 如果使用move(object)的返回值, 实例化新对象 或 给其他对象赋值, 因为move(object)的返回值是object的右值引用, 是一个右值, 编译器就会调用移动构造移动赋值重载, 之后 object的原数据会被置换走, object会拥有另一个对象的原数据
所以, move()的使用需要谨慎
因为, move()可能会导致左值对象随时失去原数据或被销毁

move()的返回值是传入参数的右值引用

但, 如果使用右值引用对象接收move()的返回值:

T object;
T&& rRefObject = move(object);

此时, rRefObject并不是一个右值, 因为右值引用对象是一个左值

容器接口中的右值引用

上面介绍了, 函数传值返回 可能会被编译器识别为右值引用返回, 并且也介绍了两个右值引用传参的类默认成员函数
除此之外, STL容器的其他地方也通过右值引用, 减少了深拷贝的出现
比如, push_back()insert()等一系列向容器中插入数据的接口:
不仅vector, 其他容器也同样实现了数据添加接口的右值引用参数版本
虽然, 之前的版本使用的左值引用, 已经避免了传参时可能发生的深拷贝
但, STL在实际的数据插入实现中, 即使传参时不发生深拷贝, 但在实际存储数据时还是会对传入的数据进行深拷贝实例化对象, 然后进行存储
所以, 可以直接使用右值引用, 表示传入的参数的数据可以进行置换, 就会直接置换数据 防止发生深拷贝

万能引用 **

C++11引入了右值引用, 用 && 表示
并且 类中也新增了两个使用右值引用的默认成员函数
因此, C++11之后就会有些场景, 就需要使用右值引用类型的参数作为模板参数
但是, 这样的模板却存在着一些问题
还是以上面的July::string类为例, 但是执行这段代码:
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;
}
首先适当的分析一下代码:
重载了4个函数Fun(), 参数都是引用类型, 会根据传入的参数类型, 判断const左值或右值
然后定义了一个函数模板, 并且函数的参数类型设置为模板参数的&&
然后在主函数内, 分别依次调用模板函数, 并传入: 右值 左值 右值 const 左值 const 右值
如果猜测执行结果, 函数的执行结果应该是 按照传入顺序 输出相应的参数类型
而实际的结果是:
无论是左值还是右值, 传入模板函数之后, 识别出的类型都是左值相关的
这是为什么呢?
首先, &&用在模板中, &&就不再是右值引用了, 而是 万能引用
万能引用, 即 左值 和 右值都可以传入
实际用过之后, 确实左值右值都可以传入, 但 参数类型统统被识别为左值引用
出现这种现象, 涉及到两个概念: 引用折叠右值引用对象为左值

引用折叠 **

什么是引用折叠?
当一个模板函数的形参为引用类型时, 这些情况会发生引用折叠:
假设存在函数
template<typename T>
void Func(T&& arg) {}
  1. 存在这样调用Func()

    int elem = 10;
    Func(elem);

    此时, Func()推导Tint&类型

    此时, 会发生引用折叠, arg的类型会折叠为左值引用

    折叠规则: T& && —> T&

  2. 存在这样调用Func()

    int elem = 10;
    Func(std::move(elem));

    此时, Func()推导Tint&&类型

    此时, 会发生引用折叠, arg的类型会折叠为右值引用

    折叠规则: T&& && —> T&&

还有其他情况, 比如auto推导时发生引用折叠:
int&& getRValue() {
    return 10;
}

int& retLValue(int& val) {
    return value;
}

int main() {
    int lval = 20;
    int& lref = retLValue(lval);
    int&& rref = getRValue();
    
    auto& val1 = rref;  // auto& &&
    auto& val2 = lref;	// auto& &
    
    auto&& val3 = lref;	// auto&& &
    auto&& val4 = rref;	// auto&& &&
    
    return 0;
}
此时, val1 val2 val3会被折叠为auto&, 最终为int&
val4则会被折叠为auto&&, 最终为int&&
简单总结就是, 只有出现T&& &&时, 引用折叠才会折叠为&&右值引用

还涉及到一个特性, 即 右值引用对象用于作表达式时, 是左值
这个特性可以从 简单的代码表现出来:
int &&a = 10;
int* b = &a;
a = 7;
右值引用变量a, 可以被取地址, 也可以被赋值
也可以通过一下代码 表现出来:
void fun(int&& f) {}

int main() {
	int &&d = 10;
    fun(d);
    
    return 0;
}
这段代码 编译是不通过的:
其实就可以说明, 当右值引用对象被直接用于当作表达式时, 会被认为是左值
当然, 还有一种验证方式, 依旧使用July::string
不过此时要在 默认构造、移动构造 和 拷贝构造里各添加一句话:
// 默认构造
string(const char* str = "")
    : _size(strlen(str))
    , _capacity(_size) {
        // 添加提示语句
        cout << "默认构造" << endl;
        _str = new char[_capacity + 1];
        strcpy(_str, str);
    }

string(string&& str) {
    // 暂不实现功能
    cout << "移动构造" << endl;
}

// 拷贝构造函数 传统
string(const string& s)  
    : _size(s._size)
    , _capacity(s._capacity) {
        // 添加提示语句
        cout << "拷贝构造" << endl;
        _str = new char[_capacity + 1];
        strcpy(_str, s._str);
    } 
然后执行下面的代码:
int main() {
    July::string &&str = "12345";
    July::string s = str;
    
    return 0;
}
执行结果是:
可以看到, 最终的执行结果是 执行了默认构造 和 拷贝构造, 都没有执行移动构造
这也可以说明, 当右值引用对象被直接用于当作表达式时, 会被认为是左值

而 最上面模板函数使用万能引用的例子中:
尽管 调用PerfectForward()函数时传入的是右值引用.
但是, 在此函数内部 再通过形参调用Fun()函数, 依旧会被识别为左值, 就是因为这两个原因
引用折叠了, 虽然T&& &&折叠之后依旧表示右值引用
但是, Fun(t) 调用时, t直接用作表达式, 会被认为是左值
而使用模板、万能引用的目的并不是这样的, 目的是 传入左值, 就以左值引用使用, 传入右值 就以右值引用使用

完美转发 **

这时候, 就要用到C++11的一个新接口: std::forward()完美转发
这个接口看起来非常的复杂, 但是实际使用并没有那么复杂:
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) {
    cout << "非完美转发: ";
    Fun(t);
    
    cout << "完美转发: ";
    Fun(std::forward<T>(t));
    cout << endl;
}

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;
}
这段代码的执行结果是:
可以发现, 经过完美转发的引用变量 会被识别为原本的类型
std::forward<type>()的返回值是传入参数的原类型

std::forward<type>()是如何做到的?
它的函数原型其实并不复杂:
template <typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept {
	return static_cast<_Tp&&>(__t);
}
传入std::forward<type>()的参数:
  1. 会先被std::remove_reference()消除传入的模板参数类型的 引用状态

    即, 将传入类型还原为无引用的原始状态:

    int&&->int

    int&->int

  2. 然后, &保证数据, 会以原始类型的左值引用, 作为形参进入forward()

    这里&防止数据传值传参, 形参变为临时数据

  3. 将数据的类型, 从原始类型的左值引用(形参类型), 强制转换为传入模板参数类型的&&, 并返回

实际std::forward<type>()做的, 就是将传入数据的类型加上了&&并返回
即, 如果typeT& 就变为T& &&, 并返回, 发生引用折叠T&
如果typeT&& 就变为T&& &&, 并返回, 发生引用折叠T&&

std:move()std::forward()都是转换变量用的

不过move()是将左值转换为右值引用做返回值

forward()则是将 变量原本表示的类型还给它

因为, 模板函数传参可能会造成引用折叠, 并且右值引用对象做表达式时, 被看作左值

不过, 完美转发的使用场景是下边这样:
我们介绍右值引用时, 提到过 STL容器在各方面支持了右值引用.
并且, STL容器都是类模板, 肯定需要使用到完美转发. 就像这样:
此例中, 由于 List 是一个模板类
所以要想 针对不同类型 在各方面实现对右值引用的支持, 就需要用到 完美转发
比如, 形参有右值引用的Insert()接口
调用此接口时需要传入 右值, 应该变为右值引用
但, 进入函数之后x就会变为左值形式, 所以要想实现node插入, 就要使用forward<>()x恢复为右值引用, 才能调用Node结构体的移动赋值
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2023 年 4 月 26 日