【问题标题】:Does RAII support resource ownership transfer?RAII 是否支持资源所有权转移?
【发布时间】:2021-05-29 09:03:09
【问题描述】:

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

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

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

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

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

假设我有两个 RAII 类实例 - Instance_SourceInstance_Destination - 我将基础资源的所有权从 Instance_Source 转移到 Instance_Destination。然后我们有:

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

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

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

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

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

【问题讨论】:

  • unique_ptr 是一个 RAII 应用程序,它允许移动它拥有的东西:stackoverflow.com/questions/29372976/…
  • 我想说std::string 也跟随 RAII,它使用移动语义很好地传输内部指针。关键是 some 实例将始终“拥有”资源并在销毁时释放它。它不一定是首先分配/获取资源的实例。
  • @nahzor:也就是说,unique_ptr 不是 RAII 的完美示例;它还允许您提取资源而不将其转移到另一个 RAII 容器(您可以删除指针并自行管理它;它也可以从它不“获取”的现有指针构造”)。允许获取的资源不受控制,这违反了 RAII 原则。 std::string 是一个更好的例子;您可以将所有权转让给另一个 string,并且您可以查看原始内容,但您不能假设将资源完全从 RAII 管理中移除(不是以任何理智的方式)。
  • 基于@ShadowRanger 的评论,也许我应该问一个不同的问题:RAII 的核心原则是什么?在大多数关于 RAII 的讨论中仍然会提到构造函数中的资源获取和析构函数中的资源释放,尽管大多数人似乎认为它们不是必需的。我认为 bolov 的回答明确了一个更重要的原则,即 资源所有权,但为什么所有关于 RAII 的讨论都没有明确指出不太重要的方面?
  • @ShadowRanger:这是有道理的。谢谢!

标签: c++ raii


【解决方案1】:

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 的作者认为std::auto_ptr “支持 RAII”,我可以自信地说,与问题中描述的属性相冲突并不会取消 RAII 的资格。

请注意,std::auto_ptr 已被 C++11 中引入的移动语义所淘汰,此后已从该语言中删除。

E.3.5.3 延迟资源获取

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

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

【讨论】:

  • 听起来不错。但是为什么网上出现的RAII的定义没有做出这样的调整,仍然坚持这两个属性。 cppreference 甚至说使用 open/close 的模式是 not RAII,尽管您可以很好地使用 open()/close() 并让 close() 被析构函数自动调用 if该资源当时仍然持有。正是这种教条式的评论让我感到困惑。
  • "取决于一个人如何定义 RAII 的细节。" 只是想补充一点,这就是我提出问题的原因。 RAII 似乎都被认为是一个被广泛采用的原则,但同时,对于 RAII 没有明确的参考定义或描述,每个人都可以同意。感觉更像是加勒比海盗的守则:“第三,守则更像是你所说的‘指导方针’,而不是实际的规则。”
  • @LaurentiuCristofor 等到你了解“强类型”、“多态性”、“接口”。就像 RAII 一样,这些都是抽象概念,在不同的上下文中具有不同的具体含义。计算机科学充满了这些。
  • 嗯,我拥有计算机科学博士学位,并且使用 C++ 已经超过 25 年了。我可以告诉你,像 RAII 这样定义不好的概念并不是常态。
【解决方案2】:

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

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

原来的问题

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

const char * some_lib_get_last_error_message();

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

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

这是 C 中的一个问题,它的处理方式是通过文档。库必须记录用户必须释放的每个资源、如何释放以及何时允许/要求释放。

问题在 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的第3个参数类型可以抛出,foo可以抛出,acquire_resource_2可以抛出,从foo返回的类型到A的转换可以抛出,第1个参数类型bar的构造函数可以抛出,从resource_r2到的转换bar 的第二个参数类型可以抛出,bar 可以抛出,从 bar 返回的类型到 b 的转换可以抛出,运算符 + 可以抛出,从 + 返回的类型到 C 的转换可以抛出,从resource_r1 到use 的第一个参数类型的转换可以抛出,从resource_r2 到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++ 机制来确保始终正确和正确地清理资源,而无需用户执行任何操作。

【讨论】:

  • 这是一个很好的答案,但似乎并不代表普遍的共识。例如,cppreference 上的 RAII 定义强调了我提到的这两点,甚至说:“Classes with open()/close(), lock()/unlock(), or init()/copyFrom ()/destroy() 成员函数是非 RAII 类的典型示例”顺便说一句,我建议您编辑您的摘要项目符号以使其数字化,以便我们在对话中更轻松地引用它们。另请注意,您的第四个结论要点中有一个错字:everting 应该是 everything
  • 你没有抓住重点。需要用户调用锁定/解锁以正确管理资源的类是非 RAII。 RAII类可以有lock/unlock成员,但不需要调用unlock,资源会在对象销毁时自动解锁。
  • 为什么网上总是假设对方一定是漏了点?我引用的声明中没有暗示用户必须使用这些方法来正确管理资源。它们的存在暗示了非 RAII 模式。也许该陈述仅在某些特定类的上下文中表示,但如果不指定该上下文,则该陈述将变得不正确且具有误导性。我并没有断章取义。我引用了整个段落。您可以自己检查:en.cppreference.com/w/cpp/language/raii
  • 我还建议您纠正您的假设,即“我正在向后看”。我从维基百科、cppreference 等广泛可用的定义的角度来看待这个问题。如果这些定义和描述与你的相似,我一开始就不会问这个问题。但他们不是。
【解决方案3】:

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

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

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

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

转让资源所有权是否违反了 RAII 的目的?不;仍然会进行任何资源清理。

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

【讨论】:

  • 好吧,正如我已经说过的:我正在寻找 RAII 的明确定义,而那些出现在网上的人坚持这两个属性。如果它们对原则来说不是必不可少的,为什么这些年来我们仍然坚持使用它们?
猜你喜欢
  • 2011-06-23
  • 2018-09-25
  • 1970-01-01
  • 2014-07-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-10-03
相关资源
最近更新 更多