使用IDisposable取消订阅事件

59

我有一个类来处理WinForms控件的事件。根据用户的操作,我正在解除引用该类的一个实例并创建一个新的实例来处理同一事件。我需要先从事件中退订旧的实例 - 这很容易实现。如果可能的话,我想以非专有方式实现这一点,似乎这是IDisposable的工作。然而,大多数文档只建议在使用非托管资源时才使用IDisposable,这里并不适用。

如果我在Dispose()中实现IDisposable并从事件中取消订阅,那么我是否在滥用它的意图?我应该提供一个Unsubscribe()函数并调用它吗?


编辑:这是一些虚拟代码,展示了我的做法(使用IDisposable)。我的实际实现涉及某些专有数据绑定(长故事)。

class EventListener : IDisposable
{
    private TextBox m_textBox;

    public EventListener(TextBox textBox)
    {
        m_textBox = textBox;
        textBox.TextChanged += new EventHandler(textBox_TextChanged);
    }

    void textBox_TextChanged(object sender, EventArgs e)
    {
        // do something
    }

    public void Dispose()
    {
        m_textBox.TextChanged -= new EventHandler(textBox_TextChanged);
    }
}

class MyClass
{
    EventListener m_eventListener = null;
    TextBox m_textBox = new TextBox();

    void SetEventListener()
    {
        if (m_eventListener != null) m_eventListener.Dispose();
        m_eventListener = new EventListener(m_textBox);
    }
}
在实际代码中,“EventListener”类更加复杂,并且每个实例都是独特重要的。我将它们用于集合中,并根据用户的点击创建/销毁它们。

结论

我接受gbjbaanb的答案,至少目前是这样。我认为使用熟悉的接口的好处超过在没有未管理的代码参与时使用它可能存在的任何缺点(这个对象的用户甚至怎么知道?)。

如果有人持不同意见,请发表/评论/编辑。如果可以提出更好的反对IDisposable的论点,那我会改变接受的答案。


4
请查看WeakEvent模式,它可能会对您有所帮助:http://msdn.microsoft.com/zh-cn/library/aa970850.aspx - gbjbaanb
1
现在已经过去了7年,那个链接显示:“很抱歉,您请求的主题不再可用。请使用搜索框查找相关信息。” - Rhyous
又过了7年,链接还有效 :-) - anielsen
9个回答

47

没问题,就这么做吧。虽然有些人认为IDisposable只是为非托管资源实现的,但事实并非如此——非托管资源只是它最大的优势和实现它的最明显原因。我认为人们之所以会这样想,是因为他们无法想出其他使用它的原因。它不像finaliser(终结器)那样存在性能问题,并且垃圾回收器也很难处理好终结器。

把任何清理代码放在你的dispose方法中。这将更清晰、更干净,而且比试图记住取消引用要容易得多,可以显著降低内存泄漏的风险。

IDisposable的目的是让你的代码在不需要进行大量手动工作的情况下发挥更好的作用。利用它的力量,忘掉某些人为的“设计意图”废话。

我记得在.NET刚出来的时候,说服微软认可确定性终结的有用性非常困难——我们赢得了这场战斗,并说服他们添加了它(即使当时它只是一种设计模式),现在就用它吧!


1
我强烈反对。当你这样做时,你正在违反“契约设计”中的“契约”。 - Domenic
9
什么契约?没有地方说“IDisposable仅适用于非托管资源”。通常会说“可以用于”,但这是很大的区别。 - gbjbaanb
4
我同意gbjbaanb的观点。即使文档确实指出IDisposable仅用于释放非托管资源,但当您将其用于其他清除操作时,您并不会违反任何硬合同(如前置条件、后置条件和类不变量)。 - user49572
1
放弃监听器不是被视为清理吗? - Benjamin Autin
6
我认为这种方法存在的问题是,几乎所有的类都要实现IDisposable接口。如果你在一个类中添加事件处理程序,则必须在该类上实现IDisposable接口。然后,当使用前一个类来调用Dispose方法结束对该类的工作时,您必须在所有使用该类的类上实现IDisposable接口。很快,你会发现一半以上的类都实现了IDisposable接口,这并不是接口预期用法的原意。 - Ignacio Soler Garcia
显示剩余5条评论

15

我个人的建议是添加一个取消订阅(Unsubscribe)方法以从事件中移除该类。IDisposable 是一种旨在确定性释放非托管资源的模式。在这种情况下,您不需要管理任何非托管资源,因此不应实现 IDisposable。

IDisposable 可用于管理事件订阅,但可能不应该使用。以 WPF 为例,这是一个充满事件和事件处理程序的库。然而,在 WPF 中几乎没有类实现 IDisposable。我认为这表明应该用另一种方式来管理事件。


7
WPF几乎没有任何IDisposable控件,因为它使用WeakEvent模式来避免内存泄漏:http://msdn.microsoft.com/en-us/library/aa970850.aspx - gbjbaanb
2
@gbjbaanb - 对的,这也是我理解的,尽管这可能支持JaredPar关于“事件应该以另一种方式进行管理”的观点。我想其中一种方式可能是使用WeakEvent模式,另一种方式可能是使用自定义的IUnsubscribable接口,例如可以模仿IDisposable的用法。 - jpierson
@jpierson 拥有一个 IUnsubscribable 接口有一个缺点:它会使得像 using(var vm = new UnsibscribableViewModel()){ ... } 这样的写法变得不可能。 - 3615
WeakEventPattern 是解决 OP 的问题的最佳方法。它是在3.0 RT中专门为此目的而设计的。 - bokibeg
这太过教条主义了。为什么不使用IDisposable呢?你可以实现自己的Unsubscribe方法,但它不会在Using语句结束时被调用,也不会被注入对象的IOC容器调用,而Dispose会被调用。如果需要清理,请使用IDisposable。 - Mick

8

在使用IDisposable模式来取消事件订阅时,有一件事情困扰着我,那就是终结问题。

IDisposable中的Dispose()函数应该由开发人员调用,但是如果没有被开发人员调用,按照标准的IDisposable模式,GC将调用此函数(至少是这样理解的)。然而,在您的情况下,如果您不调用Dispose,那么没有其他人会调用它 - 事件仍然存在,强引用会阻止GC调用终结器。

仅仅因为Dispose()函数不会被GC自动调用,这对我来说足以不在这种情况下使用。也许需要一个新的应用程序特定接口,以表明该类型的对象必须有一个Cleanup函数被调用以便被GC处理。


2
使用我的代码一段时间后,我倾向于同意你的观点。然而,我仍然担心IDisposable的歧义,并希望微软能够更好地解决这个问题。 - VitalyB
1
我完全同意你的观点。IDisposable 接口从一开始就被实现用于处理 COM 互操作和与非托管资源的集成。提供一个保证没有内存泄漏的解决方案是好的,但正如你所指出的,如果你使用 Dispose() 方法来取消事件订阅,并且你没有在代码中直接调用该方法(即“using”语句或其他方式),那么会保留一个强引用,对象永远不会被 GC 回收。这真是令人头疼的问题,绝对值得提出来。 - Doug
@VitalyB,你可以通过使用弱引用而不是强引用来解决这个问题。在这种情况下,GC可以调用终结器。 - Tim Lovell-Smith
1
@supercat 使用IDisposable来处理对象的可终结资源,而不是让GC稍后处理,这是IDisposable的有效用法,实际上也是一种优化,因为当您调用SuppressFinalize()时,它会释放终结器队列上的空间,并且当然还会释放您的终结器释放的任何资源。这就是为什么“标准”IDisposable示例要这样做的原因。 - Tim Lovell-Smith
1
@TimLovell-Smith:对于那些可以自动清理的对象来说,提供IDisposable是可以的,这样代码就可以做得更好。然而,我认为原始答案(特别是最后一段)声称不能自动清理的东西不应该实现IDisposable,因为调用者可能会认为类实现IDisposable是一个迹象,表明该类将能够自动清理自己,而不需要消费者调用像Dispose这样的清理方法。 - supercat
显示剩余2条评论

5
另一个选择是使用弱委托或类似WPF的弱事件,而不必显式取消订阅。
附注:[OT] 我认为仅提供强委托是.NET平台中最昂贵的设计错误。

不确定是否“最昂贵”,但它确实很贵。我对微软为什么还没有提供“WeakDelegate”类型感到困惑,并且让“Delegate.Combine”和“Delegate.Remove”从结果委托列表中省略任何已过期的弱委托。发布者端的弱事件不是一个适当的解决方案,因为订阅者而不是发布者知道订阅是否应该保持对象活动状态。 - supercat

5

我认为一次性是指任何GC无法自动处理的东西,包括事件引用在内。这里是我想出来的一个辅助类。

public class DisposableEvent<T> : IDisposable
    {

        EventHandler<EventArgs<T>> Target { get; set; }
        public T Args { get; set; }
        bool fired = false;

        public DisposableEvent(EventHandler<EventArgs<T>> target)
        {
            Target = target;
            Target += new EventHandler<EventArgs<T>>(subscriber);
        }

        public bool Wait(int howLongSeconds)
        {
            DateTime start = DateTime.Now;
            while (!fired && (DateTime.Now - start).TotalSeconds < howLongSeconds)
            {
                Thread.Sleep(100);
            }
            return fired;
        }

        void subscriber(object sender, EventArgs<T> e)
        {
            Args = e.Value;
            fired = true;
        }

        public void Dispose()
        {
            Target -= subscriber;
            Target = null;
        }

    }

这让你可以编写以下代码:

Class1 class1 = new Class1();
            using (var x = new DisposableEvent<object>(class1.Test))
            {
                if (x.Wait(30))
                {
                    var result = x.Args;
                }
            }

有一个副作用,您不能在事件中使用"event"关键字,因为这将防止将其作为参数传递给助手构造函数,但是这似乎没有任何不良影响。


1
可销毁模式并不是因为GC无法自动处理,而是因为支持“尽快清理”场景在 using 块中。终结器用于清理GC不知道如何清理的资源,因此无法自动执行清理。 - Tim Lovell-Smith

4
根据我所读到的所有关于一次性使用对象的内容,我认为它们主要是为解决一个问题而发明的:及时释放未受管理的系统资源。但是,我找到的所有示例不仅集中在未受管理的资源主题上,还具有另一个共同属性: 调用Dispose只是为了加速本来会在以后自动发生的过程(GC -> finalizer -> dispose)。
然而,调用取消订阅事件的Dispose方法永远不会自动发生,即使您添加了一个将调用您的dispose的finalizer。(至少只要事件拥有对象存在-如果它被调用,您也无法从取消订阅中获益,因为事件拥有对象也将消失)
因此,主要区别在于事件以某种方式构建了一个对象图,该对象图无法收集,因为事件处理对象突然成为您刚想引用/使用的服务的引用。您突然被强制调用Dispose-没有自动处理可用。因此,Dispose的意义将与在所有示例中找到的含义略有不同,这些示例中Dispose调用(根据不洁理论 ;))不是必需的,因为它将在某个时间自动调用...
无论如何。 由于一次性使用模式是相当复杂的(涉及难以正确处理的finalizer和许多指南/合同),并且在大多数方面与事件反向引用主题无关,因此我认为通过不使用可能被称为“从对象图中取消根/停止”/“关闭”的东西的比喻来将其分离在我们的头脑中会更容易一些。
我们想要实现的是禁用/停止某些行为(通过取消订阅事件)。像IStoppable这样具有Stop()方法的标准接口将是很好的,按照契约只关注以下内容:
  • 让对象(+所有自己的可停止项)断开与它所没有创建但仍然在使用的任何对象的事件之间的连接
  • 因此,它不再以隐式事件方式被调用(因此可以被视为已停止)
  • 只要传统引用到该对象消失,就可以被收集
我们称仅执行退订操作的唯一接口方法为“Stop()”。您将知道已停止的对象处于可接受的状态。也许还有一个简单的属性“Stopped”也是一个不错的选择。
甚至拥有继承自IStoppable的接口“IRestartable”,并且还具有“Restart()”方法将是有意义的,如果您只想暂停某个行为,但在将来肯定会再次需要它,或者为了存储已删除的模型对象以进行稍后的撤消恢复。
在写完所有内容后,我不得不承认曾经在这里看到过IDisposable的示例:http://msdn.microsoft.com/en-us/library/dd783449%28v=VS.100%29.aspx。但无论如何,在我了解IObservable的每一个细节和原始动机之前,我认为它不是最好的用例示例。
因为它周围有一个相当复杂的系统,而我们只有一个小问题,并且可能其中一个动机是首先摆脱事件,这将导致堆栈溢出涉及原始问题。
但似乎他们正在某些正确的轨道上。无论如何:他们应该使用我的接口“IStoppable”;),因为我坚信存在以下区别:
Dispose:“您应该调用该方法或者如果GC发生太晚,则可能会泄漏。”
Stop:“您必须调用此方法以停止某种行为。”

2
我不同意。有一个强烈的约定,如果一个对象需要清理,它的所有者可以尝试将其转换为IDisposable,并在这样做成功时调用Dispose来清理它。人们不应该猜测一个对象是否需要某种其他类型的清理。虽然许多可处置的对象会设法在被遗弃时自行清理,但实现IDisposable接口的对象在被遗弃时自行清理的暗示比没有实现IDisposable接口的对象这样做的暗示要弱得多。 - supercat
1
顺便说一下,我认为微软默认的IDisposable模式很愚蠢(唯一需要清理终结器的对象应该是那些目的是封装单个清理责任的对象;如果一个类除了处理单个清理责任之外还做其他事情,那么根据定义,任何派生类也会这样做,因此派生类不应该有用于清理的终结器(它们可能会生成“对象被错误地放弃”的日志条目)。我还要指出,“Dispose”这个名称是一个误称,因为其目的不是处置对象,而是... - supercat
在对象被放弃之前,允许它执行任何职责(通常是清理其他对象),以便完成其任务。 - supercat

3

IDisposable主要涉及资源管理,而且这个问题已经足够棘手了,我认为不应该在这方面再增加复杂度。

我也支持在你自己的接口中添加一个“取消订阅”方法。


3

一个选择可能是根本不取消订阅 - 只是改变订阅的含义。如果事件处理程序可智能化,可以根据上下文知道其应该做什么,那么您首先就不需要取消订阅。

在您特定情况下,这可能是个好主意或不好的主意 - 我认为我们没有足够的信息来确定,但值得考虑。


3
在我的情况下,我认为这意味着对象将永远存在并且我会遭受内存泄漏的问题,不幸的是。 - Jon B
@Jon B - 我认为这是大多数非UI事件发生的情况。+1 - Doug
以防John的解释不清楚,我认为他建议的是在某些情况下,可以回收对象而不是为新实例丢弃它们。这样可以使用现有的事件订阅而无需分离。将其视为对象的线程池或连接池,在其中有一组可能被回收的对象。这不是所有情况的解决方案,但如果您只改变了思考方式,那么在大多数情况下可能都适用。 - jpierson

1
不,你并没有阻止IDisposable的意图。IDisposable旨在作为一种通用方式,确保当你使用一个对象时,可以主动清理与该对象相关的所有内容。它不仅限于非托管资源,也可以包括托管资源。事件订阅只是另一种托管资源!
实际上经常出现的类似情况是,你将在自己的类型上实现IDisposable,纯粹是为了确保你可以调用另一个托管对象的Dispose()。这也不是一种歪曲,而是整洁的资源管理!

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