本文代码主要来源:LearnCPP 第十五章内容

引入:智能指针中的重复拷贝

发现简单的例子都被 Copy elision 优化掉了 qaq 从复杂一点的来吧。

C++中一个常用的操作是通过 new 函数来动态分配一个对象,然而,一个问题是容易忘记将分配的内存回收。就算在函数结束时调用了 delete 函数,也可能出现函数中间就返回或者异常的情况。在这个背景下,我们希望有一种结构可以在离开运行环境时自动回收内存。这时候,我们可以自然地想到 C++ 中的类 —— 它会在离开运行环境时自动调用析构函数。如果用一个类来包装指针,则可以将回收函数放在析构函数中,它将在超出范围时自动调用。简单的代码如下:

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
#include <iostream>

template<class T>
class Auto_ptr
{
T* m_ptr;
public:
// Pass in a pointer to "own" via the constructor
Auto_ptr(T* ptr=nullptr)
:m_ptr(ptr)
{
}

// The destructor will make sure it gets deallocated
~Auto_ptr()
{
delete m_ptr;
}

// Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
Auto_ptr<Resource> res(new Resource); // Note the allocation of memory here

// ... but no explicit delete needed
// Also note that the Resource in angled braces doesn't need a * symbol, since that's supplied by the template

return 0;
}

然而,这么写是有很大问题的。例如:当希望用拷贝构造函数创建一个新的指针时,得到的将是一个指向 res1 中创建的实例的指针。当离开运行环境时,会进行两次 delete操作,而只分配了一份空间,会导致函数出错。

1
2
3
4
5
int main()
{
Auto_ptr<Resource> res1(new Resource);
Auto_ptr<Resource> res2(res1);
// Alternatively, don't initialize res2 and then assign res2 = res1;

考虑一个常见的例子,我们将指针作为参数传入函数,会发生什么呢?调用的函数会通过拷贝构造函数创建一个指向原来实例的指针,而在退出函数时,会进行 delete 操作,main函数中指针指向的实例也被回收了!

解决这个问题最简单的方法是重写拷贝构造函数和拷贝赋值函数,进行深拷贝 [Deep Copy]。代码如下:

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
template<class T>
class Auto_ptr
{
T* m_ptr;
public:
Auto_ptr(T* ptr = nullptr)
:m_ptr(ptr)
{
}

~Auto_ptr()
{
delete m_ptr;
}

// Copy constructor
// Do deep copy of a.m_ptr to m_ptr
Auto_ptr(const Auto_ptr3& a)
{
m_ptr = new T;
*m_ptr = *a.m_ptr;
}

// Copy assignment
// Do deep copy of a.m_ptr to m_ptr
Auto_ptr& operator=(const Auto_ptr3& a)
{
// Self-assignment detection
if (&a == this)
return *this;
// Release any resource we're holding
delete m_ptr;
// Copy the resource
m_ptr = new T;
*m_ptr = *a.m_ptr;
return *this;
}

T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
bool isNull() const { return m_ptr == nullptr; }
};

但是,深拷贝的代价是昂贵的,在一些情况下,我们并不希望进行拷贝。如

1
2
3
4
5
6
7
8
9
10
11
12
Auto_ptr<Resource> generateResource()
{
Auto_ptr<Resource> res(new Resource);
return res; // this return value will invoke the copy constructor
}

int main()
{
Auto_ptr3<Resource> mainres;
mainres = generateResource(); // this assignment will invoke the copy assignment
return 0;
}

这段代码在关闭了 Copy elision 优化 [g++编译命令中增加 -fno-elide-constructors] 后的输出为:

  • Resource acquired
  • Resource acquired
  • Resource destroyed
  • Resource acquired
  • Resource destroyed
  • Resource destroyed

咦~明明应该只在 generateResource() 函数中创建过一个 Resource。分析一下:

  1. 创建指针 mainres 时并没有创建 Resource,此时指针是一个空指针。
  2. 进入 generateResource() 函数,创建了一个 Resource,同时创建了一个指针 res 指向这个 Resource。出现第一个 Resource acquired
  3. 返回时,通过拷贝构造函数,在 main() 函数中创建了一个临时指针。由于使用深拷贝,需要创建一个新的 Resource,将函数返回的指针指向的 Resource 复制过来。出现第二个 Resource acquired
  4. 离开函数,函数中创建的指针释放,调用了 delete() 函数。出现第一个 Resource destroyed
  5. 进入赋值语句,进行拷贝赋值,创建一个新的 Resource进行拷贝。出现第三个 Resource acquired
  6. 赋值语句结束,临时变量释放。出现第二个 Resource destroyed
  7. main() 函数结束,mainres 回收。出现第三个 Resource destroyed

// 如果使用 Copy elision 优化,赋值语句的两个输出会被优化,即第5,6。详见谷歌(

但我们希望看到的,是只进行一次 Resource 的创建:它只发生在调用函数中。事实上,通过深拷贝进行多测对象创建已经偏理了我们使用指针的本意。

在这里,我们并不需要通过拷贝构造函数和拷贝赋值函数复制指针指向的内容,而只需要将指针指向内容的所有权从原来的指针转移到目标对象。这就是移动语义背后的核心思想。 移动语义 [Move semantics] 意味着类将转移对象的所有权,而不是进行复制。我们希望在指针类中加入这样的构造/赋值函数,并在合适的时候调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// A copy constructor that implements move semantics
Auto_ptr(Auto_ptr& a) // note: not const
{
m_ptr = a.m_ptr; // transfer our dumb pointer from the source to our local object
a.m_ptr = nullptr; // make sure the source no longer owns the pointer
}

// An assignment operator that implements move semantics
Auto_ptr& operator=(Auto_ptr& a) // note: not const
{
if (&a == this)
return *this;
delete m_ptr; // make sure we deallocate any pointer the destination is already holding first
m_ptr = a.m_ptr; // then transfer our dumb pointer from the source to the local object
a.m_ptr = nullptr; // make sure the source no longer owns the pointer
return *this;
}

通过上面的分析,我们知道了想要实现一个会自动释放内存的指针,需要在必要的时候进行深拷贝,同时希望可以在合适的时候通过移动来优化构造/赋值函数。那么一个自然的问题出现了:我们应该如何判断什么时候进行拷贝,什么时候进行移动。

右值引用

C++11 及之后将表达式的值类别属性分为:prvalue, xvalue, and lvalue。但这与理解右值引用与移动语义关联不大,因此这里不介绍了。(其实是我还没看懂)详情参见:Value categories

左值 lvalue 与右值 rvalue

左值与右值指的是表达式的属性。 C ++中的每个表达式都有两个属性:一个类型(用于类型检查)和值类别 [Value categories](用于某些类型的语法检查,如赋值语句的左端需要是“可以修改的变量”)。在 C++ 03及更早版本中,左值和右值是仅有的两个可用值类别。

直观的,左值一开始的定义是 “适合位于赋值表达式左侧的值”,然而后来增加了 const 关键字,左值分成了两个子类别:可修改的左值与不可修改的左值。

而右值,一个简单的定义就是:不是左值的任意表达式。如常量 (1),临时变量 (x+1),匿名对象 (Function(a,b)) 等。右值的生存期只有它所在的表达式,当离开表达式时,右值将被回收。同时右值也不能被赋值:由于生存期有限,这是没有意义的。

左值引用

引用:引用变量是一个别名,也就是说,它是某个已存在变量的另一个名字。一旦把引用初始化为某个变量,就可以使用该引用名称或变量名称来指向变量。引用必须在创建时被初始化,一旦被初始化为一个对象,就不能被指向到另一个对象。[C++引用]

在C++11之前,只存在左值引用,也即引用。左值引用又分为可修改的引用与不可修改的引用。可修改的左值引用只能被可修改的左值初始化。不可修改的左值引用可以被任意左值,右值初始化,但不能通过引用修改对象的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main() {
int x = 0;
const int y = 0;

int & ref1 = x;
//int & ref2 = y; //error: cannot be initialized with const lvalue
//int & ref3 = 1; //error: cannot be initialized with rvalue
const int &ref4 = x;
const int &ref5 = y;
const int &ref6 = 1;

ref1 = 1;
cout << x << endl; // x = 1
//ref4 = 2; //error: cannot modify

return 0;
}

右值引用

在 C++11之后,新增了右值引用。表达方式是两个 &&。右值引用只能被右值初始化,同样分为可修改的右值引用与不可修改的右值引用。右值引用有两个作用:首先,右值引用可以延长右值的生存期,可以在表达式之外被使用;此外,可修改的右值引用允许修改右值。一个简单的例子:

1
2
3
4
5
6
7
8
int main()
{
int &&rref{ 5 };
// because we're initializing an r-value reference with a literal, a temporary with value 5 is created here
rref = 10;
std::cout << rref << '\n'; //output: 10
return 0;
}

和左值引用一样,右值引用的主要作用是作为函数参数。需要注意的是,右值引用表示对一个右值的引用,但它本身作为一个命名变量,是一个左值。就如前面所说的,表达式有两个属性,右值引用的类型属性为 “右值引用”,它的值类别属性则为左值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void fun(const int &lref) // l-value arguments will select this function
{
std::cout << "l-value reference to const\n";
}

void fun(int &&rref) // r-value arguments will select this function
{
std::cout << "r-value reference\n";
}

int main()
{
int x = 5;
fun(x); // l-value argument calls l-value version of function
fun(5); // r-value argument calls r-value version of function

int &&ref{ 5 };
fun(ref);// l-value argument calls l-value version of function
return 0;
}

同样的道理,如果在右值引用的函数中加入 fun(rref) 再次调用函数,此时将会进入左值引用的函数。

回到一开始的问题,可以发现,C++中对于左值引用与右值引用的自动区别,完成了判断 “什么时候进行拷贝,什么时候进行移动” 的任务:

  • 当通过左值进行构造/赋值时,由于进行构造的左值在之后还可能被使用,我们应该用深拷贝建立一个新的对象。
  • 当通过右值进行构造/赋值时,进行构造的右值正常会在表达式结束之后就回收,因此我们可以直接将指针指向这个右值,无需重新创建对象。

移动语义

移动构造函数与移动拷贝函数

通过上面的分析,我们可以通过左值引用与右值引用重载构造函数与赋值函数。当使用左值引用时,不允许修改左值。当使用右值引用时,允许修改。

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
template<class T>
class Auto_ptr
{
T* m_ptr;
public:
Auto_ptr(T* ptr = nullptr)
:m_ptr(ptr)
{
}

~Auto_ptr()
{
delete m_ptr;
}

// Copy constructor
// Do deep copy of a.m_ptr to m_ptr
Auto_ptr(const Auto_ptr& a)
{
m_ptr = new T;
*m_ptr = *a.m_ptr;
}

// Move constructor
// Transfer ownership of a.m_ptr to m_ptr
Auto_ptr(Auto_ptr&& a)
: m_ptr(a.m_ptr)
{
a.m_ptr = nullptr; // we'll talk more about this line below
}

// Copy assignment
// Do deep copy of a.m_ptr to m_ptr
Auto_ptr& operator=(const Auto_ptr& a)
{
// Self-assignment detection
if (&a == this)
return *this;
// Release any resource we're holding
delete m_ptr;
// Copy the resource
m_ptr = new T;
*m_ptr = *a.m_ptr;
return *this;
}

// Move assignment
// Transfer ownership of a.m_ptr to m_ptr
Auto_ptr& operator=(Auto_ptr&& a)
{
// Self-assignment detection
if (&a == this)
return *this;
// Release any resource we're holding
delete m_ptr;
// Transfer ownership of a.m_ptr to m_ptr
m_ptr = a.m_ptr;
a.m_ptr = nullptr; // we'll talk more about this line below
return *this;
}

T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
bool isNull() const { return m_ptr == nullptr; }
};

此时进行之前的任务,只会进行一次分配与回收:

  • Resource acquired
  • Resource destroyed

需要注意的是,有一次构造函数发生在函数值返回时,按对左右值的分析,此时返回的应该是左值。但C ++规范有一条特殊的规则,规定从函数按值返回的自动对象 [automatic objects,可以理解为非静态的局部变量] 即使是左值也可以作为右值处理。这是有道理的,因为返回值将在函数结束时销毁。

上面的移动操作仍然是发生在停止了 Copy elision 的情况下。若使用 Copy elision,编译器可以通过完全省去副本,避免了进行复制或移动的需要。

//需要注意的是,对于自定义的类,需要自己定义移动构造函数与移动拷贝函数,否则仍然是进行拷贝操作。

std::move

除了对编译器自动识别出来的右值使用移动语义之外,有时候可能也希望对左值进行移动:你知道你的程序在对象进行下一次赋值之前不会使用,此时可以进行移动。以交换函数为例:

1
2
3
4
5
6
7
template<class T>
void myswap(T& a, T& b)
{
T tmp { a }; // invokes copy constructor
a = b; // invokes copy assignment
b = tmp; // invokes copy assignment
}

交换函数一般在两个变量间进行,但是进行三次的拷贝实际上是没有必要的:我们更希望的是将变量 a 移动到临时变量,将变量 b 移动到变量 a,将临时变量移动到变量 b 完成交换。但是函数中 ab 都被认为是左值,不能进行移动构造和移动赋值。有没有方法将左值转化为右值呢?

在标准库 <utility> 中,提供了 std::move() 函数。它将一个左值转化为右值,从而可以调用移动构造函数与移动赋值函数。

1
2
3
4
5
6
7
template<class T>
void myswap(T& a, T& b)
{
T tmp { std::move(a) }; // invokes move constructor
a = std::move(b); // invokes move assignment
b = std::move(tmp); // invokes move assignment
}

此外,类似排序函数等算法也可以通过 std::move 提高效率。

其他优化

在定义了右值引用与移动语义之后,很多常见的操作都可以进行优化。

vector 中的 push_back() 函数,在 C++11之后,若希望向容器中加入一个右值, push_back() 会创建变量后调用移动构造函数而无需赋值。在有了右值的定义后,一个更方便的方法是直接在容器中构造,于是出现了 emplace_back() 函数。

参考资料:

  1. LearnCpp
  2. Copy elision - cppreference
  3. Value categories - cppreference
  4. C++引用

最后编辑:2020-08-03