[Java编程思想] 阅读第8章 多态

第8章 多态

多态通过分离做什么怎么做,从另一角度将接口和实现分离出来。多态不但能够改善代码的组织结构和可读性,还能够创建可扩展的程序——即无论在项目最初创建时还是在需要添加新功能时都可以“生长”的程序。

“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过讲细节“私有化”把接口和实现分离开来。(权限控制,private)。

多态的作用则是消除类型之间的耦合关系。继承允许将对象视为它自己本身的类型或其基类型来加以处理。这种能力极为重要,因为它允许将多种类型(从同一基类导出的)视为同一类型来处理(向上转型、向下转型)。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出而来的。这种区别是根据方法行为的不同而表现出来的,虽然这些方法都可以通过同一个基类来调用。(多态,难道就是多种状态!!!基类对象的行为,导出类对象的行为,就好比都是人,但是有的人会武术,有的人不会武术(方法);有的人瘦,有的人胖(属性)等等)

8.1 再论向上转型

导出类是基类的超集,导出类型向上转型为基类型,总是安全的。
在这里插入图片描述

对象既可以作为它自己本身的类型使用,也可以作为它的基类型使用。(因为在继承树的画法中,基类是放置在上方的)
在这里插入图片描述

软件设计之UML—UML中的六大关系

在这里插入图片描述
Music.tune()方法接受一个Instrument引用,同时也接受任何导出自Instrument的类。在main()方法中,当一个Wind引用传递到tune()方法时,就会出现这种情况,而不需要任何类型转换。这样做是允许的——因为Wind从Instrument继承而来,所以Instrument的接口必定存在于Wind中。从Wind向上转型到Instrument可能会“缩小”接口,但不会比Instrument的全部接口更窄。

8.1.1 忘记对象类型

(貌似要讲到泛型的概念了,不知怎么的想到了C++中的模板类)
编写接受导出类作为参数的方法,将会导致工作量的增加,因为你需要针对每一个导出类来编写特定的方法。(不然就会出现类型错误,一个导出类的类型和另一个导出类的类型可不太一样。)此外,如果我们忘记重载某个方法,编译器不会返回任何错误,这样关于类型的整个处理过程就变得难以操纵。
如果我们不管导出类的存在,编写的代码只是与基类打交道,会不会更好呢?(完全OK!)
这正是多态允许的。

8.2 转机

tune()方法接受一个Instrument引用。那么在这种情况下,编译器怎样才能知道这个Instrument引用指向的是wind对象,而不是Brass对象或String对象呢?实际上,编译器无法得知。为了深入理解这个问题,需要研究一下绑定这个话题。

8.2.1 方法调用绑定

将一个方法调用同一个方法主题关联起来被称作绑定。若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),叫做前期绑定。面向过程语言中选择的默认的绑定方式。(C只有一种方法调用,那就是前期绑定)
这样的方式导致了一个问题,当编译器只有一个Instrument引用时,它无法知道究竟调用哪个方法才对。
解决的方法就是后期绑定,它的含义就是在运行时根据对象的类型进行绑定。后期绑定也叫做动态绑定运行时绑定。也就是说,编译器一直不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是只要想一下就会得知,不管怎样都必须在对象中安置某种“类型信息”。
Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。(这感觉很好,果然是为面向对象而生的语言)。这意味着通常情况下,我们不必判断是否应该进行后期绑定——它会自动发生。
将某个方法声明为final,可以防止其他人覆盖该方法。更重要的一点是:这样做可以有效的“关闭”动态绑定,或者说,告诉编译器不需要对其进行动态绑定。这样,编译器就可以为final方法调用生成更有效的代码。然而,大多数情况下,这样做对程序的整体性能不会有什么改观。所以,最好根据设计来决定是否使用final,而不是出于试图提高性能的目的来使用final。

8.2.2 产生正确的行为

一旦知道Java中所有方法都是通过动态绑定实现多态这个事实之后,就可以编写只与基类打交道的程序代码了,并且这些代码对所有的导出类都可以正确运行。或者换一种说法,发送消息给某个对象,让该对象去断定应该做什么事。
(果然一牵扯到对象,事情就会变得复杂。Java核心技术,从工具的角度去解释Java,看的我十分头痛,以至于我的Java编程最终毫无进展。Java编程思想就高明的多了,完全是从oop本身来解释,一路上娓娓道来,引人入胜,赞赞赞!)

在这里插入图片描述
创建一个基类的导出类的对象,并把得到的引用立即赋值给基类,这样做看似是错误的(将一种类型赋值给另一种类型);但实际上是没有问题的,因为通过继承,导出类就是一种基类型。因此,编译器认可这条语句,也就不会产生错误信息。

假设你调用一个基类方法(它已在导出类中被覆盖):由于后期绑定(多态),还是正确调用了导出类中的方法。(就好像通过继承,编译器能够自动找到并调用一个方法的最新版本)
在这里插入图片描述
Shape基类为自它那里继承而来的所有导出类建立了一个公用接口——也就是说,所有形状都可以描绘和擦除。导出类通过覆盖这些定义,来为每种特殊类型的几何形状提供单独的行为。

RandomShapeGenerator是一种“工厂”(factory),在每次调用next()方法时,它可以为随机选择的Shape对象产生一个引用。注意向上转型是在return语句里发生的。每个return语句取得一个指向某个Circle、Square或者Triangle的引用,并将其以Shape类型从next()方法中发送出去。所以无论我们在什么时候调用next()方法时,是绝对不可能知道具体类型到底是什么的,因为我们总是只能获得一个通用的Shape引用。(也就是说,将导出类型向上转型为基类型,能够获得更大的通用性(可扩展性)。得益于动态连接(多态),编译器能够根据基类型的导出类型(继承树)找到最新版本的方法,来进行调用)。

8.2.3 可扩展性

在这里插入图片描述
由于多态机制,我们可以根据自己的需求对系统添加任意多的新类型,而不需更改tune()方法。在一个设计良好的OOP程序中,大多数或者所有方法都会遵循tune()的模型,而且只与基类接口通信。这样的程序是可扩展的,因为可以从通用的基类继承出新的数据类型,从而新添一些功能。那些操纵基类接口的方法不需要任何改动就可以应用于新类。
这样做的好处就是,对代码所作的修改,不会对程序中其他不应受到影响的部分产生破坏。换句话说,多态是一项让程序员“将改变的事物与未变的事物分离开来”的重要技术。
在这里插入图片描述

8.2.4 缺陷:“覆盖”私有方法

由于private方法被自动认为是final方法,而且对导出类是屏蔽的。因此,在这种情况下,导出类的与基类中private方法同名的方法,就会是一个全新的方法;既然基类中的private方法在子类中不可见,因此甚至也不能被重载。(基类中的private方法对导出类隐藏,故不能被重载)。

结论就是:只有非private方法才可以被覆盖;但是还需要密切注意覆盖private方法的现象,这时虽然编译器不会报错,但是也不会按照我们所期望的来执行。确切的说,在导出类中,对于基类中的private方法,最好采用不同的名字。

8.2.5 缺陷:域与静态方法

一旦你了解多态机制,可能就会开始认为所有事物都可以多态的发生(好吧,我确实是这么想的~!)。然而,只有普通的方法调用可以是多态的。例如,如果你直接访问某个域,这个访问就将在编译期进行解析。

当Sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super.field和Sub.field分配了不同的存储空间。这样,Sub实际上包含两个称为field的域:它自己的和它从Super处得到的。然而,在引用Sub中的field时所产生的默认域并非Super版本的field域。因此,为了得到Super.field,必须显示的指明super.field
在这里插入图片描述

class Sub extends Super{
    ...
    public int getSuperField(){
        return super.field;
    }
    ...
}

尽管这看起来好像会成为一个容易令人混淆的问题,但是在实践中,它实际上从来不会发生。首先,你通常会将所有的域设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问。

如果某个方法是静态的,它的行为就不具有多态性
静态方法是与类,而并非与单个的对象相关联。

8.3 构造器和多态

构造器不同于其他种类的方法。涉及到多态时仍是如此。尽管构造器并不具有多态性(它们实际上是static方法,只不过该static声明是隐式的)。

8.3.1 构造器的调用顺序

基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上连接,以使每个基类的构造器都能得到调用。这样做是有意义的,因为构造器具有一项特殊任务:检查对象是否被正确的构造。导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。只有基类的构造器才具有恰当的知识和权限来对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。这正是编译器为什么要强制每个导出类都必须调用构造器的原因。在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,它就会“默默”地调用默认构造器。如果不存在默认构造器,编译器就会报错(若某个类没有构造器,编译器会自动合成出一个默认构造器)。

在这里插入图片描述
注:IDEA 中的diagram功能好强大,直接就生成类图了。

调用构造器要遵照下面的顺序:

  1. 调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的跟,然后是下一层导出类,等等,直到最底层的导出类。
  2. 按声明顺序调用成员的初始化方法。
  3. 调用导出类构造器的主体。

当进行继承时,我们已经知道基类的一切,并且可以访问基类中任何声明为public和protected的成员。这意味着在导出类中,必须假定基类的所有成员都是有效的。一种标准方法是,构造动作一经发生,那么对象所有部分的全体成员都会得到构建。然而,在构造器内部,我们必须确保所要使用的成员都已经构建完毕。为确保这一目的,唯一的办法就是首先调用基类构造器。那么在进入导出类构造器时,基类中可供我们访问成员都已得到初始化。此外,知道构造器中的所有成员都有效也是因为,当成员对象在类内进行定义的时候(比如上例中的b、c和l),只要有可能,就应该对它们进行初始化(也就是说,通过组合方法将对象置于类内)。若遵循这一规则,那么就能保证所有基类成员以及当前对象的成员都被初始化了。但遗憾的是,这种做法并不适用于所有情况。

8.3.2 继承与清理

通过组合和继承方法来创建新类时,永远不必担心对象的清理问题,子对象通常都会留给垃圾回收器进行处理。如果确实遇到清理的问题,那么必须用心为新类创建dispose()方法。并且由于继承的缘故,如果我们有其他作为垃圾回收一部分的特殊清理动作,就必须在导出类中覆盖dispose()方法。当覆盖被继承类的dispose()方法时,务必记住调用基类版本dispose()方法;否则,基类的清理动作就不会发生。

polymorphism/Frog.java

所以万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化顺序相反。对于字段,则意味着与声明的顺序相反(因为字段的初始化是按照声明的顺序进行的)。对于基类(遵循C++中析构函数的形式),应该首先对其导出类进行清理,然后才是基类。这是因为导出类的清理可能会调用基类中的某些方法,所以需要使基类中的构件仍起作用而不应过早的销毁它们。

Frog对象有其自己的成员对象。Forg对象创建了它自己的成员对象,并且知道它们应该存活多久(只要Frog存活着),因此Frog对象知道何时调用dispose()去释放其成员对象。然而,如果这些成员对象中存在于其他一个或多个对象共享的情况,问题就变得更加复杂了,你就不能简单地假设你可以调用dispose()了。在这种情况下,也许就必需使用引用计数来跟踪仍旧访问着共享对象的对象数量了。

引用计数

实现引用计数(以后可能要用到)

class Shared {
  private int refcount = 0;
  private static long counter = 0;
  private final long id = counter++;
  public Shared() {
    System.out.println("Creating " + this);
  }
  public void addRef() { refcount++; }
  protected void dispose() {
    if(--refcount == 0)
      System.out.println("Disposing " + this);
  }
  public String toString() { return "Shared " + id; }
}

class Composing {
  private Shared shared;
  private static long counter = 0;
  private final long id = counter++;
  public Composing(Shared shared) {
    System.out.println("Creating " + this);
    this.shared = shared;
    this.shared.addRef();
  }
  protected void dispose() {
    System.out.println("disposing " + this);
    shared.dispose();
  }
  public String toString() { return "Composing " + id; }
}

在将一个共享对象附着到类上时,必须记住调用addRef(),但是dispose()方法将跟踪引用数,并决定何时执行清理。使用这种技巧需要加倍地细心,但是如果你正在共享需要清理的对象,那么你就没有太多的选择余地了。

8.3.3 构造器内部的多态方法的行为

构造器调用的层次结构带来了一个有趣的两难问题。如果在一个构造器的内部调用正在构造的对象的某个动态绑定方法,那么会发生什么情况呢?

如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,这个调用的效果可能相当难以预料,因为被覆盖的方法在对象被完全构造之前就会被调用。这可能会造成一些难于发现的隐藏错误。

从概念上讲,构造器的工作实际上是创建对象(这并非是一件平常的工作)。在任何构造器内部,整个对象可能只是部分形成——我们只知道基类对象已经进行初始化。如果构造器只是在构建对象的过程中的一个步骤,并且该对象所属的类是从这个构造器所属的类导出的,那么导出部分在当前构造器正在被调用的时刻仍旧是没有被初始化的。然而,一个动态绑定的方法调用却会向外深入到继承层次结构内部,它可以调用导出类里的方法。如果我们是在构造器内部这样做,那么就可能会调用某个方法,而这个方法所操纵的成员可能还未进行初始化——这肯定会招致灾难。

对构造器初始化顺序的完善

初始化的实际过程:

  1. 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
  2. 如例子中那样调用基类构造器。此时没调用被覆盖后的draw()方法(要在调用RoundGlyph构造器之前调用),由于步骤1的缘故,我们此时会发现radius的值为0
  3. 按照声明的顺序调用成员的初始化方法。
  4. 调用导出类的构造器主体。

这样做有一个优点,那就是所有东西都至少初始化为零(或者是某些特殊数据类型中与“零”等价的值),而不是仅仅留作垃圾。其中包括“组合”而嵌入一个类内部的对象引用,其值是null。所以如果忘记为该引用进行初始化,就会在运行时出现异常。查看输出结果时,会发现其他所有东西的值都会是零,这通常也正是发现问题的证据。
另一方面,我们应该对这个程序的结果相当震惊。(…(* ̄0 ̄)ノ)在逻辑方面,我们做的已经十分完美,而它的行为却不可思议的错了,并且编译器也没有报错。(在这种情况下,C++语言会产生更合理的行为。)注入此类的错误会很容易被人忽略,而且要花很长的时间才能发现。
因此,编写构造器时有一条有效的准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。(和我所想的一致。)在构造器内唯一能够安全调用的那些方法是基类中的final方法(也适用于private方法,它们自动属于final方法)。这些方法不能被覆盖,因此也就不会出现上述令人惊讶的问题。你可能无法总是遵循这条准则,但是应该朝着它努力。

8.4 协变返回类型

Java SE5中添加了协变返回类型,它表示在导出类中的被覆盖的方法可以返回基类方法的返回类型的某种导出类型。

polymorphism/CovariantReturn.java

顺着继承树,一个叶子节点的返回类型,可以是基类(根节点)的返回类型,更可以具体到另一个叶子节点的类型。

8.5 用继承进行设计

学习了多态之后,看起来似乎所有东西都可以被继承,(好吧,我确实又是这么想的)因为多态是一种如此巧妙的工具。事实上,当我们使用现成的类来建立新类时,如果首先考虑使用继承技术,反倒会加重我们的设计负担,使事情变得不必要地复杂起来。(之前学习时,过分看重了继承,对组合用的很少)

更好的方式是首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。组合不会强制我们的程序设计进入继承的层次结构中。而且,组合更加灵活,因为它可以动态选择类型(因此也就选择了行为);相反,继承在编译时就需要知道确切类型。

polymorphism/Transmogrify.java

既然引用在运行时可以与另一个不同的对象重新绑定起来,所以SadActor对象的引用可以在actor中被代替,然后由performPlay()产生的行为也随之改变。这样一来,我们在运行期间获得了动态灵活性(这也称作状态模式)。与此相反,我们不能在运行期间决定继承不同的对象,因为它要求在编译期间完全确定下来。

一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。

8.5.1 纯继承与扩展

采取“纯粹”的方式来创建继承层次结构似乎是最好的方式。也就是说,只有在基类中已经建立的方法才可以在导出类中被覆盖。
在这里插入图片描述
这被称作是纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了它应该是什么。继承可以确定所有的导出类具有基类的接口,且绝对不会少。按上图那么做,导出类也将具有和基类一样的接口。

也可以认为这是一种纯替代,因为导出类可以完全代替基类,而在使用它们时,完全不需要知道关于子类的任何额外信息:
(与基类的对话消息被传递给导出类。)

在这里插入图片描述

也就是说,基类可以接收发送给导出类的任何消息,因为二者有着完全相同的接口。我们只需从导出类向上转型,永远不需知道正在处理的对象的确切类型。所有这一切,都是通过多态来处理的。

按这种方式考虑,似乎只有纯粹的is-a关系才是唯一明智的做法,而所有其他的设计都会导致混乱和注定失败。这其实也是一个陷阱,因为只要开始考虑,就会转向,并发现扩展接口(遗憾的是,extends关键字似乎在怂恿我们这样做)才是解决特定问题的完美方案。这可以成为“is-like-a”(像一个)关系,因为导出类就像是一个基类——它有着相同的基本接口,但是它还具有额外方法实现的其它特性。
(在导出类中定义新的方法,这些方法组成了扩展接口)

虽然这是一种有用且明智的方法(依赖于具体情况),但是它也有缺点。导出类中接口的扩展部分不能被基类访问,因此,一旦我们向上转型,就不能调用那些新方法:

在这种情况下,如果我们不进行向上转型,这样的问题也就不会出现。但是通常情况下,我们需要重新查明对象的确切类型,以便能够访问该类型所扩充的方法。

8.5.2 向下转型与运行时类型识别

由于向上转型(在继承层次中向上移动)会丢失具体的类型信息,所以我们就想,通过向下转型——也就是在继承层次中向下移动——应该能够获取类型信息。然而,我们知道向上转型是安全的,因为基类不会具有大于导出类的接口。因此,我们通过基类接口发送的消息保证都能被接受。(总算彻底理解了接口的概念)但是对于向下转型,例如,我们无法知道一个“几何形状”它确实就是一个“圆”,它可以是一个三角形、正方形或其它一些类型。

要解决这个问题,必须有某种方法来确保向下转型的正确性,使我们不至于贸然转型到一种错误的类型,进而发出该对象无法接受的消息。这样做是及其不安全的。

在某些程序设计语言(如C++)中,我们必须执行一个特殊的操作来获得安全的向下转型。但是在Java语言中,所以转型都会得到检查!所以即使我们只是进行一次普通的加括弧形式的类型转换,在进入运行期时仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException(类转型异常)。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RTTI)。

如果想访问导出类对象的扩展接口,就可以尝试进行向下转型。如果所转类型是正确的类型,那么转型成功;否则,就会返回一个ClassCastException异常。

RTTI的内容不仅仅包括转型处理。例如它还提供一种方法,使你可以在试图向下转型之前,查看你所要处理的类型。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值