C++11新特性之右值引用与移动


前六篇在这里:
C++11新特性之新类型与初始化: http://blog.guoyb.com/2016/06/18/cpp11-1
C++11新特性之类型推断与类型获取: http://blog.guoyb.com/2016/06/25/cpp11-2
C++11新特性之lambda: http://blog.guoyb.com/2016/06/30/cpp11-3
C++11新特性之容器相关特性: http://blog.guoyb.com/2016/07/09/cpp11-4
C++11新特性之智能指针: http://blog.guoyb.com/2016/08/02/cpp11-5
C++11新特性之Class: http://blog.guoyb.com/2016/08/14/cpp11-6


这是C++11新特性介绍的第七部分,涉及到左右值引用、移动构造、移动赋值、完美转发等。
不想看toy code的读者可以直接拉到文章最后看这部分的总结。

右值引用

右值是一个行将销毁的值,例如(i * 10)这种表达式的值。新标准中允许通过&&标识定义一个右值引用,将其绑定到一个右值上。但是,一个右值引用变量又是一个左值,因为它是一个变量了嘛。

1
2
3
4
5
6
7
8
9
10
11
std::cout<<"test rvalue reference:\n";
int j = 42;
int &lr = j;
//int &&rr = j; // Wrong. Can't bind a rvalue ref to a lvalue.
//int &lr2 = i * 42; // Wrong. Can't bind a lvalue ref to a rvalue.
const int &lr3 = j * 42;
int &&rr2 = j * 42;
//int &&rr3 = rr2; // Wrong. rr2 is a rvalue ref and rvalue ref is a lvalue.
int &lr4 = rr2;
std::cout<<j<<'\t'<<lr<<'\t'<<lr3<<'\t'<<rr2<<'\t'<<lr4<<std::endl;
std::cout<<"test rvalue ref done.\n"<<std::endl;

std::move

std::move函数的作用很简单,就是获得一个左值的右值引用,这样我们就找到了一种途径将一个右值引用绑定到一个左值上。

但是,使用std::move也意味着交出左值的控制权,之后就不能再使用这个左值了,因为使用std::move之后,无法对这个左值做任何保证。

1
2
3
4
5
6
7
8
std::cout<<"test std::move:\n";
std::string str5 = "asdf";
std::string &lr5 = str5;
std::string &&rr5 = std::move(str5);
rr5[0] = 'b';
lr5[1] = 'z';
std::cout<<rr5<<'\t'<<lr5<<'\t'<<str5<<std::endl;
std::cout<<"test std::move done.\n"<<std::endl;

移动构造

新标准中一些内置类型(如string)都实现了移动构造函数。所谓移动构造,就是接受一个右值引用,从而接受该右值引用所引用的对象,而没有实际的大块内存拷贝操作(可以想象成只拷贝了一个指针而不是整块的内存)。调用移动构造函数的关键是要传入一个相应的右值引用,这时上面提到的std::move函数就派上用场了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::cout<<"test move constructor:\n";
std::allocator<std::string> alloc;
size_t size = 5;
auto old_strs = alloc.allocate(size);
for(size_t i = 0; i < size; i++)
{
alloc.construct(old_strs + i, "abcde");
}
std::cout<<"old_strs[0]: "<<old_strs[0]<<std::endl;
auto new_strs = alloc.allocate(size);
for(size_t i = 0; i < size; i++)
{
alloc.construct(new_strs + i, std::move(*(old_strs + i)));
}
std::cout<<"new_strs[0]: "<<new_strs[0]<<std::endl;
std::cout<<"old_strs[0]: "<<old_strs[0]<<std::endl;
for(size_t i = 0; i < size; i++)
{
alloc.destroy(old_strs + i);
}
alloc.deallocate(old_strs, size);
std::cout<<"test move constructor done.\n"<<std::endl;

调用移动构造函数之后,右值引用所绑定的对象保证可析构可销毁的状态。

定义自己的移动构造函数

上面说到了,移动构造函数的关键是接受一个右值引用,窃取该对象的内容为己所用(不拷贝),并且保证被窃取的对象保持可析构可销毁的状态。那么,我们当然可以定义一个自己的移动构造函数。

一个整型数组的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class IntVec
{
public:
IntVec() = default;
IntVec(size_t capacity);
IntVec(IntVec &rhs);
IntVec(IntVec &&rhs) noexcept;
IntVec &operator=(IntVec &&rhs) & noexcept;
~IntVec();

int push_back(int val);
void print_info();

size_t capacity;
size_t size;
int *pointer;
};

其中各个函数的定义为:

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
IntVec::IntVec(IntVec &rhs)
{
this->capacity = rhs.capacity;
this->size = rhs.size;
this->pointer = new int[this->capacity];
for(size_t i = 0; i < size; i++)
this->pointer[i] = rhs.pointer[i];
std::cout<<"IntVect copy constructor.\n";
}

IntVec::IntVec(size_t capacity)
: capacity(capacity), size(0)
{
this->pointer = new int[capacity];
}

IntVec::IntVec(IntVec &&rhs) noexcept
: capacity(rhs.capacity), size(rhs.size), pointer(rhs.pointer)
{
rhs.pointer = nullptr;
rhs.capacity = rhs.size = 0;
std::cout<<"IntVect move constructor.\n";
}

IntVec &IntVec::operator=(IntVec &&rhs) & noexcept
{
if(this != &rhs)
{
if(this->pointer)
delete [] this->pointer;
this->pointer = rhs.pointer;
this->capacity = rhs.capacity;
this->size = rhs.size;
rhs.pointer = nullptr;
rhs.capacity = rhs.size = 0;
}
std::cout<<"IntVect move assign constructor.\n";
return *this;
}
IntVec::~IntVec()
{
if(this->pointer)
delete [] this->pointer;
}

push_back和print_info的定义就不赘述了。

可以看到,在移动构造函数里,只需要窃取指针及其状态,并将右值引用对象的状态重置,即可完成移动构造的操作。

同样的,我们还可以定义移动赋值运算。

值得注意的是,两个移动函数都添加了noexcept标识符。这也是C++11新标准中引入的,用于向标准库指明此函数不会抛出异常,以避免标准库在和我们定义的这个类进行交互时做一些不必要的工作。如果我们不承诺noexcept,那么当标准库容器扩展容量时,就不能调用移动构造函数来移动容器内的现存元素,而只能采取比较耗费资源的拷贝构造函数。

这一部分的测试代码如下:

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
std::cout<<"test custom move copy constructor/move assign operator.\n";
IntVec iv1(10);
for(size_t i = 0; i < 5; i++)
iv1.push_back(i);
std::cout<<"-------iv1:\n";
iv1.print_info();

IntVec iv2(std::move(iv1));
std::cout<<"-------iv2:\n";
iv2.print_info();
std::cout<<"-------iv1:\n";
iv1.print_info();

IntVec iv3 = iv2;
std::cout<<"-------iv3:\n";
iv3.print_info();
std::cout<<"-------iv2:\n";
iv2.print_info();

IntVec iv4(5);
std::cout<<"-------iv4:\n";
iv4.print_info();
iv4 = std::move(iv2);
std::cout<<"-------iv4:\n";
iv4.print_info();
std::cout<<"-------iv2:\n";
iv2.print_info();

std::cout<<"test custom move copy constructor/move assign operator done.\n"<<std::endl;

移动迭代器

新标准中提供了std::make_move_iterator函数用于从普通迭代器获得移动迭代器。对移动迭代器解引用将会获得对应的右值引用,从而方便的对整个容器进行移动操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
std::cout<<"test move iterator:\n";
auto new_strs2 = alloc.allocate(size);
std::uninitialized_copy(std::make_move_iterator(new_strs),
std::make_move_iterator(new_strs + size),
new_strs2);
std::cout<<"new_strs[0]: "<<new_strs[0]<<std::endl;
std::cout<<"new_strs2[0]: "<<new_strs2[0]<<std::endl;
for(size_t i = 0; i < size; i++)
{
alloc.destroy(new_strs + i);
}
alloc.deallocate(new_strs, size);
std::cout<<"test move iterator done.\n"<<std::endl;

引用折叠规则

当左右引用遇到模板参数的时候,需要用到引用折叠规则来获得最终的模板推断类型和形参类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
void vague_func(T&& val)
{

std::cout<<"val: "<<val<<std::endl;
T val2 = val;
val2++;
std::cout<<"val2: "<<val2<<'\t'<<"val: "<<val<<std::endl;
}

std::cout<<"test ref folding:\n";
int val = 2;
int &lref = val;
int &&rref = 2;
std::cout<<"-------with val:\n";
vague_func(2);
std::cout<<"-------with lref:\n";
vague_func(lref);
std::cout<<"-------with rref:\n";
vague_func(rref);
vague_func(std::move(val));
std::cout<<"test ref done.\n"<<std::endl;

在上述vague_func中,虽然val的类型是T&&,看上去是个右值引用,但是实际上也是可以接受左值引用的类型的。当传入一个左值时,如lref,编译器会推断T = int&而不是T = int。那么这时实际实例化的vague_func实际是:

void vague_func(int& && val)

根据引用折叠规则,除了T&& &&折叠为T&&之外的所有情况均折叠为T&,那么最终vague_func为:

void vague_func(int& val)

因此,vague_func也可以接受一个左值实参。这种引用折叠规则,也是std::move得以实现的基础,有兴趣的读者可以自行去了解下其实现,就一行代码^o^

但是,vague_func的模板类型推断规则,也造成了T类型的不确定(int还是int&?),这给后续的编码也带来了困难。

std::forward

在上述vague_func中,如果传入一个右值,但是val却是一个变量,也就是一个左值。那么如何保持原来实参的类型信息呢,这时需要用到std::forward。

std::forward(val)返回类型是T&&,这时,根据折叠规则,如果实参val是个左值,则返回T&;如果是右值,则返回T&&。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void f(int &&i)
{

std::cout<<i<<"\t i is a right ref.\n";
}

void g(int &i)
{

std::cout<<i<<"\t i is a left ref.\n";
}

template <typename F, typename T>
void forward_func(F f, T&& val)
{

f(std::forward<T>(val));
}

std::cout<<"test forward:\n";
forward_func(f, 5);
forward_func(g, rref);
forward_func(g, val);
std::cout<<"test forward done.\n"<<std::endl;

程序输出

程序的整体输出如下:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
test move constructor:
old_strs[0]: abcde
new_strs[0]: abcde
old_strs[0]:
test move constructor done.

test rvalue reference:
42 42 1764 1764 1764
test rvalue ref done.

test std::move:
bzdf bzdf bzdf
test std::move done.

test custom move copy constructor/move assign operator.
-------iv1:
capacity: 10
size: 5
pointer: 0x1523160
IntVect move constructor.
-------iv2:
capacity: 10
size: 5
pointer: 0x1523160
-------iv1:
capacity: 0
size: 0
pointer: nullptr
IntVect copy constructor.
-------iv3:
capacity: 10
size: 5
pointer: 0x1523190
-------iv2:
capacity: 10
size: 5
pointer: 0x1523160
-------iv4:
capacity: 5
size: 0
pointer: 0x15231c0
IntVect move assign constructor.
-------iv4:
capacity: 10
size: 5
pointer: 0x1523160
-------iv2:
capacity: 0
size: 0
pointer: nullptr
test custom move copy constructor/move assign operator done.

test move iterator:
new_strs[0]:
new_strs2[0]: abcde
test move iterator done.

test ref folding:
-------with val:
val: 2
val2: 3 val: 2
-------with lref:
val: 2
val2: 3 val: 3
-------with rref:
val: 2
val2: 3 val: 3
val: 3
val2: 4 val: 3
test ref done.

test forward:
5 i is a right ref.
3 i is a left ref.
3 i is a left ref.
test forward done.

总结

  1. 新标准中允许通过&&标识定义一个右值引用,将其绑定到一个右值上。
  2. std::move函数的作用是获得一个变量的右值引用。
  3. 移动构造,就是接受一个右值引用,从而接受(窃取)该右值引用所引用的对象,而没有实际的大块内存拷贝操作,并且保证被窃取后的对象可析构可销毁。
  4. 可以定义自己的移动构造函数以及移动赋值运算。
  5. noexcept用于向标准库指明此函数不会抛出异常。声明移动构造函数和移动赋值运算为noexcept以避免标准库在和我们定义的这个类进行交互时做一些不必要的工作。
  6. 新标准中提供了std::make_move_iterator函数用于从普通迭代器获得移动迭代器。对移动迭代器解引用将会获得对应的右值引用,从而方便的对整个容器进行移动操作。
  7. 引用折叠规则,除了T&& &&折叠为T&&之外的所有情况均折叠为T&,主要用于模板类型推断中。
  8. std::forward(val)用于保持实参的左右值信息。

完整代码详见move_and_forward.cpp

转载请注明出处: http://blog.guoyb.com/2016/08/20/cpp11-7/

欢迎使用微信扫描下方二维码,关注我的微信公众号TechTalking,技术·生活·思考:
后端技术小黑屋

评论