大家好!我是大聪明-PLUS!
介绍
资源管理是 C++ 程序员必须持续关注的问题。资源包括内存块、操作系统内核对象、多线程锁、网络连接、数据库连接,以及任何在动态内存中创建的对象。资源通常通过句柄访问,句柄通常是指针或其别名(HANDLE例如 `int`、`int` 等),有时也可能是整数(例如 UNIX 文件句柄)。使用后,资源必须被释放;否则,迟早有一天,未释放资源的应用程序(以及其他应用程序)会耗尽资源。这个问题非常棘手;.NET、Java 和其他一些平台的关键特性之一就是基于垃圾回收的统一资源管理系统。
C++ 的面向对象特性自然而然地引出了以下解决方案:管理资源的类将资源句柄作为成员变量,在获取资源时初始化该句柄,并在析构函数中释放资源。但经过一番思考(或经验积累)后,就会发现事情并非如此简单。主要问题在于复制语义。如果管理资源的类使用编译器默认生成的复制构造函数,那么在复制对象后,我们会得到同一资源的两个句柄副本。如果其中一个对象释放了资源,那么另一个对象就可以尝试使用或释放已被释放的资源,这在任何情况下都是不正确的,并可能导致所谓的未定义行为------也就是说,任何事情都可能发生,包括程序崩溃。
幸运的是,在 C++ 中,程序员可以通过定义自己的复制构造函数和复制赋值运算符来完全控制复制过程,这使得上述问题得以解决,而且通常有多种解决方案。复制的实现必须与资源释放机制紧密耦合,这统称为复制所有权策略。著名的"三大规则"指出,如果程序员定义了复制构造函数、复制赋值运算符或析构函数这三个操作中的至少一个,那么他们必须定义所有三个操作。复制所有权策略具体规定了如何实现这一点。目前主要有四种复制所有权策略。
1. 基本版权所有权策略
在获取资源之前或释放资源之后,句柄必须具有一个特殊值,表明它与该资源无关。该值通常为零,有时为 -1,并强制转换为句柄的类型。无论哪种情况,这样的句柄都称为空句柄。管理资源的类必须识别空句柄,并且在这种情况下不得尝试使用或释放资源。
1.1 版权禁令策略
这是最简单的策略。在这种情况下,类实例的复制和赋值操作都被直接禁止。析构函数会释放已获取的资源。在 C++ 中,禁用复制操作很容易:类必须声明(但不能定义)私有复制构造函数和复制赋值运算符。
class` `X`
{
`private`:
`X`(`const` `X&`);
`X&` `operator=`(`const` `X&`);
`// ...`
};`
编译器和链接器会阻止复制尝试。
C++11 标准针对这种情况提供了一种特殊的语法:
class` `X`
{
`public`:
`X`(`const` `X&`) `=` `delete`;
`X&` `operator=`(`const` `X&`) `=` `delete`;
`// ...`
};`
这种语法更具描述性,并且在尝试复制时会产生更清晰的编译器消息。
在之前的标准库版本(C++98)中,I/O 流类(例如 `Stream`、`Stream` 等)使用了无复制策略std::fstream,并且在 Windows 系统上,许多 MFC 类(例如CFile`MFC`、 `MFC`、 CEvent` CMutexMFC` 等)也使用了该策略。在 C++11 标准库中,一些类使用此策略来支持多线程同步。
1.2 独家所有权策略
在这种情况下,实现复制和赋值时,资源句柄会从源对象移动到目标对象,这意味着它保持唯一性。复制或赋值后,源对象的句柄为空,无法使用该资源。析构函数会释放已获取的资源。在 C++11 中,实现方式如下:禁止使用上述方式进行常规复制和复制赋值,并实现了移动语义,即定义了移动构造函数和移动赋值运算符。(关于移动语义的更多内容,请参见下文。)
class` `X`
{
`public`:
`X`(`const` `X&`) `=` `delete`;
`X&` `operator=`(`const` `X&`) `=` `delete`;
`X`(`X&&` `src`) `noexcept`;
`X&` `operator=`(`X&&` `src`) `noexcept`;
`// ...`
};`
因此,独家所有权策略可以被视为禁止抄袭策略的延伸。
在 C++11 标准库中,智能指针std::unique_ptr<>和其他一些类(例如std::thread`int` std::unique_lock<>、`int` 等)以及以前使用非复制策略的类(例如std::fstream`int`、`int` 等)都采用了这种策略。在 Windows 系统上,以前使用非复制策略的 MFC 类也采用了独占所有权策略(例如CFile`int` CEvent、`int` CMutex、`int` 等)。
1.3 深度复制策略
在这种情况下,可以复制和赋值类实例。必须定义复制构造函数和复制赋值运算符,以便目标对象从源对象复制资源。每个对象都拥有资源的副本,可以独立地使用、修改和释放资源。析构函数会释放已获取的资源。使用深拷贝策略的对象有时被称为值对象。
这种策略并非适用于所有资源。它可以应用于基于内存的缓冲区资源,例如字符串,但如何将其应用于内核对象(例如文件、互斥锁等)尚不完全清楚。
std::vector<>标准库中的所有对象字符串类型和其他容器都采用了深拷贝策略。
1.4 共同所有权策略
在这种情况下,可以复制和赋值类实例。必须定义一个复制构造函数和一个复制赋值运算符,它们会复制资源句柄(以及其他数据),但不会复制资源本身。这样,每个对象都拥有自己的句柄副本,可以使用和修改它,但不能释放它,只要至少还有一个其他对象拥有该句柄的副本。当最后一个拥有句柄副本的对象超出作用域时,资源才会被释放。具体实现方式如下所述。
共享所有权策略通常用于智能指针,也很自然地用于不可变资源。在 C++11 标准库中,智能指针实现了这一策略std::shared_ptr<>。
2. 深度复制策略------问题与解决方案
T考虑C++98 标准库中用于交换某种类型对象状态的函数模板。
template<typename` `T>`
`void` `swap`(`T&` `a`, `T&` `b`)
{
`T` `tmp`(`a`);
`a` `=` `b`;
`b` `=` `tmp`;
}`
如果一个类型T拥有一个资源并使用深拷贝策略,那么我们需要执行三次资源分配操作、三次复制操作和三次资源释放操作。在大多数情况下,此操作完全可以无需任何资源分配或复制即可完成;对象只需交换内部数据,包括资源句柄。还有许多类似的例子,需要创建临时资源副本并立即释放。这种低效的日常操作促使人们寻求优化方案。让我们来看看主要的几种选择。
2.1 书面复制
写时复制 (COW),也称为惰性复制,可以看作是将深拷贝策略与共享所有权策略相结合的一种尝试。最初,复制对象时,复制的是资源句柄,而不是资源本身,此时资源对所有所有者都是共享的只读资源。然而,一旦任何所有者需要修改共享资源,资源本身就会被复制,然后该所有者就可以使用自己的副本进行操作。COW 实现解决了状态共享问题:无需额外的资源分配或复制。COW 在字符串实现中非常流行,例如CString(MFC、ATL)。
COW(写时复制)理念发展出了以下资源管理方案:资源是不可变的,由对象采用共享所有权策略进行管理。当需要修改资源时,会创建一个新的、经过适当修改的资源,并返回一个新的所有者对象。该方案用于 .NET 和 Java 平台上的字符串和其他不可变对象。在函数式编程中,它用于更复杂的数据结构。
2.2. 为类定义状态交换函数
上文我们已经展示了,如果使用复制和赋值这种简单直接的方式实现状态交换函数,效率会非常低下。然而,这种方式却被广泛使用;例如,标准库中的许多算法都使用了这种方式。为了强制算法使用std::swap()专门为某个类定义的函数,而不是使用空指针 [ ],需要采取两个步骤。
- 在类中定义一个成员函数
Swap()(名称不重要),该函数实现状态交换。
class` `X`
{
`public`:
`void` `Swap`(`X&` `other`) `noexcept`;
`// ...`
};`
为确保此函数不会抛出异常,在 C++11 中,此类函数必须声明为noexcept。
- 在与类相同的命名空间中
X(通常在同一个头文件中),定义一个自由(非成员)函数,swap()如下所示(名称和签名很重要):
inline` `void` `swap`(`X&` `a`, `X&` `b`) `noexcept` { `a`.`Swap`(`b`); }`
此后,标准库算法将使用它来代替std::swap()。这是通过一种称为参数依赖查找(ADL)的机制实现的。
在 C++ 标准库中,所有容器、智能指针和其他类都以上述方式实现状态共享。
成员函数Swap()通常很容易定义:只需依次对基类和成员应用状态交换操作(如果它们支持的话),std::swap()否则就不要应用。
此处的描述较为简化;
状态交换函数可以被视为类的基本操作之一。其他操作都可以用它来优雅地定义。例如,复制赋值运算符就是用 `copy` 函数定义的,Swap()如下所示:
X&` `X::operator=`(`const` `X&` `src`)
{
`X` `tmp`(`src`);
`Swap`(`tmp`);
`return` `*this`;
}`
2.3. 编译器删除中间副本
让我们考虑一下这个类
class` `X`
{
`public`:
`X`();
`// ...`
};`
以及该功能
X` `Foo`()
{
`// ...`
`return` `X`();
}`
最直接的方法是,函数返回Foo()是通过复制实例来实现的X。然而,编译器可以从代码中移除复制操作;对象会在调用点直接创建。这被称为返回值优化(RVO)。编译器开发者已经使用 RVO 很长时间了,目前已在 C++11 标准中进行了规范。虽然是否采用 RVO 由编译器决定,但程序员在编写代码时可以考虑到这一点。为了实现这一点,函数最好只有一个返回点,并且返回类型与函数的返回类型相匹配。在某些情况下,建议定义一个特殊的私有构造函数,称为"计算构造函数";
在其他情况下,编译器也可能删除中间副本。
2.4 移动语义的实现
移动语义的实现包括定义一个移动构造函数,该构造函数有一个指向源的右值引用类型的参数,以及一个具有相同参数的移动赋值运算符。
在 C++11 标准库中,状态交换函数模板定义如下:
template<typename` `T>`
`void` `swap`(`T&` `a`, `T&` `b`)
{
`T` `tmp`(`std::move`(`a`));
`a` `=` `std::move`(`b`);
`b` `=` `std::move`(`tmp`);
}`
根据带有右值引用参数的函数的重载解析规则,如果类型T具有移动构造函数和移动赋值运算符,则使用它们,并且不会进行临时资源分配或复制。否则,使用复制构造函数和复制赋值运算符。
使用移动语义可以避免在比上述状态交换函数更广泛的范围内创建临时副本。移动语义适用于任何右值(即临时的、未命名的值),以及函数的返回值(包括左值),前提是该返回值是在本地创建的,并且没有应用 RVO(返回值优化)。在所有这些情况下,都能保证移动后源对象无法以任何方式使用。移动语义也适用于经过移动转换的左值std::move()。但是,在这种情况下,程序员需要负责移动后如何使用源对象(例如std::swap())。
C++11 标准库经过重新设计,充分考虑了移动语义。许多类现在都提供了移动构造函数和移动赋值运算符,以及其他带有右值引用参数的成员函数。例如,std::vector<T>它还提供了 `move` 函数的重载版本void push_back(T&& src)。所有这些都使得在许多情况下可以避免使用临时副本。
实现移动语义并不会否定类的状态交换函数的定义。专门定义的状态交换函数可能比默认函数更高效std::swap()。此外,可以使用状态交换成员函数轻松定义移动构造函数和移动赋值运算符,如下所示("复制和交换"惯用法的一种变体):
class` `X`
{
`public`:
`X`() `noexcept` {}
`void` `Swap`(`X&` `other`) `noexcept` {}
`X`(`X&&` `src`) `noexcept` : `X`()
{
`Swap`(`src`);
}
`X&` `operator=`(`X&&` `src`) `noexcept`
{
`X` `tmp`(`std::move`(`src`));
`Swap`(`tmp`);
`return` `*this`;
}
`// ...`
};`
移动构造函数和移动赋值运算符是需要保证不抛出异常的成员函数之一,因此它们被声明为 `undefined` noexcept。这使得一些标准库容器操作能够在不违反强异常安全保证的前提下进行优化;所提出的模式在默认构造函数和状态交换成员函数不抛出异常的前提下提供了这种保证。
C++11 标准允许编译器自动生成移动构造函数和移动赋值运算符;为此,必须使用"=default".
class` `X`
{
`public`:
`X`(`X&&`) `=` `default`;
`X&` `operator=`(`X&&`) `=` `default`;
`// ...`
};`
操作的实现方式是:如果基类和类成员支持移动,则依次应用移动运算符;否则,应用复制运算符。显然,这种方法并非总是可行。原始描述符不会被移动,但通常也无法复制。在某些情况下,编译器可以生成类似的移动构造函数和移动赋值运算符,但最好不要使用此功能;这些条件相当复杂,并且会随着类的修改而轻易改变。
一般来说,移动语义的实现和使用是一个相当微妙的问题。编译器可能会在程序员期望进行移动操作的地方使用复制操作。以下是一些避免或至少降低这种情况发生概率的规则。
- 尽可能使用禁止复制措施。
- 声明一个移动构造函数和一个移动赋值运算符
noexcept。 - 为基类和成员实现移动语义。
std::move()对具有右值引用类型的函数参数应用转换。
规则 2 已在前面讨论过。规则 4 与命名右值引用是左值这一事实有关。这可以通过移动构造函数的定义来说明。
class` `B`
{
`// ...`
`B`(`B&&` `src`) `noexcept`;
};
`class` `D` : `public` `B`
{
`// ...`
`D`(`D&&` `src`) `noexcept`;
};
`D::D`(`D&&` `src`) `noexcept`
: `B`(`std::move`(`src`))
{`/* ... */`}`
上述移动赋值运算符的定义中给出了该规则的另一个例子。移动语义的实现也将在 6.2.1 节中讨论。
2.5. 放置与插入
放置操作背后的思想与 RVO(参见 2.3 节)类似,但它适用于输入参数而非函数的返回值。传统上,将对象插入容器时,首先会创建一个对象(通常是临时对象),然后将其复制或移动到存储位置,之后销毁该临时对象。而使用放置操作,对象会立即在存储位置创建,并且只传递构造函数参数。C++11 标准库容器的成员函数 `init` 和 `init`emplace()就是emplace_front()这样emplace_back()工作的。当然,这些是具有可变数量模板参数的模板成员函数------可变参数模板------因为构造函数参数的数量和类型事先未知。此外,还使用了其他高级 C++11 技术,例如转发和通用引用。
这种安置方式具有以下优点:
- 对于不支持移动的对象,复制操作将被排除在外。
- 对于需要移动的物体,放置几乎总是更有效率。
让我们举个例子,看看同一个问题可以用不同的方法解决。
std::vector<std::string>` `vs`;
`vs`.`push_back`(`std::string`(`3`, `'X'`));
`vs`.`emplace_back`(`3`, `'7'`); `
插入操作会创建一个临时对象std::string,将其移动到存储位置,然后销毁该临时对象。放置操作则会立即在存储位置创建对象。放置操作更简洁,效率也可能更高。
2.6 结果
实现深拷贝策略的类的主要问题之一是会创建资源的临时副本。目前描述的方法都无法完全解决这个问题,也无法完全替代其他方法。无论如何,程序员必须识别这种情况,并根据所述问题和语言特性编写正确的代码。最简单的例子是向函数传递参数:参数必须按引用传递,而不是按值传递。编译器不会检测到此错误,但它会导致不必要的复制,或者导致程序行为与预期不符。另一个例子是使用 move 操作:程序员必须严格遵守编译器选择 move 操作的条件,否则程序会在后台默默地执行复制操作。
上述问题导致以下建议:应尽可能避免深度复制;实际需要深度复制的情况非常少见,.NET 和 Java 平台上的编程经验也证实了这一点。作为替代方案,我们可以建议使用一个特殊函数来实现深度复制,该函数通常Clone()称为Duplicate().
但是,如果在实现资源所有者类时决定采用深度复制策略,那么除了实现复制语义之外,还可以建议采取以下步骤:
- 定义状态交换函数。
- 定义移动构造函数和移动赋值运算符。
- 定义所需的成员函数和带有右值引用参数的自由函数。
在 .NET 和 Java 平台上,主要的复制所有权策略是共享所有权,但如有必要,也可以实现深度复制策略。例如,在 .NET 中,这需要实现 `Integer.Copy` 接口IClonable。如上所述,这种情况很少发生。
3. 实施共同所有权战略的可能方案
对于具有内部引用计数的资源,实现共享所有权策略相当简单。在这种情况下,当资源的拥有者对象被复制时,引用计数会递增;在析构函数中,引用计数会递减。当其值达到零时,资源将被释放。内部引用计数被基本的 Windows 操作系统资源所使用:操作系统内核对象(通过 `CREATE TABLE` 管理)HANDLE和 COM 对象。对于内核对象,引用计数通过 `CREATE TABLE` 函数递增DuplicateHandle(),并通过 `CREATE TABLE` 函数递减CloseHandle()。对于 COM 对象,则使用 `CREATE TABLE`IUnknown::AddRef()和`CREATE TABLE` 成员函数IUnknown::Release()。ATL 库包含一个智能指针CСomPtr<>,它以这种方式管理 COM 对象。对于使用 C 标准库函数打开的 UNIX 文件句柄,引用计数通过 `CREATE TABLE` 函数递增_dup(),并通过文件关闭函数递减。
在 C++11 标准库中,智能指针std::shared_ptr<>也使用引用计数器。然而,由该智能指针控制的对象可能没有内部引用计数器,因此需要创建一个称为控制块的特殊隐藏对象来管理引用计数器。显然,这会带来额外的开销。
使用引用计数器存在一个固有缺陷:如果资源所有者对象相互引用,它们的引用计数器永远不会为零(循环引用问题)。在某些情况下,资源之间不能相互引用(例如,操作系统内核对象),因此这个问题并不重要。然而,在其他情况下,程序员必须监控这种情况并采取适当的措施。当用于std::shared_ptr<>此类目的时,建议使用辅助智能指针std::weak_ptr<>。
共享所有权策略的实施还必须考虑多线程访问所有者对象的可能性。
共享所有权策略是 .NET 和 Java 平台上的主要复制所有权策略。负责移除未使用对象的运行时组件称为垃圾回收器。它会定期运行,并使用复杂的对象图分析算法。
4. 排他性占有策略与运动语义
只有在 C++ 引入对右值引用和移动语义的支持之后,安全地实现独占所有权策略才成为可能。C++98 标准库包含一个std::auto_ptr<>实现了独占所有权策略的智能指针,但它的用途有限;特别是,它不能存储在容器中。这是因为它可以将指针从仍然需要它的对象中移走(换句话说,就是窃取它)。在 C++11 中,使用右值引用的规则确保数据只能从临时的、未命名的对象中移走;否则,会发生编译时错误。因此,C++11 标准库std::auto_ptr><>已弃用该智能指针,并推荐使用std::unique_ptr<>基于移动语义的智能指针。
独占所有权策略也得到了其他一些类的支持:I/O 流类(std::fstream例如,`Stream` 等)以及用于处理控制流的类(std::thread例如, `ControlFlow` 等)。在 MFC 中,以前使用非复制策略的类(例如,` ControlFlow` 等)std::unique_lock<>开始使用这种策略。CFile``CEvent``CMutex
5. 反抄袭策略------快速入门
乍一看,无复制策略似乎对程序员的限制非常严格。然而,实际上,许多对象并不需要复制。因此,在设计资源管理类时,建议以无复制策略作为起点。如果需要复制,编译器会立即检测到,然后分析复制的目的(如果有的话),并进行必要的调整。在某些情况下,例如在传递调用栈时,可以使用引用。如果需要将对象存储在标准库容器中,可以使用指向动态内存中创建的对象的指针(最好是智能指针)。总的来说,使用动态内存和智能指针是一个相当通用的选择,在其他情况下也很有帮助。更复杂的选择是实现移动语义。详情请参见第 6 节。
粗心的软件设计如果未能实现任何复制所有权策略,往往能侥幸过关,因为拥有资源的对象实际上并没有被复制。在这种情况下,禁用复制或实现不同的复制所有权策略似乎都无济于事。然而,即使在特定情况下错误的代码不会暴露其缺陷,编写正确的代码仍然至关重要。错误的代码最终会引发问题。
6. 资源及其所有者对象的生命周期
在许多情况下,了解资源生命周期与其所有者之间的关系至关重要。这自然与副本所有权策略密切相关。让我们来探讨几种方案。
6.1 初始化期间的资源获取
最简单的情况下,资源的生命周期与其所有者的生命周期相同。也就是说,管理资源的类满足以下条件:
- 资源获取仅在类构造函数中发生。如果获取失败,则会抛出异常,并且不会创建对象。
- 资源释放仅发生在析构函数中。
- 禁止复制和移动。
此类类的构造函数通常需要传入参数来获取资源,因此没有默认构造函数。在 C++11 标准库中,一些类就是这样实现的,以支持多线程同步。
这种资源管理方案是资源获取即初始化(RAII)惯例的一种变体。RAII 惯例在许多书籍和网络上都有广泛的讨论(但其解释往往略有不同,或者不够清晰);上述变体可以称为"严格"RAII。在这种类中,将资源描述符设为常量成员是自然而然的,因此,可以使用术语"不可变 RAII"。
6.2 高级资源生命周期管理选项
按照 RAII 惯用法实现的类非常适合创建生命周期短、结构简单的对象。然而,如果该对象需要成为另一个类的成员、数组的元素或其他容器,那么缺少默认构造函数和复制移动语义会给程序员带来诸多问题。此外,资源获取有时需要多个步骤,且步骤数可能事先未知,这使得在构造函数中实现资源获取变得极其困难。接下来,我们将探讨解决此问题的可能方案。
6.2.1. 延长资源生命周期
如果满足以下条件,我们将称管理资源的类支持扩展资源生命周期:
- 存在一个默认构造函数,它不会获取资源。
- 对象创建后,还有一种机制可以捕获资源。
- 存在一种机制,可以在对象销毁之前释放资源。
- 析构函数会释放捕获的资源。
在 C++11 标准库中,字符串、容器、智能指针和其他一些类支持扩展的资源生命周期。但是,需要注意的是,clear()字符串和容器中实现的成员函数会销毁所有存储的对象,但可能不会释放已分配的内存。要彻底释放所有资源,必须采取其他措施。例如,可以使用 `delete`方法shrink_to_fit(),或者直接赋值给一个由默认构造函数创建的对象(见下文)。
根据 RAII 惯用法实现的类可以使用标准模板进行修改,以支持扩展的资源生命周期。这需要额外定义一个默认构造函数、一个移动构造函数和一个移动赋值运算符。
class` `X`
{
`public`:
`// RAII`
`X`(`const` `X&`) `=` `delete`;
`X&` `operator=`(`const` `X&`) `=` `delete`;
`X`();
`~X`();
`X`() `noexcept`;
`X`(`X&&` `src`) `noexcept`
`X&` `operator=`(`X&&` `src`) `noexcept`;
`// ...`
};`
此后,资源的延长生命周期就很容易实现了。
X` `x`;
`x` `=` `X`();
`x` `=` `X`();
`x` `=` `X`(); `
这就是该类的实现方式std::thread。
如第 2.4 节所示,定义移动构造函数和移动赋值运算符的标准方法是使用状态交换成员函数。此外,状态交换成员函数使得定义单独的资源获取和释放成员函数变得非常容易。以下是相应的改进版本。
class` `X`
{
`// RAII`
`// ...`
`public`:
`X`() `noexcept`;
`X`(`X&&` `src`) `noexcept`;
`X&` `operator=`(`X&&` `src`) `noexcept`;
`void` `Swap`(`X&` `other`) `noexcept`;
`void` `Create`();
`void` `Close`() `noexcept`;
`// ...`
};
`X::X`() `noexcept` {}`
移动构造函数和移动赋值运算符的定义:
X::X`(`X&&` `src`) `noexcept` : `X`()
{
`Swap`(`src`);
}
`X&` `X::operator=`(`X&&` `src`) `noexcept`
{
`X` `tmp`(`std::move`(`src`));
`Swap`(`tmp`);
`return` `*this`;
}`
定义获取和释放资源的各个成员函数:
void` `X::Create`()
{
`X` `tmp`();
`Swap`(`tmp`);
}
`void` `X::Close`() `noexcept`
{
`X` `tmp`;
`Swap`(`tmp`);
}`
值得注意的是,在上述模式中,资源获取总是在构造函数中完成,而资源释放则在析构函数中完成;状态交换成员函数仅起技术性作用。这简化了资源获取和释放的编码,并使其更加可靠,因为编译器会接管部分实现逻辑,尤其是在析构函数中。在析构函数中,编译器会确保成员和基类的析构函数按照构造函数调用的相反顺序调用,这几乎总是能保证不会存在对已删除对象的引用。
上述定义复制赋值运算符和资源获取成员函数的示例使用了"复制并交换"的惯用法,即先获取新资源,再释放旧资源。这种方案提供了所谓的强异常安全保证:如果在获取资源时发生异常,对象将保持操作前的状态(事务语义)。在某些情况下,另一种方案可能更合适:先释放旧资源,再获取新资源。这种方案提供的异常安全保证较弱,称为基本异常安全保证:如果在获取资源时发生异常,对象不一定会保持原有状态,但新状态是正确的。此外,使用此方案定义复制赋值运算符需要进行自赋值检查。
因此,从 RAII 过渡到扩展资源生命周期与从禁止复制策略过渡到独占所有权策略非常相似。
6.2.2 一次性资源获取
此选项可视为 RAII 和扩展资源生命周期之间的中间方案。如果满足以下条件,我们将资源管理类视为采用一次性资源获取方式:
- 存在一个默认构造函数,它不会获取资源。
- 对象创建后,还有一种机制可以捕获资源。
- 禁止获取此资源。如果尝试获取,则会抛出异常。
- 资源释放仅发生在析构函数中。
- 禁止复制。
这几乎就是 RAII,唯一的区别在于它能够正式地将对象创建和资源获取分开。这样的类可以有移动构造函数,但不能有移动赋值运算符,否则就会违反第 3 点的条件。这简化了在标准容器中存储对象的过程。尽管它还不够完善,但这个方案非常实用。
6.2.3. 提高间接性
另一种延长资源生命周期的方法是增加间接寻址的层级。在这种情况下,RAII 对象本身被视为资源,指向它的指针则被视为资源句柄。获取资源涉及在动态内存中创建对象,释放资源则涉及将其删除。管理此类资源的类可以是标准库中的智能指针类,也可以是具有类似功能的类(此类称为句柄类)。复制所有权策略由智能指针决定,或者很容易实现(对于句柄类而言)。这种方法比 6.2.1 节中描述的方法要简单得多;它唯一的缺点是增加了动态内存的使用。
6.3 共同所有权
使用共享所有权策略时,资源所有者可以通过 RAII 方案与资源紧密绑定,也可以使用更灵活的方案:在资源的整个生命周期内反复获取和解除绑定。无论采用哪种方案,只要至少有一个其他对象绑定到该资源,该资源就会一直存在。
7. 结果
管理资源的类不得包含编译器默认生成的复制构造函数、复制赋值运算符或析构函数。这些成员函数必须按照复制所有权策略进行定义。
版权所有权策略主要有 4 种:
- 反抄袭策略。
- 独家所有权策略。
- 深度复制策略。
- 共同所有权策略。
状态交换函数应被视为该类的基本操作。它用于标准库算法,并用于定义其他类成员函数:复制赋值运算符、移动构造函数和移动赋值运算符,以及资源获取和释放成员函数。
定义移动构造函数和移动赋值运算符可以优化使用深度复制策略的类的开发。对于使用非复制策略的类,这允许扩展复制所有权策略,实现更灵活的资源生命周期管理方案,并简化对象在容器中的放置。
设计资源所有者类时,建议按以下步骤操作。首先禁用复制。如果编译后发现需要复制,且无法通过简单方法避免,则应尝试在动态内存中创建对象,并使用智能指针来管理其生命周期(参见 6.2.3 节)。如果此方案不可行,则必须实现移动语义(参见 6.2.1 节)。标准库容器是复制操作的主要使用者之一,实现移动语义几乎可以消除对其使用的所有限制。如上所述,最好避免自行实现深度复制策略;实际上很少需要这样做。同样,最好避免自行实现共享所有权策略;而应使用智能指针std::shared_ptr<>。
应用程序
附录 A. R 值参考文献
右值引用是一种普通的 C++ 引用,它与普通引用的区别在于初始化规则和带有右值引用类型参数的函数的重载解析规则。右值引用T用 `.` 表示T&&。
例如,我们将使用以下类:
class` `Int`
{
`int` `m_Value`;
`public`:
`Int`(`int` `val`) : `m_Value`(`val`) {}
`int` `Get`() `const` { `return` `m_Value`; }
`void` `Set`(`int` `val`) { `m_Value` `=` `val`; }
};`
与常规引用一样,右值引用也必须进行初始化。
Int&&` `r0`; `
右值引用与普通 C++ 引用的第一个区别在于,它们不能用左值初始化。例如:
Int` `i`(`7`);
`Int&&` `r1` `=` `i`; `// error C2440: 'initializing' : cannot convert from 'Int' to 'Int &&'
要正确初始化,需要使用右值:
Int&&` `r2` `=` `Int`(`42`); `// OK`
`Int&&` `r3` `=` `5`; `// OK
或者必须将左值显式转换为右值引用类型:
Int&&` `r4` `=` `static_cast<Int&&>`(`i`); `// OK
通常情况下,人们不会使用右值引用转换运算符std::move(),而是使用函数(或者更确切地说是函数模板)来执行相同的操作(头文件<utility>)。
右值引用可以使用内置类型的右值进行初始化,但普通引用不允许这样做。
int&&` `r5` `=` `2` `*` `2`; `// OK`
`int&` `r6` `=` `2` `*` `2`; `// error
初始化后,右值引用可以像普通引用一样使用。
Int&&` `r` `=` `7`;
`std::cout` `<<` `r`.`Get`() `<<` `'\n'`;
`r`.`Set`(`19`);
`std::cout` `<<` `r`.`Get`() `<<` `'\n'`; `
R值引用会被隐式转换为常规引用。
Int&&` `r` `=` `5`;
`Int&` `x` `=` `r`; `// OK`
`const` `Int&` `cx` `=` `r`; `// OK
右值引用很少用作独立变量;它们通常用作函数参数。根据初始化规则,如果一个函数具有右值引用参数,则只能使用右值参数调用该函数。
void` `Foo`(`Int&&`);
`Int` `i`(`7`);
`Foo`(`i`); `// error, lvalue аргумент`
`Foo`(`std::move`(`i`)); `// OK`
`Foo`(`Int`(`4`)); `// OK`
`Foo`(`5`); `// OK
如果存在多个重载函数,那么在解析右值参数的重载时,使用右值引用参数的版本优先于使用常规引用或常规常量引用参数的版本,尽管后者也可能是有效的选项。这条规则是右值引用的第二个关键特性。
一个带有按值传递参数的函数和一个带有右值引用参数的重载版本,对于右值参数而言是模棱两可的。
举个例子,我们来考虑一下函数重载。
void` `Foo`(`Int&&`);
`void` `Foo`(`const` `Int&`);`
以及几种称呼它们的选项
Int` `i`(`7`);
`Foo`(`i`); `// Foo(const Int&)`
`Foo`(`std::move`(`i`)); `// Foo(Int&&)`
`Foo`(`Int`(`6`)); `// Foo(Int&&)`
`Foo`(`9`); `// Foo(Int&&)
需要注意的一点是,命名右值引用本身就是一个左值。
Int&&` `r` `=` `7`;
`Foo`(`r`); `// Foo(const Int&)`
`Foo`(`std::move`(`r`)); `// Foo(Int&&)
定义具有右值引用参数的函数时必须考虑到这一点;此类参数是左值,可能需要使用右值引用运算符std::move()。请参阅第 2.4 节中的移动构造函数和移动赋值运算符示例。
C++11 中与右值引用相关的另一个新特性是用于非静态成员函数的引用限定符。这些限定符允许基于隐藏参数的类型(左值/右值)进行重载this。
class` `X`
{
`public`:
`X`();
`void` `DoIt`() `&`;
`void` `DoIt`() `&&`;
`// ...`
};
`X` `x`;
`x`.`DoIt`(); `// DoIt() &`
`X`().`DoIt`(); `// DoIt() &&
附录 B. 移动语义
对于拥有内存缓冲区类型资源并使用深度资源复制策略(例如std::string` std::vector<>init`、`init` 等)的类而言,防止创建临时资源副本是一个重要问题。解决此问题最有效的方法之一是实现移动语义。为此,需要定义一个带有指向源对象的右值引用参数的移动构造函数,以及一个带有相同参数的移动赋值运算符。实现后,数据(包括资源句柄)将从源对象复制到目标对象,并将源对象的资源句柄清零;不会发生资源复制。根据上述重载规则,如果一个类同时具有复制构造函数和移动构造函数,则移动构造函数用于右值初始化,复制构造函数用于左值初始化。如果该类只有移动构造函数,则对象只能使用右值进行初始化。赋值运算符的工作方式类似。除非使用 RVO(资源变量优化),否则从函数返回局部创建的值(包括左值)时也会使用移动语义。