C++2.0 右值引用、移动语义、完美转发 自认为详解

C++2.0 右值引用、移动语义、完美转发

抛开以前听过左值和右值的一切传说,投入到我的主观分析里面,看完再做思考,不足或者错误之处,欢迎随时指出!

1. 左值

左值有自己的名字,不管是真名还是别名。
能够使用使用**&或者std::addressof(…)取得地址,左值既能放在=**操作符左边也能在其右边

tips: 常见左值有:
函数名 [当我们将函数名作为一个值来使用时,该函数名自动转换为指向对应函数的指针] 和具名变量,具名变量如std::cin、std::endl等
返回左值引用的函数调用
前置自增/自减运算符连接的表达式++i/--i、由赋值运算符或复合赋值运算符连接的表达式 (a=b、a+=b、a%=b)
解引用表达式*p、字符串字面量"abc"等
auto a = int16_t(233);
auto b = int16_t(666);

a = b;     			// 正确 
// b + a = 100;		// 错误 'b + a'作为算术表达式的计算结果是一个临时变量
int64_t c = b + a; 	// 正确 c作为一个左值,'b + a'的计算结果写入c中

左值相当于一个容器,它具有名字,能够存放那些和容器容积同样大小的数据,能够被继续使用

2. 右值

不是左值的值就是右值。即没有名字就是右值。也就是后续无法继续使用
即不能对右值取地址,生命周期在表达式结束后就立马结束,不能将右值放在’='操作符左边

tips: 不具名的临时对象是右值, 
寄存器中的数据是右值, 
字面量(3、false这类)是右值, 
void类型的值,
返回非引用类型的函数调用,
后置自增/自减运算符连接的表达式i++/i--、算术表达式(a+b、a&b、a<<b)、逻辑表达式(a&&b、a||b、~a)、比较表达式(a==b、a>=b、a<b)、取地址表达式(&a)等都是右值
// std::string *ptr = &std::string();	// 错误 不能对非左值取地址

3. 左值引用

// 定义一个只接受int型左值的函数
auto Func(int &param) {	// param即外部传入实参的左值的引用
    ;
}

int x = 666;	
int &y = x;		// y只是左值x的一个别名 y即x 和指针等价 
y = 100;		// 改变y就是改变x
int z = y;		// 左值因为可以在'='右边,因此可以将自己拷贝给另一个左值
int &z1 = y;	// 也可以再给x整一个别名


int *p = std::addressof(x);	// p是指针类型对象 表述其所指是一个int型对象 分配栈空间存放所引用的对象x的地址
*p = 233;		// 对指针解引用返回一个左值 因此可以对左值赋值

Func(x);	// 因此调用上面定义的函数模板是没问题的
Func(z1);	
Func(*p)

上面所述的非常量左值引用有一个缺陷,他们只能作为一个左值的引用。因此C++2.0以前就允许使用常量左值引用来引用右值

int num = 10;
const int &b = num;
const int &c = 10;

4. 右值引用

右值不具名,所以想要使用右值只能通过别名(即引用)的方式来操作。

常量左值引用虽然能够引用右值,但是无法对右值进行修改。
因而C++2.0创造了一种新的引用方式,叫做右值引用。

如果X是一个类型,那么X&& 就是对X类型的右值引用,为了更好的区分X&被称为左值引用。和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化。

// 定义一个只接受int类型的右值的函数
auto Func(int &&param) {				  
	;
}

const int &b = 100;
// b++;		// 错误 常量左值引用不可修改引用值
int && a = 10;
a = 100;	// 非常量右值引用可以修改引用值

const int &&c = 100;	// 常量右值引用不可以修改引用值,此处定义常量左值更好

// 因此调用上面定义的函数模板是没问题的
Func(10);	
引用类型非常量左值常量左值非常量右值常量右值场景
&YNNN函数的可变入参
const&YYYY函数的不变入参
&&NNYN移动语义,完美转发
const&&NNYY无使用场景

// 定义一个返回右值(临时对象)的函数模板
auto GetStr(const char* ptr, size_t len) -> std::string {
	return std::string(ptr, len);
}

// 警告: 不要试图在临时对象生命周期结束后,使用其地址,这样的行为是未定义的
char *ptr = GetStr("hi", strlen("hi")).data();	// ptr保存临时对象的地址 表达式结束 临时对象析构 ptr指针悬挂 再使用会导致不明确结果 甚至崩溃
// std::cout << ptr << std::endl;					

// 右值引用
std::string &&STR = GetStr("hi", ::strlen("hi"));	// 使用'&&'表示右值引用 STR 就是临时std::string的右值引用
												// 上面表达式结束后,临时对象不会被立即释放 原因后面描述

char *ptr = STR.data();			// ptr保存STR.data()
std::cout << ptr << std::endl; 	// 在STR生命周期内使用该指针是安全且正确的

5. 拷贝

C++2.0之前如果我们没有在class中定义构造和析构函数,编译器会给我们生成默认的三大函数 —— 析构、拷贝构造、拷贝赋值。我们可以使用拷贝构造将对象a的内容原封不动地复制一份给对象b,即出现了2份位于不同地址上地相同数据,做的拷贝!在频繁、大量地拷贝过程中会造成大量内存的申请和释放,以及大量数据的拷贝,这种行为是比较耗时的。

下面代码就是传统的类的定义(考虑到c++2.0之前只有左值引用,因此之前只称为引用,拷贝的原则上是不修改源, 因此使用const修饰参数)

// 例如定义一个my_class.h
#pragma once

#include <memory>
#include <string>

class MyClass {
public:
	MyClass();
	MyClass(char*);

	MyClass(MyClass const& obj);
	MyClass& operator=(MyClass const& obj);

	~MyClass();

	std::string ToString() const;

public:
	std::unique_ptr<char[]> data_;
};
#include "my_class.h"
#include <iostream>
#include <sstream>

using namespace std;

MyClass::MyClass()
	: data_{ new char[] {"hello"} } {
}

MyClass::MyClass(char* ptr)
	: data_{ new char[string(ptr).size() + 1]{0} } {
	::memcpy(this->data_.get(), ptr, string(ptr).size());
}

MyClass::~MyClass() {
}

MyClass::MyClass(MyClass const& obj)
	: data_{ nullptr } {
	if (this != addressof(obj)) {
		data_.reset(new char[string(obj.data_.get()).size() + 1]{ 0 });
		::memcpy(this->data_.get(), obj.data_.get(), string(obj.data_.get()).size());
	}
}

MyClass& MyClass::operator=(MyClass const& obj) {
	if (this != addressof(obj)) {
		data_.reset(new char[string(obj.data_.get()).size() + 1]{ 0 });
		::memcpy(this->data_.get(), obj.data_.get(), string(obj.data_.get()).size());
	}
	return *this;
}

6. 移动

在了解移动语义前,先了解编译器的选择

/* 
	C++函数重载基于函数签名,即函数名+参数(类型+个数)
	从前面的代码中可以得到:
*/
void func(int &)  // 只能接受非常量左值
void func(int &&) // 只能接受非常量右值
   
int x = 100;
func(x);	// 调用void func(int &)
func(100);	// 调用void func(int &&)


// 如果函数同名,编译器优先选择类型匹配的那个
// 如果没有定义 void func(int &&) 或者没定义 void func(int &):
// 例1:
void func(int const &); // 虽然它左右值都可以接受 但此处让它接受左值
void func(int &&);		// 作为接受右值的函数
// 例2:
void func(int const &); // 可以接受右值但用处几乎没有,这种搭配一般接受常量左值引用 
void func(int &);		// 作为接受左值的函数

到了C++2.0 就不再是三大了,而变成了五大 —— 析构、拷贝构造、拷贝赋值、移动构造、移动赋值
所谓移动就是为了解决上面拷贝带来的大量数据在开辟内存和搬移过程中的开销,使用拷贝去实现数值的复制,同时让源对象放弃对内存的所有权。

tips: 以前的经验会告诉自己,使用浅拷贝是具有风险的,比如使用一个指针指向申请在堆上的对象后,将此地址赋值给另一个相同类型的指针,即一块内存被两个指针引用,任何一方对同 一对象析构都会造成另一方在二次析构上导致程序崩溃。

因此在使用移动语义进行浅拷贝时,一定要千万注意,处理好内存的所有权问题。

下面重新定义MyClass
class MyClass
{
public:
	MyClass();
	MyClass(char*);
	MyClass(MyClass && obj) noexcept;	// 指定参数为右值 且必须告诉编译器不会抛异常,不让编译器不会信任你
	MyClass& operator= (MyClass && obj) noexcept;

    MyClass(MyClass const& obj);
	MyClass& operator=(MyClass const& obj);
    
	~MyClass();

	std::string ToString() const;

public:
	std::unique_ptr<char[]> data_;
};
MyClass::MyClass(MyClass const& obj)
	: data_{ nullptr } {
	if (this != addressof(obj)) {
		data_.reset(new char[string(obj.data_.get()).size() + 1]{ 0 });
		::memcpy(this->data_.get(), obj.data_.get(), string(obj.data_.get()).size());
	}
}

MyClass& MyClass::operator=(MyClass const& obj) {
	if (this != addressof(obj)) {
		data_.reset(new char[string(obj.data_.get()).size() + 1]{ 0 });
		::memcpy(this->data_.get(), obj.data_.get(), string(obj.data_.get()).size());
	}
	return *this;
}

MyClass::MyClass(MyClass&& obj) noexcept {
	if (this != addressof(obj)) {
		data_ = std::move(obj.data_);	// 或者写成 data_.reset(obj.data_.release()); 意在交换内存的所有权
	}
}

MyClass& MyClass::operator= (MyClass&& obj) noexcept {
	if (this != addressof(obj)) {
		data_ = std::move(obj.data_);	
	}
	return *this;
}

7.完美转发

完美转发的意思:对函数的传参,参数如果是左值就交出去左值,参数如果是右值就交出去右值
在讨论完美转发前,先讨论右值引用真的是右值?

#include "my_class.h"
#include <iostream>

void foo(MyClass &&x) {
	MyClass y = x;	
}

MyClass& func(MyClass &&x) {
	return x;	
}

​ 对于foo(…) 如果将x视为右值,那么,MyClass y = x将调用X类的移动构造函数(事实上走的拷贝构造)。
​ 对于func(…) 函数的返回类型定义为X的引用,如果x为右值,那么,一个右值是不能绑定到左值引用上去的。
​ 为避免这种情况的出现,C++2.0规定:具名的右值引用是左值。这样一来,例一中MyClass y = x将调用X的拷贝构造函数,执行后x不发生变化,继续访问x不会出问题。 func(…)中,return x也将得到允许。

​ 因此可以粗略地得出结论:

int main(int argc, char* argv[]) {
	using namespace std;

    // 1.具名的右值引用编译器认为它是左值 可取地址
    MyClass &&rhs = MyClass(); 
    // 2.传入临时对象 但是在foo(...)中使用了具名的右值引用
	foo(MyClass());
	// 3.
	MyClass a;
	a = MyClass();	// 右值无名 因此调用移动赋值函数
	return 0;
}

​ 进一步地,从汇编指令考察:指针, 左值引用,右值引用

#include <cstdio>

struct St
{
	int i;
	char c;
};

int main(int argc, char* argv[])
{	 
	St a;

	St* pa = &a;
	St& lhs = a;

	lhs.i = 666;
	pa->c = '!';

	::printf("-------------\n");

	St&& rhs = St();
    rhs.i = 666;

	return 0;
}

反汇编之后:

struct St
{
	int i;
	char c;
};

int main(int argc, char* argv[])
{
00E02550 55                   push        ebp  
00E02551 8B EC                mov         ebp,esp  
00E02553 81 EC 08 01 00 00    sub         esp,108h  
00E02559 53                   push        ebx  
00E0255A 56                   push        esi  
00E0255B 57                   push        edi  
00E0255C 8D 7D B8             lea         edi,[ebp-48h]  
00E0255F B9 12 00 00 00       mov         ecx,12h  
00E02564 B8 CC CC CC CC       mov         eax,0CCCCCCCCh  
00E02569 F3 AB                rep stos    dword ptr es:[edi]  
00E0256B A1 08 30 E1 00       mov         eax,dword ptr [__security_cookie (0E13008h)]  
00E02570 33 C5                xor         eax,ebp  
00E02572 89 45 FC             mov         dword ptr [ebp-4],eax  
00E02575 B9 5B 60 E1 00       mov         ecx,offset _F17F7B77_main@cpp (0E1605Bh)  
00E0257A E8 AD F0 FF FF       call        @__CheckForDebuggerJustMyCode@4 (0E0162Ch)  
	St a;

	St* pa = &a;
00E0257F 8D 45 F0             lea         eax,[a]  				// 把 a 的地址写入寄存器 eax
00E02582 89 45 E4             mov         dword ptr [pa],eax  	 // 把 eax 中 a 的地址写入 pa
	St& lhs = a;
00E02585 8D 45 F0             lea         eax,[a]  				// 把 a 的地址写入寄存器 eax
00E02588 89 45 D8             mov         dword ptr [lhs],eax  	 // 把 eax 中 a 的地址写入 lhs

	lhs.i = 666;
00E0258B 8B 45 D8             mov         eax,dword ptr [lhs]  	 // 从 lhs 中取出 a 的地址写入寄存器 eax
00E0258E C7 00 9A 02 00 00    mov         dword ptr [eax],29Ah   // 把666 写入 a 的第一个字段
	pa->c = '!';
00E02594 8B 45 E4             mov         eax,dword ptr [pa]  	 // 从 pa 中取出 a 的地址写入寄存器 eax
00E02597 C6 40 04 21          mov         byte ptr [eax+4],21h   // 把 ! 写入 a 的第二个字段

	::printf("-------------\n");
00E0259B 68 A8 0B E1 00       push        offset string "-------------\n" (0E10BA8h)  
00E025A0 E8 04 EC FF FF       call        _printf (0E011A9h)  
00E025A5 83 C4 04             add         esp,4  

	St&& rhs = St();
00E025A8 33 C0                xor         eax,eax  					// 清空寄存器
00E025AA 89 45 BC             mov         dword ptr [ebp-44h],eax  	  // 对St的匿名对象 ebp-44h 做初始化0
00E025AD 89 45 C0             mov         dword ptr [ebp-40h],eax  
00E025B0 8D 4D BC             lea         ecx,[ebp-44h]  			 // 把 ebp-44h 的地址写入寄存器 eax
00E025B3 89 4D CC             mov         dword ptr [rhs],ecx  		 // 把 eax 中 a 的地址写入 rhs
	rhs.i = 666;
00E025B6 8B 45 CC             mov         eax,dword ptr [rhs]  
00E025B9 C7 00 9A 02 00 00    mov         dword ptr [eax],29Ah  

	return 0;
00E025BF 33 C0                xor         eax,eax  
}

综上:

指针,左值引用,右值引用他们是等价的,都保存所引用对象的地址

右值引用 rh对应的汇编代码等价于一个左值引用引用了一个匿名变量(该变量名在 C++ 代码中不可见,本例中变量的值存在于栈上,为便于表述本文将其简称为匿名变量),该匿名变量在栈上的地址偏移量为 [ebp-44h]。因为左值引用和指针生成的汇编代码相同,所以右值引用的汇编也等价于一个指针指向一个匿名变量。对右值引用重新赋值修改,改的是匿名变量的值。
在我看来有名字的对象就是左值(不知道准不准确,希望指正)

现在使用函数模板+移动语义可以实现完美转发,首先我们需要知道在函数模板中右值引用的规则:
C++2.0之前不允许使用引用的引用,如果A& &将会造成编译错误;
C++2.0新特性中使用右值引用的函数模板存在一个折叠规则:

  • A& & --> A& 左值的左值引用是一个左值
  • A& && --> A& 左值的右值引用是一个左值
  • A&& & --> A& 右值的左值引用是一个左值
  • A&& && --> A&& 右值的右值引用是一个右值
template<typename T, typename U>
void foo(T &&t, U &&u) {}

foo("hello world", 100);
/**
 *  根据折叠规则:
 *  左值"hello world" 解析出来的T的类型为 const char (&)[12] 因此参数类型特化为 const char (&)[12] && 经过折叠后类型为 const char (&)[12]
 *  右值10 解析出来的T的类型为 int 因此参数类型特化为 int&& 经过折叠后类型为 int&&
**/

因此,定义一个模板函数,能够实现对左值和右值的同时支持。

但是仅仅有了以上的规则,我们仍旧不足以解决上述遇到的不能完美转发的问题,要**std:forward(…)**来完成 ,它不依赖外层函数模板推导的类型,而是使用原本的类型进行返回,例如:

template<typename T, typename Arg> 
std::shared_ptr<T> Factory(Arg &&arg) { 
	return shared_ptr<T>(new T(std::forward<Arg>(arg)));
} 

研究一波标准库是如何做到这一点的

int main()
{
    MyClass m("nihao");
    auto p = Factory<MyClass>(m);
    auto p1 = Factory<MyClass>(std::move(m));
    return 0;
}
// g++关于std::move(...) 和std::forward(...)的实现
// Reference transformations.

  /// remove_reference
  template<typename _Tp>
    struct remove_reference
    { typedef _Tp   type; };

  template<typename _Tp>
    struct remove_reference<_Tp&>
    { typedef _Tp   type; };

  template<typename _Tp>
    struct remove_reference<_Tp&&>
    { typedef _Tp   type; };  
 
  /**
   *  @brief  Forward an lvalue.
   *  @return The parameter cast to the specified type.
   *
   *  This function is used to implement "perfect forwarding".
   */
  template<typename _Tp>
    constexpr _Tp&&
    forward(typename std::remove_reference<_Tp>::type& __t) noexcept
    { return static_cast<_Tp&&>(__t); }

  /**
   *  @brief  Forward an rvalue.
   *  @return The parameter cast to the specified type.
   *
   *  This function is used to implement "perfect forwarding".
   */
  template<typename _Tp>
    constexpr _Tp&&
    forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
    {
      static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
		    " substituting _Tp is an lvalue reference type");
      return static_cast<_Tp&&>(__t);
    }

  /**
   *  @brief  Convert a value to an rvalue.
   *  @param  __t  A thing of arbitrary type.
   *  @return The parameter cast to an rvalue-reference to allow moving it.
  */
  template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

std::forward<Arg>(arg)存在两种定义:constexpr T&& forward(T&& __t) noexceptconstexpr T&& forward(T& __t) noexcept

  1. 对于auto p = Factory<MyClass>(m);编译器会推导为:std::forward<MyClass& &&>(arg)也就是会调用constexpr T&& forward(T& __t) noexcept
    由于__t是左值 ,那么 return static_cast<_Tp&&>(__t);后按照规则:T& && -> T& 返回了一个左值引用,因此使用拷贝构造函数去初始化对象。
  2. 对于auto p1 = Factory<MyClass>(std::move(m));编译器会推导为:std::forward<MyClass&& &&>(arg)也就是会调用constexpr T&& forward(T&& __t) noexcept
    由于__t是右值 ,那么 return static_cast<_Tp&&>(__t);后按照规则:T&& && -> T&& 返回了一个右值引用,因此使用移动构造函数去初始化对象

参考:

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

歪锅锅

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值