我的问题:RAII 在多大程度上替代了垃圾收集等其他设计模式?我假设手动内存管理不用于表示系统中的共享所有权。
我不确定是否称其为设计模式,但在我同样强烈的意见中,仅谈论内存资源,RAII 几乎解决了 GC 可以解决的几乎所有问题,同时引入的更少。
对象的共享所有权是糟糕设计的标志吗?
我同意共享所有权在大多数情况下远非理想的想法,因为高级设计不一定需要它。唯一一次我发现它不可避免是在持久数据结构的实现过程中,它至少被内化为实现细节。
我发现 GC 或共享所有权的最大问题是,它不会让开发人员在应用程序资源方面免除任何责任,但会让人产生这样做的错觉。如果我们有这样的情况(Scene
是资源的唯一逻辑所有者,但其他事物持有指向它的引用/指针,就像相机存储用户定义的场景排除列表以从渲染中省略):
假设应用程序资源就像一个图像,它的生命周期与用户输入相关联(例如:当用户请求关闭包含它的文档时应该释放图像),那么正确释放资源的工作是相同的有或没有 GC。
如果没有 GC,我们可能会将其从场景列表中删除并允许调用其析构函数,同时触发事件以允许Thing1
,Thing2
并将Thing3
其指向它的指针设置为 null 或将它们从列表中删除,以便它们没有悬空指针。
使用 GC,基本上是一样的。我们从场景列表中删除资源,同时触发事件以允许Thing1
、Thing2
和Thing3
将它们的引用设置为 null 或将它们从列表中删除,以便垃圾收集器可以收集它。
雷达下的无声程序员错误
这种情况的不同之处在于发生程序员错误时会发生什么,例如Thing2
未能处理删除事件。如果Thing2
存储一个指针,它现在有一个悬空指针,我们可能会崩溃。这是灾难性的,但我们可能很容易在单元和集成测试中捕捉到一些东西,或者至少 QA 或测试人员会很快捕捉到一些东西。我不在任务关键型或安全关键型环境中工作,所以如果崩溃的代码设法以某种方式发布,如果我们能够获得错误报告、重现它、检测它并相当快地修复它,那仍然不是那么糟糕.
如果Thing2
存储一个强引用并共享所有权,我们就会有一个非常安静的逻辑泄漏,并且图像在Thing2
被销毁之前不会被释放(它可能在关闭之前不会被销毁)。在我的领域中,这种无声的错误本质是非常有问题的,因为即使在交付之后它也可能被悄悄地忽视,直到用户开始注意到在应用程序中工作一个小时会导致它占用千兆字节的内存,例如,并开始放慢速度直到他们重新启动它。到那时,我们可能已经积累了大量这些问题,因为它们很容易像隐形战斗机一样在雷达下飞行,而我最不喜欢的就是隐形战斗机。
正是由于这种沉默的本性,我倾向于不喜欢热情地共享所有权,而 TBH 我从来不明白为什么 GC 如此受欢迎(可能是我的特定领域——我承认我对关键任务的领域非常无知,例如)到了我渴望没有 GC的新语言的地步。我发现调查与共享所有权相关的所有此类泄漏非常耗时,有时调查数小时才发现泄漏是由我们无法控制的源代码(第三方插件)引起的。
弱引用
弱引用在概念上对我来说是理想的Thing1
,Thing2
和Thing3
. 这将允许他们事后检测资源何时被破坏,而无需延长其生命周期,也许我们可以保证在这些情况下发生崩溃,或者有些人事后甚至可以优雅地处理这个问题。对我来说,问题是弱引用可以转换为强引用,反之亦然,因此在内部和第三方开发人员中,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 或引用计数的情况。其他人可能也遇到过一些自己的情况,但根据我的经验,很少有案例真正需要在设计级别共享所有权。