“如果重新使用某个方法而不进行更改,将该方法放在基类中,否则创建一个接口”是否是一种很好的经验法则?


10

我的一位同事想出了一个在创建基类或接口之间进行选择的经验法则。

他说:

想象一下您将要实现的每个新方法。对于它们中的每一个,请考虑以下问题:是否可以由多个类以完全相同的形式实现此方法,而无需进行任何更改?如果答案为“是”,则创建一个基类。在其他所有情况下,请创建一个接口。

例如:

考虑类catdog,它们扩展了类mammal并具有一个方法pet()。然后我们添加类alligator,该类不会扩展任何内容并且具有一个方法slither()

现在,我们要eat()为所有这些方法添加一个方法。

如果实施eat()方法将是完全一样的catdog而且alligator,我们应该创建一个基类(比方说,animal),它实现了这个方法。

但是,如果它的实现alligator有丝毫不同,我们应该创建一个IEat接口并制作mammalalligator实现它。

他坚持认为这种方法适用于所有情况,但对我来说似乎过于简化了。

遵循此经验法则是否值得?


27
alligatoreat当然,的实现不同之处在于它接受catdog作为参数。
sbichenko

1
如果您确实希望抽象基类共享实现,但是应该使用接口以实现正确的可扩展性,则通常可以使用特征来代替。也就是说,如果您的语言支持这一点。
阿蒙

2
当您将自己粉刷到角落时,最好是在离门最近的那一处。
JeffO

10
人们犯的最大错误是认为接口只是空的抽象类。接口是程序员说:“只要遵循此约定,我不在乎您给我什么。” 然后,应该通过组合来(理想地)实现代码重用。你的同事错了。
riwalk

2
我的CS教授教过,超类应为is a,接口应为acts likeis。因此,狗is a哺乳动物和acts like食者。这将告诉我们,哺乳动物应该是一类,食者应该是一种界面。它一直是非常有用的指南。旁注:的一个例子isThe cake is eatableThe book is writable
MirroredFate 2013年

Answers:


13

我认为这不是一个好的经验法则。如果您担心代码重用,则可以执行PetEatingBehavior哪个角色来定义猫和狗的饮食功能。然后,您可以将IEat代码和代码重用在一起。

这些天,我越来越少地使用继承的理由。一大优势是易于使用。采取GUI框架。为此设计API的一种流行方法是公开一个巨大的基类,并记录用户可以重写的方法。因此,我们可以忽略应用程序需要管理的所有其他复杂事情,因为基类给出了“默认”实现。但是我们仍然可以通过在需要时重新实现正确的方法来自定义事物。

如果您坚持使用api的界面,则用户通常需要做更多的工作才能使简单的示例生效。但是带有接口的API通常耦合性较低,并且维护起来也更容易,原因很简单,原因是它IEat包含的信息少于Mammal基类。因此,消费者将依赖较弱的合同,获得更多的重构机会,等等。

引用Rich Hickey:简单!=简单


您能否提供PetEatingBehavior实施的基本示例?
sbichenko

1
@exizt首先,我不擅长选择姓名。所以PetEatingBehavior可能是错误的。我不能给您一个实现,因为这只是一个玩具示例。但是我可以描述一下重构步骤:它有一个构造函数,该构造函数可以使用猫/狗使用的所有信息来定义eat方法(牙齿实例,胃实例等)。它仅包含猫和狗在其eat方法中使用的通用代码。您只需创建一个PetEatingBehavior成员并将其调用转发到dog.eat/cat.eat。
西蒙·贝格

我不敢相信这是许多使OP远离继承的唯一答案:(继承不用于代码重用!
Danny Tuppeny

1
@DannyTuppeny为什么不呢?
sbichenko 2013年

4
@exizt从类继承是一件大事;您对某事具有(可能是永久的)依赖性,在许多语言中,您只能拥有其中一种。重用代码是用完这个通常仅是基类的琐碎事情。如果您最终获得了要与另一个类共享它们的方法,但是由于重用其他方法(甚至它是多态使用的“真实”层次结构的一部分),该类已经有了基类,该怎么办?当ClassA是ClassB的一种类型时使用继承(例如,汽车从Vehicle继承),而不是在共享一些内部实现细节时使用继承:-)
Danny Tuppeny 2013年

11

遵循此经验法则是否值得?

这是一个不错的经验法则,但我知道我违反了很多地方。

公平地说,我使用(抽象)基类比同龄人更多。我这样做是作为防御性编程。

对我来说(使用多种语言),抽象基类增加了关键约束,即可能只有一个基类。通过选择使用基类而不是接口,您的意思是“这是该类(和任何继承者)的核心功能,并且不适合将willn-nilly与其他功能混合使用”。接口相反:“这是对象的某些特征,不一定是其核心职责”。

这些类型的设计选择会为实施人员创建暗含的指南,以指导他们如何使用您的代码,并扩展其代码。由于它们对代码库的影响更大,因此在做出设计决策时,我倾向于更强烈地考虑这些因素。


1
3年后重读此答案,我发现这是一个很好的观点:通过选择使用基类而不是接口,您的意思是“这是该类(以及任何继承者)的核心功能,它是不适合将
Willy

9

您朋友的简化问题在于,确定一种方法是否需要更改非常困难。最好使用能说明超类和接口背后思想的规则。

与其通过查看您认为的功能来弄清楚应该做什么,而是要查看要创建的层次结构。

这是我的一位教授如何讲授差异的方法:

超类应为is a,接口应为acts likeis。因此,狗is a哺乳动物和acts like食者。这将告诉我们,哺乳动物应该是一类,食者应该是一种界面。一个示例isThe cake is eatableor The book is writable(同时创建eatablewritable接口)。

使用这样的方法是相当简单和容易的,并且会导致您按照结构和概念进行编码,而不是按照代码的预期方式进行编码。这使维护更容易,代码更易读,并且设计更易于创建。

不管代码实际上怎么说,如果您使用这样的方法,您可能会在五年后回来使用相同的方法来追溯您的步骤,并轻松地弄清楚程序的设计方式和交互方式。

只是我的两分钱。


6

应exizt的要求,我将评论扩展为更长的答案。

人们犯的最大错误是认为接口只是空的抽象类。界面是程序员说:“只要遵循此约定,我不在乎您给我什么。”

.NET库是一个很好的示例。例如,当您编写一个接受的函数时,IEnumerable<T>您的意思是:“我不在乎如何存储数据。我只是想知道我可以使用foreach循环。

这导致了一些非常灵活的代码。突然要进行集成,您需要做的就是按照现有接口的规则进行操作。如果实现该接口困难或令人困惑,则可能暗示您正在尝试在圆孔中推一个方形钉。

但是随后出现了一个问题:“代码重用如何?我的CS教授告诉我,继承是解决所有代码重用问题的解决方案,继承使您可以编写一次并在任何地方使用,并且可以在挽救孤儿的过程中治愈脑膜炎。从波涛汹涌的海洋中流出来,再也不会有更多的眼泪等等。”

仅因为您喜欢“代码重用”一词的声音而使用继承是一个非常糟糕的主意。Google代码样式指南十分简洁地说明了这一点:

组合通常比继承更合适。... [B]由于实现子类的代码分散在基类和子类之间,因此可能很难理解实现。子类不能覆盖非虚函数,因此子类不能更改实现。

为了说明为什么继承不总是答案,我将使用一个名为MySpecialFileWriter†的类。一个盲目相信继承是所有问题的解决方案的人会认为您应该尝试从中继承FileStream,以免重复FileStream代码。聪明的人意识到这很愚蠢。您应该只FileStream在类中有一个对象(作为局部变量或成员变量)并使用其功能。

FileStream示例看似人为,但事实并非如此。如果您有两个类都以完全相同的方式实现相同的接口,那么您应该有第三个类来封装重复的任何操作。您的目标应该是编写类,这些类是可以像乐高积木一样放在一起的独立的可重用块。

这并不意味着应该不惜一切代价避免继承。有很多要考虑的方面,而大多数内容将通过研究“组成与继承”这个问题来涵盖。我们自己的堆栈溢出对此问题有一些很好的答案

最终,您的同事的情绪缺乏做出明智决定所需的深度或理解。研究主题并自己解决。

†说明继承时,每个人都使用动物。那没用。在11年的开发中,我从未编写过一个名为的类Cat,因此我将不使用它作为示例。


因此,如果我理解正确,那么依赖注入将提供与继承相同的代码重用功能。但是,您将继承用于什么呢?您会提供用例吗?(我非常感谢FileStream
sbichenko

1
@exizt,不,它不提供相同的代码重用功能。(在许多情况下)它提供了更好的代码重用功能,因为它可以很好地控制实现。类通过其对象和公共API进行显式交互,而不是通过重载现有函数来隐式获得功能并完全更改一半功能。
riwalk13年

4

例子很少说明重点,尤其是当它们与汽车或动物有关时。动物的进食(或加工食物)方式与它的遗传并没有真正的联系,没有一种单一的进食方式适用于所有动物。这更多地取决于其物理功能(可以说输入,处理和输出的方式)。哺乳动物可以是食肉动物,食草动物或杂食动物,而鸟类,哺乳动物和鱼类可以吃昆虫。可以使用某些模式来捕获此行为

在这种情况下,通过抽象行为,您会更好,例如:

public interface IFoodEater
{
    void Eat(IFood food);
}

public class Animal : IFoodEater
{
    private IFoodProcessor _foodProcessor;
    public Animal(IFoodProcessor foodProcessor)
    {
        _foodProcessor = foodProcessor;
    }

    public void Eat(IFood food)
    {
        _foodProcessor.Process(food);
    }
}

或者实现其中一个访问者模式FoodProcessor试图喂动物兼容(ICarnivore : IFoodEaterMeatProcessingVisitor)。关于活体动物的想法可能会阻碍您考虑淘汰其消化道,并用一种​​通用的消化道来代替,但是您确实在帮他们一个忙。

这将使您的动物成为基类,甚至更好,在添加新型食品和相应的加工机时,您将不再需要更改,因此您可以专注于使您成为特定动物类的事物工作如此独特。

因此,是的,基类有其位置,经验法则确实适用(通常,并非总是如此,因为这是经验法则),但仍在寻找可以隔离的行为。


1
IFoodEater有点多余。您还希望能吃些什么?是的,以动物为例是可怕的事情。
欣快的

1
食肉植物呢?:)严重的是,在这种情况下不使用接口的一个主要问题是,您已经将进餐与动物紧密地联系在一起,并且引入了很多新的抽象方法是不合适的,这些新抽象方法不适用于动物基类。
朱莉娅·海沃德

1
我认为这里的“饮食”是错误的想法:变得更加抽象。一个IConsumer接口怎么样。食肉动物可以食用肉,食草动物可以食用植物,而植物则可以食用阳光和水。

2

接口实际上是合同。没有功能。由实施者来实施。别无选择,因为必须执行合同。

抽象类为实现者提供了选择。可以选择拥有每个人都必须实现的方法,提供一种具有可以覆盖的基本功能的方法,或者提供所有继承者都可以使用的某些基本功能。

例如:

public abstract class Person
    {
        /// <summary>
        /// Inheritors must implement a hello
        /// </summary>
        /// <returns>Hello</returns>
        public abstract string SayHello();
        /// <summary>
        /// Inheritors can use base functionality, or override
        /// </summary>
        /// <returns></returns>
        public virtual string SayGoodBye()
        {
            return "Good Bye";
        }
        /// <summary>
        /// Base Functionality that is inherited 
        /// </summary>
        public void DoSomething()
        {
            //Do Something Here
        }
    }

我想说您的经验法则也可以由基类处理。特别是由于许多哺乳动物可能“吃掉”相同的食物,然后使用基类可能比接口更好,因为一个人可以实现大多数继承者可以使用的虚拟进食方法,然后例外情况可以被覆盖。

现在,如果“吃”的定义因动物而异,那么也许界面会更好。有时这是一个灰色区域,取决于具体情况。


1

没有。

接口与方法的实现方式有关。如果根据何时使用接口来决定如何实现方法,则您做错了什么。

对我而言,接口和基类之间的区别在于需求的立场。当某些代码段需要具有特定API的类以及由此API隐含的行为时,便可以创建接口。如果没有实际调用该接口的代码,则无法创建接口。

另一方面,基类通常是重用代码的方式,因此您很少为将调用该基类的代码而烦恼,而只考虑到该基类所属对象的层次结构。


等等,为什么要eat()每只动物中重新实现?我们可以mammal实现它,不是吗?不过,我了解您对要求的看法。
sbichenko

@exizt:对不起,我没有正确阅读您的问题。
欣快的

1

这不是一个很好的经验法则,因为如果您想要不同的实现,则始终可以使用带有抽象方法的抽象基类。保证每个继承者都有该方法,但是每个继承者都定义自己的实现。从技术上讲,您可以有一个仅包含一组抽象方法的抽象类,并且它基本上与接口相同。

当您想要某些状态或行为的实际实现可在多个类中使用时,基类很有用。如果发现自己有几个具有相同字段和具有相同实现的方法的类,则可以将其重构为基类。您只需要注意如何继承即可。基类中的每个现有字段或方法都必须在继承程序中有意义,尤其是当将其强制转换为基类时。

当您需要以相同的方式与一组类进行交互时,接口非常有用,但是您无法预测或不关心它们的实际实现。例如,像一个Collection接口。上帝只知道某人决定实施一系列物品的各种方式。链表?数组列表?堆?队列?这个SearchAndReplace方法无关紧要,只需要传递一些可以调用Add,Remove和GetEnumerator的方法即可。


1

我自己在看这个问题的答案,看看别人怎么说,但我会说三点我想考虑的事情:

  1. 人们对界面的信任度过高,对阶级的信任度过低。接口本质上是非常基础的类,有时使用轻量级的东西可能会导致诸如过多的样板代码之类的事情。

  2. 覆盖方法,super.method()在其中调用并让它的其余部分仅执行不干扰基类的操作,这根本没有错。这不违反《里斯科夫替代原则》

  3. 根据经验,即使通常是最佳实践,在软件工程中如此僵化和教条主义也是一个坏主意。

所以我会说一点盐。


5
People put way too much faith into interfaces, and too little faith into classes.滑稽。我倾向于认为相反的说法是正确的。
西蒙·贝格

1
“这不违反《里斯科夫换人原则》。” 但是,它非常接近违反LSP。安全要比后悔好。
欣快感2013年

1

我想对此采取语言和语义的方法。没有接口DRY。尽管可以将其用作实现集中化的机制以及拥有实现它的抽象类,但它并不适合DRY。

接口是合同。

IAnimal接口是鳄鱼,猫,狗和动物的概念之间的全有或全无的契约。它的本质是“如果您想成为动物,要么必须具有所有这些属性和特征,要么就不再是动物”。

另一方面,继承是一种重用机制。在这种情况下,我认为拥有良好的语义推理可以帮助您确定哪种方法应该去哪里。例如,虽然所有动物都可能睡觉,并且睡觉的时间相同,但我不会使用基类。我首先将该Sleep()方法放在IAnimal界面中,以强调(语义)成为动物的需要,然后将猫,狗和扬子鳄的基础抽象类用作编程技术,以免重复我自己。


0

我不确定这是否是最佳的经验法则。您可以abstract在基类中放置一个方法,并让每个类都实现它。由于每个人mammal都必须吃饭,所以这样做是有道理的。然后每个人都mammal可以使用其一种eat方法。我通常只使用接口在对象之间进行通信,或者将其标记为将某个类标记为某种东西。我认为过度使用接口会使代码草率。

我也同意@Panzercrisis的观点,即经验法则应该只是一般性准则。不是应该用石头写的东西。毫无疑问,有时会无法正常工作。


-1

接口是类型。他们没有提供实现。他们简单地定义了处理该类型的合同。实现该接口的任何类都必须满足此合同。

接口和抽象/基类的使用应由设计要求确定。

单个类不需要接口。

如果两个类在一个函数中可互换,则应实现一个公共接口。然后,可以将实现相同功能的所有功能重构为实现该接口的抽象类。如果没有区别功能,则在那时可以认为该接口是冗余的。但是,这两个类之间有什么区别?

随着更多功能的添加和层次结构的扩展,将抽象类用作类型通常很麻烦。

偏爱继承而不是继承-所有这些都消失了。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.