C++转移语义

参考https://www.ibm.com/developerworks/cn/aix/library/1307_lisl_c11/index.html

转移语义的目的

右值引用(RValue Reference)是C++11引入的新特性,它是实现了转移语义(move sementics)和精确传递(Perfect Forwarding),主要目的是:

  • 消除两个对象相互交互时不必要的对象拷贝,节省运算存储资源,提高运行效率
  • 能够更简洁明确地定义泛型函数。

左值与右值的定义

C++(C)的表达式和变量要么是左值,要么是右值

  • 左值的定义就是非临时对象。在下边的多条代码中可以被使用的对象。所有的变量都是左值。能够改变就是特征
  • 右值是临时的对象,他们只在当前的语句中有效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//例子
//1. 简单赋值语句. i是左值,1是右值
int i = 0;

//2. 右值也可以出现在赋值表达式的左边,但是不能作为赋值的对象。因为右值只在当前语句有效,赋值没有意义。
((i>0)? i:j) = 1;

//3. 表达式, i是左值,而i+1是右值
i = i+1;

//4. 在c++11之前右值是不能被引用的,最大的限度就是使用常量来绑定一个右值引用
const int a = 1; //这里a是一个常量
int b[a]; //正确,a是常量
const int &a = 1; //这里a是一个右值引用,a是一个常量,加了const之后a这个变量不允许修改
int b[a]; //可能错误,编译器可能会认为这里a是一个引用变量,只是它不能改而已。在不同的编译器上可能会出现不一样的状态。

int &a = 1; //错误使用右值引用,引用的初始化必须是左值。

//5. 实际上在下边的情况下,右值是可以被修改的:
T().set().get();
//T()构造函数创建一个右值,set()修改赋值,get()取出变量的值。

既然右值可以被修改,那么就可以实现右值引用。右值引用能够方便地解决工程实际问题。

C++11中 左值声明符号为&,右值为&&

转移语义

右值引用是用来支持转移语义的。转移语义可以将资源(堆,系统对象等)从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高c++应用程序的性能。临时对象的维护(创建和销毁)对性能有严重的影响

转移语义与拷贝语义是相对的。类比当我们将一个文件拷贝到另一个地址时,会比剪切慢很多。

通过转移语义,临时对象的资源能够转移到其他的对象里。也就是将右值转移成了一个左值,而没有使用拷贝构造函数或者拷贝赋值运算符。

C++11之前的拷贝构造函数和拷贝赋值运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class MyString { 
private:
char* _data;
size_t _len;
void _init_data(const char *s) {
_data = new char[_len+1];
memcpy(_data, s, _len);
_data[_len] = '\0';
}
public:
MyString() {
_data = NULL;
_len = 0;
}

MyString(const char* p) {
_len = strlen (p);
_init_data(p);
}

MyString(const MyString& str) {
_len = str._len;
_init_data(str._data);
std::cout << "Copy Constructor is called! source: " << str._data << std::endl;
}

MyString& operator=(const MyString& str) {
if (this != &str) {
_len = str._len;
_init_data(str._data);
}
std::cout << "Copy Assignment is called! source: " << str._data << std::endl;
return *this;
}

virtual ~MyString() {
if (_data) free(_data);
}
};

int main() {
MyString a;
a = MyString("Hello");
//MyString a;调用了default构造函数
//MyString("Hello"); 调用了构造函数
//a = MyString("Hello"); 调用了拷贝赋值运算符
//离开右值作用区间,调用析构函数
std::vector<MyString> vec;
vec.push_back(MyString("World"));
}

MyString(“Hello”)和MyString(“World”)都使用了构造函数来生成临时对象,也就是右值。在右值语句结束之后,临时对象就会被销毁。这个过程中,如果能直接将临时对象申请的资源转换到左值对象的话,就可以节省资源,并且节省资源申请和释放的时间

转移构造函数

1
2
3
4
5
6
7
8
MyString(MyString&& str) { 
std::cout << "Move Constructor is called! source: " << str._data << std::endl;
_len = str._len;
_data = str._data;
//修改右值参数的链接和标记资源
str._len = 0;
str._data = NULL;
}

它和拷贝构造函数有点类似,但有几点需要注意:

  1. 参数符号必须是右值&&
  2. 右值参数不可以是常量!因为我们要修改右值(右值不是常量哦,常量是不能够修改的变量)
  3. 右值参数的资源链接和标记必须修改。否则,右值的析构函数就会释放资源。转移到新对象的资源也就无效了。

转移赋值操作符

1
2
3
4
5
6
7
8
9
10
MyString& operator=(MyString&& str) { 
std::cout << "Move Assignment is called! source: " << str._data << std::endl;
if (this != &str) {
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL;
}
return *this;
}

同样需要注意以上三点。

使用了转移语义之后,编译器会区分左值和右值。运行结果如下

1
2
Move Assignment is called! source: Hello 
Move Constructor is called! source: World

在设计和实现类的时候,对于需要动态申请大量资源的类,应该设计转移构造函数和转移赋值函数,以提高应用程序的效率

标准库函数 std::move

既然编译器只对优质引用才能调用转移构造函数和转移赋值函数,而所有命名对象都只能是左值引用。如果已知一个左值对象不想再被使用二线对他调用转移构造函数和转移赋值运算符,也就是将一个左值引用当做一个右值引用来使用。std::move()就提供了这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
void ProcessValue(int& i) { 
std::cout << "LValue processed: " << i << std::endl;
}

void ProcessValue(int&& i) {
std::cout << "RValue processed: " << i << std::endl;
}

int main() {
int a = 0;
ProcessValue(a);
ProcessValue(std::move(a));
}

运行结果是:

1
2
LValue processed: 0 
RValue processed: 0

如果要实现swap的话。std::move会极大提升函数性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//一般swap函数
template <class T> swap(T& a, T& b) //都是左值参数
{
T tmp(a); // copy a to tmp 调用构造函数
a = b; // copy b to a 调用拷贝赋值
b = tmp; // copy tmp to b 调用拷贝赋值
//析构T
}

//使用move函数
template <class T> swap(T& a, T& b)
{
T tmp(std::move(a)); // move a to tmp , 调用转移构造函数, tmp占用的是a的内存空间
a = std::move(b); // move b to a , 调用转移赋值,a占用的是b的空间,在执行这条语句前,a只是的变量名
b = std::move(tmp); // move tmp to b 调用转移赋值,b占用tmp的内存空间
}

注意std::move只是将参数转换为了右值,比如std::move(a)并没有将a丢掉,只是返回了右值。

1
2
3
4
5
6
7
8
9
10
11
12
//简单的move实现
template <typename T>
decltype(auto) move (T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType> (param);
}

作者:李则言
链接:https://www.zhihu.com/question/50652989/answer/122044415
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

真正的移动操作是在移动构造函数或者移动赋值操作符中发生的。

1
2
T (T&& rhs);
T& operator= (T&& rhs);

精确传递Perfect Forwarding

Perfect Forwaring适用于将一组参数原封不动的传递给另一个函数。”原封不动“不仅仅是参数的值不变,在C++中除了参数值之外,还有以下两组属性:

  • 左值、右值
  • const, non-const
    精确传递就是在参数传递过程中,这些属性和参数值都不能改变。这在泛型函数中,这样的需求非常普遍。

举个例子:

1
2
3
4
5
6
7
8
template <typename T> 
void forward_value(const T& val) {
process_value(val);
}
template <typename T>
void forward_value(T& val) {
process_value(val);
}

forward_value为每一个参数必须重载两种类型,T&和const T&.对于编写函数重载的次数是和参数类型成正比的关系。

使用右值引用:

1
2
3
4
template <typename T>
void forward_value(T&& val) {
process_value(val);
}

这里只需要定义一次,接受一个右值引用的参数,就能将左右的参数类型原封不动的传递给目标函数。可以简洁的调用不同类型的参数

1
2
3
4
5
int a = 0; 
const int &b = 1;
forward_value(a); // int&
forward_value(b); // const int&
forward_value(2); // int&&

推导规则
C++11中定义T&&的推导规则是:
右值实参为右值引用,左值实参仍然为左值引用 一句话就是参数的属性不变。