M.1 - 智能指针和移动语义简介
Key Takeaway
- 移动语义和拷贝语义是二选一的,只有在可能发生拷贝语义的地方,才应该考虑它执行的是移动语义还是拷贝语义(取决于右值还是左值)
考虑下面函数,函数中动态分配了一个资源:
1 2 3 4 5 6 7 8 |
|
尽管上面的代码看起来相当简单,但是在实践中很容易忘记释放 ptr
。即使你记得在函数结束时删除 ptr
,如果函数提前退出,也有许多因素导致ptr
不能被正确删除,例如函数的提前返回:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
抛出异常也可能会导致问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
在上述两个程序中,提前返回或throw语句,都可能会导致函数在没有删除 ptr
的情况下终止。因此,分配给变量ptr
的内存可能出现内存泄漏(而且是每次调用此函数并提前返回时都会泄漏)。
本质上,发生这类问题是因为指针变量不具备自我清理的能力。
智能指针类可以拯救我们吗?
对于类对象来说,它们最大的优势是可以在离开作用域是自动执行析构函数。这样一来,如果我们在构造函数中分配或获取内存,则可以在析构函数中进行释放,这样就可以确保内存在对象被销毁前(不论是离开作用域还是显式删除等)释放。这也正是资源获取即初始化(RAII)编程范式的核心思想(见13.9 - 析构函数)。
那么,是否可以编写一个类,帮助我们管理和清理指针呢?当然可以!
考虑设计这样一个类,它的唯一工作,就是管理一个传入的指针。当该类对象离开作用域时,释放指针所指向的内存。只要该对象被声明为一个局部变量,我们就可以保证,当它离开作用域时(不论何时、也不论函数如何终止),它管理的指针就会被销毁。
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 |
|
程序打印:
1 2 |
|
考虑一下这个程序和类是如何工作的。首先,动态创建Resource
,并将其作为参数传递给模板化的Auto_ptr1
类。从这一刻开始,Auto_ptr1
变量res
拥有该资源对象(Auto_ptr1
是m_ptr
是组合关系)。因为res
被声明为局部变量并具有语句块作用域,所以当语句块结束时,它将离开作用域并被销毁(不用担心忘记释放它)。由于它是一个类,因此在销毁它时,将调用Auto_ptr1
析构函数。该析构函数将确保它所持有的Resource
指针被删除!
只要Auto_ptr1
被定义为一个局部变量(具有自动持续时间,因此类名中有“Auto”部分),无论函数如何终止(即使它提前终止),Resource
总是可以在声明它的块的末尾被销毁。
这种类被称为智能指针。智能指针类是一种组合类,它用于管理动态分配的内存,并且能够确保指针对象在离开作用域时被删除(内置的指针对象被称为“笨指针”,正是因为它们不能自己清理自己所管理的内存)。
现在让我们回到上面的someFunction()
例子,看看如何使用智能指针解决我们面临的问题:
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 |
|
如果用户输入非0,则程序打印:
1 2 3 |
|
如果用户输入0,则程序会提前退出,打印:
1 2 |
|
注意,即使在用户输入零且函数提前终止的情况下,资源仍然被正确地释放。
因为ptr
变量是一个局部变量,所以ptr
将在函数终止时被销毁(不管它以何种方式终止)。因为Auto_ptr1
析构函数将清理Resource
,所以我们可以保证Resource
将被正确地清理。
严重缺陷
Auto_ptr1
类有一个严重的缺陷,是由自动生成的代码引起的。你能看出来吗?
(提示:如果编译器会为类提供哪些默认的函数?)
时间到!
猜到了吗?考虑下面代码:
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 |
|
程序输出结果:
1 2 3 |
|
程序很可能(但不一定)在此时崩溃。发现问题的所在了吗?因为我们没有提供复制构造函数或赋值操作符,所以C++为我们提供了一个。它提供的函数只做浅拷贝。因此,当我们用res1
初始化res2
时,两个Auto_ptr1
变量都指向同一个Resource
。当res2
超出作用域时,它删除资源,给res1
留下一个悬垂指针。当res1
去删除它的(已经删除的)资源时,程序崩溃!
下面的代码也会出现类似的问题:
1 2 3 4 5 6 7 8 9 10 11 |
|
在这个程序中,res1
将按值复制到passByValue
的参数res
中,导致Resource
指针被复制,进而导致程序崩溃。
显然这不是什么好事。我们如何解决这个问题?
一种方法是显式地定义和删除复制构造函数和赋值操作符,从而从根本上阻止拷贝。这会阻止按值传递的情况(这很好,因为我们本来就不应该按值传递它们)。
但是,我们如何从函数返回Auto_ptr1
给调用者呢?
1 2 3 4 5 |
|
我们无法将Auto_ptr1
按引用返回,因为局部变量Auto_ptr1
会在函数结束时删除,所以主调函数得到的是一个悬垂引用。我们可以将指针r返回为 Resource*
,但是我们可能会在后面忘记删除 r
,这就违背了使用智能指针的初衷。所以,按值返回Auto_ptr1
是唯一有意义的选项——但我们最终会得到浅拷贝、重复的指针并导致程序崩溃。
另一种选择是重写拷贝构造函数和赋值操作符来进行深拷贝。这样,我们至少可以避免指向同一个对象的重复指针。但是拷贝的开销可能会很大(而且可能不需要甚至不可能),而且我们不希望仅仅为了从函数返回Auto_ptr1
而对对象进行不必要的复制。另外,对普通指针赋值或初始化时并不会拷贝对象,为什么智能指针要具有不同的逻辑呢?
该怎么做呢?
移动语义
如果,我们可以让拷贝构造函数和赋值运算符不去拷贝指针(拷贝语义),而是将它所管理的资源传递或移动给目标指针呢?这正是移动语义背后的核心思想。移动语义说的就是对象所有权发生了转移而不是拷贝。
更新代码:
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 |
|
打印:
1 2 3 4 5 6 7 |
|
注意,我们重载的赋值运算符现在可以把m_ptr
的所有权从res1
传递给res2
!这样一来,我们将不会得到重复的指针,资源清理可以顺利进行。
std::auto_ptr
为什么是糟糕的实现
现在是讨论std::auto_ptr
的合适时机了。std::auto_ptr
在C++ 98中引入,在C++ 17中被删除,它是C++第一次尝试标准化智能指针。auto_ptr
选择了Auto_ptr2
类一样的移动语义实现方式。
然而,std::auto_ptr
(以及我们的Auto_ptr2
类)有许多问题,使得使用它很危险。
首先,由于std::auto_ptr
通过复制构造函数和赋值操作符实现了移动语义,将std::auto_ptr
按值传递给函数将导致资源被移动到函数形参中(并在函数形参超出作用域时销毁)。然后,当你从调用者访问auto_ptr
实参时(没有意识到它已经被移动和删除了)时,进行解引用会导致程序崩溃。
其次,std::auto_ptr
总是使用非数组删除来删除其内容。这意味着auto_ptr
不能正确地处理动态分配的数组,因为它使用了错误的内存释放方式。更糟糕的是,它不能阻止您向它传递动态数组,这样它就可能导致内存泄漏。
最后,auto_ptr
不能很好地处理标准库中的许多其他类,包括大多数容器和算法。这是因为那些标准库类假定当它们复制一个资源时,它进行了实际的复制,而不是移动。
由于上述缺点,std::auto_ptr
在C++ 11中已弃用,在C++ 17中已删除。
继续
std::auto_ptr
的核心问题是:C++ 11之前,C++语言根本没有区分“拷贝语义”和“移动语义”的机制。对拷贝语义重写来实现移动语义,会导致奇怪的边界情况和不易察觉的错误。例如,你可以写res1 = res2
,但无法知道res2
是否会被改变!
正因为如此,在C++ 11中,正式定义了“移动”的概念,并在语言中添加了“移动语义”,以正确区分复制和移动。现在,我们已经知道移动语义是很有用的,在本章的其余部分我们会继续探索移动语义的其他话题。我们还将使用移动语义修复Auto_ptr2
类。
在C++ 11中,std::auto_ptr
已经被一堆其他类型的“移动语义兼容的”智能指针所取代:std::unique_ptr
, std::weak_ptr
和std::shared_ptr
。我们还将探讨其中最流行的两个:unique_ptr
(它直接替换了 auto_ptr
)和 shared_ptr
。