RAII 是否支持资源所有权转移?

Does RAII support resource ownership transfer?

我过去主要认为 RAII 是关于使用对象生命周期来避免资源泄漏,这在实践中对我来说已经足够好了。但我最近讨论了一些关于什么是 RAII 模式,什么不是,这让我在网上搜索了更多的定义和评论,结果却增加了更多的混乱而不是清晰。

RAII class 的标准定义似乎需要两个属性:

  1. RAII class 的构造函数应该获取资源,或者如果在该过程中失败则抛出异常。
  2. RAII class 的析构函数应该释放资源。

但是我也看到在一些 RAII 定义中提到资源所有权可以在此类 RAII classes 的实例之间“安全转移”。因此资源所有权转移似乎被接受为 RAII 模式的一部分。

但似乎资源所有权转移也导致破坏了这两个似乎定义 RAII 的属性。

假设我有两个 RAII 实例 class - Instance_SourceInstance_Destination - 我从 [=10= 转移了基础资源的所有权] 到 Instance_Destination。那么我们有:

因此,在我需要允许资源所有权转移的场景中,我发现我必须“放松”关于在构造函数中获取资源并在析构函数中释放它们的 2 个 RAII 要求。这在实践中效果很好,但它仍然构成理论上的 RAII 模式吗?

这就是我提出问题的原因:RAII 是否支持资源所有权转移?

如果答案是,那么看起来大多数 RAII 定义应该重新设计,以不依赖于构造函数和析构函数应该对资源做什么。

如果答案是,那么这应该被强调为 RAII 的一个重要限制。

Does RAII support resource ownership transfer?

可以,是的。

But then it seems that resource ownership transfer also leads to breaking those very 2 properties that seem to define RAII.

有点取决于如何定义 RAII 的细节。


解决方案是扩展问题中显示的 RAII 的定义,以允许表示空状态。如果存在空状态的表示,则可以通过将源 RAII 对象保留在这种空状态来移动资源的所有权。

问题中给出的构造和破坏定义对于对此进行调整是微不足道的:

  1. 构造要么获取资源,要么初始化为空状态。从技术上讲,这不是必需的,但如果允许空状态,则允许默认构造很方便。
  2. 析构函数释放资源当且仅当它拥有任何资源。
  3. 移动的附加定义,如前一段所述。

标准库中的大多数 RAII classes 都具有空状态表示,并且支持传输它们的资源。此类RAIIclasses及其空状态的典型例子:

  • 任何动态容器 - 不包含任何元素(且容量为空)的容器
  • 任何智能指针 - 空值
  • std::fstream - 与文件无关的流
  • std::thread - 不与线程关联的包装器

标准库也有 RAII classes,它们没有空状态的表示,因此不支持资源传输。 class 的一个例子是 std::lock_guard.


I wish someone could also provide a historical perspective

我所拥有的定义的最早来源是 Stroustrup 的书“C++ 编程语言第 3 版”。根据维基百科的估计,RAII 是在 1984-89 年左右开发的,因此到本书出版时,它已经是一个 8-13 岁的想法了。以下是最相关的内容,希望不会侵犯版权:

14.4.1 Using Constructors and Destructors

The technique for managing resources using local objects is usually referred to as "resource acquisition is initialization." This is a general technique that relies on the properties of constructors and destructors and their interaction with exception handling.

...

A constructor tries to ensure that its object is completely and correctly constructed. When that cannot be achieved, a well-written constructor restores - as far as possible - the state of the system to what it was before creation.

14.4.2 Auto_ptr

... auto_ptr, which supports the "resource acquisition is initialization" technique.

鉴于std::auto_ptr不一定拥有资源,因此它的析构函数在这种情况下不会释放资源,它可以将资源转移到另一个实例,创造RAII的作者认为std::auto_ptr“支持 RAII”,我有信心地说,与问题中描述的属性相冲突并不会取消 RAII 的资格。

请注意,std::auto_ptr 因在 C++11 中引入移动语义而被废弃,此后已从该语言中删除。

E.3.5.3 Delaying resource acquisition

... resources should be acquired in constructors whenever delayed resource acquisition isn't mandated by the semantics of a class.

我没有找到关于 RAII 如何与转移资源所有权的能力相关的明确描述。我怀疑在为具有移动语义的语言编写的后续版本中可能会对此进行更多讨论。

我觉得您的分析是基于试图将 RAII 的“规则”视为 RAII 的定义。您似乎在尝试将 RAII 视为正确执行或未正确执行的程序。

RAII 与任何编程习语一样,是一个 原则 存在的目的。 RAII的目的是为了更准确地确保清理需要清理的资源。 RAII 的原则是将这种资源清理绑定到特定程序范围内的某些东西。在 C++ 中执行此操作的方法是使用堆栈对象的构造函数和析构函数(或对象 owned/managed 间接通过堆栈对象,因为 RAII 可以嵌套),它们表示资源绑定的范围。

但这就是 C++ 完成它的方式。或者更确切地说,一个版本的 C++。

转让资源所有权是否违反RAII原则?不;这些资源的清理仍将通过受程序范围界定的机制进行。由于转移,整体范围可能更大,但仍然有界

转移资源所有权是否违反RAII的目的?不;任何资源清理仍会发生。

这是否违反了对RAII规则的一些阅读?也许吧,但我们发明 RAII 并不是为了将自己锁定在 RAII 如何在一种语言的一个版本中使用的低级细节中。我们发明 RAII 是为了解决一个问题。转让所有权并不能阻止 RAII 解决该问题。

构造函数和析构函数是 RAII 技术的机制,它们不是这个习语的目的。

为了更好地理解 RAII,您需要了解对 RAII 的需求从何而来以及 RAII 实际解决了什么问题。

原题

为此考虑 C。你有一个图书馆认为它是 API 它给你一个资源:

const char * some_lib_get_last_error_message();

给你的资源是函数返回的指针所表示的字符串。现在回答这个问题:对象是需要手动生命周期管理的资源吗?如果是这样,谁负责它的creation/destruction?原始指针不足以表达此属性,因此无法回答这个问题(不查看实现或文档)。

该字符串可以是具有静态存储持续时间的对象。在这种情况下,图书馆的用户不得以任何方式“清理”该资源。这样做(例如 free)会导致严重的问题。或者对象可以是具有动态存储持续时间的对象。如果是这样的话,那么我们就会面临另一个困境:谁负责清洁它以及清洁应该如何进行。可能是图书馆以某种方式清理了它。如果是这种情况用户清洗会导致严重的问题。可能是用户必须清洁它。在这种情况下,如果用户忘记清洁它,则会导致严重的问题。然后是用户应该如何清洁它的问题。它可能需要 free 或者它可能需要调用其他库 API。此外,可能还有其他条件来控制何时可以完成此资源的清理。可能要求用户在清理它之前执行一些其他操作,或者可能要求用户在某些其他事件之前不清理它。

这是 C 中的一个问题,它的处理方式是通过文档。图书馆必须记录用户必须释放的每一种资源、如何释放以及何时 allowed/required 释放。

这个问题在 C++ 中更加严重

对于 C++,问题变得更加严重:因为 exceptions C++ 有许多隐藏的函数退出点。因此,使用 C 习惯用法几乎不可能确保按需释放资源。考虑:

auto user_function()
{
    auto resource_r1 = acquire_resource_r1();

    A a = foo(X{}, Y{}, resource_r1);

    auto resource_r2 = acquire_resource_r2();
    B b = bar("text", resource_r2);

    C c = a + b;
    c.use(resource_r1, resournce_r2);

    release_r1(resource_r1);
    release_r2(resource_r2)
}

虽然这在 C 中没有问题,但在 C++ 中这是不正确的代码。根据涉及的类型:acquire_resournce_r1 可能抛出,X 构造函数可能抛出,Y 构造函数可能抛出,从 X 到 foo 的第一个参数类型的转换可能抛出,从 Y 到 foo 的第二个参数类型的转换可能抛出,从 resource_r1 到 A 的第三个参数类型的转换可能抛出,foo 可能抛出,acquire_resource_2 可能抛出,从 foo 返回的类型到 A 的转换可能抛出,第一个的构造函数bar 的参数类型可能会抛出,从 resource_r2 到 bar 的第二个参数类型的转换可能会抛出,bar 可能会抛出,从 bar 返回的类型到 b 的转换可能会抛出,运算符 + 可能抛出,从 + 返回的类型到 C 的转换可能抛出,从 resource_r1 到 use 的第一个参数类型的转换可能抛出,从 resource_r2 到use 的第一个参数类型可以抛出,use 可以抛出,release_r1 可以抛出,release_r2 可以抛出。是的,上面的函数中可以隐藏20个左右的出口点。

那么如何确保 resource_r1resource_r2 始终正确清洁?使用 C 惯用法这样做简直就是一场噩梦。想想这里需要什么样的 try/catch 怪物才能正确清理 resource_r1resource_r2。这也会完全违背异常的目的,因为你需要 catch 异常才能正确地进行清理,即使你不能也不想以任何方式处理错误。

更不用说它仍然会遇到与 C 中相同的问题:您不知道谁负责清理资源。

C++ 解决方案

Bjarne Stroustrup 和 Andrew Koenig 提出了一个巧妙而优雅的解决方案:将资源的生命周期绑定到对象的生命周期。并使用具有自动、线程或静态存储持续时间的对象。一个对象有两个生命周期事件:构造和销毁;对于具有自动、线程和静态存储持续时间的对象,这是由编译器自动完成的。资源有两个生命周期事件:获取和释放;这些需要手动完成。所以RAII将资源的获取绑定到对象的构造,资源的释放绑定到资源的销毁。现在编译器将为您做所有事情:它将正确地清理资源……正确地……不管抛出的异常……或获取的顺序。这不仅是正确完成的,而且资源的用户根本不必为资源生命周期的手动管理而烦恼。

这给我们带来了所有权的概念。请记住,在 C 语言中,没有明确的实体负责资源的销毁。对于 RAII,每个资源总是(至少)有一个所有者:资源生命周期绑定到的对象。这不仅解决了“谁必须清洁”的问题,也解决了“如何清洁”的问题。资源的所有者负责清理并知道如何清理资源。

RAII 中最重要的概念是所有权。只要资源有所有者,资源 acquisition/release 总是正确完成。

结论

所以,总结一下:

  • 资源必须始终由一个对象(或共享所有权的多个对象)拥有。
  • 最常见的资源获取点是在对象构造中。然而,情况并非总是如此。可以在没有资源的情况下创建对象,并且可以在对象的生命周期内获取资源。
  • 释放资源最常见的点是在拥有它的对象的析构函数中。然而,情况并非总是如此。资源可以在对象销毁前由用户手动释放。无论如何,作为资源所有者的对象类型必须始终在析构函数中检查它是否仍然拥有资源,如果拥有则清理它。
  • 资源的所有者可以在资源的生命周期内改变。对象可以交换或窃取彼此的资源。只要没有资源最终成为孤儿(没有所有者)就可以。
  • RAII 利用析构函数、对象生命周期、范围退出、初始化顺序和堆栈展开的 C++ 机制来确保始终正确和正确地清理资源,而无需用户采取任何操作。