移动语义
参考:
- https://www.jianshu.com/p/d19fc8447eaa
- https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
- https://www.jianshu.com/p/b90d1091a4ff
- https://www.cnblogs.com/catch/p/3507883.html
实验环境:os: centos8.5 / kernel: 4.18.0 / gcc: 8.5.0 / arch: x86-64
1. 左值(lvalue)与右值(rvalue)
1.1 概念
c++ 中将所有值分为了左值和右值,左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束后就不再存在的临时对象。
左值例如:
int a;
Class obj;
字面量等
右值例如:
Class func(void) 函数返回的临时对象
func(Class obj) 调用函数传递的临时对象
int a = 1, 1 是右值等
区分左右值的一个直觉性方法是看能不能对值取地址,能取地址的为左值,不能取地址的为右值(不能取地址并不意味着不存在于内存中,只是短暂存在于内存中)。同时,在代码中左值都是具名化的,右值没有名称。
需要明白的是,从汇编的角度看,无论是左值还是右值在内存中都是一个地址(当然,也可以是一个立即数),左右值的概念是编译器的行为,而不是对象本身携带的属性。
1.2 左值引用与右值引用
- 左值引用是对左值对象取的一个别名,使用左值引用就像在使用左值本身,将一个左值传递给左值引用基本没有任何开销(无需构造一个新对象,也不需要调用任何拷贝构造函数)。
- 既然右值对象没有名字,那么有没有方法得到右值对象而不造成任何开销呢,于是就有了右值引用。右值引用接收右值对象,将其变为一个左值(具有名字,可以取地址),延长了右值对象的生命。
但是有规定,左值引用只能绑定到左值,右值引用只能绑定右值:
int a = 1;
int& b = 2; // 编译错误,左值引用绑定到了右值
int&& c = a; // 编译错误,右值引用绑定到了左值
1.3 常量左值引用
常量左值引用是在 c++11 前就出现的特性:既可以接收左值(包括左值引用),又可以接收右值(包括右值引用),这个特性的用处是,在拷贝构造函数中,可以接收一个临时对象:
定义拷贝构造函数:
Class(const Class& obj) { ... }
拷贝构造函数既可以接收左值:
Class a;
Class b(a);
也可以接收右值:
Class a(Class());
2. 引用折叠与通用引用
2.1 引用折叠
如果间接创建了一个引用的引用,那么就会发生引用折叠:
定义函数:
template <class T>
void func(T& obj) {}
调用:
int a = 1;
int& b = a;
func(b);
其中,func() 函数形参为模板引用类型,而调用 func() 函数时传递的是一个左值引用类型的实参,那么func()被实例化时,就会出现两个引用的情况(非右值引用):int& & obj。
为此,c++11 定义了引用折叠规则(T 为一个具体类型):
- T& &、T& &&、T&& & 都折叠成 T&,即折叠成左值引用类型
- T&& && 折叠为 T&&,即折叠成右值引用类型
必须要明白的是,T 被推导的类型与 obj 对象被推导的类型,这里说的引用折叠规则是针对的 obj 对象最终的左右值类型,对于上面这段代码:
- T 被推导成 int& 即左值引用类型
- obj 推导(折叠)成左值引用类型
如果函数调用变为 int a = 1; func(a);,那么:
- T 被推导成 int 即左值类型
- obj 推导成左值引用类型
如果函数调用变为 func(1);,那么,编译会报错,为什么呢:
- T 被推导成 int 即左值类型
- func() 形参实例化为左值引用,无法接收一个右值
2.2 通用引用
通用引用与常量左值引用一样,既可以接收左值,也可以接收右值:
通用引用:
template<class T>
void func(T&& param) {}
接收左值:
int a = 0;
func(a);
接收右值:
func(1);
对于通用引用,c++11 对于模板类型 T 的自动推导又定义了如下规则:
- 实参为左值(包括左值引用)时,T 被推导为左值引用 T&
- 实参为右值时,T 被推导为原始类型 T
那么,对于上述的 func() 函数,接收左值时,T 被推导为 T&,形参 T& && param 会发生引用折叠,实例化后为 void func(int& param),param 对象最终变为左值引用类型。
注意通用引用的定义范围:
对于 vector::push_back() 函数接口:
void std::vector<T>::push_back(T&& val)
push_back() 的形参不是通用引用,因为在创建 std::vector 对象时已经指定了 T 的具体类型
3. std::move() 与 std::forward()
3.1 std::move()
std::move() 用于将左值转型为右值引用(如果值本身就是右值引用,则不做任何改变),函数实现为(MSVC 14.28.29333):
// FUNCTION TEMPLATE move
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);
}
其中,remove_reference_t<_Ty> 用于去除 _Ty 的引用属性(下文描述),得到原本类型。且 move(_Ty&& _Arg) 函数的形参是一个万能引用类型。
如果调用为 Class a; std::move(a);,那么:
- _Ty 被推导为左值引用 Class&
- 形参 Class& && _Arg 被折叠成左值引用 Class&
- static_cast<remove_reference_t<_Ty>&&>(_Arg) 去掉引用,变为 static_cast<Class&&>(_Arg)
- _Arg 对象为左值引用,待转型对象为右值引用,即可以实施转型,并返回右值引用
如果调用为 std::move(Class a);,那么:
- _Ty 被推导为右值引用 Class
- 形参实例化为 Class&& _Arg
- static_cast<remove_reference_t<_Ty>&&>(_Arg) 去掉引用,变为 static_cast<Class&&>(_Arg)
- _Arg 对象为右值引用,待转型对象也为右值引用,什么都不用干,直接返回右值引用
如果实参是一个 const 类型,调用 std::move() 又会发生什么呢,如下代码:
#include <utility>
#include <stdio.h>
void func(int&& val) {
printf("rvalue ref called\n");
}
void func(int& val) {
printf("lvalue ref called\n");
}
void func(const int& val) {
printf("const lvalue ref called\n");
}
void func(const int&& val) {
printf("const rvalue ref called\n");
}
int main() {
const int a = 1;
func(std::move(a));
return 0;
}
使用 g++ test.cpp -o mytest --std=c++11 进行编译,运行得到:
可见,move() 语义也能施加到 const 类型的变量上,且 const 属性会得到保留。但是注意到最终调用的是常量右值引用的 func() 版本,这个看起来很奇葩,如果定义没有常量右值引用版本,那么最终会调用常量左值引用版本。
3.1.1 remove_reference_t
remove_reference_t 实际上是个类型别名(MSVC 14.28.29333):
// STRUCT TEMPLATE remove_reference
template <class _Ty>
struct remove_reference {
using type = _Ty;
using _Const_thru_ref_type = const _Ty;
};
template <class _Ty>
struct remove_reference<_Ty&> {
using type = _Ty;
using _Const_thru_ref_type = const _Ty&;
};
template <class _Ty>
struct remove_reference<_Ty&&> {
using type = _Ty;
using _Const_thru_ref_type = const _Ty&&;
};
template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;
其主体是三个接收不同左右值属性的模板推导类型。
注意这里有一个 c++ 语法特性,在 3 个结构体中,通过 using type = _Ty; 定义了模板 _Ty 的别名,外部代码可以通过 Class::type 的方式访问这个别名。类似 stl 特性萃取中的 T::value_type。
3.2 std::forward()
3.2.1 完美转发
所谓完美转发,即:
#include <utility>
#include <stdio.h>
void func(int&& val) {
printf("rvalue ref called\n");
}
void func(int& val) {
printf("lvalue ref called\n");
}
void func(const int& val) {
printf("const value called\n");
}
template <class T>
void forward(T&& val) {
func(std::forward<T>(val));
// func(val);
}
int main() {
const int a = 1;
int b = 1;
forward(a);
forward(b);
forward(1);
return 0;
}
上述代码中,my_forward() 的形参是个通用引用类型,即可以接收左值和右值。当接收左值的时候,val 实例化为左值引用,接收右值时,val 实例化为右值引用,且右值引用可以看作是左值。
如果调用重载函数 func() 为 func(val); 的方式,那么无论传给 my_forward() 的是左值还是右值,结果都是调用 func() 的左值引用版本。当以 func(std::forward
同时可以看到,std::forward 也会保持住 const 特性。
3.2.2 std::forward()
源码实现中有两个版本,分别接收左值和右值(MSVC 14.28.29333):
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(
remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept { // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
return static_cast<_Ty&&>(_Arg);
}
对于如下代码(左值调用):
函数定义:
template <class T>
void my_forward(T&& val) {
func(std::forward<T>(val));
}
左值函数调用:
int a = 1;
my_forward(a);
- my_forward() 形参为通用引用,T 推导为 int&,val 实例化(引用折叠)为左值引用类型
- 调用 std::forward 的左值引用版本,std::forward 实例化为 std::forward(int& _Arg) {return static_cast<int&>(_Arg)},即返回左值引用
对于如下代码(右值调用):
函数定义:
template <class T>
void my_forward(T&& val) {
func(std::forward<T>(val));
}
右值函数调用:
my_forward(1);
- my_forward() 形参为通用引用,T 推导为 int,val 实例化为右值引用类型
- 调用 std::forward 的左值引用版本(右值引用实际为左值),std::forward 实例化为 std::forward(int& _Arg) {return static_cast<int&&>(_Arg)},即返回右值引用
4. 类构造语义
4.1 拷贝构造
有如下代码:
#include <vector>
#include <stdio.h>
#include <string.h>
// 拷贝构造函数调用计数
static int copy_construct_count = 0;
class Base {
public:
Base(int _size) {
size = _size;
buf = new char[size];
}
~Base() {
delete[] buf;
}
Base(const Base& obj) {
size = obj.size;
buf = new char[size];
// 内存复制
memcpy(buf, obj.buf, size);
copy_construct_count++;
}
public:
char* buf = nullptr;
int size = 0;
};
int main() {
std::vector<Base> vec;
vec.reserve(100);
for (int i=0; i<100; ++i) {
vec.push_back(Base(100));
}
printf("count %d\n", copy_construct_count);
return 0;
}
使用 g++ test.cpp -o mytest --std=c++11
编译并执行:
可见,代码调用了 100 次拷贝构造函数。
我们知道,vector 存储元素是有自己的一片存储区域的。上述代码中,reserve() 函数预先分配了 100*sizeof(Base) 字节的内存。这里每插入一个 Base 对象,需要先构造一个临时对象,然后调用拷贝构造来构造 vector 管理的对象。
4.2 移动构造
有如下代码:
#include <vector>
#include <stdio.h>
#include <string.h>
// 拷贝构造函数调用计数
static int copy_construct_count = 0;
// 移动构造函数调用计数
static int move_construct_count = 0;
class Base {
public:
Base(int _size) {
size = _size;
buf = new char[size];
}
~Base() {
delete[] buf;
}
Base(const Base& obj) {
size = obj.size;
buf = new char[size];
// 拷贝资源
memcpy(buf, obj.buf, size);
copy_construct_count++;
}
Base(Base&& obj) {
// 移动资源
size = obj.size;
buf = obj.buf;
obj.size = 0;
obj.buf = nullptr;
move_construct_count++;
}
public:
char* buf = nullptr;
int size = 0;
};
int main() {
std::vector<Base> vec;
vec.reserve(100);
for (int i=0; i<100; ++i) {
vec.push_back(Base(100));
}
printf("copy: %d, move: %d\n", copy_construct_count, move_construct_count);
return 0;
}
使用 g++ test.cpp -o mytest --std=c++11
编译并执行:
可见,代码调用了 100 次移动构造函数,0 次拷贝构造函数。
4.3 效率分析
上述代码中,vector::push_back() 函数接口的定义(http://www.cplusplus.com/reference/vector/vector/push_back/):
void push_back (const value_type& val);
void push_back (value_type&& val);
即可以接收左值和右值,当我们传入左值的时候,调用左值引用版本,同时调用类的拷贝构造函数。当我们传入右值的时候,调用右值引用版本,同时调用类的移动构造函数。如果类没有定义移动构造函数,那么将调用拷贝构造函数(常量左值引用可以接收右值)。
- 拷贝构造是拷贝其它类的资源,移动构造是拿取其他类的资源作为自己的资源,其它类被移动构造后,那么这个类应当不会再被使用了。
- 一般移动构造发生在创建的临时对象身上,因为临时对象的资源在临时对象析构后就不再使用,所以拿过来比复制会更合理。同时,一些左值也可以通过 std::move() 变为右值被移动构造,但是必须注意,资源被移动后类不应该再被使用。
- 同样还有赋值运算符重载和移动赋值运算符重载,前者也是拷贝别人的资源,后者拿取别人的资源。但是跟前面的两个构造函数的一点区别是,类本身也是有自己的资源的,那么这个时候如何处理自身的资源呢?答案是取决于实现,要么丢弃,要么两个对象互换资源。
4.4 vector::emplace_back()接口
emplace_back() 接口能接收构造容器元素所需的参数,然后原地构造类对象。所谓原地构造,即 vector 预先申请了一块内存区域,在用户调用 emplace_back() 的时候,通过接口传递进来的参数,在预申请的内存块上调用 placement new 来调用类的构造函数构造出对象,省去了创建临时对象的过程,只需要调用一次构造函数即可。而 push_back() 插入一个元素需要调用一次构造函数,如果不是插入的右值,还需要调用一次拷贝构造。、
5. 总结
左值、右值、std::move()、std::forward()、完美转发等概念和函数的出现,是为了移动语义服务的。而移动语义的作用就是将一个临将死亡对象的资源转移过来,然后继续使用,转移过程中,无需发生拷贝动作。