接口和抽象类






4.95/5 (89投票s)
关于何时使用接口和抽象类的深入解释。
引言
我经常看到人们问接口和抽象类有什么区别,大多数答案只关注不同的特性,而不是如何使用它们。
也就是说,大多数答案会告诉你诸如:
- 抽象类可以有实现,而接口不能;
- 在 .NET 中我们没有多重继承,所以我们不能使用多个抽象基类,但我们可以实现多个接口;
- 接口是一种契约,抽象类不仅仅是契约(这个对我来说并没有什么用,但它是一个常见的答案)。
嗯,还有许多其他答案也朝着相同的方向。无论这些答案多么正确,它们都没有回答真正的问题(即使有时没有明确提出)
我们何时应该使用接口,何时应该使用抽象类?
简短回答:如果我们要创建一个方法,该方法接收一个对象来调用其一个或多个方法,并期望这些方法根据接收到的对象具有不同的实现,那么我们应该要求一个接口。现在,如果我们需要为这样的方法提供一个对象,我们应该优先实现一个抽象类,而不是直接实现接口。
我知道,这个简短的回答不是很清楚,也没有解释为什么要这样做,所以本文就是为了回答这个问题。
输入参数 - 始终使用接口
想象一下,您正在开发一个系统,并且在某些时候,该系统必须生成日志。我不会讨论日志是否应该成为主逻辑的一部分,我要讨论的是您可能将日志写入文本文件、数据库或其它地方的事实。
此时,您只需要知道您希望能够执行以下操作:
logger.Log("An error happened in module X.");
logger.ConcatLog("An error happened in module ", moduleName, ".");
logger.FormatLog("An error happened in module {0}.", moduleName);
如果不够清楚,这三个方法之间的区别在于:第一个只接收一个字符串;第二个接收一个由多个对象连接而成的 params
数组;第三个接收一个带占位符({0})的字符串,然后是一个将填充这些占位符的对象数组。
编写这些方法的接口很容易:
interface ILogger
{
void Log(string message);
void ConcatLog(params object[] messageParts);
void FormatLog(string format, params object[] parameters);
}
请注意,此时您根本不需要关心这些方法将如何实现。您只需要一个接口就能够进行调用。如果您想到可能需要的其他方法,现在就将它们添加到接口中也没有问题。您所说的是:“我需要调用这些方法,并且我不关心它们是如何实现的。”
抽象类 - 为可能需要它的人提供基本实现
在我看来,如果您想为接口提供基本实现,并且您认为其某些(或大多数)方法将始终使用相同的代码实现,或者您认为将来可能会添加具有默认实现的新方法,那么抽象类会很有用。
例如,无论我们是要将日志记录到数据库、文本文件还是通过 TCP/IP 发送消息,ConcatLog()
和 FormatLog()
方法都可以这样实现:
public void ConcatLog(params object[] messageParts)
{
string message = string.Concat(messageParts);
Log(message);
}
public void FormatLog(string format, params object[] parameters)
{
string message = string.Format(format, parameters);
Log(message);
}
因此,一个抽象类可以实际实现这两个方法,并保持 Log()
方法为抽象。然后,在开发 FileLogger
、DatabaseLogger
和 TcpIpLogger
时,任何开发人员都可以使用实现三个方法中两个的抽象类来避免重复代码。
为什么不从抽象类开始?
考虑到我的例子,一个常见的问题是:为什么不从抽象类开始,避免创建一个“无用”的接口?
嗯,谁能保证这个接口没用呢?
例如,我目前实现的这些方法并不完全正确。它没有验证输入参数,所以如果调用 ConcatLog()
时传入 null
,会抛出一个 ArgumentNullException
,指出 args
是 null
。但 ConcatLog()
接收的是 messageParts
,而不是 args
。类似的问题也发生在 FormatLog()
上。当然,如果我们是抽象类的作者,我们可以通过修改抽象类来解决这个问题,但是如果抽象类来自一个编译好的库,而我们只是这个库的使用者呢?
如果已编译库的开发人员总是通过接口接收内容,无论抽象类是否存在,我们都可以自由地完全重新实现这些方法,并在需要时提供正确的错误消息。
另一个例子是 NullLogger
怎么样?也就是说,您将一个日志器作为参数传递给一个期望日志器的方法,但您的日志器什么也不做。
您可以用抽象类实现一个 NullLogger
,但三个方法中的两个会浪费时间格式化/连接不会被记录的消息。一个实现所有接口方法而不做任何事的 NullLogger
会更快。这种“无操作”对象有助于避免检查 null
,并且非常常见,甚至有一个设计模式专门用于它:空对象模式。
最后,我们永远不知道用户需要什么样的统计数据。想象一下,有人想创建一个日志记录器,它不仅记录消息,还记录每个日志方法被调用的次数。如果从总是重定向到 Log()
消息的抽象类开始,我们将只能计算 Log()
方法被调用的次数,而不管用户是否实际调用了其他方法。通过完整实现接口,我们将避免这个问题,因为我们可以为每个方法单独生成统计数据。
因此,站在为他人提供组件的开发者的立场上,我们必须努力提供完全正确的组件,并允许用户重新实现他们认为合适的任何内容,无论是由于我们犯了错误还是仅仅因为他们有特殊需求。
一个带有实现但完全是虚的抽象类
解决前一个问题的一个可能方案是创建一个抽象类,它实现 ConcatLog()
和 FormatLog()
,并且仍然允许它们被重写(即,使它们成为 virtual
),完全避免接口的存在。
这种方法工作得非常好,因为用户可以重新实现类的任何方法。因此,孤立地看这个问题,一个带有实现但保留所有方法为虚的抽象类比接口是更好的解决方案。但请继续阅读,因为我将进一步探讨它们之间的差异。
未来的变化
这是接口和抽象类之间的一个重大区别。
如果向接口添加新方法,所有实现它的类都必须更改以包含新方法,即使所有实现都相同。
如果向抽象类添加新方法,只要它带有默认实现,就不需要更改其他任何东西。用户只会看到那里有一个新方法,他们可以调用或重写。
当然,抽象类中的新抽象方法和接口中的新方法一样麻烦。但有了默认实现,我们可以看到抽象类具有优势。
然而,我们还应该问另一个问题:我们为什么要向接口或抽象类添加另一个方法?
可以保证,使用旧版本接口或抽象类工作的现有已编译代码不会调用新方法,那为什么还要创建这种破坏性更改呢?
好的,我将尝试列出一些这样做的原因:
- 代码仍在开发中,我们只是发现我们需要更多方法(在这种情况下,我们可以说没有“现有代码”会损坏);
- 代码在一个提供给许多不同用户的库中,用户正在要求他们认为缺失的新方法(因此,用户将尽快使用新方法);
- 我们正在更改调用实际接口或类的代码,并且我们希望现有方法的新变体能让事情更快;
- 与前一种情况类似,我们正在更改调用接口或抽象类的代码,但现在我们发现我们需要新方法,这些方法根本无法通过现有方法模拟。这不仅仅是一个性能问题;
- 我们收到了很多抱怨,有些方法的参数太多且难以使用,因此用户希望使用更易于使用的重载。
嗯,我很确定还有很多其他原因,但我将在此打住。
对于第一种情况,我们可以自由更改,因为我们尚未“关闭”代码。唯一重要的是我们的目的和我们自己关于应用程序可能演变的规则。
对于第二种情况,我将以 .NET Stream
类为例。在第一个版本中,它不支持超时,但这是一种非常常见的需求,后来它被添加了,并带有一个默认实现,表明它不支持超时(CanTimeout
返回 false
),如果我们尝试使用读写超时属性,则会抛出 InvalidOperationException
。更改一个已经存在的接口来添加这些属性是错误的,因为这会破坏所有现有的实现,但有可能创建另一个具有额外功能的接口。无论如何,在 .NET 中,Stream
是一个抽象类,而不是接口,并且新属性是带着默认实现添加的。
对于第三种情况,我们可以在抽象类中添加这些额外的默认实现方法。这样,那些想使用更快实现的人就可以使用。那些不想使用的人将继续使用他们的代码,而无需更改。不幸的是,更改接口会导致破坏性更改。因此,我们可能会忽略更改,保持较慢的速度,我们可能会导致破坏性更改(并让一些用户愤怒),或者我们可能会添加一个额外的接口,我们的代码将尝试使用检查转换来使用它,否则将使用“默认”实现。不幸的是,再次强调,添加额外的接口和接口查找会影响性能,因此,在某些情况下,我们将失去我们所追求的性能提升。
对于第四种情况,使用接口或抽象类都会导致破坏性变更。所以这里没有区别。
对于第五种情况,我们可以通过添加扩展方法来解决问题,这些扩展方法作为更易于使用的重载(这适用于接口和类,但用户必须添加正确的 using
子句才能看到这些扩展),我们可以将它们添加到抽象类中而没有问题,即使它们不是虚的。将它们添加到接口将是一个破坏性更改,但与扩展方法相比,它的优点是如果你看到接口,你可以看到所有的重载。
接口 + 抽象类
除了第四个问题,它总是会造成破坏性变更,我们可以通过始终拥有一个接口 + 一个抽象类来解决所有问题,从而允许我们添加虚拟且始终可见的新方法(这比扩展方法更好)。
如果所有实现都基于您提供的抽象类,那么向接口添加新方法并不是问题,因为抽象类可以提供默认实现。这实际上可以创建一个“设计模式”,即每个接口都有一个抽象类,即使它在第一个版本中没有提供任何默认实现。
因此,那些喜欢在接口更改时重新审视代码的人可以直接实现它(如果一切都记录良好,如果/当接口更改时,他们不应该责怪您造成了破坏性更改)。那些喜欢“默认行为”而不是审查接口更改的用户只需继承抽象类。如果这样做,我们就回到了本文开头给出的简短答案,即方法参数应始终使用接口,以便它们可以支持任何实现(基于抽象类或不基于),但实现应尝试使用抽象类以避免破坏性更改。
大接口还是小接口?- 高级
我已经完成了接口和抽象类之间的直接区别,我相信从现在开始,这篇文章变得高级了。所以,如果您不想看高级文本,可以跳转到 总结 主题。
现在我有一个问题,我知道有两种截然不同的答案:我们应该使用大接口还是小接口?
我刚才谈到了方法重载。您认为接口应该提供所有可能的重载,还是只应该包含参数最多的方法,而重载应该写在单独的帮助类中?
许多人会说我们应该使用接口隔离原则(也称为ISP),并且我们应该只提供参数最多的方法,任何重载都应该在别处。嗯,实际上ISP并没有谈论方法重载,它谈论的是接口做了超出其职责范围的事情。例如,我在WPF中使用的大多数IValueConverter
都不需要ConvertBack()
方法,因为我使用单向绑定,但接口强制我实现该方法并抛出NotSupportedException
。它不是现有方法的重载,它是一个具有不同目的但未使用的方法。
因此,重载不一定是 ISP 违规,但遵循该原则的人们心照不宣地认为不应该将重载放在接口中。这又回到了之前提出的问题,即我们可能无法监控每个方法被调用了多少次,因为我们只能重写参数最多的方法。除此之外,我们如何使这些额外方法可访问?
现在的常见解决方案是使用扩展方法。通过扩展方法,我们甚至可以为接口“添加”带有实现的方法。然而,它们不是接口的真正方法,如果单元没有 using
正确的命名空间,它们就不会出现在用户面前。这可能会让那些知道接口“有”某个方法但调用却不起作用的开发人员感到困惑。
此外,这些方法被视为在接口之上的“实现”。也就是说,我们不依赖接口,我们仍然依赖实现(即使它们很小),然后这些实现再依赖接口。
所以,我们有两种选择:
- 小接口 + 扩展方法: 任何简单填充默认值的重载都被视为扩展方法,接口保持不变。我们不会破坏任何依赖或实现接口的代码,因为接口没有改变。新的重载不是真正的接口方法,如果用户遗漏了 using 子句,他们可能看不到它们。重写这些重载是不可能的(但稍后我会介绍一种变通方法);
- 大接口: 任何重载都作为接口的一部分。因此,添加新的重载对接口来说是一个破坏性变更,但我们可以通过在接口之上提供抽象类来解决那些想要实现它们的人的麻烦,当接口改变时,我们需要更新这些抽象类。由于这些方法是接口的一部分,它们总是可以接收特定的实现(对于确切知道哪些方法被调用以及在远程场景中更倾向于发送更少数据而不是发送默认值很有用),并且当我们访问接口时,它们总是可见的。
与大多数开发人员所说的相反,我通常更喜欢大接口,只要我保留抽象类以避免破坏性更改。但我知道,这是一种个人偏好。
接口隔离原则 (ISP) 和“可重写”扩展方法
扩展方法是写入静态类中的静态方法,可以像调用其目标类型的成员方法一样调用。
由于它们是 static
的,所以它们不能被重写。但有一个变通办法。扩展方法应首先尝试将其接收到的对象转换为实现相同功能的接口。如果转换有效,它应使用该版本。如果无效,则继续使用其自己的实现。
我们可以在一些 LINQ 方法中看到这一点。例如,ElementAt()
方法首先尝试将可枚举对象转换为 IList
,后者已经有一个索引器(对于列表和数组来说非常快),但是当转换失败时,它会枚举可枚举对象直到达到请求的索引。
为了实现这种功能,我们通常会保留一个包含最小所需方法的基本接口(在我们的示例中,一个只有 Log()
方法的 ILogger
),然后我们会有子接口,例如 IFormatLogger
和 IConcatLogger
,每个接口都有一个专门的功能(通常是一个方法,但也可以有一些相关的方法组合在一起)。
通过这种方法,我们可以扩展基本接口,并仍然允许在运行时进行“可重写”的行为,更好的是,额外的方法可以在不同的程序集中添加。
这种方法的缺点是:
- 在实现基本接口时,用户可能不知道所有可以重写的额外方法,这更成问题,因为用户必须知道扩展方法和该方法使用的接口;
- 存在额外的接口查找。这种查找通常很快,但这意味着创建额外方法以稍微提高基本接口的速度可能最终不会带来任何好处,因为我们从方法本身获得了收益,但从额外的查找中失去了收益。
接口隔离原则与装饰
接口隔离原则 将大接口拆分为许多小接口。除了关于抽象类、接口、方法重写和性能的其他讨论之外,这项技术还有另一个问题:它与装饰结合得很糟糕。
装饰是指我们实现一个接口(甚至是抽象类)来添加一些行为,然后我们重定向到另一个实现。例如,我们可以使用装饰器来计算每个方法被调用的次数,用于在应用程序结束时生成统计数据,但我们仍然调用另一个实现的方法来完成实际工作。
当我们有一个包含许多方法的单个类或接口时,我们知道必须实现多少个方法。但当我们有许多分离的接口时,这就不那么容易了。我们可能需要装饰器,它们实现的接口与目标对象完全相同,这在许多情况下是不可行的。
为了说清楚,我将以流为例。在 .NET 中,它是一个单一的 Stream
抽象类。在 Java 中,我们有许多接口来表示相同的东西。我不会关注 Java 版本,但 .NET Stream 类可以分为:
- 读取部分:因为有只读流;
- 写入部分:因为有只写流;
- 超时部分:读写部分可能支持或不支持超时;
- 寻道:在处理文件(以及其他一些流)时,我们可以“重新定位”我们在该流上看到的内容。例如,在视频文件中,我们可以跳转到第45分钟而无需等待45分钟,并且可以随时返回到开头。然而,大多数流是只向前移动的。
由于 .NET 中的所有内容都包含在一个类中,如果我们想创建一个装饰器,我们知道我们只需要继承一个类并重写所有可以重写的方法即可。
如果我们有许多不同的接口组合,我们将如何创建装饰器?
- 如果装饰器只实现了读取接口,而没有实现超时接口,那么无论基对象是否支持超时,装饰器的用户都将无法再访问超时功能;
- 如果装饰器实现了超时接口,那么用户可以简单地假设该实例支持超时,但如果被装饰对象不支持,则情况并非如此。在配置超时时抛出异常似乎是错误的,因为装饰器实现了接口。当内部流不支持超时配置时忽略超时配置也是错误的,毕竟代码可能会忽略超时替代方案,因为装饰器显然支持超时。为超时编写默认实现太多了,并且如果无法取消挂起的操作,可能会导致错误(哦...我忘记在可能性列表中放入
ICancellableStream
)。将CanTimeout
放在超时接口中是反直觉的,因为用户期望只有当对象支持超时时才看到该接口,毕竟许多开发人员忘记检查对象是否不为null
,现在想象一下检查对象是否不为null
,它是否实现了接口,并且它是否还具有CanTimeout
为true
。
那么,我们应该为每种可能的组合创建一个装饰器类吗?也就是说,一个只用于读取接口的装饰器,一个用于读取和超时接口的装饰器,一个用于写入的装饰器,一个用于读取和写入的装饰器……我想你已经明白了,这需要很多很多的实现来提供所有可能的组合。
我们是否只应该在运行时创建装饰器,而不是硬编码它们,而是分析现有对象?这将使生成装饰器变得非常困难,并且在所有情况下都可能不可能,因为某些受限环境不允许在运行时生成代码。
所有接口都应该有一个 CanSomething
吗?这样装饰器就可以实现所有接口,并且仍然可以说明它们不支持哪些操作?Stream
类就是这样判断它是否支持某些功能,但它是一个具有许多可能性的单一类。一个具有单一类型操作的接口,却说它不支持该操作,这很奇怪。而且,如果装饰器必须拥有所有接口,为什么不从一开始就有一个基类,列出所有可能的动作,并带有那些 CanSomething
属性呢?
嗯,这就是 .NET Stream 类所做的。通过不拥有独立的接口,它保证了所有 Stream
实现都将拥有所有相同的方法,即使其中一些会抛出 NotSupportedException
。
因此,对于特定的场景,抽象类可能仍然是比隔离接口更好的选择,无论是为了更容易创建适配器还是为了其性能。
接口隔离原则 + 更轻松的装饰
我已经介绍了如何使用“可重写”的扩展方法。那么,为什么不将这个概念进一步扩展,以实现更轻松的装饰呢?
实际上,装饰最大的问题在于类型转换是直接在接收到的对象上完成的。所以,要么对象实现了接口,要么就没有。它没有机会说:“嘿,即使我实现了接口,在这种特定情况下也要忽略它。”
这就是 CanSomething
属性试图表达的。但在一个设计良好的架构中,接口永远不需要一个属性来判断它们是否工作。然而,框架不会简单地将接收到的实例直接转换为另一个接口。它会“请求”那个其他接口。
这可以通过使用事件和静态事件以高度可配置的方式完成,我不会在本文中讨论这一点,因为它已经很长了;或者它可以非常简单,比如在主接口中有一个 TryGetService<T>()
。
所以,如果完全由分离的接口构建,一个 Stream 可能看起来是这样的:
public interface IStream:
IDisposable
{
T TryGetService<T>();
}
public interface IReadStream:
IStream
{
// The partial methods may read less than the amount requested.
int PartialRead(byte[] buffer);
int PartialRead(byte[] buffer, int offset);
int PartialRead(byte[] buffer, int offset, int length);
// These methods will either read all the requested amount
// or throw an exception if the stream ends before that amount.
void Read(byte[] buffer);
void Read(byte[] buffer, int offset);
void Read(byte[] buffer, int offset, int length);
}
public interface IWriteStream:
IStream
{
// The partial methods may write less than the amount requested.
int PartialWrite(byte[] buffer);
int PartialWrite(byte[] buffer, int offset);
int PartialWrite(byte[] buffer, int offset, int length);
// These methods will either write all the requested amount
// or throw an exception if the stream is full before all
// the data was written.
void Write(byte[] buffer);
void Write(byte[] buffer, int offset);
void Write(byte[] buffer, int offset, int length);
}
// Maybe the size should be in another interface, but I consider
// that we should not be able to reposition to the end if we don't
// know the size of the stream, so I put both together.
public interface IReadPositionableStream:
IReadStream
{
long Position { get; }
long Size { get; }
}
public interface IWritePositionableStream:
IWriteStream
{
void SetPosition(long value);
void SetSize(long value);
}
public interface IReadTimeoutStream:
IReadStream
{
TimeSpan? ReadTimeout { get; set; }
}
public interface IWriteTimeoutStream:
IWriteStream
{
TimeSpan? WriteTimeout { get; set; }
}
有了这些接口,需要支持超时的读取流的代码可以有一个类型为 IReadTimeoutStream
的输入参数。
一个想要写入可能被重新定位的流的代码可以直接请求 IWritePositionableStream
,但它也可以通过请求 IWriteTimeoutStream
来尝试配置超时。
在这样的请求过程中,装饰器可能会检查其内部流是否支持该接口。如果内部流不支持,它可能会返回 null
来表示它不支持该服务,但实际上在它的代码中,它可能会实现所有这些接口,并在可能时重定向到内部流。它实际上可以将装饰委托给另一个对象,因此它不需要实现所有接口,它只需要知道如何查找/创建其他实现。
所以,一个重要的提醒是:
如果你想使用隔离接口,不要进行直接类型转换。通过调用可能返回正确接口对象或 null
的方法来询问对象是否可以执行额外的操作。这将大大简化装饰。
多重继承
在比较接口和抽象类时,常见的一个论点是类可以实现多个接口,但只能继承一个抽象类(在 .NET 中……C++ 支持多重继承)。因此,人们常说如果我们想在 .NET 中实现多重继承,接口是首选方式。
嗯,这实际上是一个糟糕的论点。使用抽象类,我们可以获得拥有默认实现的好处。例如,我使用实现 INotifyPropertyChanged
接口并相应地调用事件的基类。考虑到还有一个 INotifyPropertyChanging
接口,我很乐意能够通过继承两个基类(每个接口一个)来实现这两个接口的默认实现,而这正是我不想公开的继承方式(私有继承,在 .NET 中也不可用)。我希望我的类的用户只看到我的类直接继承自对象并实现接口,但我不需要为接口编写重复的代码。
所以,考虑到我们可能因为它们的默认实现而需要抽象类,并且考虑到接口不能有默认实现,能够实现多个接口根本无助于实现多重继承,我们仍然需要为它们全部提供实现。
最后,如果我们回到前一个主题,如果框架“请求”服务而不是进行直接类型转换,我们完全可以接受仅限于实现一个基类或一个接口的类,因为任何额外的服务都可以在运行时通过正确的调用并在需要时接收实现其他接口的不同实例来发现。
最终,我们的类可以实现多个接口是一个很好的特性,但不是必需的,它可能是一个必要的特性,以便能够使用已经存在的、使用接口转换来查找功能的框架。换个角度看,这些框架之所以这样创建,仅仅是因为支持多个接口。也许如果从不支持多接口实现,所有框架都会工作得更好。
总结
正确的事情
- 如果你想调用一个抽象,调用一个接口,而不是一个抽象类。也就是说,输入参数应该使用接口的类型;
- 如果您想为接口提供默认实现,请创建一个实现它的抽象类,无论有多少方法保持抽象;
- 如果你想实现一个接口或一个抽象类,以便能够将参数传递给一个期望该接口的方法,如果你喜欢实现所有方法并在将来接口添加新方法时强制你重新验证实现,请使用接口。如果你喜欢使用默认实现,无论它们今天是否存在还是将来可能添加,请使用抽象类。
也许有些开发者会认为下一项值得商榷,尤其是我从未见过有名的模式谈论它,但对我来说,这是正确的做法。
- 如果你正在编写一个框架,它可能会请求对象的不同服务,不要进行类型转换,而是优先使用方法,甚至更好的是使用事件来请求。这使得装饰更加容易(因为实现特定接口的对象仍然可以声明它不支持该服务,因为被装饰的对象不支持),并且框架的用户甚至可以为第三方创建的对象提供接口实现。
值得商榷的事情
- 使用小接口更好;
- 接口隔离原则使代码更易于维护;
- 使用大接口更好;
- 当你可以创建一个抽象类并使所有方法都为虚或抽象时,你不应该创建接口;
- 您应该在一个程序集中编写接口,并在另一个程序集中编写它们的默认实现(如果有)。
前两点是基于我们不应该强制用户实现大接口的理念,如果我们只对它的一小部分感兴趣,但是这种隔离可能会使我们难以了解所有可能使用或实现的方法,并且当我们确实想要使用“额外”功能时,可能会导致一些性能损失;
第三点则相反,它明确指出所有可能性都集中在一个地方,因此用户知道他们可以装饰和调用的所有方法,而无需了解任何“帮助”类;
第四点试图避免重复。如果一个抽象类可以重新实现所有方法,那么就没有必要再用一个接口来表达相同的东西(这实际上可能会让事情稍微快一点,因为类方法的虚派发似乎比接口方法的虚派发更快)。但除了可能的性能和多重继承问题(因为你可能需要使用基类做其他事情)之外,这可能会创建错误的模式,原因是你可能会忘记在类中放置字段或非虚方法,以及由于下一条原因;
第五点与接口的使用方式有关。需要使用接口的代码并不关心实现是否使用抽象类作为基类。这样的代码不需要引用除包含接口的程序集之外的任何程序集。这允许使用完全不同的实现,而无需加载任何不会使用的代码。不幸的是,当您想使用默认实现时,您需要引用两个程序集(接口程序集 + 默认实现程序集)。
错误的事情
- 你应该总是使用接口。抽象类不好,因为类继承不好。
这一点,嗯,我认为它们之间有足够的区别,每个都有其优点,并且它们可以相互补充,所以在所有情况下都没有必要选择一个而放弃另一个。
示例
这个示例应用程序作为应用程序没有用处,只作为“概念验证”有用。
其目的是展示我们如何通过“请求服务”而不是直接类型转换来结合接口隔离原则、接口 + 抽象类和装饰。
事实上,它包含了本文介绍的 IStream
及其子接口 + 实现它们的抽象类,以及一个将所有调用重定向到普通 .NET Stream
的适配器。如果我们真的想重新创建 Stream
,我们应该避免重定向到 .NET Stream,但这只是一个示例。
我相信这个示例既展示了这种解决方案的潜力(因为它可以“无限”扩展,同时保持装饰可能性),又揭示了分离接口的问题,因为一个可以用单个抽象类 + 单个实现完成的事情是通过许多类和接口完成的,这使得直接使用或实现变得困难。所以,它既证明了这种模式的潜力,也证明了它们的问题。
菱形问题
实际上,在基本流中拥有 TryGetService<T>
产生了一种 菱形问题,这被认为是多重继承的一个大问题。由于 TryGetService<T>
可以返回另一个对象,而该对象也具有 TryGetService<T>
,因此这样的另一个对象可能对此方法有不同的实现。这纯粹是一个错误,因为返回的每个对象都应该对此方法具有相同的实现。
为了解决这个问题,在示例中我创建了 StreamPart
抽象类。当调用 TryGetService<T>
时,流的每个“部分”实际上都会重定向到主流,因此我们可以说它们没有自己的实现(或者它们使用相同的实现)。如果它们有自己的实现,那会非常奇怪,因为我们可以向主流程请求服务并获得它,但从服务本身,这样的请求可能会失败。总而言之,这是对象可以通过可能提供新对象来回答它们是否支持服务的架构问题,但是,正如您所看到的,它可以通过始终重定向到“第一个”对象来解决。