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

时间:2021-02-27 02:21:34

标签: c++ raii

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

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

  1. RAII 类的构造函数应该获取资源或在该过程中失败时抛出异常。
  2. RAII 类的析构函数应该释放资源。

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

但是,似乎资源所有权的转移也会导致打破似乎定义 RAII 的那两个属性。

假设我有两个 RAII 类的实例 - Instance_SourceInstance_Destination - 我将底层资源的所有权从 Instance_Source 转移到 {{1} }.然后我们有:

  • 与属性 2 的冲突:
    • Instance_Destination 的析构函数不会释放任何资源,所以我们现在打破了析构函数应该释放资源的要求。
  • 与属性 1 的冲突:
    • 在初始化中获取资源的一个优点是我可以使用知道它们包含有效资源的实例(否则它们不会被构造)。但是随着所有权转移,我现在剩下的实例不再包含有效资源,这消除了在构造函数中获取资源应该带来的主要优势。
    • 在某些情况下,我不希望 Instance_Source 在其构建期间获取任何资源,因为我希望它仅在特殊条件下获得 Instance_Destination 获取的内容的所有权。为了支持这样的场景,我必须打破构造函数获取资源的要求,允许在不获取任何资源的情况下初始化 Instance_Source

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

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

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

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

3 个答案:

答案 0 :(得分:5)

<块引用>

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

可以,是的。

<块引用>

但是,似乎资源所有权的转移也会导致打破似乎定义 RAII 的那两个属性。

取决于人们如何定义 RAII 的细节。


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

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

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

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

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

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


<块引用>

我希望有人也能提供一个历史视角

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

<块引用>

14.4.1 使用构造函数和析构函数

使用本地对象管理资源的技术通常被称为“资源获取即初始化”。这是一种通用技术,它依赖于构造函数和析构函数的属性以及它们与异常处理的交互。

...

构造函数试图确保其对象被完全正确地构造。当无法实现时,编写良好的构造函数会尽可能地将系统状态恢复到创建前的状态。

<块引用>

14.4.2 Auto_ptr

... auto_ptr,支持“资源获取即初始化”技术。

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

请注意,std::auto_ptr 因在 C++11 中引入移动语义而过时,并已从语言中删除。

<块引用>

E.3.5.3 延迟资源获取

... 应该在构造函数中获取资源只要类的语义不要求延迟获取资源

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

答案 1 :(得分:2)

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

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

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

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

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

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

答案 2 :(得分:1)

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

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

原来的问题

为此,请考虑 C。您有一个认为它是 API 的库,它为您提供了资源:

const char * some_lib_get_last_error_message();

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

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

这是 C 语言中的一个问题,它的处理方式是通过文档。图书馆必须记录用户必须发布的每项资源、如何发布以及何时允许/要求发布。

问题在C++中加重

对于 C++,问题变得更加严重:由于异常,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的第三个param类型可以抛出,foo可以抛出,acquire_resource_2可以抛出,从foo返回的类型到A的转换可以抛出,bar的第一个param类型的构造函数可以抛出,从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 中最重要的概念是所有权。只要资源有所有者,资源获取/释放总是正确完成。

结论

所以,总结一下:

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