【问题标题】:Is shared ownership of objects a sign of bad design?对象的共享所有权是糟糕设计的标志吗?
【发布时间】:2025-12-27 22:05:11
【问题描述】:

背景:在阅读Dr. Stroustrup's papers 和常见问题解答时,我注意到来自传奇 CS 科学家和程序员的一些强烈的“意见”和很好的建议。其中之一是 C++0x 中的 shared_ptr。他开始解释shared_ptr 以及它如何表示指向对象的共享所有权。最后一行he says and I quote

shared_ptr 代表共享 所有权,但共享所有权不是 我的理想:最好是一个对象 有明确的所有者和明确的, 可预测的寿命。

我的问题:RAII 在多大程度上替代了垃圾收集等其他设计模式?我假设手动内存管理不用于表示系统中的共享所有权。

【问题讨论】:

  • 我认为这句话的出处与此处相同:“请不要轻率地用 shared_ptrs 替换指针以防止内存泄漏” - 不要除非必要,否则使用共享所有权。我非常怀疑 Stroustrup 没有看到管理共享所有权在更复杂的系统中的价值。
  • @gf 我同意你的观点,这是一个设计决策,没有任何决策是 100% 完美的,我认为 Stroustrup 博士的意思是一样的。我会将我的问题改写为“RAII 何时失败”?
  • 这是一个糟糕设计的标志。但这并不是必然糟糕的设计。对我来说,可怕的是所有现代脚本语言默认情况下都使用共享所有权。如果下一个“节点”或“python”默认使用“unique_ptr”样式所有权,那就太好了。当然它会更难使用......但可能更有价值。

标签: c++ garbage-collection raii


【解决方案1】:

RAII 在多大程度上替代了垃圾收集等其他设计模式?我假设手动内存管理不用于表示系统中的共享所有权

嗯,使用 GC,您实际上不必考虑 所有权。只要任何人需要,该对象就会一直存在。共享所有权是默认设置,也是唯一的选择。

当然,所有都可以通过共享所有权来完成。但它有时会导致代码非常笨拙,因为您无法控制或限制对象的生命周期。您必须在 finally 子句中使用 C# 的 using 块或 try/finally 和 close/dispose 调用,以确保对象超出范围时得到清理。

在这些情况下,RAII 更适合:当对象超出范围时,所有的清理工作都应该自动进行。

RAII 在很大程度上替代了 GC。 99% 的情况下,共享所有权并不是您想要的理想状态。这是一个可以接受的折衷方案,以换取通过垃圾收集器省去很多麻烦,但它与您想要的 并不真正匹配。您希望资源在某个时候死亡。不是之前,也不是之后。当 RAII 是一个选项时,它会在这些情况下产生更优雅、简洁和健壮的代码。

虽然 RAII 并不完美。主要是因为它不能很好地处理您只是不知道对象的生命周期的偶尔情​​况。只要有人使用它,它就必须存在很长时间。但是您不想永远保留它(或者只要围绕所有客户端的范围,这可能只是主要功能的全部)。

在这些情况下,C++ 用户必须“降级”到共享所有权语义,通常通过shared_ptr 进行引用计数来实现。在这种情况下,GC 胜出。它可以更稳健地实现共享所有权(例如,能够处理循环)和更有效(与体面的 GC 相比,引用计数的摊销成本巨大

理想情况下,我希望同时看到这两种语言。大多数时候,我想要 RAII,但偶尔,我有一种资源,我只想扔到空中,而不用担心它会在何时何地降落,只要相信它会在它到来时被清理干净这样做是安全的。

【讨论】:

  • 我想要一个参考或解释为什么 ref-counting 成本与 GC 相比是“巨大的”。
  • 使用引用计数,每次创建或销毁引用时,您都需要更新引用计数器(并且必须以原子方式完成,因此成本更高)。使用 GC,您不需要在这些情况下更新单个字节的数据,而是每隔一段时间运行一次垃圾收集,它会遍历当前活动的对象。其摊销成本远小于引用计数的恒定递增-递减周期。
  • 当任何人对它们感兴趣时可以更改的对象通常应该有一个独立的所有者。当任何人对它们感兴趣时不会更改但需要随后清理的对象(例如不可变的 GDI 对象)在理想情况下应该具有共享所有权。不需要清理的不可变对象应该是无主的(只要需要它们就应该存在,不需要时就消失)。
【解决方案2】:

程序员的工作是用他选择的语言优雅地表达事物。

C++ 对栈上对象的构造和销毁具有非常好的语义。如果可以在作用域块的持续时间内分配资源,那么优秀的程序员可能会选择阻力最小的路径。对象的生命周期由可能已经存在的大括号分隔。

如果没有很好的方法将对象直接放入堆栈,也许可以将其作为成员放入另一个对象中。现在它的生命周期稍微长了一点,但 C++ 仍然自动做很多事情。对象的生命周期由父对象界定——问题已被委派。

不过,可能没有一位父母。下一个最好的事情是一系列养父母。这就是auto_ptr 的用途。仍然很好,因为程序员应该知道所有者是哪个特定的父级。对象的生命周期由其所有者序列的生命周期界定。 shared_ptr:生命周期由一群所有者界定。

但也许此资源与系统中的任何其他对象、对象集或控制流都不并发。它是在某个事件发生时创建并在另一个事件时销毁的。尽管有很多工具可以通过委托和其他生命周期来划分生命周期,但它们不足以计算任何任意函数。所以程序员可能会决定编写一个由多个变量组成的函数来确定一个对象是存在还是消失,并调用newdelete

最后,编写函数可能很困难。也许管理对象的规则会花费太多时间和内存来实际计算!可能真的很难优雅地表达它们,回到我原来的观点。因此,我们有垃圾收集:对象的生命周期由您何时需要和何时不需要。


抱歉,我认为回答您问题的最佳方式是上下文:shared_ptr 只是一个计算对象生命周期的工具,它适用于广泛的替代方案。它工作时工作。它应该在它优雅的时候使用。如果您的所有者池少于一个所有者,或者您尝试使用它作为增加/减少的复杂方式来计算一些复杂的函数,则不应使用它。

【讨论】:

  • @Potatoswatter 您的回答强调了一些严肃的观点。对我来说,重点是你谈到的所有权“差距”。为什么会发生?难道我的程序不应该知道它正在使用它“请求”的资源做什么。与垃圾收集相比,我会欣赏一个 RAII 失败的示例。
  • GCC 使用垃圾收集,因为它比花费时间和可能的内存来跟踪它使用的大量对象更优雅并且可能更有效。垃圾收集也非常适合用户可能实现内存泄漏的脚本语言。
  • @Potatoswatter GCC 是用 C 而不是 C++ 编写的,所以这个例子回答了错误的问题:)
  • 您是否要求使用 RAII 用 C++ 编写并因此失败的产品? ;v)我上面概述的信念是 RAII 和 GC 之间的中间立场是新的/删除的。也许我应该概括并说编译器不太适合 RAII。我知道一个编译器确实对所有内容都使用了引用计数,但它并不是很成功,所以你去吧。不过,它早于 C++。
  • 应该auto_ptrunique_ptr,还是你是老派?
【解决方案3】:

我的问题:RAII 在多大程度上替代了其他设计模式 像垃圾收集?我假设手动内存管理 不用于表示系统中的共享所有权。

我不确定是否称其为设计模式,但在我同样强烈的意见中,仅谈论内存资源,RAII 几乎解决了 GC 可以解决的所有问题,同时引入的更少。

对象的共享所有权是糟糕设计的标志吗?

我认为共享所有权在大多数情况下远非理想,因为高级设计不一定需要它。唯一一次我发现它不可避免是在持久数据结构的实现过程中,它至少被内化为实现细节。

我发现 GC 或共享所有权的最大问题是,它不会让开发人员在应用程序资源方面免除任何责任,但会让人产生这样做的错觉。如果我们有这样的情况(Scene 是资源的唯一逻辑所有者,但其他事物持有指向它的引用/指针,例如存储用户定义的场景排除列表以从渲染中省略的相机):

假设应用程序资源就像一个图像,它的生命周期与用户输入相关联(例如:当用户请求关闭包含它的文档时应该释放图像),然后正确释放资源的工作无论有没有 GC,都是一样的。

如果没有 GC,我们可能会将其从场景列表中删除并允许调用其析构函数,同时触发事件以允许 Thing1Thing2Thing3 将指向它的指针设置为 null 或删除将它们从列表中提取出来,这样它们就没有悬空指针。

使用 GC,基本上是一样的。我们在触发事件时从场景列表中删除资源,以允许 Thing1Thing2Thing3 将它们的引用设置为 null 或从列表中删除它们,以便垃圾收集器可以收集它。

雷达下的无声程序员错误

这种情况的不同之处在于发生程序员错误时会发生什么,例如 Thing2 未能处理删除事件。如果Thing2 存储了一个指针,它现在有一个悬空指针,我们可能会崩溃。这是灾难性的,但我们可能很容易在单元和集成测试中捕捉到一些东西,或者至少 QA 或测试人员会很快捕捉到一些东西。我不在任务关键型或安全关键型环境中工作,所以如果崩溃的代码设法以某种方式发布,如果我们能够获得错误报告、重现它、检测它并相当快地修复它,那仍然不是那么糟糕.

如果Thing2 存储了一个强引用并共享所有权,我们就有一个非常静默的逻辑泄漏,并且直到Thing2 被销毁(它可能在关闭之前它可能不会被销毁)才会被释放。在我的领域中,错误的这种沉默性质是非常有问题的,因为即使在交付之后它也可能被悄悄地忽视,直到用户开始注意到在应用程序中工作一个小时会导致它占用千兆字节的内存,例如,并开始减速直到他们重新启动它。到那时,我们可能已经积累了大量这些问题,因为它们很容易像隐形战斗机一样在雷达下飞行,而我最不喜欢的就是隐形战斗机。

正是由于这种沉默的本性,我倾向于不喜欢热情地共享所有权,而 TBH 我一直不明白为什么 GC 如此受欢迎(可能是我的特殊领域——我承认我非常不了解那些有使命感的领域) -critical,例如)到我渴望新语言的地步没有 GC。我发现调查与共享所有权相关的所有此类泄漏非常耗时,有时调查数小时才发现泄漏是由我们无法控制的源代码(第三方插件)引起的。

弱引用

对于Thing1Thing2Thing3,弱引用在概念上对我来说是理想的。这将允许他们事后检测资源何时被破坏,而无需延长其生命周期,也许我们可以保证在这些情况下发生崩溃,或者有些人事后甚至可以优雅地处理这个问题。对我来说,问题是弱引用可以转换为强引用,反之亦然,因此在内部和第三方开发人员中,即使弱引用本来可以,仍然可能有人不小心将强引用存储在Thing2更合适。

我过去曾尝试鼓励在内部团队中尽可能多地使用弱引用,并记录应该在 SDK 中使用它。不幸的是,很难在如此广泛和混杂的人群中推广这种做法,而且我们仍然存在逻辑漏洞。

在任何给定时间,任何人都可以轻松地通过在对象中存储对它的强引用来延长对象的生命周期,这在俯视庞大的代码库时开始变得非常可怕这会泄漏大量资源。我经常希望需要一种非常明确的语法来将任何类型的强引用存储为某种对象的成员,这至少会导致开发人员三思而后行。

显式破坏

所以我倾向于显式销毁持久性应用程序资源,如下所示:

on_removal_event:
    // This is ideal to me, not trying to release a bunch of strong
    // references and hoping things get implicitly destroyed.
    destroy(app_resource);

...因为我们可以依靠它来释放资源。我们不能完全确定系统中的某些东西最终不会出现悬空指针或弱引用,但至少这些问题在测试中往往很容易检测和重现。它们不会被忽视并积累多年。

对我来说,一个棘手的案例一直是多线程。在这些情况下,我发现有用而不是全面的垃圾收集,或者说,shared_ptr,就是以某种方式简单地推迟销毁:

on_removal_event:
    // *May* be deferred until threads are finished processing the resource.
    destroy(app_resource);

在某些系统中,持久线程以某种方式统一,使得它们具有processing 事件,例如,我们可以在未处理线程的时间片中以延迟方式标记要销毁的资源(几乎开始感觉像是停止世界的 GC,但我们保持显式销毁)。在其他情况下,我们可能会使用引用计数,但要避免shared_ptr,其中资源的引用计数从零开始,并将使用上面的显式语法销毁,除非线程通过增加计数器在本地延长其生命周期临时(例如:在本地线程函数中使用作用域资源)。

虽然看起来很迂回,但它避免了将 GC 引用或 shared_ptr 暴露给外部世界,这很容易诱使一些开发人员(在您的团队内部或第三方开发人员内部)存储强引用(shared_ptr,例如)作为Thing2 之类的对象的成员,从而无意中延长了资源的生命周期,并且可能远远超过适当的时间(可能一直到应用程序关闭)。

RAII

同时,RAII 与 GC 一样自动消除物理泄漏,但此外,它还适用于内存以外的资源。我们可以将它用于一个作用域互斥体,一个在销毁时自动关闭的文件,我们甚至可以使用它来通过作用域保护等自动逆转外部副作用。

因此,如果有选择,我必须选择一个,这对我来说很容易 RAII。在我工作的领域中,由共享所有权引起的无声内存泄漏绝对是致命的,如果(并且很可能)在测试期间尽早发现,悬挂指针崩溃实际上是更可取的。即使在一些非常模糊的事件中,如果它在发生错误的站点附近出现崩溃,这仍然比使用内存分析工具并试图找出谁在涉水数百万时忘记释放引用更可取的代码行。坦率地说,GC 为我的特定领域(VFX,在场景组织和应用程序状态方面有点类似于游戏)引入的问题比它解决的问题要多,除了那些非常无声的共享所有权泄漏之外,原因之一是因为它会给开发人员一种错误的印象,即他们不必考虑资源管理和持久应用程序资源的所有权,而无意中导致左右逻辑泄漏。

“RAII 何时失败”

我在整个职业生涯中遇到的唯一一个我想不出任何可能的方法来避免某种共享所有权的情况是,当我实现了一个持久数据结构库时,如下所示:

我用它来实现一个不可变的网格数据结构,它可以修改部分而不是唯一的,就像这样(用 400 万个四边形进行测试):

每一帧,当用户拖动并雕刻它时,都会创建一个新的网格。不同之处在于,新网格是强引用部分,不是由画笔制作的唯一部分,因此我们不必复制所有顶点、所有多边形、所有边等。不可变版本使线程安全、异常安全、无损编辑、撤消系统、实例化等。

在这种情况下,不可变数据结构的整个概念都围绕共享所有权展开,以避免复制并非唯一的数据。这是一个真实的案例,无论如何我们都无法避免共享所有权(至少我想不出任何可能的方式)。

这是我遇到的唯一可能需要 GC 或引用计数的情况。其他人可能也遇到过一些他们自己的情况,但根据我的经验,很少有案例真正需要在设计级别共享所有权。

【讨论】:

    【解决方案4】:

    垃圾回收是一种设计模式吗?我不知道。

    共享所有权的一大优势在于其固有的可预测性。使用 GC,资源回收不在您的掌控之中。这才是重点。使用它的开发人员通常不会考虑何时以及如何发生。通过共享所有权,您可以控制(请注意,有时控制过多是一件坏事)。假设您的应用程序产生了一百万个 shared_ptr 到 X。所有这些都是您所做的,您对它们负责,并且您可以完全控制何时创建和销毁这些引用。因此,一个坚定而细心的程序员应该知道谁引用了什么以及引用了多长时间。如果你想销毁一个对象,你必须销毁所有对它的共享引用,中提琴,它已经消失了。

    这对开发实时软件的人来说会产生一些深远的影响,而这必须是完全可预测的。这也意味着你可以用看起来很像内存泄漏的方式来捏造。我个人不想在不需要的时候成为一个坚定而细心的程序员(继续笑,我想去野餐和骑自行车,不计算我的引用),所以在适当的情况下,我更喜欢 GC路线。我编写了一些实时声音软件,并使用共享引用来可预测地管理资源。

    您的问题:RAII 何时失败? (在共享参考的上下文中) 我的回答:当您无法回答问题时:谁可能对此有参考?当恶性平淡的所有权循环发展时。

    我的问题:GC 什么时候失败? 我的回答:当您想要完全控制和可预测性时。当 GC 是 Sun Microsystems 在最后期限前编写的,并且具有只能由从微软借来的醉酒的原始人类代码猴子设计和实现的荒谬行为。

    我的观点:我认为 BS 对清晰的设计非常认真。显然,拥有一个销毁资源的地方通常比拥有许多可能销毁资源的地方更清晰。

    【讨论】:

      最近更新 更多