当传递到另一个对象时,谁应该在 IDisposable 对象上调用 Dispose?

Posted

技术标签:

【中文标题】当传递到另一个对象时,谁应该在 IDisposable 对象上调用 Dispose?【英文标题】:Who should call Dispose on IDisposable objects when passed into another object? 【发布时间】:2011-05-04 09:51:37 【问题描述】:

当一次性对象被传递到另一个对象的方法或构造函数时,关于谁应该调用Dispose() 是否有任何指导或最佳实践?

这里有几个例子说明我的意思。

IDisposable 对象被传递给一个方法(完成后是否应该将其处理掉?):

public void DoStuff(IDisposable disposableObj)

    // Do something with disposableObj
    CalculateSomething(disposableObj)

    disposableObj.Dispose();

IDisposable 对象被传递给一个方法并保留一个引用(当MyClass 被释放时它应该释放它吗?):

public class MyClass : IDisposable

    private IDisposable _disposableObj = null;

    public void DoStuff(IDisposable disposableObj)
    
        _disposableObj = disposableObj;
    

    public void Dispose()
    
        _disposableObj.Dispose();
    

我目前认为,在第一个示例中,DoStuff()调用者 应该处理对象,因为它可能创建了对象。但在第二个示例中,感觉就像 MyClass 应该处置该对象,因为它保留了对它的引用。这样做的问题是调用类可能不知道MyClass 保留了一个引用,因此可能决定在MyClass 完成使用它之前处置该对象。 这种情况有什么标准规则吗?如果有,将一次性对象传递给构造函数时它们是否不同?

【问题讨论】:

【参考方案1】:

P.S.:我已经发布了一个new answer(包含一组简单的规则应该调用Dispose,以及如何设计一个处理IDisposable 对象的API)。虽然目前的答案包含有价值的想法,但我开始相信它的主要建议在实践中通常不起作用:在“粗粒度”对象中隐藏 IDisposable 对象通常意味着那些需要成为 IDisposable 自己;所以一个在一个开始的地方结束,问题仍然存在。


当一次性对象被传递到另一个对象的方法或构造函数时,是否有任何指导或最佳实践?

简答:

是的,关于这个话题有很多建议,我所知道的最好的是Eric EvansDomain-Driven Design 中的Aggregates 概念。 (简单地说,应用于IDisposable 的核心思想是:将IDisposable 封装在一个粗粒度的组件中,使其不被外部看到,并且永远不会传递给组件消费者。)

此外,IDisposable 对象的创建者也应该负责处理它的想法过于严格,在实践中通常行不通。

我的其余答案以相同的顺序详细介绍了这两点。我将用一些指向与同一主题相关的其他材料的提示来结束我的回答。

更长的答案 - 这个问题的含义更广泛:

关于这个主题的建议通常不是针对IDisposable。每当人们谈论对象生命周期和所有权时,他们所指的都是同一个问题(但更笼统)。

为什么这个话题在 .NET 生态系统中很少出现?因为 .NET 的运行时环境(CLR)执行自动垃圾收集,它为您完成所有工作:如果您不再需要一个对象,您可以简单地忘记它,垃圾收集器最终会回收它的记忆。

那么,为什么会提出IDisposable 对象的问题?因为IDisposable 是关于对(通常是稀疏或昂贵的)资源生命周期的显式、确定性控制:IDisposable 对象应该在不再需要时立即释放——以及垃圾收集器的不确定保证(“我'将最终回收你使用的内存!”)根本不够好。

您的问题,用更广泛的对象生命周期和所有权术语重新表述:

哪个对象 O 应该负责结束(一次性)对象 D 的生命周期,该对象也会传递给对象 @987654352 @?

让我们建立一些假设:

IDisposable 对象调用 D.Dispose()D 基本上结束了它的生命周期。

从逻辑上讲,一个对象的生命周期只能结束一次。 (暂时不要介意这与IDisposable 协议相反,该协议明确允许多次调用Dispose。)

因此,为了简单起见,只有一个对象 O 应该负责处理 D。我们打电话给 O 所有者。

现在我们进入问题的核心:C# 语言和 VB.NET 都没有提供强制对象之间所有权关系的机制。所以这变成了一个设计问题:所有接收到另一个对象 D 引用的对象 O,X,Y,Z 必须遵循并遵守一个约定,该约定准确地规定了谁拥有所有权在D

使用聚合简化问题!

我在这个主题上找到的最好的建议来自Eric Evans' 2004 年的书,Domain-Driven Design。让我从书中引用:

假设您正在从数据库中删除一个 Person 对象。除了这个人,还有姓名、出生日期和工作描述。但是地址呢?同一地址可能还有其他人。如果您删除该地址,这些 Person 对象将具有对已删除对象的引用。如果你离开它,你会在数据库中积累垃圾地址。自动垃圾收集可以消除垃圾地址,但该技术修复,即使在您的数据库系统中可用,也会忽略一个基本的建模问题。(第 125 页)

看看这与您的问题有何关系?这个例子中的地址相当于你的一次性物品,问题都是一样的:谁应该删除它们?谁“拥有”它们?

Evans 继续建议将 Aggregates 作为此设计问题的解决方案。再次从书中:

Aggregate 是一组关联对象,我们将其视为一个用于数据更改目的的单元。每个聚合都有一个根和一个边界。边界定义了聚合内的内容。根是聚合中包含的单个特定实体。根是 Aggregate 中唯一允许外部对象持有引用的成员,尽管边界内的对象可能持有对彼此的引用。 (pp. 126-127)

这里的核心信息是您应该将IDisposable 对象的传递限制为其他对象的严格限制集合(“聚合”)。该聚合边界之外的对象永远不应直接引用您的IDisposable。这大大简化了事情,因为您不再需要担心所有对象的最大部分,即聚合之外的那些,是否可能Dispose 您的对象。您需要做的就是确保边界内部的对象都知道谁负责处理它。这应该是一个很容易解决的问题,因为您通常会一起实现它们并注意保持聚合边界合理“紧密”。

IDisposable 对象的创建者也应该处置它的建议怎么样?

这个指导方针听起来很合理,并且有一种吸引人的对称性,但仅凭它本身,它在实践中往往行不通。可以说,这与说“永远不要将对 IDisposable 对象的引用传递给其他对象”的含义相同,因为一旦这样做,您就有可能使接收对象假设它的所有权和在你不知情的情况下处理它。

让我们看一下 .NET 基类库 (BCL) 中明显违反此经验规则的两个突出的接口类型:IEnumerable<T>IObservable<T>。两者本质上都是返回 IDisposable 对象的工厂:

IEnumerator<T> IEnumerable<T>.GetEnumerator() (请记住,IEnumerator<T> 继承自 IDisposable。)

IDisposable IObservable<T>.Subscribe(IObserver<T> observer)

在这两种情况下,调用者都应该处理返回的对象。可以说,我们的指导方针在对象工厂的情况下根本没有意义……除非我们可能要求IDisposable 发布。

顺便说一下,此示例还展示了上述聚合解决方案的局限性:IEnumerable<T>IObservable<T> 在本质上都过于笼统,无法成为聚合的一部分。聚合通常是非常特定于域的。

更多资源和想法:

在 UML 中,对象之间的“具有”关系可以通过两种方式建模:作为聚合(空菱形)或组合(填充菱形)。组合与聚合的不同之处在于包含/引用对象的生命周期以容器/引用者的生命周期结束。您最初的问题暗示了聚合(“可转让所有权”),而我主要转向使用组合的解决方案(“固定所有权”)。请参阅Wikipedia article on "Object composition"。

Autofac(.NET IoC 容器)通过两种方式解决了这个问题:或者通过通信,使用所谓的relationship type、Owned<T>,后者获得了IDisposable 的所有权;或者通过工作单元的概念,在 Autofac 中称为生命周期范围。

关于后者,Autofac 的创建者 Nicholas Blumhardt 写了"An Autofac Lifetime Primer",其中包括“IDisposable 和所有权”一节。整篇文章是一篇关于 .NET 中的所有权和生命周期问题的优秀论文。我推荐阅读它,即使是对 Autofac 不感兴趣的人。

在 C++ 中,Resource Acquisition Is Initialization (RAII) 习惯用法(一般)和 smart pointer types(特别是)帮助程序员正确处理对象生命周期和所有权问题。不幸的是,这些不能转移到 .NET,因为 .NET 缺乏 C++ 对确定性对象销毁的优雅支持。

另请参阅 this answer 到 Stack Overflow 上的问题 "How to account for disparate implementation needs?",它(如果我理解正确的话)遵循与我基于聚合的答案类似的想法:围绕 @ 构建粗粒度组件987654381@ 使其完全包含在其中(并且对组件使用者隐藏)。

【讨论】:

+1 好答案。我喜欢聚合的想法、良好的现实世界示例和模式。 不错的答案。几点注意事项:(1)我认为“所有权”的概念对几乎所有可变对象都很重要,无论它们是否拥有资源。我认为 99.44% 的可变对象应该有一个所有者对象,该对象将可变对象的状态视为自己的状态。持有对可变对象的引用的其他对象应该将这些引用视为封装了它的身份,而不是它的状态。 (2) 聚合概念在这里很强大,因为它使聚合中对象的身份成为局部而不是全局概念。 @supercat:你的第一条笔记给了我很多思考。一旦我有时间更深入地思考它就会回答它。我想我们应该聚在一起喝一杯(极客)啤酒! ;) 正如你已经说过的,C++ 析构函数也就是确定性对象的生命周期。 很好的问题,但不幸的是大多数系统都不是基于域的。有基于服务的,我们有一些控制器、服务、处理器和存储库。所以聚合不适合这些场景【参考方案2】:

一般规则是,如果您创建(或获得了该对象的所有权),那么您有责任处置它。这意味着,如果您在方法或构造函数中接收到作为参数的一次性对象,则通常不应将其丢弃。

请注意,.NET 框架中的某些类确实会处理它们作为参数接收的对象。例如处理 StreamReader 也会处理底层的Stream

【讨论】:

这是我最初的想法,但我查看了 StreamWriter 类,该类在其构造函数中采用 Stream 并在其 Dispose 方法中处理 Stream。 @Jon:Mark 关于所有权是正确的。然而,在这里你进入了 API 设计的艺术。 .NET 框架中的某些地方设计者没有遵循这条规则,因为他们认为这使得在常见场景中更容易使用这些类型。例如,现在您可以编写:new StreamReader(File.Open("log.txt")) 而不必将File.Open 包装在自己的using 中。这种设计将用户引导到“成功的坑”。然而,这种设计在 IMO 上是有问题的,因为用户可能不会预料到这种行为,这使得它在其他场景中更难使用,并且损害了整体可用性。 @Steven:StreamReader 必须提供一种方法,通过该方法,以 StreamReader 结束的例程可以处理任何包装的对象,而不必知道它们是什么(因为 StreamReader 的不同派生可能包装不同的对象)。处理此类问题的好方法可能是将构造函数参数传递给 StreamReader,指示 (1) 流是否应该取得对象的所有权并处置它,或者 (2) 底层对象预计比流长,其他东西会处理它。 @supercat:没错。取得所有权并处置对象本身并不是一件坏事,但 API 至少应该让您能够抑制处置,正如您所描述的那样。 这似乎是显而易见的方法,但工厂/建造者除外(它应该创造一些东西而什么都不做)。 .Net 框架有一些值得注意的例外(Streams、TextReader、TextWriter 等),在规则 CA2213 (docs.microsoft.com/en-us/visualstudio/code-quality/…) 的特殊情况文档中称为“处置所有权转移”(更多讨论:github.com/dotnet/roslyn-analyzers/issues/2413)【参考方案3】:

这是my previous answer的后续;查看它最初的评论以了解我为什么要发布另一个。

我之前的回答是对的:每个IDisposable 都应该有一个专属的“所有者”,他将负责Dispose-ing 一次。 然后管理IDisposable 对象变得非常类似于在非托管代码场景中管理内存。

.NET 的前身技术,组件对象模型 (COM),在对象之间使用了以下protocol for memory management 职责:

“内参数必须由调用者分配和释放。 “输出参数必须由被调用方分配;它们由调用方释放 […]。 “in-out-parameters 最初由调用者分配,然后在必要时由被调用者释放和重新分配。对于 out 参数也是如此,调用者负责释放最终返回值。”李>

(对于错误情况还有其他规则;有关详细信息,请参阅上面链接的页面。)

如果我们要为 IDisposable 调整这些准则,我们可以制定以下内容……

关于IDisposable 所有权的规则:

    IDisposable 通过常规参数传递给方法时,不会转移所有权。被调用的方法可以使用IDisposable,但不能使用Dispose(也不能传递所有权;参见下面的规则4)。 当通过out 参数或返回值从方法返回IDisposable 时,所有权将从方法转移到其调用者。调用者必须Dispose 它(或以相同方式传递IDisposable 的所有权)。 当通过ref 参数将IDisposable 赋予方法时,其所有权将转移给该方法。该方法应将IDisposable 复制到局部变量或对象字段中,然后将ref 参数设置为null

从上面得出的一条可能很重要的规则:

    如果您没有所有权,则不得将其传递。这意味着,如果您通过常规参数收到IDisposable 对象,请不要将相同的对象放入ref IDisposable 参数中,也不要通过返回值或out 参数公开它。

示例:

sealed class LineReader : IDisposable

    public static LineReader Create(Stream stream)
    
        return new LineReader(stream, ownsStream: false);
    

    public static LineReader Create<TStream>(ref TStream stream) where TStream : Stream
    
        try      return new LineReader(stream, ownsStream: true); 
        finally  stream = null;                                   
    

    private LineReader(Stream stream, bool ownsStream)
    
        this.stream = stream;
        this.ownsStream = ownsStream;
    

    private Stream stream; // note: must not be exposed via property, because of rule (2)
    private bool ownsStream;

    public void Dispose()
    
        if (ownsStream)
        
            stream?.Dispose();
        
    

    public bool TryReadLine(out string line)
    
        throw new NotImplementedException(); // read one text line from `stream` 
    

这个类有两个静态工厂方法,因此它的客户可以选择是保留还是传递所有权:

通过常规参数接受Stream 对象。这向调用者发出信号,表明所有权不会被接管。因此调用者需要Dispose:

using (var stream = File.OpenRead("Foo.txt"))
using (var reader = LineReader.Create(stream))

    string line;
    while (reader.TryReadLine(out line))
    
        Console.WriteLine(line);
    

通过ref 参数接受Stream 对象。这向调用者发出了所有权将被转移的信号,因此调用者不需要Dispose

var stream = File.OpenRead("Foo.txt");
using (var reader = LineReader.Create(ref stream))

    string line;
    while (reader.TryReadLine(out line))
    
        Console.WriteLine(line);
    

有趣的是,如果 stream 被声明为 using 变量:using (var stream = …),编译将失败,因为 using 变量不能作为 ref 参数传递,因此 C# 编译器有助于在此特定情况下强制执行我们的规则案例。

最后,请注意File.OpenRead 是通过返回值返回IDisposable 对象(即Stream)的方法的示例,因此返回流的所有权转移给调用者。

缺点:

这种模式的主要缺点是 AFAIK,没有人使用它(目前)。因此,如果您与任何不遵循上述规则的 API(例如,.NET Framework 基类库)进行交互,您仍然需要阅读文档以找出谁必须在 IDisposable 对象上调用 Dispose

【讨论】:

CLS 合规性要求重载函数签名的差异超过其参数的引用;我建议有一个参数(可能是enumbool)来指示工厂是否应该获得所有权会更简洁。以这种方式使用参数可以包装对象创建,而无需编写两个单独的包装方法。 @supercat:同意一个额外的takeOwnership 参数就足够了(并且也符合 CLS)。我回答的核心是将IDisposable 作为ref 传递的想法,以便可以重置调用者的引用。我发现这非常优雅和新颖的IDisposable 模式,我以前从未见过讨论过。然而,由于ref 参数是不变的(如果这是正确的术语),因此这种模式仅适用于一个构造函数并不能很好地工作。所以,是的,如果 (a) CLS 合规性很重要,或者 (b) 你想少打字,你的建议绝对是更好、更容易的选择。 不幸的是,问题的根源在于需要拥有一个对象;但是没有机制来声明(并记住、传递)谁拥有该对象。如果有一个可选的“IDisposable.Owner”字段,就会有一种“传递接力棒”的方法。以及一种了解“其他人已经拥有它”的方法。以及一种了解“现在其他人拥有它,所以我不应该处置它”的方法。还可以轻松拥有“多个所有者”(所有者字段将是一个集合)。当最后一个所有者说“我完成了”时,它会立即处理。 “引用计数”是这个的廉价版本。【参考方案4】:

一般来说,一旦您处理了 Disposable 对象,您就不再处于理想的托管代码世界中,在这种世界中,生命周期所有权是一个有争议的问题。因此,您需要考虑逻辑上什么对象“拥有”或负责一次性对象的生命周期。

一般来说,对于刚刚传递给方法的一次性对象,我会说不,该方法不应该处理该对象,因为一个对象承担另一个对象的所有权然后完成处理的情况非常罕见它以相同的方法。在这些情况下,调用者应负责处理。

在谈论成员数据时,没有自动回答说“是,总是处置”或“不,从不处置”。相反,您需要考虑每个特定情况下的对象并问自己:“这个对象是否对一次性对象的生命周期负责?”

经验法则是负责创建可处置对象的对象拥有它,因此负责稍后处置它。如果有所有权转让,这不成立。例如:

public class Foo

    public MyClass BuildClass()
    
        var dispObj = new DisposableObj();
        var retVal = new MyClass(dispObj);
        return retVal;
    

Foo 显然负责创建dispObj,但它会将所有权传递给MyClass 的实例。

【讨论】:

对于在其生命周期内永远不会改变的对象或未共享的对象,所有权只是一个有争议的问题;但是如果封装在共享对象中的状态可以改变但不清楚谁拥有它,那么使用共享对象编写正确的代码几乎是不可能的。 是的,弱设计就是弱。共享可变对象中的旋转状态是造成混乱的秘诀。一个封装良好的对象应该适当地隔离它的内部,这样它的接口就不会鼓励用户破坏它。【参考方案5】:

在我对 .NET 编程了解很多之前,我决定做一件事,但这似乎仍然是个好主意,就是有一个接受 IDisposable 的构造函数,也接受一个布尔值,它表示对象的所有权是否将也被转移。对于可以完全存在于using 语句范围内的对象,这通常不会太重要(由于外部对象将在内部对象的 Using 块范围内进行处置,因此无需外部对象处置内在的;事实上,它可能有必要不这样做)。然而,当外部对象将作为接口或基类传递给不知道内部对象存在的代码时,这种语义可能变得必不可少。在这种情况下,内部对象应该一直存活到外部对象被销毁,而知道内部对象应该在外部对象死亡时死亡的是外部对象本身,因此外部对象必须能够销毁内在的。

从那时起,我有了一些额外的想法,但还没有尝试过。我很好奇其他人的想法:

    IDisposable 对象的引用计数包装器。我还没有真正想出最自然的模式来做到这一点,但是如果一个对象使用带互锁递增/递减的引用计数,并且如果(1)所有操作该对象的代码都正确使用它,并且(2)没有循环引用是使用该对象创建的,我希望应该有一个共享的IDisposable 对象,该对象在最后一次使用再见时被销毁。可能应该发生的情况是公共类应该是私有引用计数类的包装器,并且它应该支持构造函数或工厂方法,该方法将为同一个基实例创建一个新包装器(将实例的引用计数增加一个)。或者,如果即使放弃了包装器也需要清理该类,并且如果该类有一些定期轮询例程,则该类可以为其包装器保留WeakReferences 的列表并检查以确保至少其中一些仍然存在。 让IDisposable 对象的构造函数接受一个委托,它会在对象第一次被释放时调用该委托(IDisposable 对象应在 isDisposed 标志上使用Interlocked.Exchange 以确保它只被释放一次)。然后,该委托可以处理任何嵌套对象(可能需要检查是否还有其他人持有它们)。

这两种模式看起来都不错吗?

【讨论】:

我最近在一个项目中实现了(1)(必要时)。一次性对象是原生 API 的包装器,该 API 具有一个主要的“会话”对象,可以从中创建“连接”。不幸的是,当会话关闭时,连接会随着会话而死,这不是我希望包装器拥有的语义。所以我将会话拆分为一个引用计数的“核心”和一个 IDisposable 包装器。这样,“连接”可以引用并保持“会话核心”处于活动状态,即使“会话”对象已被释放。 这样做也简化了我的锁定代码。每当我需要“会话核心”时,我都会使用using (var core = GetCore()) 块。 GetCore 然后创建一个 SharedReference 辅助对象,它在核心对象上“拥有”一个“计数”。如果核心对象已经被释放,GetCore 将抛出一个ObjectDisposedException。否则它将返回SharedReference 对象,这将再次确保“核心”对象没有被释放,即使包装对象同时被放置在另一个线程上。并 +1 提及Interlocked.Exchange :) 很高兴您发现这个答案很有帮助。您的编辑看起来不错;在 VB.Net(我更喜欢 C#)中,关键字是 Using,但由于人们似乎出于某种原因更喜欢 C#,所以小写形式很好。

以上是关于当传递到另一个对象时,谁应该在 IDisposable 对象上调用 Dispose?的主要内容,如果未能解决你的问题,请参考以下文章

Django 将选定对象从表单传递到另一个表单

当数据从一个视图控制器传递到另一个视图控制器时,我如何能够传递图像?

当用户单击链接时,将变量传递到另一个 url 的选项或方法是啥? [关闭]

将变量从一个子传递到另一个

当他们被传递到另一个 AppDomain 时,是不是可以将代表编组为代理?

Java Web开发:在传递请求对象时将控制从一个servlet转移到另一个[重复]