首页 > 编程语言 >C++ 逆向之 move 函数

C++ 逆向之 move 函数

时间:2024-11-02 10:58:14浏览次数:5  
标签:逆向 右值 int move 左值 C++ 引用 &&

众所周知,在 C++ 11 后,增加了右值引用类型,那么函数参数传递一共有三种方式,分别是非引用类型传递(值传递)左值引用传递右值引用传递,其中值传递会对实参进行一份拷贝传递给函数,左值引用和右值引用则直接引用实参传递给函数,这就是它们最大的区别。

为什么要区分值传递和引用传递呢?对于一些小型的变量,或许没有感觉有太大区别,但是对于一些大型的对象(比如容器、类对象等),执行一个拷贝操作是非常消耗性能的一件事情,而且我们可能有这种需求,比如整个项目中希望某个对象只存在一个实例,出于以上需求,因此就有了 std::move 函数的用武之地。

一、std::move 函数的作用

std::move 是 C++ STL 中的一个函数模板,它的主要作用有两个:

  1. 所有权转移:将一个对象的资源(例如动态分配的内存、文件句柄等)转移给另一个对象,从而避免不必要的复制操作,提高程序执行的性能和效率。
  2. 移动语义:将一个左值(或左值引用)转化为右值引用,避免复制的同时,也能够使得一个左值作为实参传递给只接受右值引用参数的函数。

在对 std::move 函数进行逆向之前,我们首先通过一个简单地实例程序来对该函数的功能和特性有一个直观的认识:

class MyClass
{
private:
	int* __value;    // 模拟类内部的资源

public:
	// 无参构造函数
	MyClass() 
	{
		std::cout << "调用了无参构造函数" << std::endl;
	};

	// 有参构造函数
	MyClass(int value) : __value(new int(value))    // 会新开辟内存并复制资源
	{
		std::cout << "调用了有参构造函数:MyClass(int value)," << 
			"MyClass 类中的资源 __value 指针的值为:" << __value << std::endl << std::endl;
	}

	// 析构函数
	~MyClass()
	{
		if (__value)    // 释放资源内存
		{
			delete __value;
		}
		std::cout << "调用了析构函数:~MyClass()" << std::endl;
	}

	// 拷贝构造函数
	MyClass(const MyClass& other) : __value(new int(*other.__value))    // 会新开辟内存并复制资源
	{
		std::cout << "调用了拷贝构造函数:MyClass(MyClass& other)," <<
			"拷贝类中的资源 __value 指针的值为:" << __value << std::endl << std::endl;
	}

	// 赋值运算符重载
	MyClass& operator=(const MyClass& other)    // 会新开辟内存并复制资源
	{
		if (&other == this)    // 自我赋值直接返回,避免赋值开销
		{
			return *this;
		}
		if (__value)    // 如果被赋值对象存在资源,则进行释放
		{
			delete __value;
		}
		__value = new int(*other.__value);    // 进行资源赋值

		std::cout << "调用了重载后的赋值运算符:MyClass& operator=(MyClass& other)," <<
			"被赋值类中的资源 __value 指针的值为:" << __value << std::endl << std::endl;

		return *this;
	}

	// 移动构造函数
	MyClass(MyClass&& other) noexcept : __value(other.__value)
	{
		if (other.__value)
		{
			other.__value = nullptr;    // 转移资源所有权
		}

		std::cout << "调用了移动构造函数:MyClass(MyClass&& other)," <<
			"被移动赋值类中的资源 __value 指针的值为:" << __value << std::endl << std::endl;
	}
};

在上面的类中,为了演示 std::move 的功能,我们分别添加了无参构造函数、有参构造函数、析构函数、拷贝构造函数、赋值运算符重载和移动构造函数。

首先我们来调用有参构造函数、拷贝构造函数以及演示赋值运算符重载:

// 调用有参构造函数,会新开辟内存并复制资源
MyClass myClass1(10);

// 调用拷贝构造函数,会新开辟内存并复制资源
MyClass myClass2(myClass1);

// 调用无参构造函数,并对构造对象进行赋值,会新开辟内存并复制资源
MyClass myClass3;
myClass3 = myClass1;    // MyClass myClass3 = myClass1; 该语句会调用拷贝构造函数

运行结果如下:

我们可以明显的看出,有参构造函数、拷贝构造函数以及赋值运算符,都会申请内存并将原对象拷贝,产生性能上的开销,然后我们添加移动构造函数进行对比:

// 调用移动构造函数,转移资源的所有权,不会开辟新内存和复制资源
MyClass myClass4(std::move(myClass1));
//MyClass myClass4 = std::move(myClass1);

运行结果如下:

在移动构造函数中,我们实现了对原类对象所有权的转移,并没有出现开辟内存和复制资源的情况出现,提高了程序执行的性能,再者,在我们的移动构造函数中,形参为右值引用,而在实例中,我们通过 std::move 函数将左值 myClass1 传递给了只接受右值引用的移动构造函数。

二、左值、右值、左值引用和右值引用

在 C++ 中,左值是可以被取地址的表达式,它具有变量名,且在内存中具有持久性;而右值是临时的、不可取地址的表达式,表达式结束后内存会被回收,该右值也不复存在;而左值引用和右值引用是分别为左值和右值取了别名。

例如:

int x = 10;

在这个表达式中,x 为左值,而 10 为右值,表达式结束后 x 依然存在,而右值 10 是临时的。

我们继续来扩展代码:

int x = 10;
int& y = x;    // 把 x 的地址传给了左值引用 y
int&& z = std::move(x);    // 把 x 的地址转化为右值传给了右值引用 z

那么现在问题就出现了:

  1. x、y 和 z 分别是左值还是右值?
  2. x、y 和 z 有区别吗?

首先我们来看第一个问题,粗略一看,大家可能会以为 x 会被当做左值传递,y 会被当做左值引用传递,而 z 会被当做右值引用传递,那是不是真的是这样子呢?我们来测试一下就知道了!

为了清楚地得看出他们分别是左值还是右值,我们添加两个左右值形参函数,并进行打印测试:

void process(int& i) {
    std::cout << "Lvalue reference to " << i << std::endl;
}


void process(int&& i) {
    std::cout << "Rvalue reference to " << i << std::endl;
}

process(x);
process(y);
process(z);
process(10);

执行结果如下:

第一个、第二个和第四个结果都在意料之中,但是第三个结果就令人诧异了,明明我们定义的是一个右值引用变量,怎么传递给形参里面会被当做左值引用来传递呢?

为了搞清楚原理,我们可能有必要去深究一下底层的汇编代码,看看到底发生了什么事情!

我们知道 process(int i) 函数是不能和 process(int& i)process(int&& i) 共存的,因为共同存在同一个命名空间之内的话,编译器分不清你是要进行值传递还是进行引用传递,从而产生错误:有多个重载函数实例与参数列表匹配,比如 process(10),既可以进行值传递调用 process(int i) 函数,也可以进行右值引用传递,调用 process(int&& i) 函数,那我们需要对两种情况进行分开讨论。

首先我们来讨论 x、y 、z 和 10 进行值传递的时候,汇编层面发生了什么操作:
应用层代码

void process(int i) {
    std::cout << "non reference to " << i << std::endl;
}
process(x);
process(y);
process(z);
process(10);

汇编层代码

    process(x);
00007FF7CA0E6D40  mov         ecx,dword ptr [x]  -> [x]=0xA,对 x 的值进行了拷贝
00007FF7CA0E6D43  call        process (07FF7CA0E1546h)  
    process(y);
00007FF7CA0E6D48  mov         rax,qword ptr [y]  -> [y]=0x0000005CB58FFAB4,这个是 x 的地址
00007FF7CA0E6D4C  mov         ecx,dword ptr [rax]  -> 通过 x 的地址得到 x 值并对其进行拷贝
00007FF7CA0E6D4E  call        process (07FF7CA0E1546h)  
    process(z);
00007FF7CA0E6D53  mov         rax,qword ptr [z]  -> [z]=0x0000005CB58FFAB4,这个是 x 的地址   
00007FF7CA0E6D57  mov         ecx,dword ptr [rax]  -> 通过 x 的地址得到 x 值并对其进行拷贝  
00007FF7CA0E6D59  call        process (07FF7CA0E1546h)  
    process(10);
00007FF7CA0E6D5E  mov         ecx,0Ah    -> 直接对参数 10 进行了拷贝  
00007FF7CA0E6D63  call        process (07FF7CA0E1546h) 

通过分析我们可以得出两个结论:

  1. 不管是 x、y、z 还是 10,在进行值传递的时候,底层都会发生拷贝的情况
  2. 细心地朋友会发现,左值 x 在进行值传递的时候是直接对 x 进行拷贝的,右值 10 在进行值传递的时候是直接对 10 进行拷贝的,但是左值引用 y 和右值引用 z 中保存的是 x 的地址,在进行值传递的时候,先得到 x 的地址,然后再通过 x 的地址得到 x 的值,最后对 x 的值进行拷贝

然后我们继续看当 x、y、z 和 10 被作为引用传递的时候会发生什么情况:
应用层代码

void process(int& i) {
    std::cout << "Lvalue reference to " << i << std::endl;
}


void process(int&& i) {
    std::cout << "Rvalue reference to " << i << std::endl;
}
process(x);
process(y);
process(z);
process(10);

汇编层代码

    process(x);
00007FF63DAA24B0  lea         rcx,[x]  -> rax=0x000000B3478FFC14,直接取变量 x 的地址作为函数参数   
00007FF63DAA24B4  call        move<int> (07FF63DAA154Bh)  
    process(y);
00007FF63DAA24B9  mov         rcx,qword ptr [y]  -> [y]=0x000000B3478FFC14,其实也是直接取的变量 x 的地址作为函数参数
00007FF63DAA24BD  call        move<int> (07FF63DAA154Bh)  
    process(z);
00007FF63DAA24C2  mov         rcx,qword ptr [z]  -> [z]=0x000000B3478FFC14,其实也是直接取的变量 x 的地址作为函数参数
00007FF63DAA24C6  call        move<int> (07FF63DAA154Bh) 
    process(10);
00007FF63DAA24CB  mov         dword ptr [rbp+124h],0Ah  -> 实参 10 作为一个右值引用存放在了栈上作为函数参数,这个存储位置是临时的,存放一个右值 10 
00007FF63DAA24D5  lea         rcx,[rbp+124h]  -> 将栈上这个临时存放的右值 10 的栈地址传递给函数作为函数参数  
00007FF63DAA24DC  call        process (07FF63DAA1550h) 

通过对以上代码进行分析,我们得出两个结论:

  1. x、y、z 都作为左值引用传递,而 10 作为右值引用传递
  2. 不管是左值引用传递还是右值引用传递,在汇编层面都没有发生值的拷贝操作,提高了程序执行的性能

到这里我们还是没有解决右值引用 z 为什么会被当做左值引用传递的问题,继续分析,我们分别对它们存储的值以及地址进行打印:

int x = 10;
int& y = x;    // 把 x 的地址传给了左值引用 y
int&& z = std::move(x);    // 把 x 的地址转化为右值传给了右值引用 z
printf("x: %d\r\n", x);  
printf("&x: %p\r\n", &x);
printf("y: %d\r\n", y);
printf("&y: %p\r\n", &y);
printf("z: %d\r\n", z);    
printf("&z: %p\r\n", &z);

运行结果如下:

通过这个结果可以看出,在应用层看来,x、y 和 z 是完全等价的,我们知道一个变量是由内存地址和其对应地址中的值组成的,而 x、y 和 z 表示的内存地址和其对应地址中的值又完全相等,虽然左值 x 和 左值引用 y、右值引用 z 在汇编层面的操作有所不同(汇编层面左值引用 y 和右值引用 z 保存的其实是 x 的地址),但是应用层屏蔽了这一差异,所以才会有引用是给变量取了一个别名的说法,其实代表的是同一个变量,都可以被取地址、都有变量名且在内存中具有持久性,只要 x 不消失,y 和 z 就不会消失,所以这就解释了为什么我们定义的右值引用 z 会被当做左值引用传递给函数。

到这里其实第二个问题:x、y 和 z 有区别吗?,也迎刃而解,x 和 y、z 在汇编层面的操作是有差异,但是在应用层面屏蔽了这一差异,所以应用层 x、y、z 完全等价,连语义都是等价的,x、y、z 作为参数传递的时候都会被当做左值。

那么如果我们有需求一定要将 x、y、z 都被当做右值传递,调用右值引用作为参数的重载函数实例应该怎么做呢?我们可以如下操作:

int x = 10;
int& y = x;    // 把 x 的地址传给了左值引用 y
int&& z = std::move(x);    // 把 x 的地址转化为右值传给了右值引用 z

process(std::move(x));
process(std::move(y));
process(std::move(z));
process(10);

运行结果如下:

三、逆向 std::move 函数

通过上面的演示,我们知道了 std::move 函数能够将左值、左值引用转为右值引用,那么底层到底是如何实现的呢?

我们揪出 VS 编译器中底层对 std::move 函数的定义:

template <class _Ty>
struct remove_reference {
    using type = _Ty;    // 如果参数是非引用类型,则直接返回非引用类型本身 _Ty
    using _Const_thru_ref_type = const _Ty;
};

template <class _Ty>
struct remove_reference<_Ty&> {
    using type = _Ty;    // 如果参数是左值引用,则移除左值引用,返回其底层的非引用类型(参数为 int& 则返回 int)
    using _Const_thru_ref_type = const _Ty&;
};

template <class _Ty>
struct remove_reference<_Ty&&> {
    using type = _Ty;    // 如果参数是右值引用,则移除右值引用,返回其底层的非引用类型(参数为 int&& 也返回 int)
    using _Const_thru_ref_type = const _Ty&&;
};

template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;    // 移除左值或右值引用,返回其底层的非引用类型

template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
  • _NODISCARD:这是一个属性,用于告诉编译器,这个函数的返回值不应该被忽略。如果调用者忽略了返回值,编译器会发出警告。
  • constexpr:表示这个函数可以在编译时计算,并且可以用于常量表达式中。
  • noexcept:表示这个函数保证不会抛出异常。
  • remove_reference_t<_Ty>:通过代码可知,如果 _Ty 为 int& 或 int&&,则 remove_reference_t<_Ty> 等价于 int

其他的代码我相信读者都能看懂,值得一提的是,std::move 函数功能实现的精髓,正是我们接下来要介绍的两个主角:结构体模板 remove_reference_t 和 万能引用 &&

首先我们来看结构体模板 remove_reference_t,它等价于 typename remove_reference<_Ty>::type,而关键字 typename 是告诉编译器 remove_reference<_Ty>::type 是一个依赖于模板参数 _Ty 的类型,当我们有一个嵌套从属类型(即依赖于模板参数的类型)时,我们需要使用关键字 typename 来明确告诉编译器。

那么我们剔除关键字 typename 来继续研究 remove_reference<_Ty>::type,它对应有三个结构体模板,分别对应 _Ty 为非引用类型(原始类型)、左值引用类型和右值引用类型,对应的功能如下:

  1. remove_reference:当我们传入的 _Ty 为非引用类型(原始类型),例如 int,则返回非引用类型本身 int
  2. remove_reference<_Ty&>:当我们传入的 _Ty 为左值引用类型,例如 int&,则返回其底层非引用类型 int
  3. remove_reference<_Ty&&>:当我们传入的 _Ty 为右值引用类型,例如 int&&,则返回其底层非引用类型 int

所以,remove_reference_t<_Ty> 的功能其实是移除左值或右值引用,返回其底层的非引用类型,我们可以看如下示例:

 // 非引用类型(值传递)、左值引用和右值引用
remove_reference_t<int> d = 40;
remove_reference_t<int&> e = 50;
remove_reference_t<int&&> f = 60;
std::cout << "remove_reference_t<int>:d=" << d << " e=" << e << " f=" << f << std::endl;

remove_reference<int>::type a = 10;    // 非引用类型使用版本
remove_reference<int&>::type b = 20;    // 左值引用使用左值特例版本
remove_reference<int&&>::type c = 30;    // 右值引用使用右值特例版本
std::cout << "remove_reference<int>::type:a=" << a << " b=" << b << " c=" << c << std::endl;

运行结果如下:

可以看到 remove_reference_t<_Ty> 确实移除了 _Ty 类型的左右值引用,所以当 _Tyint&int&& 的时候 return static_cast<remove_reference_t<_Ty>&&>(_Arg); 语句其实等价于 return static_cast<int&&>(_Arg),即都不管 _Ty 是左值引用(int&)还是右值引用(int&&)类型,都返回其底层的非引用类型(int)。

那既然不管我们传入结构体模板的是左值引用还是右值引用都返回其底层的非引用类型,那么为什么只有 move(_Ty&& _Arg) 一个右值引用的特例版本呢,翻遍源代码也没有找到 std::move 的左值引用特例版本。

接下来就轮到我们万能引用 T&& 发力了!在模板中,万能引用 T&& 既能接受左值引用,又能接受右值引用,这就是 C++ 11 引入的新特性--引用折叠。

当我们将引用类型传递到模板参数或涉及类型推倒的场景时,就可能会出现引用嵌套的情况,比如我们刚刚讨论的,当 _Ty 的类型为 int&& 时,传入到结构体模板中的 _Ty&& 就变成了 int&&&&,那这种情况就是通过引用折叠来处理的,折叠的规则如下:

  1. T& + & = T&
  2. T& + && = T&
  3. T&& + & = T&
  4. T&& + && = T&&

其实记忆起来也非常方便,只有右值引用(&&)传递到万能引用 T&& 中才会折叠为右值引用,其他的情况都会别折叠为左值引用!

所以 std::move 既能接受左值引用,也能接受右值引用,且统一返回左值引用!

标签:逆向,右值,int,move,左值,C++,引用,&&
From: https://www.cnblogs.com/lostin9772/p/18521229

相关文章

  • C++17 折叠表达式
    折叠表达式C++17之前处理参数包C++17折叠表达式的出现让我们不必再用递归实例化模板的方式来处理参数包。在C++17之前,实现必须分成两个部分:终止条件递归条件这样的实现不仅写起来麻烦,对C++编译器来说也很难处理。C++17折叠表达式能显著的减少程序员和编译器的工作量......
  • 《 C++ 修炼全景指南:十八 》缓存系统的技术奥秘:LRU 原理、代码实现与未来趋势
    摘要本篇博客深入解析了LRU(LeastRecentlyUsed)缓存机制,包括其核心原理、代码实现、优化策略和实际应用等方面。通过结合双向链表与哈希表,LRU缓存实现了高效的数据插入、查找与删除操作。文章还对LRU的优化方案进行了详细讨论,包括在不同应用场景下的性能提升、内存优化......
  • C++ ──── 红黑树的实现
    目录1.红黑树的概念2.红黑树的性质3. 红黑树节点的定义4.红黑树的插入操作 5. 红黑树的验证6.红黑树的删除7. 红黑树与AVL树的比较8. 红黑树的应用总代码:1.红黑树的概念        红黑树,是一种二叉搜索树,但在每个结点上增加一个存储位表示结......
  • C/C++ 知识点:重载、覆盖和隐藏
    文章目录一、重载、覆盖和隐藏1、重载(overload)1.1、定义1.2、使用`const`关键字1.3、实现原理2、覆盖(override)2.1、定义2.2、覆盖的条件2.3、`override`关键字3、隐藏(hiding)3.1、定义3.2、隐藏的条件3.3、隐藏与覆盖的区别3.4、示例前言:在C++中多态性是一个......
  • c++:vector
    一、vector是什么?1.1vector的介绍vector是表示可变大小数组的序列容器。 就像数组一样,vector也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素进行访问,和数组一样高效。但是又不像数组,它的大小是可以动态改变的,而且它的大小会被容器自动处理。本质......
  • C++详细笔记(五)
    1.类和对象1.1运算符重载(补)1.运算符重载中,参数顺序和操作数顺序是一致的。2.一般成员函数重载为成员函数,输入流和输出流重载为全局函数。3.由1和2只正常的成员函数默认第一个参数为this指针而重载中参数顺序和操作数顺序要一致,则导致使用时为d<<cout;(不符合使用习惯正常为......
  • CodeForces Dora and C++ (2007C) 题解
    CodeForcesDoraandC++(2007C)题解题意给一个数组\(c_{1...n}\),定义数组的\(range\)是最大值减最小值,即极差。给出两个值\(a\)和\(b\),每步操作中,可给数组中任一一个数增大\(a\)或增大\(b\)。问任意步操作后(可以是\(0\)步),极差的最小值。思路(要直接看答案可以跳......
  • 07C++选择结构(1)——教学
    一、基础知识1、关系运算符因为我们要对条件进行判断,必然会用到关系运算符:名称大于大于等于小于小于等于等于不等于符号>>=<<===!=关系表达式的值是一个逻辑值,即“真”(True)或“假”(False)。如果条件成立,其值为“真”;如果条件不成立,其值为“假”。2、逻......
  • C++写一个简单的JSON解析
    参考用C++编写一个简易的JSON解析器(1)写一个动态类型-知乎欢迎测试和反馈bug首先,json包含string,number,integer,object,array,bool,null这些类型对于object映射,使用map,对于array使用vector我们定义一个类Val用来存储,使用variant来存储具体的值std::variant-cppreferen......