为类或接口声明IDisposable?

66

从以下情况开始:

public interface ISample
{
}

public class SampleA : ISample
{
   // has some (unmanaged) resources that needs to be disposed
}

public class SampleB : ISample
{
   // has no resources that needs to be disposed
}

SampleA类应该实现IDisposable接口来释放资源。 你可以用以下两种方式解决:

1. 将所需的接口添加到SampleA类中:

public class SampleA : ISample, IDisposable
{
   // has some (unmanaged) resources that needs to be disposed
}

2. 将其添加到接口ISample中,并强制派生类实现它:

public interface ISample : IDisposable
{
}
如果你将其放入接口中,即使它们没有需要清理的内容,也会强制任何实现都要实现IDisposable。另一方面,很明显地看到接口的具体实现需要一个dispose/using块,而且你不需要像清理时那样强制转换为IDisposable。在两种方式中可能还存在一些利弊...你为什么建议使用其中一种方式而不是另一种方式呢?

5
使用接口编写的代码(可能会使用已构建的实例)导致结束该实例的有用生命周期的可能性有多大? - Damien_The_Unbeliever
3
好的,假设ISample来自工厂方法的结果或通过依赖注入获得。 - Beachwalker
6
如果这个对象确实是来自工厂,那么很可能你的代码需要负责其处理,所以我会把它放在接口上。但如果这个对象是被注入的,那么我会假设注入器也要负责其生命周期,所以它就不适合放在接口上了。我认为这个问题并没有一个通用的答案。 - Damien_The_Unbeliever
7个回答

29
根据SOLID接口隔离原则,如果您将IDisposable添加到接口中,则会向不感兴趣的客户端提供方法,因此应将其添加到A中。
此外,接口从未被处理,因为可处置性与接口本身无关,而是与接口的具体实现相关。
任何接口都可能潜在地使用或不使用需要处置的元素。

8
虽然理论上您的建议是最正确的,但在实践中处理潜在的一次性实现界面是很麻烦的。很多事情取决于谁来管理对象的生命周期。我认为这里有一个更好的答案:https://dev59.com/nGYq5IYBdhLWcg3wkRi_#14368765 - nawfal
此外,如果接口本身实现了IDisposable,代码分析工具更容易发出警告。 - nawfal
2
@nawfal:如果理论与实践不符,那意味着我们使用的工具是错误的,而不是相反。按照定义,接口是不可丢弃的,因为您只能处理实现,而不是操作契约。 - Ignacio Soler Garcia

18

如果您将 using() {} 模式应用于所有接口,则最好让 ISample 继承自 IDisposable ,因为在设计接口时的经验法则是优先考虑“易用性”而不是“易实现性”


在foreach中如何使用using语句? - M Afifi
3
“...而你只需看到界面(interface)……” 这就是关键。很多好的面向对象编程(OOP)方案只会使用接口,所以我认为让接口实现 IDisposable 是有意义的。这样,消费代码可以以相同的方式处理所有的子类。 - Bob Horn

7

个人而言,如果所有的ISample都应该是可丢弃的,我会将其放在接口上,如果只有一些是可丢弃的,则只在应该的类上放置。

听起来你遇到了后一种情况。


但是如果您的库的用户不知道Dispose()的调用方式,而且可能需要进行处理的实现,该如何确保Dispose()的调用呢? - Beachwalker
@Stegi - 很简单。只需测试 is IDisposable 并在需要时调用 Dispose - Jamiec
是的,我知道,但这会破坏代码……因为每个其他实现(例如IList,I...)都可以是IDisposable。对我来说,IDisposable应该是一个通用的对象基类实现(即使为空)。 - Beachwalker
1
@Stegi - 那么你已经回答了自己的问题。 - Jamiec
@Stegi 假设您对 tryf 的性能影响感到满意。 - M Afifi

6
如果实现IFoo接口的某些实现将实现IDisposable,而且在某些情况下,对该实例的最后一个引用将存储在类型为IFoo的变量或字段中,则应该实现IDisposable接口。如果任何实现可能实现IDisposable,并且使用工厂接口创建实例(例如通过IEnumerable接口创建IEnumerator的实例),则几乎肯定应该实现IDisposable接口。
比较IEnumerable和IEnumerator是有意义的。一些实现IEnumerable接口的类型也会实现IDisposable接口,但是创建此类类型的代码将知道它们,知道它们需要处理并按照其特定类型使用它们。这些实例可以作为类型为IEnumerable的参数传递给其他程序,但是这些程序不会知道对象最终需要处理,但是大多数情况下这些程序不是持有对象引用的最后一个程序。相比之下,IEnumerator的实例通常由不知道这些实例背后类型的代码(仅知道它们由IEnumerable返回)创建、使用和最终丢弃。如果IEnumerable.GetEnumerator()方法的某些实现返回IEnumerator的实现,那么如果在放弃它们之前未调用IDisposable.Dispose()方法,则资源将泄漏,而大多数接受IEnumerable类型参数的代码将无法确定是否可能传递这样的类型。虽然IEnumerable可能包括一个名为EnumeratorTypeNeedsDisposal的属性来指示返回的IEnumerator是否必须被处理,或者仅要求调用GetEnumerator()的程序检查返回对象的类型以查看它是否实现IDisposable接口,但无论如何,调用可能不执行任何操作的Dispose方法都比确定是否需要Dispose并仅在必要时调用它更快且更容易。

1
@binki:如果 IEnumerator 实现了 IDisposable,那么事情会更清晰。接收一个未知类型的 IEnumerable 的代码必须假定可能需要在返回的 IEnumerator 上调用 Dispose。如果将返回的枚举器传递给其他方法以供稍后使用,则后者可能有责任对其调用 Dispose。由于在已知实现了 IDisposable 的对象上调用无操作的 Dispose 比检查对象是否实现了 IDisposable 更快... - supercat
1
对象履行其职责的最简单方法是在放弃它们之前调用它们所拥有的枚举器上的 Dispose,而不考虑这些枚举器是否关心。 - supercat
@binki: 当代码收到 IEnumerable<T> 时,它会有什么义务,而收到 IEnumerable 时没有?IEnumerator<T> 实现 IDisposable 并不对调用 IEnumerable.GetEnumerator 的代码产生新的义务。调用 IEnumerable.GetEnumerator 的代码必须为了正确性,准备好在任何实现 IDisposable 的返回实例上调用 DisposeIEnumerator<T> 实现 IDisposable 并不会添加任何新的义务-它只是使遵守 IEnumerable 已经具有的义务更容易。 - supercat
我在IEnumerator.GetEnumerator()的文档中没有看到任何关于Dispose的提及。在哪里规定了调用方有义务使用(nonGenericEnumerator as IDisposable)? 我个人认为,IEnumerator.GetEnumerator()的返回类型不是IDisposable这一事实表明调用方不应该对其调用Dispose()。C#编译器也同意这种推论 - binki
2
@binki:如果GetEnumerator返回的实例实现了IDisposable,则foreach调用C#代码。 - supercat
显示剩余2条评论

4

IDisposable是一个非常常见的接口,因此将您的接口继承它并没有什么坏处。这样,您就可以避免在代码中进行类型检查,而只需在一些ISample实现中具有无操作(no-op)的实现即可。因此,从这个角度来看,您的第二选择可能更好。


这完全不正确。如果你的类有一个实现了IDisposable接口的字段,那么这个类也必须是IDisposable的,以便调用该字段的Dispose方法。如果你在不需要的情况下添加IDisposable,最终会导致你的一半类都是IDisposable的。 - Ignacio Soler Garcia

4

我开始觉得在接口上放置IDisposable可能会引起一些问题。这意味着实现该接口的所有对象的生命周期都可以被安全同步地结束。也就是说,它允许任何人编写下面这样的代码,并要求所有的实现支持IDisposable

using (ISample myInstance = GetISampleInstance())
{
    myInstance.DoSomething();
}

只有访问具体类型的代码才能知道控制对象生命周期的正确方法。例如,某个类型可能根本不需要处理,它可能支持IDisposable,或者在使用完毕后可能需要等待某些异步清理过程(例如这里的选项2)。

接口作者无法预测实现类所有可能的未来生命周期/作用域管理需求。接口的目的是允许对象公开一些API,以便使其对某些使用者有用。一些接口可能与生命周期管理相关(例如IDisposable本身),但将其与与生命周期管理无关的接口混合在一起可能会使编写接口实现变得困难或不可能。如果您只有很少的接口实现,并且结构化代码使接口的使用者和生命周期/作用域管理器在同一个方法中,那么此区别一开始可能并不明显。但是,如果您开始传递对象,这一点会更加清晰明了。

void ConsumeSample(ISample sample)
{
    // INCORRECT CODE!
    // It is a developer mistake to write “using” in consumer code.
    // “using” should only be used by the code that is managing the lifetime.
    using (sample)
    {
        sample.DoSomething();
    }

    // CORRECT CODE
    sample.DoSomething();
}

async Task ManageObjectLifetimesAsync()
{
    SampleB sampleB = new SampleB();
    using (SampleA sampleA = new SampleA())
    {
        DoSomething(sampleA);
        DoSomething(sampleB);
        DoSomething(sampleA);
    }

    DoSomething(sampleB);

    // In the future you may have an implementation of ISample
    // which requires a completely different type of lifetime
    // management than IDisposable:
    SampleC = new SampleC();
    try
    {
        DoSomething(sampleC);
    }
    finally
    {
        sampleC.Complete();
        await sampleC.Completion;
    }
}

class SampleC : ISample
{
    public void Complete();
    public Task Completion { get; }
}

在上面的代码示例中,我展示了三种生命周期管理场景,加上你提供的两种。
1. SampleA 是支持同步 using () {}IDisposable。 2. SampleB 使用纯垃圾回收(它不消耗任何资源)。 3. SampleC 使用资源,阻止其被同步处理,并需要在其生命周期结束时使用 await(以便通知生命周期管理代码它已完成资源消耗并冒泡任何异步遇到的异常)。
通过将生命周期管理与其他接口分开,您可以防止开发人员错误(例如,意外调用 Dispose() )并更清晰地支持未来未预期的生命周期/范围管理模式。

5
不需要清理的类型可以通过 void IDisposable.Dispose() {}; 简单易行地实现 IDisposable 接口。如果从工厂函数返回的某些对象需要清理,则更容易和简单的方法是让它返回一个实现了 IDisposable 接口的类型,并要求客户端在每次调用工厂函数时保持平衡,以调用返回对象的 Dispose 方法,而不是要求客户端确定哪些对象需要清理。 - supercat

1

个人而言,我会选择1,除非你为2提供一个具体的例子。

一个很好的2的例子是一个IList

IList意味着您需要为集合实现索引器。但是,IList实际上也意味着您是一个IEnumerable,并且您应该为您的类编写GetEnumerator()

在您的情况下,您犹豫不决的是实现ISample的类是否需要实现IDisposable。如果没有每个实现您的接口的类都必须实现IDisposable,那么就不要强制它们这样做。

特别关注IDispoableIDispoable特别强制使用您的类的程序员编写一些相当丑陋的代码。例如:

foreach(item in IEnumerable<ISample> items)
{
    try
    {
        // Do stuff with item
    }
    finally
    {
        IDisposable amIDisposable = item as IDisposable;
        if(amIDisposable != null)
            amIDisposable.Dispose();  
    }
}

代码不仅可怕,而且为了确保每次迭代列表都有一个 finally 块来处理该项的释放,即使 Dispose() 在实现中只是返回,也会有显著的性能损失。

将代码粘贴到这里的一个评论中以回答其中一个评论,更容易阅读。


但是,如果有工厂(或简单使用IoC容器)提供实现对象,无论是否需要处理它们,您都必须通过强制转换和显式调用Dispose()来调用它们。这样,您的代码需要了解(可能的)实现...某种耦合?否则,您可以使用非常简单的using块。 - Beachwalker
@Stegi使用using块可能看起来不丑,但仍会有性能损失。此外,我不知道您如何在foreach内部使用using块。Microsoft的代码中充斥着这样的例子,IDisposable amIDisposable = object as IDisposable; if(amIDisposable != null) amIDisposable.Dispose(); 因为as如果无法将其转换为IDisposable,则不会引发异常,因此性能损失几乎不存在。 - M Afifi
即使只有1%的类实现或继承自某个特定类型会在IDisposable.Dispose中执行任何操作,如果将来需要在声明为该类型的变量或字段上调用IDisposable.Dispose(而不是实现或派生类型之一),那么这表明该类型本身应该继承或实现IDisposable。在运行时测试对象实例是否实现了IDisposable并在其上调用IDisposable.Dispose(就像非泛型IEnumerable一样)是一个主要的代码异味。 - supercat

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