当传入另一个对象时,谁应该在IDisposable对象上调用Dispose?
是否有任何指导或最佳做法围绕谁应该在一次性对象传入另一个对象的方法或构造函数时调用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()
的调用者应该处理对象,因为它可能创build了对象。 但在第二个例子中,感觉像MyClass
应该处理对象,因为它保持对它的引用。 问题在于调用类可能不知道MyClass
已经保存了一个引用,因此可能决定在MyClass
完成使用之前处理该对象。 这种场景是否有任何标准规定? 如果有的话,当一次性对象被传递给构造函数时它们有什么区别吗?
一般的规则是,如果您创build了对象(或获得了对象的所有权),那么您有责任对其进行处理。 这意味着如果你在一个方法或构造函数中接受一个可抛弃的对象作为参数,你通常不应该抛弃它。
请注意,.NET框架中的某些类会将收到的对象作为参数进行处理。 例如,configurationStreamReader
也会处理底层的Stream
。
PS:我发布了一个新的答案 (包含一组简单的应该调用
Dispose
的规则,以及如何devise一个处理IDisposable
对象的API)。 虽然现在的答案包含有价值的观点,但我认为它的主要build议往往不会在实践中发挥作用:在“粗粒度”对象中隐藏IDisposable
对象往往意味着需要成为IDisposable
对象; 所以最后一个开始,问题依然存在。
是否有任何指导或最佳做法围绕谁应该在一次性对象传入另一个对象的方法或构造函数时调用
Dispose()
?
简短的回答:
是的,关于这个话题有很多的build议,我所知道的最好的是埃里克·埃文斯的域驱动devise中的聚合的概念。 (简而言之,应用于IDisposable
的核心思想是:将IDisposable
封装在一个更粗糙的组件中,这样它就不会被外部看到,也不会传递给组件使用者。)
而且,一个IDisposable
对象的创build者也应该负责处理它的想法太严格了,而且在实践中往往是不行的。
我的答案的其余部分按照相同的顺序在两点上都有更详细的说明。 我会用几个指针来完成我的答案,以获得与同一主题相关的更多材料。
更长的答案 – 这个问题是从更广泛的angular度来讲的:
关于这个主题的build议通常不是特定于IDisposable
。 每当人们谈论对象的生命周期和所有权时,他们都指的是同样的问题(但是更笼统地说)。
为什么在.NET生态系统中很less出现这个主题? 因为.NET的运行时环境(CLR)执行自动垃圾收集,所有这些工作都是为你做的:如果你不再需要一个对象,你可以简单地把它忘掉,垃圾收集器最终将回收它的内存。
那么为什么这个问题会出现IDisposable
对象呢? 因为IDisposable
是关于一个(通常是稀疏或昂贵的)资源的生命周期的明确的,确定性的控制: IDisposable
对象应该在不再需要的时候被释放 – 而垃圾回收器的不确定性保证(“我最终将回收你使用的内存 !“)根本不够好。
你的问题,在对象生命周期和所有权的更广泛的条款中重新expression:
哪个对象
O
应该负责结束一个(一次性)对象D
的生命周期,这个对象也被传递给对象X,Y,Z
?
我们来build立一些假设:
-
为一个
IDisposable
对象调用D.Dispose()
基本上结束了它的生命周期。 -
逻辑上,一个对象的生命周期只能结束一次。 (现在不用担心,这与
IDisposable
协议相反,它明确允许多次调用Dispose
。) -
因此,为了简单起见,恰好有一个对象
O
应该负责处理D
让我们打电话给主人。
现在我们来看问题的核心:C#语言和VB.NET都不提供强制对象间所有权关系的机制。 因此,这变成了一个devise问题:接收到另一个对象D
的引用的所有对象O,X,Y,Z
都必须遵循并遵守一个约定,该约定恰好规定了谁对D
具有所有权。
简化聚合的问题!
我在这个主题上find的唯一最好的build议来自Eric Evans的2004年的书籍, 领域驱动devise 。 让我从书中引用:
假设你正在从数据库中删除一个Person对象。 随着人去一个名字,出生date和工作描述。 但是地址呢? 可能有其他人在同一个地址。 如果删除地址,则这些Person对象将引用已删除的对象。 如果离开它,则会在数据库中累积垃圾地址。 自动垃圾收集可以消除垃圾地址,但即使在数据库系统中可用,技术修复也会忽略基本的build模问题。 (第125页)
看看这与你的问题有关吗? 这个例子中的地址相当于你的一次性对象,问题是一样的:谁应该删除它们? 谁拥有他们?
埃文斯继续build议聚合作为解决这个devise问题。 从书再次:
聚合是一组关联的对象,我们把它们作为一个单元用于数据更改。 每个Aggregate都有一个根和一个边界。 边界定义了聚合内部的内容。 根是一个包含在Aggregate中的单一特定的实体。 根是外部对象被允许持有引用的聚合的唯一成员,尽pipe边界内的对象可以保持对彼此的引用。 (第126-127页)
这里的核心信息是,你应该限制你的IDisposable
对象的传递到一个严格限制的集合(“集合”)的其他对象。 超出聚合边界的对象不应直接引用您的IDisposable
。 这大大简化了事情,因为您不再需要担心所有对象的最大部分(即集合外部的对象)是否可能会Dispose
您的对象。 所有你需要做的是确保边界内的对象都知道谁负责处理它。 这应该是一个容易解决的问题,正如你通常一起实施它们一样,注意保持聚合边界合理“紧密”。
对于IDisposable
对象的创build者也应该处置它的build议呢?
这个指导方针听起来很合理,它有一个吸引人的对称性,但是它本身在实践中往往不起作用。 可以这么说,“不要将对IDisposable
对象的引用传递给其他对象”,因为一旦你这么做了,你就冒着接收对象承担所有权的风险,并且不知道自己的处置。
我们来看一下.NET Base Class Library(BCL)中的两个显着的接口types,它们显然违反了这个规则: IEnumerable<T>
和IObservable<T>
。 两者都是返回IDisposable
对象的工厂:
-
IEnumerator<T> IEnumerable<T>.GetEnumerator()
(请记住,IEnumerator<T>
从IDisposable
inheritance。) -
IDisposable IObservable<T>.Subscribe(IObserver<T> observer)
在这两种情况下, 调用者都希望处理返回的对象。 可以说,我们的指导原则在对象工厂的情况下是没有意义的,除非我们可能要求IDisposable
的请求者 (而不是直接创build者 )发布它。
顺便说一句,这个例子也certificate了上面概括的集合解决scheme的局限性: IEnumerable<T>
和IObservable<T>
本质上都是过于笼统的,不可能是聚合的一部分。 聚合通常是特定领域的。
进一步的资源和想法:
-
在UML中,对象之间的“有一个”关系可以用两种方式build模:聚合(空钻石)或组合(实心钻石)。 组成不同于聚合,因为包含/引用的对象的生命周期以容器/引用者的生命周期结束。 你原来的问题暗含了聚合(“可转让的所有权”),而我主要是转向使用组合(“固定所有权”)的解决scheme。 请参阅维基百科关于“对象构成”的文章 。
-
Autofac (.NET IoC容器)通过两种方式解决了这个问题:通过使用所谓的关系types进行通信
Owned<T>
,通过IDisposable
获取所有权; 或通过工作单位的概念,在Autofac中称为终身范围。 -
关于后者,Autofac的创始人Nicholas Blumhardt撰写了“An Autofac Lifetime Primer” ,其中包括“IDisposable and ownership”部分。 整篇文章是关于.NET中所有权和生命期问题的一篇很好的论文。 我build议阅读,甚至对那些对Autofac不感兴趣的人。
-
在C ++中, 资源获取初始化(RAII)习惯用法(一般而言)和智能指针types (特别是)帮助程序员正确地获得对象生命期和所有权问题。 不幸的是,这些不能转换为.NET,因为.NET缺乏C ++对确定性对象销毁的优雅支持。
-
另请参阅堆栈溢出问题的这个答案 , “如何解决不同的实现需求? (如果我理解正确)遵循与我的基于聚合的答案类似的思路:在
IDisposable
周围构build一个粗粒度组件,使其完全被包含(并且从组件消费者中隐藏)。
一般来说,一旦处理一个Disposable对象,您就不再处于理想的托pipe代码世界中,在这个世界中,生命周期的所有权是一个有争议的问题。 因此,您需要考虑逻辑上“拥有”什么对象,或者是否对您的一次性对象的生命周期负责。
一般来说,对于一个刚刚被传入方法的可丢弃对象来说,我会说不,方法不应该处理这个对象,因为一个对象非常less地承担另一个对象的所有权,然后在同样的方法。 在这种情况下,调用者应该负责处理。
在讨论会员资料时,没有自动回答“是,总是处置”或“不,不要处理”。 相反,你需要考虑每个特定情况下的对象,并问自己:“这个对象是否对可丢弃对象的生命周期负责?”
经验法则是负责创build一次性处理器的对象拥有该处理器,因此稍后负责处理。 如果有所有权转让,这并不成立。 例如:
public class Foo { public MyClass BuildClass() { var dispObj = new DisposableObj(); var retVal = new MyClass(dispObj); return retVal; } }
Foo
显然负责创builddispObj
,但它将所有权传递给MyClass
的实例。
这是我以前答复的后续行动。 看到它的最初的评论,以了解为什么我张贴另一个。
我之前的回答有一件事是正确的: 每个IDisposable
应该有一个专属的“所有者”,负责Dispose
一次。 pipe理IDisposable
对象与非托pipe代码场景中的内存pipe理非常相似。
.NET的前身技术组件对象模型(COM)使用以下协议来处理对象之间的内存pipe理责任:
- “参数必须由调用者分配和释放。
- “输出参数必须由被叫方分配,由主叫方释放。
- “In-out参数最初是由调用者分配的,然后在必要时由被调用者释放和重新分配。对于out参数,调用者负责释放最终的返回值。
(对于错误情况,还有其他规则;请参阅上面链接的页面以获取详细信息。)
如果我们IDisposable
这些指南适用于IDisposable
,我们可以放下以下内容:
关于IDisposable
所有权的规则:
- 当通过常规参数将
IDisposable
传递给方法时,不会传递所有权。 被调用的方法可以使用IDisposable
,但是不能Dispose
它(也不要传递所有权;参见下面的规则4)。 - 当通过
out
参数或返回值从方法返回一个IDisposable
,所有权将从该方法传递给其调用者。 调用者将不得不Dispose
它(或以相同的方式将所有权交给IDisposable
)。 - 当一个
IDisposable
通过一个ref
参数被赋予一个方法时,它的所有权被转移到那个方法上。 该方法应该将IDisposable
复制到局部variables或对象字段中,然后将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` } }
这个类有两个静态工厂方法,从而让它的客户select是否要保留或传递所有权:
-
通过常规参数接受
Stream
对象。 这告诉呼叫者所有权不会被接pipe。 因此调用者需要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
variables:using (var stream = …)
,编译将会失败,因为using
variables不能作为ref
parameter passing,所以C#编译器帮助强制执行我们的规则。
最后,请注意, File.OpenRead
是通过返回值返回IDisposable
对象(即Stream
)的方法的一个示例,因此返回stream的所有权将传递给调用方。
坏处:
这种模式的主要缺点是AFAIK,没有人使用它(还)。 因此,如果您与任何不遵循上述规则的API进行交互(例如,.NET Framework基类库),则仍然需要阅读文档以找出必须在IDisposable
对象上调用Dispose
。
有一件事我在对.NET编程知道很多之前就决定要做,但是看起来还是个好主意,有一个接受IDisposable
的构造函数也接受一个布尔值,它表示对象的所有权是否也将被转移。 对于可以完全存在于using
语句范围内的对象,这通常不会太重要(因为外部对象将被放置在内部对象的Using块的范围内,所以不需要外部对象来放置内部对象一个;事实上,可能有必要这样做)。 但是,当外部对象作为接口或基类传递给不知道内部对象存在的代码时,这样的语义可能变得至关重要。 在这种情况下,内部对象应该活着直到外部对象被销毁,而知道内部对象的东西在外部对象所做的事情是外部对象本身时应该死亡,所以外部对象必须能够销毁内在的一个。
从那以后,我有了一些额外的想法,但没有尝试过。 我会好奇别人的想法:
- 一个
IDisposable
对象的引用计数包装器。 如果一个对象使用带互锁递增/递减的引用计数,并且(1)所有操作对象的代码都正确使用它,并且(2)没有循环引用是使用该对象创build的,我期望应该可以有一个共享的IDisposable
对象,当最后一次使用过程被破坏的时候会被破坏。 也许应该发生的事情是,公共类应该是一个私人引用计数类的包装,它应该支持一个构造函数或工厂方法,它将为同一个基础实例创build一个新的包装(将实例的引用计数提高一个)。 或者,即使在放弃包装时,如果类需要清理,并且如果类有一些定期的轮询例程,那么类可以在其包装中保留一个WeakReference
列表,并检查以确保其中至less有一些仍然存在。 - 让一个
IDisposable
对象的构造函数接受一个委托,它将在第一次处理该对象时调用它(一个IDisposable
对象应该在isDisposed标志上使用Interlocked.Exchange
来确保它只被处理一次)。 然后该委托可以处理任何嵌套的对象(可能会检查是否还有其他人仍然拥有它们)。
这两种看起来都不错吗?