一个类的实例和表示实例的类之间有什么区别?

19

我以Java为例,但这是一个更一般的面向对象设计相关问题。

以Java中的 IOException 为例。例如,为什么有一个 FileNotFoundException 类?难道不应该是一个IOException的实例,其中causeFileNotFound吗?我会说FileNotFoundExceptionIOException的一个实例。这个结束于何处? FileNotFoundButOnlyCheckedOnceExceptionFileNotFoundNoMatterHowHardITriedException..?

我还在我工作的项目中看到过代码,其中存在FirstLineReaderLastLineReader等类。对我来说,这些类实际上代表实例,但我在许多地方都看到了这样的设计。例如,看看Spring Framework源代码,它带有数百个这样的类,每次看到一个类时,我都看到了一个实例,而不是蓝图。类不是用来表示蓝图的吗?

我想问的是,如何在这两个非常简单的选项之间做出决策:

选项1:

enum DogBreed {
    Bulldog, Poodle;
}

class Dog {
    DogBreed dogBreed;
    public Dog(DogBreed dogBreed) {
        this.dogBreed = dogBreed;
    }
}

选项2:

class Dog {}

class Bulldog extends Dog {
}

class Poodle extends Dog {
}

第一种选项要求调用者配置它正在创建的实例。在第二个选项中,该类已经代表了实例本身(就我所看到的而言,这可能是完全错误的..)。

如果您同意这些类代表实例而不是蓝图,那么您会说创建代表实例的类是一个好习惯,还是像我所说的那样完全错误,我的声明 "代表实例的类" 纯属胡说八道?


你所忽略的是,在选项1中,如果你想让一只斗牛犬的行为与贵宾犬不同,你总是需要编写像 if( dogBreed == Bulldog) {} ... 这样的代码,而在第二个选项中,你只需覆盖方法即可。但仍然有一些情况下,选项1是完全适合的。只是对于问题描述中的语义不适用。换句话说:选项1是“有一个”关系,而选项2是“是一个”关系。 - Fildor
顺便说一下:“FileNotFoundException是IOException的一个实例”这句话并不准确,因为它已经通过继承成为了“is-a”的关系。所以如果你使用catch(IOException ioe),你也会捕获到FileNotFoundException。 - Fildor
@Fildor 是的,也许我的例子不太好,但考虑到问题的其他部分,我认为它强化了我所要问的问题。 - Koray Tugay
让我们扭转一下:如果我按照你的建议去做,那么你将完全消除继承。任何类都只是一个带有字段的对象...因为最终任何类都只是“Object”类的“实例”... - Fildor
@Fildor 是的,也许吧。我并不敢说自己真的知道。只是在努力学习。也许答案很简单:“类有时确实是实例”。 - Koray Tugay
有些类有时是其他类的特殊形式。例如,FileNotFoundException 是 IOException 的一种特殊类型。但在面向对象编程中,“实例”通常指运行时类的一个具体实例。因此,以您的方式使用它有点棘手。 - Fildor
6个回答

18

编辑过的内容

首先:我们知道继承的定义,并且可以在SO和互联网上找到许多示例。但是,我认为我们应该进行更深入的研究并稍微更加科学。

注 0:
关于继承实例术语的澄清。
首先,让我给开发生命周期命名开发范围,当我们对系统进行建模和编程时,以及运行时范围,有时我们的系统正在运行。

我们有类和对它们进行建模和开发的开发范围。而对象则用于运行时范围。在开发范围中没有对象。

在面向对象中,实例的定义是:从类创建一个对象。

另一方面,在谈论类和对象时,我们应该明确我们对开发范围运行时范围观点

因此,在这个介绍中,我想澄清继承:
继承是类之间的关系,不是对象之间的关系
继承可以存在于开发范围,而在运行时范围中不存在继承。

运行我们的项目后,父类和子类之间没有关系(如果只有子类和父类之间的继承关系)。因此,问题是:super.invokeMethod1()super.attribute1是什么?,它们不是子类和父类之间的关系。父类的所有属性和方法都会传递给子类,这只是访问从父类传递的部分的一种表示法。

此外,在开发范围中没有任何对象。因此,在开发范围中没有任何实例。这只是Is-AHas-A关系。

因此,当我们说:

我会说FileNotFoundExceptionIOException一个实例

我们应该明确我们的范围(开发和运行时)。

例如,如果FileNotFoundExceptionIOException的实例,则在运行时,特定的FileNotFoundException异常(对象)与FileNotFoundException之间的关系是什么?它是一个实例吗?

注意1:
为什么要使用继承?继承的目标是基于相同类型扩展父类功能

  • 可以通过添加新属性或新方法来进行扩展。
  • 或者重写现有方法。
  • 此外,通过扩展父类,我们也可以实现可重用性。
  • 我们不能限制父类的功能(里氏替换原则)
  • 我们应该能够在系统中将子代替为父代(里氏替换原则)
  • 等等。

注意2:
继承层次结构的宽度和深度
继承的宽度和深度可以与许多因素相关:

  • 项目:项目的复杂性(类型复杂性)、架构和设计。项目规模、类数量等。
  • 团队:团队掌控项目复杂性的专业知识。
  • 等等。

然而,我们对此有一些启发(面向对象设计启发,Arthur J. Riel)。

从理论上讲,继承层次结构应该是深度的—越深越好。

在实践中,继承层次结构的深度不应超过一个人的短期记忆力所能容纳的数量。这个深度的常用值为6.

请注意,它们是基于短期记忆数字(7)的启发式方法。也许团队的专业知识会影响这个数字,但在许多层次结构中,如组织图表中经常使用。

注意3:
当我们使用错误的继承时?
根据:

  • 注意1:继承的目标(扩展父类功能)
  • 注意2:继承的宽度和深度

在这种情况下,我们使用错误的继承:

  1. 我们在继承层次结构中拥有一些类,但没有扩展父类功能。扩展应该是合理的,并且应该足以创建一个新类。合理性是从Observer的角度出发的,观察者可以是项目架构师或设计师(或其他架构师和设计师)。

  2. 我们在继承层次结构中有大量的类。这被称为超专业化。一些原因可能会导致这种情况:

    • 也许我们没有考虑到Note 1(扩展父功能)
    • 也许我们的模块化(封装)不正确。我们把许多系统用例放在一个包中,应该进行设计重构。

    他们是其他原因,但与此答案并不完全相关。

    Note 4:
    我们该怎么办?当我们使用错误的继承时?

    解决方案1:我们应该执行设计重构来检查类的价值,以便扩展父功能。在这种重构中,可能会删除许多系统类。

    解决方案2:我们应该执行设计重构来进行模块化。在这种重构中,可能会将我们包的一些类传输到其他包。

    解决方案3:使用组合而非继承
    我们可以使用此技术的许多原因之一是动态层次结构。因此,我们更喜欢组合而非继承。
    请参见Tim Boudreau(Sun的成员)的注释

     

    对象层次结构不扩展

    解决方案4:使用实例而非子类

    这个问题是关于这种技术的。让我称之为实例优于子类

    什么时候可以使用:

    1. (提示1):考虑Note 1,当我们没有准确扩展父类功能时。或者扩展不合理且不足够。

    2. (提示2):考虑Note 2,如果我们有很多子类(半相同或相同的类),它们略微扩展了父类,并且我们可以在不继承的情况下控制此扩展。请注意,这并不容易说出来。我们应该证明它不违反其他面向对象原则,例如开闭原则。

    我们该怎么办?
    Martin Fowler建议(书1第232页和书2第251页):

     

    用字段替换子类,将方法更改为超类字段并消除子类。

    我们可以使用其他技术,如问题所述的enum


4
首先,将异常问题与一般系统设计问题一起考虑,实际上是在问两个不同的问题。异常只是复杂的值,它们的行为是微不足道的:提供消息、提供原因等等。它们自然地具有层次结构, 最上面是Throwable,其他的异常会反复对它进行特化。通过提供自然过滤机制,层次结构简化了异常处理:当你说catch(IOException ...),你知道你将得到关于I/O所有发生的坏事情。测试,在大型对象层次结构中可能很难看,但对于异常来说没有什么可测试的东西。
因此,如果您正在设计类似的具有微不足道行为的复杂值,则具有高层次结构是一个合理的选择:不同类型的树或图节点是一个很好的例子。
您提出的第二个例子似乎涉及到具有更复杂行为的对象。这些对象有两个方面:
1.需要测试行为 2.具有复杂行为的对象在系统演变过程中经常改变彼此之间的关系。
这就是经常听到的“组合优于继承”的原因。从90年代中期以来,人们已经深刻理解到,由许多小对象组成的大组合通常比必须是大对象的继承层次结构更容易进行测试、维护和更改。
尽管如此,您提供的实现选择方法还是有所偏颇。你需要回答的问题是“我感兴趣的狗的行为是什么?” 然后用一个接口来描述这些行为,并根据接口编程。
interface Dog {
  Breed getBreed();
  Set<Dog> getFavoritePlaymates(DayOfWeek dayOfWeek);
  void emitBarkingSound(double volume);
  Food getFavoriteFood(Instant asOfTime);
}

当你理解了行为规律后,实现决策就变得更加清晰。

在实现时的一个经验法则是将简单、常见的行为放在抽象基类中:

abstract class AbstractDog implements Dog {
  private Breed breed;
  Dog(Breed breed) { this.breed = breed; }
  @Override Breed getBreed() { return breed; }
}

您应该能够通过创建只针对未实现方法抛出UnsupportedOperationException并验证已实现方法的最小具体版本来测试这样的基础类。任何更高级别的设置需求都是代码臭味:您已经将太多东西放入了基础类中。
这样的实现层次结构有助于减少样板文件,但超过2个级别就是代码臭味。如果您发现自己需要3个或更多级别,则很可能可以且应该将低级别类中的常见行为块包装在帮助器类中,以便于测试并在整个系统中组合使用。例如,与其在基类中提供protected void emitSound(Mp3Stream sound);方法供继承者使用,不如创建一个新的class SoundEmitter {}并在Dog中添加一个带有此类型的final成员。
然后通过填写其余行为来创建具体类:
class Poodle extends AbstractDog {
  Poodle() { super(Breed.POODLE); }
  Set<Dog> getFavoritePlaymates(DayOfWeek dayOfWeek) { ... }
  Food getFavoriteFood(Instant asOfTime) { ... }
}

观察:犬只必须能够返回其品种的需求,我们决定在抽象基类中实现“获取品种”行为导致存储了一个枚举值。 我们最终采用了更接近选项1的方法,但这不是先验选择,而是从思考行为和最干净的实现方式中得出的。

混合苹果和橙子有什么问题吗?它们都是水果啊? - Koray Tugay
1
@KorayTugay 对不起,我使用了一种本地表达方式。它的意思是它们是不相似的。对一个做出结论并不一定告诉你关于另一个的任何信息。 - Gene

3

以下评论是在子类实际上不扩展其父类功能的情况下发表的。

来自 Oracle 文档:

Signals that an I/O exception of some sort has occurred. This class is the general class of exceptions produced by failed or interrupted I/O operations.

它表示IOException是一个通用异常。如果我们有一个原因枚举:
enum cause{
    FileNotFound, CharacterCoding, ...;
}

我们无法在自定义代码中抛出IOException,如果其中的原因没有包含在枚举中。换句话说,它使得IOException更加具体而不是一般性的。假设我们不是在编写库,并且下面的Dog类的功能是我们业务需求中的特定功能:
enum DogBreed {
    Bulldog, Poodle;
}

class Dog {
    DogBreed dogBreed;
    public Dog(DogBreed dogBreed) {
       this.dogBreed = dogBreed;
    }
}

个人认为使用枚举是有好处的,因为它能简化类的结构(减少类的数量)。


3
选项1必须在声明时列出所有已知的原因。
选项2可以通过创建新类来扩展,而不必触及原始声明。
当基础/原始声明由框架完成时,这一点非常重要。如果有100个已知的固定I/O问题原因,那么枚举或类似的东西可能是有意义的,但是如果新的通信方式可能会出现,这些方式也应该是I/O异常,那么类层次结构就更有意义了。您添加到应用程序中的任何类库都可以扩展更多的I/O异常,而无需触及原始声明。
这基本上是SOLID中的O,开放扩展,关闭修改。
但这也是为什么例如DayOfWeek类型的枚举在许多框架中存在的原因。西方世界很难突然醒来决定拥有14个独特的日子、8个或6个。因此,为这些事情设立类可能是过度的。这些事情更加固定(敲木头)。

3
你引用的第一个代码涉及异常。继承对于异常类型来说是自然的选择,因为区分try-catch语句中感兴趣的异常的语言提供构造是通过使用类型系统。这意味着我们可以轻松地选择处理更具体的类型(FileNotFound),或者更一般的类型(IOException)。
测试字段的值以查看是否处理异常意味着要跳出标准语言结构并编写一些保护代码(例如测试值并重新抛出,如果不感兴趣)。
此外,异常需要在DLL(编译)边界上可扩展。当我们使用枚举时,如果要扩展设计而不修改引入枚举的源代码(以及消费枚举的其他源代码),可能会遇到问题。
除了异常之外的其他事物,今天的智慧鼓励组合优先于继承,因为这通常会导致设计更简单、更易维护。您的选项1更像是组合示例,而您的选项2显然是继承示例。
如果您同意这些类表示实例而不是蓝图,那么您是否认为创建表示实例的类是一种好做法,还是我的"类表示实例"陈述完全无意义?
我同意你的观点,并不认为这代表着良好的做法。如所示的这些类不是特别可定制的,也不代表增加的价值。 如果一个类没有提供任何重写、新状态或新方法,那么它与其基类没有太大区别。因此,声明这样的类几乎没有什么价值,除非我们打算在其上进行实例测试(就像异常处理语言构造在内部执行的操作一样)。从这个问题提出的虚构示例中,我们无法真正确定这些子类是否具有附加值,但看起来似乎并没有。
需要明确的是,继承有更糟糕的示例,比如一个职业(预先)例如教师或学生继承自Person。这意味着一个教师不能同时成为一个学生,除非我们参与添加更多类,例如使用多重继承的TeacherStudent。
我们可能将这种类爆炸称为一种情况,因为有时由于不合适的is-a关系而需要一系列的类。 (添加一个新类,您需要一个全新的行或列的爆炸式类。)
使用遭受类爆炸的设计实际上为使用这些抽象化的客户端创建了更多工作,因此这是一个失败的情况。问题在于我们信任自然语言,因为当我们说某人是学生时,从逻辑角度来看,这不是相同的永久"is-a"/instance-of关系(子类化),而是可能是在Person中扮演的潜在临时角色:人可能会同时扮演许多可能的角色。在这些情况下,组合明显优于继承。
在您的情况下,BullDog 很可能只能是 BullDog,因此子类化的永久 is-a 关系保持不变,虽然增加的价值很小,但至少这种层次结构不会导致类爆炸。
请注意,使用枚举方法的主要缺点是,根据您使用的语言,枚举可能无法扩展。如果需要任意可扩展性(例如由其他人且不更改代码),则可以选择使用可扩展但类型弱的内容,如字符串(拼写错误未被捕获,重复项未被捕获等),或者可以使用继承,因为它提供了具有更强类型的良好可扩展性。异常需要这种由其他人进行扩展而不修改和重新编译原始内容和其他内容,因为它们在 DLL 边界上使用。
如果您控制枚举并且可以根据需要重新编译代码作为一个单位来处理新的狗类型,则不需要此可扩展性。

1
The two options you present do not actually express what I think you're trying to get at. What you're trying to differentiate between is composition and inheritance.
Composition works like this:
class Poodle {
    Legs legs;
    Tail tail;
}

class Bulldog {
    Legs legs;
    Tail tail;
}

两者都有一组共同的特征,我们可以将其聚合以“组成”一个类。我们可以在需要的地方专门化组件,但可以预期,“腿”大多像其他腿一样工作。
Java选择继承而不是组合IOExceptionFileNotFoundException
也就是说,FileNotFoundException是一种(即extendsIOException,只允许基于超类的身份处理(尽管您可以指定特殊处理)。
选择组合而不是继承的论点已经被其他人反复阐述,并且可以通过搜索“组合与继承”轻松找到。

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