C++ 逆向之 move 函数
众所周知,在 C++ 11 后,增加了右值引用类型,那么函数参数传递一共有三种方式,分别是非引用类型传递(值传递)、左值引用传递和右值引用传递,其中值传递会对实参进行一份拷贝传递给函数,左值引用和右值引用则直接引用实参传递给函数,这就是它们最大的区别。
为什么要区分值传递和引用传递呢?对于一些小型的变量,或许没有感觉有太大区别,但是对于一些大型的对象(比如容器、类对象等),执行一个拷贝操作是非常消耗性能的一件事情,而且我们可能有这种需求,比如整个项目中希望某个对象只存在一个实例,出于以上需求,因此就有了 std::move
函数的用武之地。
一、std::move 函数的作用
std::move
是 C++ STL 中的一个函数模板,它的主要作用有两个:
- 所有权转移:将一个对象的资源(例如动态分配的内存、文件句柄等)转移给另一个对象,从而避免不必要的复制操作,提高程序执行的性能和效率。
- 移动语义:将一个左值(或左值引用)转化为右值引用,避免复制的同时,也能够使得一个左值作为实参传递给只接受右值引用参数的函数。
在对 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
那么现在问题就出现了:
- x、y 和 z 分别是左值、右值、左值引用还是右值引用?
- 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)
通过分析我们可以得出两个结论:
- 不管是 x、y、z 还是 10,在进行值传递的时候,底层都会发生拷贝的情况
- 细心地朋友会发现,左值 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)
通过对以上代码进行分析,我们得出两个结论:
- x、y、z 都作为左值引用传递,而 10 作为右值引用传递
- 不管是左值引用传递还是右值引用传递,在汇编层面都没有发生值的拷贝操作,提高了程序执行的性能
到这里我们还是没有解决右值引用 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 就不会消失,但是他们又不完全等价,至少在左值、右值、左值引用和右值引用的语义上并不相同。
其实,上面的代码中,x 是一个左值,10是一个右值,y 是一个左值引用,z 是一个右值引用,但是在进行函数参数传递的时候,x、y 和 z 在汇编层面都会被强制转为左值引用,因此 z 就会丢失右值引用的语义,所以这就解释了为什么我们定义的右值引用 z 会被当做左值引用传递给函数。
到这里其实第二个问题:x、y 和 z 有区别吗?
,也迎刃而解,x 和 y、z 在汇编层面的操作是有差异,但是在应用层面屏蔽了这一差异,所以应用层 x、y、z 在内存地址和值上是相等的,但是在语义上有所差别,所以我们如果需要以右值语义作为参数传递,就需要用到 std::move
函数。
例如,我们有需求一定要将 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
为非引用类型(原始类型)、左值引用类型和右值引用类型,对应的功能如下:
- remove_reference:当我们传入的
_Ty
为非引用类型(原始类型),例如int
,则返回非引用类型本身int
- remove_reference<_Ty&>:当我们传入的
_Ty
为左值引用类型,例如int&
,则返回其底层非引用类型int
- 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
类型的左右值引用,所以当 _Ty
为 int&
或 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&&&&
,那这种情况就是通过引用折叠
来处理的,折叠的规则如下:
- T& + & = T&
- T& + && = T&
- T&& + & = T&
- T&& + && = T&&
其实记忆起来也非常方便,只有右值引用(&&
)传递到万能引用 T&&
中才会折叠为右值引用,其他的情况都会别折叠为左值引用!。
所以 std::move
既能接受左值引用,也能接受右值引用,且统一返回左值引用!
为了证实我们的观点,我们也来手搓一个伪造的 std::move
函数:
// 伪造的 std::move() 函数
template <class T>
remove_reference_t<T>&& fake_move(T&& arg)
{
return static_cast<remove_reference_t<T>&&>(arg);
}
int x = 10;
// std::move 函数演示
std::cout << "原始的 std::move() 函数" << std::endl;
process(std::move(x));
process(std::move(20));
std::cout << std::endl;
std::cout << "从源代码中截取出来的 move() 函数" << std::endl;
process(move(x));
process(move(20));
std::cout << std::endl;
std::cout << "伪造的 fake_move() 函数" << std::endl;
process(fake_move(x));
process(fake_move(20));
std::cout << std::endl;
运行结果如下: