具有终结器但不是IDisposable的Singleton模式

16

以下是我从《CLR via C#》、《Effective C#》和其他资源中了解到有关IDisposable和finalizers的内容:

  • IDisposable用于确定性地清理托管和非托管资源。
  • 负责非托管资源(如文件句柄)的类应实现IDisposable,并提供finalizer以确保即使客户端代码不调用实例上的Dispose(),它们也会被清理。
  • 仅负责托管资源的类永远不应该实现finalizer。
  • 如果你有一个finalizer,则必须实现IDisposable(这允许客户端代码正确地调用Dispose(),而finalizer则可以防止资源泄漏,即使客户端代码忘记调用Dispose())。

虽然我理解并同意上述所有观点的论据,但有一种场景,我认为打破这些规则是有道理的:一个负责非托管资源的单例类(例如提供对特定文件的单一访问点)。

我认为在单例上有Dispose()方法总是不妥当的,因为单例实例应该在应用程序的生命周期内存在,如果任何客户端代码调用了Dispose(),那么你就会遇到麻烦。然而,你需要一个finalizer,以便在卸载应用程序时,finalizer可以清理非托管资源。

因此,对于一个不实现IDisposable但拥有finalizer的单例类来说,这似乎是合理的一件事情。然而,这种设计类型与我理解的最佳实践相反。

这是一个合理的方法吗?如果不是,为什么不是,并且有什么更好的替代方案?

6个回答

6
我首先要提到的是,面向对象设计模式及其影响并不总是影响每个语言决策,即使在面向对象的语言中也是如此。你可以在某种语言(Smalltalk)中找到更容易实现的经典设计模式,而在另一种语言(C++)中则不然。
话虽如此,我不确定我同意单例实例应该只在应用程序结束时才被处理的前提。我阅读过Singleton(或Design Patterns: Elements of reusable Object-Oriented Software)的设计模式描述,其中没有提到这是该模式的属性。单例应该确保在任何时刻只存在一个类实例;这并不意味着它必须存在于应用程序存在的整个时间内。
我有一种感觉,在实践中,大多数应用程序的生命周期中都存在许多单例。然而,考虑一个使用TCP连接与服务器通信但也可以在断开连接模式下存在的应用程序。当连接时,您需要一个单例来维护连接信息和连接状态。一旦断开连接,您可能希望保留同一个单例 - 或者您可能会处理掉该单例。虽然有些人认为保留单例更有意义(我甚至可能是其中之一),但设计模式本身并不排除您将其处理掉 - 如果重新建立连接,则可以再次实例化单例,因为此时不存在其任何实例。
换句话说,您可以创建逻辑上需要具有IDisposable的单例的场景。

2
事实是,如果您可以构想出在应用程序生命周期内替换对象的原因,那么该对象就不能合法地成为单例。至于为什么...假设我抓住了 Singleton 并将其传递给需要它的东西(因为依赖注入是一件好事)。然后某个地方决定替换它。现在就有了两个单例(我一直在传递的旧的和新的)。这种可能性违反了单例的整个定义——即代码总是看到恰好一个实例。 - cHao
首先,天啊,这个答案太老了。其次,你的例子似乎没有意义。如果你将 Singleton 的实例传递给一个对象,然后另一个对象决定要使用 Singleton,那么该模式允许这样做 - 它应该返回对当前内存中的对象的引用。我想说的是,该模式并不排除你处理该对象的可能性。在这种情况下,使用你的例子,如果该对象被处理,则单一实例不应再存在,因此当另一个对象请求 Singleton 时,它应该得到一个新的实例。 - Matt Jordan
4
如果对象已被处理,实例仍然存在 - 在对象被处理前获取该对象的任何人仍将拥有它。因此,它显然无法被垃圾回收,但现在无法使用。考虑在多线程环境下这种情况的后果。类似于“Singleton.getInstance().doStuff()”这样的表达式不再是线程安全的,即使“getInstance”和“doStuff”都是线程安全的。一个线程可能会在两个调用之间出现并处置我们刚刚获取的实例。这甚至假设了您神话般的每个人总是调用“getInstance”的场景。 - cHao

5

如果未受管理的资源仅在应用程序退出时释放,您甚至不需要担心终结器,因为进程卸载应该会为您处理这个问题。

如果您有多个应用程序域,并且您想要处理应用程序域卸载,那么这可能是一个可能的问题,但可能是您不需要关心的问题。

我赞同那些说这种设计可能不是正确的做法(并且如果随后发现您确实需要两个实例,则会使修复变得更加困难)。 在入口点中创建对象(或延迟加载包装对象),并将其通过代码传递到需要它的地方,明确谁负责向谁提供它,然后您可以自由更改使用一个决策,对其余代码没有太大影响(使用给定的内容)。


4
只要您的终结器不调用任何其他托管对象(例如Dispose)的方法,那么您就可以放心使用。只需记住,终结顺序是不确定的。也就是说,如果您的单例对象Foo持有需要处理的对象Bar的引用,您不能可靠地编写以下代码:
~Foo()
{
    Bar.Dispose();
}

垃圾回收器可能已经回收了Bar。

为了避免陷入OO(面向对象)的泥潭(即开始战争),使用单例模式的另一种替代方法是使用静态类。


Scott所说的 - 一次性单例只是一个根本性的问题。 - annakata
1
GC真的可以收集Bar吗?根据我在其他地方阅读到的内容,虽然Bar可能已经完成了最终化,但是被等待最终化的Foo引用的对象仍然有资格进行最终化,但不会进行垃圾回收;我正在寻求对这一点的澄清。当然,调用Bar.Dispose将是不安全的,除非人们知道它不会获取任何锁,创建任何新对象或抛出任何异常(我认为即使是捕获的异常也可能很危险,因为会创建Exception对象),但其他操作可能是安全的。 - supercat
@supercat:最终化的顺序不能保证。如果一个对象在最终化队列中,那么它对其他对象的引用在确定这些对象是否可以被回收的目的上不存在。因此,尽管Foo持有对Bar的引用,但它类似于弱引用。因此,在Foo最终化之前,Bar可以被回收。 - Jim Mischel
@JimMischel:我曾经认为事情是那样工作的,但实际上并不是。在系统确定哪些项目没有活动引用之后,所有具有终结器的这些项目都会被添加到需要运行其终结器的根对象队列中。这将导致所有这样的对象以及它们持有引用的任何对象变为活动状态(尽管它们不再有资格添加到终结队列中,但它们的终结器将尽快运行,并且一旦终结器运行完毕,很可能不会再有其他引用存在)。顺便说一下,有两种类型的弱引用: - supercat
@JimMischel:普通的弱引用会在对象被添加到finalize-now队列时无效,但复活跟踪弱引用只有在对象实际销毁之前才会保持有效。请注意,由于任何类都可以为任何其他类的任何公开实例创建复活跟踪引用,因此通常不能保证已声明为立即终结的类实例不会在最终器实际运行之前或之后的任何时间复活并调用其方法。 - supercat
显示剩余2条评论

2
尽管这样做可能会引起代码审查方面的担忧和FxCop警告,但在不使用IDisposable的情况下实现finalizer并没有本质上的错误。然而,在单例上这样做并不是可靠地捕获进程或AppDomain关闭的方法。
冒着提供主观设计建议的风险:如果对象确实是无状态的,请将其设置为静态类。如果它有状态,请问为什么要将其设置为Singleton:您正在创建一个可变的全局变量。如果您想要捕获应用程序关闭,请在主循环退出时处理它。

如果对象确实是无状态的,请将其设置为静态类。如果它具有状态,请质疑为什么要使用单例模式。我完全同意,但我喜欢引用的简洁性。你有相关的参考资料吗? - annakata
我是即时构思出这个的。在波特兰设计模式仓库中,我一直在反对大多数单例使用,因为它们对代码中的因果关系产生影响。 - Jeffrey Hantin

0

除了单例模式适用于任何特定情况之外,

我认为释放单例并没有什么问题。与延迟实例化相结合,这意味着如果您暂时不需要资源,则可以释放它,然后根据需要重新获取它。


一段代码可能会获取并“Dispose”一个单例,但这并不能说明是否还有其他人在使用它。如果最终器被调用,那么没有人在使用它;需要注意的是,唯一可能发生这种情况的方式是如果指向该单例的静态/全局引用是WeakReference。否则,该引用本身将保护单例免于最终化。 - supercat

0
如果您想创建一个带有终结器的单例,那么您应该将静态引用设置为WeakReference。这需要一些额外的工作来确保访问器的线程安全性,但它将允许单例在没有人使用它时进行垃圾回收(如果随后有人调用GetInstance()方法,则会获得一个新实例)。如果使用静态强引用,即使没有其他引用指向它,它也会保持单例实例的存活状态。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接