13

背景:在阅读Stroustrup 博士的论文和常见问题解答时,我注意到一些来自传奇 CS 科学家和程序员的强烈“意见”和很好的建议。其中之一是关于shared_ptrC++0x 的。他开始解释shared_ptr以及它如何表示指向对象的共享所有权。在最后一行,他说,我引用

. Ashared_ptr代表共享所有权,但共享所有权不是我的理想:如果一个对象有一个明确的所有者和一个明确的、可预测的生命周期,那就更好了。

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

4

4 回答 4

14

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

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

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

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

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

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

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

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

于 2009-12-25T09:04:21.373 回答
8

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

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

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

不过,可能没有一位父母。下一个最好的事情是一系列养父母。这是auto_ptr为了什么。仍然很好,因为程序员应该知道所有者是哪个特定的父级。对象的生命周期由其所有者序列的生命周期界定。决定论和本身优雅的链条的一个步骤是shared_ptr:生命周期由一群所有者的联合划定。

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

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


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

于 2009-12-25T04:29:56.600 回答
4

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

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

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

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

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

在此处输入图像描述

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

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

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

雷达下的无声程序员错误

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

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

在此处输入图像描述

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

弱引用

弱引用在概念上对我来说是理想的Thing1,Thing2Thing3. 这将允许他们事后检测资源何时被破坏,而无需延长其生命周期,也许我们可以保证在这些情况下发生崩溃,或者有些人事后甚至可以优雅地处理这个问题。对我来说,问题是弱引用可以转换为强引用,反之亦然,因此在内部和第三方开发人员中,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事件,例如,我们可以在未处理线程的时间片中以延迟方式标记要销毁的资源(几乎开始感觉像停止-the-world GC,但我们保持显式销毁)。在其他情况下,我们可能会使用引用计数,但以一种避免的方式shared_ptr,其中资源的引用计数从零开始,并且将使用上面的显式语法销毁,除非线程通过临时递增计数器来本地延长其生命周期(例如:在本地线程函数中使用作用域资源)。

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

RAII

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

因此,如果有选择,我必须选择一个,这对我来说很容易 RAII。我工作的领域是共享所有权导致的无声内存泄漏绝对是致命的,如果(并且很可能)在测试期间尽早发现,悬挂指针崩溃实际上是更可取的。即使在一些非常模糊的事件中,如果它在发生错误的站点附近出现崩溃,这仍然比使用内存分析工具并试图找出谁在涉水数百万时忘记释放引用更可取的代码行。直截了当地说,GC 引入的问题多于它为我的特定领域解决的问题(VFX,在场景组织和应用程序状态方面有点类似于游戏),

“RAII 什么时候失败”

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

在此处输入图像描述

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

在此处输入图像描述

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

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

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

于 2018-02-04T06:51:46.407 回答
3

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

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

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

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

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

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

于 2009-12-26T08:02:51.170 回答