接口与基类的区别

836

我应该在什么时候使用接口,什么时候使用基类?

如果我不想定义方法的基本实现,是否总是应该使用接口?

如果我有一个Dog和Cat类,为什么我要实现IPet而不是PetBase?我可以理解为ISheds或IBarks(IMakesNoise?)等功能提供接口,因为这些功能可以针对每个宠物进行设置,但我不明白为什么要为通用Pet使用哪种方式。


11
我认为你应该考虑的一个要点是——接口可能会带来一些限制,直到非常晚的阶段你才会意识到这些限制。例如,在 .NET 中,您无法序列化接口成员变量,因此,如果您有一个名为 Zoo 的类和一个 IAnimals 数组成员变量,您将无法序列化 Zoo(这意味着编写 WebServices 或其他需要序列化的东西将会很麻烦)。 - synhershko
2
这个问题可能有助于理解接口的概念。https://dev59.com/cWoy5IYBdhLWcg3wdt4L - gprathour
我只是好奇。我在 CLR via C# 中遇到了以下摘录:“我倾向于使用接口技术而不是基类型技术,因为基类型技术不允许开发人员选择最适合特定情况的基类型。” 我无法理解摘录中的意思。我们可以创建几个基类型,并为其中任何一个创建派生类型,因此开发人员可以选择基类型。请问有人能解释一下吗?我相信这可能是这个问题的一部分。还是我应该发布另一个关于具体摘录的问题? - qqqqqqq
38个回答

545

让我们以一个狗和猫类的例子来说明,使用C#语言:

狗和猫都是动物,具体来说是四足哺乳动物(“动物”这个词太过于笼统)。假设你已经有了一个抽象类Mammal,用于它们两个:

public abstract class Mammal

这个基类可能会有一些默认方法,例如:

  • Feed(喂食)
  • Mate(交配)

所有这些行为在不同物种之间的实现都大致相同。要定义这些行为,您需要:

public class Dog : Mammal
public class Cat : Mammal

现在假设还有其他哺乳动物,通常我们会在动物园里看到它们:
public class Giraffe : Mammal
public class Rhinoceros : Mammal
public class Hippopotamus : Mammal

这仍然是有效的,因为在功能核心中,Feed()Mate()仍然是相同的。

但是,长颈鹿、犀牛和河马并不是你可以养成宠物的动物。这就是接口有用的地方:

public interface IPettable
{
    IList<Trick> Tricks{get; set;}
    void Bathe();
    void Train(Trick t);
}

在猫和狗之间,上述合同的实现方式并不相同;将它们的实现放在一个抽象类中继承是不好的想法。

你的狗和猫的定义现在应该如下所示:

public class Dog : Mammal, IPettable
public class Cat : Mammal, IPettable

理论上,您可以从更高的基类覆盖它们,但本质上,接口允许您将仅需要的内容添加到类中,而无需继承。

因此,由于通常只能从一个抽象类继承(在大多数静态类型的OO语言中...例外包括C ++),但能够实现多个接口,因此它允许您在严格的根据需要的基础上构造对象。


168
我认为这并不简单。你稍微改变了问题(要求),以便接口更有意义。你应该时刻问自己,你是在定义一个合同(接口)还是共享实现(基类)。 - David Pokluda
20
接口是一种合同。您只公开服务所需的合同部分。如果您有一个“宠物园”,当然不希望向用户公开“交配”! - Anthony Mastrean
10
@David Touche,虽然我这么做是为了更好地阐述接口和抽象类与他的理解有何区别。狗和猫似乎并不是一个严格的要求! - Jon Limjap
@DaveRook 哈哈!所以我用了“可能”的限定词。Feed和Mate看起来是有效的,也许Hunt应该在另一个接口中,比如IPredator或ICarnivore :D - Jon Limjap
2
需要注意的是,在解释型、JIT环境中(特别是JVM中),虚拟方法调用比接口方法调用快得多,具体取决于上下文。我强调“上下文”,因为JVM通常可以优化掉缓慢的方法查找。(例如,如果一个接口继承者列表倾向于是相同类的实例。这很遗憾地使基准测试有些困难。)如果您正在尝试优化某些性能敏感的东西,并且只有在这种情况下才应考虑这一点。 - Philip Guin
14
另外,界面允许宠物石头洗澡和学习技巧,但不支持喂养和交配,因为对于一块石头来说这是荒谬的。 - eclux

155

嗯,Josh Bloch在Effective Java第二版中亲自说过:

优先使用接口而不是抽象类

一些主要观点:

  • 现有类可以轻松地改装以实现新的接口。您所需要做的就是添加所需的方法(如果它们尚不存在)并将一个implements子句添加到类声明中。

  • 接口非常适合定义混合类型。粗略地说,混合类型是指类可以实现除其“主要类型”之外的类型,以声明它提供某些可选行为。例如,Comparable是一种混合接口,允许类声明其实例与其他可相互比较的对象相对有序。

  • 接口允许构建非分层类型框架。类型层次结构对于组织某些内容非常有用,但其他内容并不总是适合严格的层次结构。

  • 通过包装器类惯用语,接口使功能增强变得安全而强大。如果使用抽象类来定义类型,则希望添加功能的程序员别无选择,只能使用继承。

此外,您可以通过为导出的每个非平凡接口提供抽象骨架实现类来结合接口和抽象类的优点。

另一方面,接口很难进化。如果你在接口中添加一个方法,它会破坏所有实现它的内容。
附:购买本书。它更加详细。

75
每当需要对接口进行更改时,不会破坏原来的方式是创建一个继承自旧接口的新接口。这样可以保留现有的实现,并允许您在新接口中进行任何所需的更改。 - Scott Lawrence
5
我们有“接口隔离原则”。这个原则教导我们在编写接口时要注意。应该只添加必要的方法,避免不必要的方法,因为实现接口的类也必须实现这些不必要的方法。例如,如果我们创建一个名为“Worker”的接口并添加一个“午休”方法,所有的工人都必须实现它,即使是机器人工人。因此,包含了非特定方法的接口被称为污染的或者臃肿的接口。 - Eklavyaa
3
自Java 8以来,默认方法使您能够向接口添加新功能,并确保现有实现该接口的类的向后兼容性。如果在实现类中未覆盖默认方法,则默认情况下会调用默认方法。 所有实现类都可以覆盖默认方法,或者它们可以使用instance.defaultMethod()直接调用它们。 - Arpit

146

接口和基类代表两种不同的关系形式。

继承(基类)代表一种“is-a”关系。例如,狗或猫“是”宠物。这种关系始终表示类的(单一)目的(与“单一职责原则”结合使用)。

接口则代表一个类的额外特征。我将其称为“is”关系,例如在C#中的"Foo可被处理",因此有IDisposable接口。


15
在所有答案中,这个答案提供了最好的简洁性和清晰度的结合。 - Matt Wilko
1
有人曾经告诉我,在存在“拥有”关系时应该使用接口。但我不确定这是否总是正确的;我的笔记本电脑有一个屏幕,所以它应该实现IScreen接口,还是拥有一个Screen属性更自然呢?我觉得后者更自然一些。 - Berend
2
@berend 你说的很有趣,因为这些屏幕是通过接口实现的 - VGA、HDMI等。 - Konstantin
仅仅因为它是面向对象编程,我们就必须伸展我们的想象力来跟随现实生活场景。这并不总是适用的。 - Crismogram

113

现代风格是定义 IPet PetBase。

接口的优点在于其他代码可以完全“干净”地使用它,而不必与其他可执行代码有任何联系。同时,接口还可以混合使用。

但是基类对于简单的实现和常见的实用程序非常有用。因此,也提供抽象基类以节省时间和代码。


4
接口定义了其他类如何使用你的代码。基类帮助实现者实现你的接口。这是两种不同的东西,用于两个不同的目的。 - Bill Michell
我觉得这有些可疑:这是.NET建议吗?你有参考资料吗?原则上,创建“基类”的原因只有几个,通常委托优于继承。 - andygavin
29
这里没有什么“现代”的东西。基类和接口具有相同的API只是一种冗余。在某些情况下,您可以使用这种方法,但不应该推广使用! - smentek
3
第二个最受支持的回答中最受支持的评论实际上与回答本身不同,很有意思。我必须说,我看到许多界面和基类共存的例子。从这个意义上说,这是“现代”的方式。例如,在MVVM模式中,实际上有ViewModelBase类,它实现了INotifyPropertyChanged接口。但是当我的同事问我为什么要有基类而不是在每个视图模型中实现该接口时,我不知道如何说服他。 - tete
2
正确。这不是一个选1个还是另一个的问题。它们存在是为了解决两个非常不同的问题。接口是实现类必须遵循的契约。它们最近(有时狂热地)被用于IoC和TDD方面。抽象/基类用于分组具有层次结构的公共逻辑和属性。它减少了重复的代码,从而增加了解决方案的可维护性并使其更不容易出错。 - ComeIn
显示剩余4条评论

68

接口

  • 大多数语言允许你实现多个接口。
  • 修改接口会造成破坏性的变化。所有实现都需要重新编译/修改。
  • 所有成员都是公共的。实现必须实现所有成员。
  • 接口有助于解耦。你可以使用模拟框架来模拟接口后面的任何内容。
  • 接口通常表示一种行为。
  • 接口实现是相互解耦/隔离的。

基类

  • 允许您添加一些默认实现,您可以通过从派生中获得它们(C# 8.0 中通过接口可以有默认实现)。
  • 除了 C++,你只能从一个类派生。即使可以从多个类中继承,通常也是一个不好的想法。
  • 相对容易更改基类。派生不需要做任何特殊的事情。
  • 基类可以声明受保护和公共函数,可以被派生访问。
  • 抽象基类不能像接口那样轻松地进行模拟。
  • 基类通常表示类型层次结构(IS A)。
  • 类派生可能会依赖于一些基础行为(具有对父实现的熟悉知识)。如果你更改一个人的基本实现,会影响到其他人,很容易产生混乱。

注意:框架设计指南建议使用基类(而不是接口),因为它们版本更好。在vNext中向抽象基类添加新方法是一项非破坏性的更改。 - Gishu

62

一般来说,应该优先使用接口而不是抽象类。使用抽象类的一个原因是当有多个具体类存在公共实现时。当然,你仍然应该声明一个接口(IPet),并让一个抽象类(PetBase)实现该接口。通过使用小巧、独立的接口,您可以使用多种接口来进一步提高灵活性。接口允许在不同边界之间最大限度地提供类型的灵活性和可移植性。在跨边界传递引用时,始终传递接口而不是具体类型。这样接收端就可以确定具体实现,并提供最大的灵活性。在TDD / BDD编程中,这是绝对正确的。

Gang of Four在他们的书中指出:"由于继承暴露了子类的细节实现,所以经常说'继承破坏了封装'。我相信这是真的。


哎呀。就我个人而言,我认为这是完全相反的。接口应该只包含类型的最基本功能,而基类应该提供一个丰富的框架,以便进行定制。如果把这些放在接口中,那么实现将会变得非常困难。 - user1228
1
没有人说你的接口需要很大。更小、多个接口和更丰富的基类会让一个出色的API。 - Kilhoffer
3
是不是只有我一个人觉得大多数“普通工作者”类都共享同一实现?这与您偏爱接口的一般规则相违背。我建议将您的概括分为两个:那些不包含或仅包含少量逻辑的通用类应该实现一个接口。那些包含“相当”数量逻辑的通用类应该派生自一个基类(因为它们很可能共享功能)。 - goku_da_master
@Kilhoffer:“接口允许在跨越边界时最大限度地灵活和可移植类型。”请详细说明此声明。 - JAVA

52

这与.NET有关,但《框架设计准则》一书认为,在不断演变的框架中,通常使用类会更具灵活性。一旦一个接口被发布,您就没有机会在不破坏使用该接口的代码的情况下进行更改。但是,使用类可以修改它而不会破坏链接到它的代码。只要您进行正确的修改,包括添加新功能,即可扩展和发展代码。

Krzysztof Cwalina在第81页上说:

在.NET Framework的三个版本过程中,我已经与我们团队中的很多开发人员讨论了这个准则。其中许多人,包括最初不同意这些准则的人,都表示后悔将某些API作为接口发布。我甚至没有听说过有哪个人后悔发布了一个类。

话虽如此,接口确实有其应用场景。作为一般指南,请始终提供接口的抽象基类实现,以便作为实现接口的一种方式的示例。在最好的情况下,该基类将节省大量工作。


当我开始探索为什么这个页面 https://learn.microsoft.com/en-us/dotnet/standard/library-guidance/breaking-changes 建议使用抽象基类而不是接口时,我发现了这个。 (不确定页面上的建议是否适用于每个包) - gawkface

20

Juan,

我认为接口是描述类的一种方式。例如,一个约克夏犬品种类可能是父级犬类的后代,但它还实现了IFurry、IStubby和IYippieDog接口。因此,类定义了类本身,而接口告诉我们关于它的信息。

这样做的好处在于,例如,我可以收集所有IYippieDog并将它们放入我的Ocean集合中。因此,现在我可以跨越一组特定的对象,找到满足我要求的对象,而无需过多地检查类。

我发现接口真正应该定义类的公共行为子集。如果它为实现所有公共行为的所有类定义,则通常不需要存在。它们对我没有任何有用的信息。

尽管如此,这个想法与每个类都应该有一个接口,并且应该编写接口的想法相反。那很好,但你最终会得到很多一对一的接口和类,这会使事情变得混乱。我理解的想法是这样做实际上不会花费太多成本,现在您可以轻松地交换东西。然而,我发现我很少这样做。大多数时候我只是直接修改现有类,如果该类的公共接口需要更改,我仍然会遇到相同的问题,只是现在必须在两个地方进行更改。

因此,如果您和我一样思考,您肯定会说猫和狗都是可宠物化的(IPettable)。这是一种匹配它们两个的特征。

但另一个问题是它们是否应该有相同的基类?问题是它们是否需要被广泛视为同一件事。当然,它们都是动物,但这是否符合我们将如何一起使用它们的方式。

比如我想收集所有的动物类并将它们放入我的Ark容器中。

或者它们需要成为哺乳动物吗?也许我们需要某种跨动物哺乳工厂?

它们甚至需要在一起链接吗?仅知道它们都是可宠物化的是否足够?

我经常会有一种想要衍生整个类层次结构的冲动,即使我只需要一个类。我这样做是因为我预计将来可能会用到它,但实际上通常从未用过。即使我确实需要用到它,通常我也会发现需要做很多修复工作。那是因为我正在创建的第一个类不是Dog,我没有那么幸运,而是鸭嘴兽。现在我的整个类层次结构都基于奇怪的情况,我有很多浪费的代码。

你可能也会发现并非所有的猫都能被抚摸(就像那个无毛的),这时你可以将接口移动到适合的派生类中。你会发现这是一种比突然将所有猫都从PettableBase派生出来更少破坏性的改变。


19

以下是“接口”和“基类”的基本简单定义:

  • 基类 = 对象继承。
  • 接口 = 函数继承。

干杯


15

这篇Java World文章中很好地解释了这个问题。

个人而言,我倾向于使用接口来定义接口 - 即系统设计的部分,指定如何访问某些内容。

通常情况下,我会有一个类实现一个或多个接口。

抽象类我用作其他东西的基础。

以下是上述文章JavaWorld.com文章,作者Tony Sintes,04/20/01中的摘录:


接口与抽象类

选择接口和抽象类不是一种二选一的方案。如果需要更改设计,请将其设为接口。但是,您可以有提供某些默认行为的抽象类。抽象类非常适合应用程序框架内部。

抽象类允许您定义某些行为; 它们强制子类提供其他行为。例如,如果您有一个应用程序框架,则抽象类可以提供默认服务,例如事件和消息处理。这些服务允许您的应用程序插入到应用程序框架中。但是,仅您的应用程序才能执行某些特定于应用程序的功能。这样的功能可能包括启动和关闭任务,这通常取决于应用程序。因此,抽象基类可以声明抽象的关闭和启动方法,而不是尝试定义该行为本身。基类知道它需要这些方法,但抽象类允许您的类承认它不知道如何执行这些操作; 它只知道必须启动这些操作。当启动时,抽象类可以调用启动方法。当基类调用此方法时,Java将调用由子类定义的方法。

许多开发人员忘记了定义抽象方法的类也可以调用该方法。抽象类是创建计划继承层次结构的一种极好方式。它们也是类层次结构中非叶类的良好选择。
类与接口之间的区别,有些人认为应该用接口来定义所有类,但我认为这个建议有点过分。当我看到我的设计中某些东西经常更改时,我使用接口。
例如,策略模式允许您在程序中交换新的算法和流程而不更改使用它们的对象。媒体播放器可能知道如何播放CD、MP3和wav文件。当然,您不想将这些播放算法硬编码到播放器中;这会使添加新格式(如AVI)变得困难。此外,您的代码将布满无用的case语句。更糟糕的是,每次添加新算法都需要更新这些case语句。总的来说,这不是一种非常面向对象的编程方式。
使用策略模式,您可以将算法封装在一个对象后面。如果这样做,您可以随时提供新的媒体插件。让我们把插件类称为MediaStrategy。那个对象将有一个方法:playStream(Stream s)。因此,要添加新算法,我们只需扩展我们的算法类。现在,当程序遇到新的媒体类型时,它只需将流的播放委托给我们的媒体策略即可。当然,您需要一些管道来正确地实例化所需的算法策略。
使用接口是一个极好的选择。我们已经使用了明确指出设计中将要更改的位置的策略模式。因此,应将策略定义为接口。通常情况下,当您想要对象具有某种类型(在这种情况下是MediaStrategy)时,应优先考虑接口而不是继承。依赖继承进行类型标识是危险的;它将您锁定在特定的继承层次结构中。Java不允许多重继承,因此您无法扩展提供有用实现或更多类型标识的东西。

2
“仅依赖继承来确定类型身份是危险的;它会将你锁定在特定的继承层次结构中。” 这句话完美地描述了我更喜欢接口的原因。 - Engineer
而不是扩展,更好的做法是组合接口中每个方法背后的实现。 - Engineer

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