Java学习之多态(八)

注:本博客内容是本人在看《Jave编程思想》这本书时从该书上抄录下来的一些片段。这里也强烈建议各位读者去购买这本书进行阅读学习。

在面向对象的程序设计语言中,多态是继数据抽象和继承之后的第三种基本特征

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

一、再论向上转型

在前面的章节我们已经知道,对象既可以作为它本身的类型使用,也可以作为它的基类型使用。而这种把某个对象的引用视为对其基类型的引用的做法被称为向上转型——因为在继承数的画法中,基类是放置在上方的。

但是,这样做也有一个问题。如下例子。

首先,为乐器创建演奏乐符(note)Note类:

public enum  Note {
    MIDDLE_C, C_SHARP, B_FLAT;
}

在这里Wind是一种Instrument,因此可以从Instrument类继承。

package com.jackson.polymorphism;

class Instrument {
    public void play(Note note) {
        System.out.println("Instrument.play()");
    }
}

class Wind extends Instrument {
    @Override
    public void play(Note note) {
        System.out.println("wind.play()" + note);
    }
}

public class Music {
    public static void tune(Instrument i) {
        i.play(Note.MIDDLE_C);
    }

    public static void main(String[] args) {
        Wind wind = new Wind();
        // Upcasting
        tune(wind);
    }
}

输出结果:

wind.play()MIDDLE_C

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

1.1 忘记对象

Music.java看起来似乎有些奇怪。为什么所有人都故意忘记对象的类型呢?在进行向上转型时,就会发生这种情况;并且如果让tune()方法直接接受一个Wind引用作为自己的参数,似乎会更加直观。但这样做会引发一个重要问题:如果这样做,就需要为系统内Instrument的每种类型都编写一个新的tune()方法。假设按照这种推理,现在再加入Stringed(弦乐)和Brass(管乐)这两种Instrument(乐器)。

class Stringed extends Instrument {
    @Override
    public void play(Note note) {
        System.out.println("Stringed.play() " + note);
    }
}

class Brass extends Instrument {
    @Override
    public void play(Note note) {
        System.out.println("Brass.play() " + note);
    }
}
package com.jackson.polymorphism;


public class Music2 {
    public static void tune(Wind i) {
        i.play(Note.MIDDLE_C);
    }
    public static void tune(Stringed i) {
        i.play(Note.MIDDLE_C);
    }
    public static void tune(Brass i) {
        i.play(Note.MIDDLE_C);
    }

    public static void main(String[] args) {
        Wind wind = new Wind();
        tune(wind);
        Stringed stringed = new Stringed();
        tune(stringed);
        Brass brass = new Brass();
        tune(brass);
    }
}

输出结果:

wind.play()MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C

这样做虽然行得通,但是有一个主要缺点:必须为每一个新的Instrument类编写特定类型的方法。这就意味着在开始就需要更多的编程,这也意味着如果以后想添加类似tune()的新方法或者添加自Instrument导出的新类,仍需要做大量的工作。此外,如果我们忘记重载某个方法,编译器不会返回任何错误信息,这样关于类型的整个处理过程就变得难以操纵。

如果我们只写这样一个简单的方法,它仅接受基类作为参数,而不是那些特殊的导出类。这样情况会变好吗?也就是说,如果我们不管导出类的存在,在编写的代码只是与基类打交道,会不会更好呢?这正是多态对允许的。

二、转机

允许Music.java中的程序后,我们就会发现其难点所在。Wind.play()方法将产生输出结果,这无疑是我们所期望的的输出结果,但它看起来似乎没有什么意义。我们来看一下tune()方法:

public static void tune(Instrument i) {
        i.play(Note.MIDDLE_C);
}

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

2.1 方法调用绑定

将一个方法调用同一个方法主体关联起来被称作绑定。若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),叫做前期绑定。它是面向过程的语言中不需要选择的默认的绑定方式。

上述程序之所以令人迷惑,主要是因为前期绑定。因为,当编译器只有一个Instrument引用时,它无法知道究竟调用那个方法才对。

解决的办法就是后期绑定,它的含义就是在运行时根据对象的类型进行绑定。后期绑定也叫动态绑定或运行时绑定。如果一种语言想实现后期绑定,就必须具有某种机制,以便在运行时能够判断对象的类型,从而调用恰当的方法。也就是说,编译器一直不知道对象的类型,但是方法调用机制能够找到正确的方法,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是只要想一下就会得知,不管怎样都必须在对象中安置某种“类型信息”。

Java中除了static方法和final方法(private方法属于final方法)之外,其他的所有方法都是后期绑定。这意味着通常情况下,我们不需要判定是否进行后期绑定——它会自动发生。

为什么要将方法声明为final呢?正如前面提到的那样,它可以防止其他人覆盖该方法。但更重要的一点或许是:这样做可以有效地“关闭”动态绑定,或者说,告述编译器不需要对其进行动态绑定。这样,编译器就可以为final方法调用生成更有效的代码。然而,大多数情况下,这样做并不会对程序的整体性能有什么改观。所以,最好根据设计来决定是否使用final。而不是出于试图提高性能的目的使用final。

2.2 产生正确的行为

一旦知道Java中所有方法都是通过动态绑定实现多态这个事实之后,我们就可以编写只与基类打交道的程序代码了,并且这些代码所有导出类都可以正确运行。或者换一种说法,发送消息给某个对象,让该对象去判定应该做什么事。

例如,在“几何形状”这个例子中,有一个基类Shape,以及多个导出类——如Circle、Square、Triangle等。它们之间的继承关系图如下:

向上转型可以像下面这条代码这么简单:

Shape shape = new Circle();

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

假设你调用一个基类方法(该方法已在导出类中覆盖):

shape.draw();

你可能再次认为调用的是Shape的draw()方法,因为这毕竟是一个Shape引用,那么编译器是怎么知道去做什么事情呢?由于后期绑定(多态),还是正确调用了Circle.draw()方法。

2.3 可扩展性

现在,让我们返回到“乐器”(Instrument)示例。由于有对态机制,我们可根据自己的需求对系统添加任意多的新类型,而不需要更改tune()方法。在一个设计良好的OOP程序中,大多数或者所有方法都会遵循tune()的模型,而且只与基类接口通信。这样的程序是可扩展的,因为可以从通用的基类继承出新的数据类型,从而添加一些功能。那些操纵基类接口的方法不需要任何更改就可以应用于新类。

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

package com.jackson.polymorphism;

class Derived extends OverridePrivate {
    public void f() {
        System.out.println("Derived public method f()");
    }
}

public class OverridePrivate {
    private void f() {
        System.out.println("OverridePrivate private method f()");
    }

    public static void main(String[] args) {
        OverridePrivate op = new Derived();
        op.f();
    }
}

输出结果:

OverridePrivate private method f()

我们期望输出的是“Derived public method f()”,但是由于private方法被自动认为是final方法,而且对导出类是屏蔽的。因此,在这种情况下,Derived类中的f()方法就是一个全新的方法;既然基类中f()方法在Derived中不可见,因此甚至也不能重载。

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

2.5 缺陷:域与静态方法

一旦你了解了多态机制,可能就会开始认为所有事物都是可以多态地发生。然而,只有普通的方法调用可以是多态的。例如,如果你访问某个域,这个访问就将在编译期进行解析。例如:

package com.jackson.polymorphism;

class Super {
    public int field = 0;
    public int getField() {
        return field;
    }
}

class Sub extends Super {
    public int field = 1;
    @Override
    public int getField() {
        return field;
    }
    public int getSuperField() {
        return super.field;
    }
}
public class FieldAccess {
    public static void main(String[] args) {
        Super sup = new Sub();
        System.out.println("sup.field =" + sup.field + ", sup.getField() = " + sup.getField());
        Sub sub = new Sub();
        System.out.println("sub.field = " + sub.field + ", sub.getFiled() = " + sub.getField()
                + ", sub.getSupperField() = " + sub.getSuperField());
    }
}

输出结果:

sup.field =0, sup.getField() = 1
sub.field = 1, sub.getFiled() = 1, sub.getSupperField() = 0

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

尽管这看起来好像会成为一个令人混淆的问题,但在实践中,它实际上从来不会发生。首先,你通常会将所有的域都设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问。另外,你可能不会对基类中的域和调出类中的域赋予相同的名字,因为这种做法容易令人混淆。

如果某个方法是静态的,它的行为就不具有多态:

package com.jackson.polymorphism;

class StaticSuper{
    public static String staticGet() {
        return "StaticSuper.staticGet()";
    }
    public String dynamicGet() {
        return "StaticSuper.dynamicGet()";
    }
}

class StaticSub extends StaticSuper {
    public static String staticGet() {
        return "StaticSub.staticGet()";
    }
    @Override
    public String dynamicGet() {
        return "StaticSub.dynamicGet()";
    }
}

public class StaticPolymorohism {
    public static void main(String[] args) {
        StaticSuper sup = new StaticSub();
        System.out.println(StaticSuper.staticGet());
        System.out.println(sup.dynamicGet());
    }
}

静态方法是与类,而并非与单个的对象相关联的。

三、构造器与多态

通常,构造器不同于其他种类的方法。涉及到多态时仍是如此。尽管构造器并不具有多态性(它们实际上是static方法,只不过该static声明是隐式的),但是还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作。

3.1 构造器的调用顺序

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

如下例子展示了组合、继承以及多态在构建顺序上的作用:

package com.jackson.polymorphism;

class Meal {
    Meal() {
        System.out.println("Meal");
    }
}
class Bread {
    Bread () {
        System.out.println("Bread");
    }
}
class Cheese{
    Cheese() {
        System.out.println("Cheese");
    }
}
class Lettuce{
    Lettuce() {
        System.out.println("Lettuce");
    }
}
class Lunch extends Meal{
    Lunch() {
        System.out.println("Lunch");
    }
}
class PortableLunch extends Lunch{
    PortableLunch() {
        System.out.println("PortableLunch");
    }
}
public class Sandwich extends PortableLunch{
    private Bread b = new Bread();
    private Cheese c = new Cheese();
    private Lettuce l = new Lettuce();
    public Sandwich() {
        System.out.println("Sandwich()");
    }
    public static void main(String[] args) {
        new Sandwich();
    }
}
Meal
Lunch
PortableLunch
Bread
Cheese
Lettuce
Sandwich()

从输出结果可以看出,一个复杂对象调用构造器要遵照下面的顺序:

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

构造器的调研顺序是很重要的。当进行继承时,我们已经直到基类的一切,并且可以访问基类中任何声明为public和protected的成员。这意味着在导出类中,必须假定基类的所有成员都是有效的,一种标准的方法是,构造动作一经发生,那么对象所有部分的全体成员都会得到构建。然而,在构造器内部,我们必须确保所要使用的成员都已经构建完毕。为确保这一目的,唯一的办法就是首先调用基类构造器。那么在进入导出类构造器是,在基类中可供我们访问的成员都已得到初始化。

3.2 继承与清理

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

package com.jackson.polymorphism;

class Characteristic {
    private String s;

    public Characteristic(String s) {
        this.s = s;
        System.out.println("Creating Characteristic +" + s);
    }
    protected void dispose() {
        System.out.println("disposing Characteristic " + s);
    }
}

class Description{
    private String s;

    public Description(String s) {
        this.s = s;
        System.out.println("Creating Description " + s);
    }
    protected void dispose(){
        System.out.println("Disposing Description " + s);
    }
}

class LivingCreature {
    private Characteristic p = new Characteristic("is alive");
    private Description t = new Description("Basic living Creature");

    public LivingCreature() {
        System.out.println("LivingCreature()");
    }
    protected void dispose() {
        System.out.println("LivingCreature dispose");
        p.dispose();
        t.dispose();
    }
}
class Animal extends LivingCreature{
    private Characteristic p = new Characteristic("has heart");
    private Description t = new Description("Animal not Vegetable");

    public Animal() {
        System.out.println("Animal()");
    }
    @Override
    protected void dispose() {
        System.out.println("Animal dispose");
        t.dispose();
        p.dispose();
        super.dispose();
    }
}

class Amphibian extends Animal{
    private Characteristic p = new Characteristic("Can live in water");
    private Description t = new Description("Both water and land");

    public Amphibian() {
        System.out.println("Amphibian()");
    }
    @Override
    protected void dispose() {
        System.out.println("Amphibian dispose");
        t.dispose();
        p.dispose();
        super.dispose();
    }

}
public class Frog extends Amphibian{
    private Characteristic p =new Characteristic("Croaks");
    private Description t = new Description("Eats Bugs");

    public Frog() {
        System.out.println("Frog()");
    }
    @Override
    protected void dispose() {
        System.out.println("Frog dispose");
        t.dispose();
        p.dispose();
        super.dispose();
    }

    public static void main(String[] args) {
        Frog frog = new Frog();
        System.out.println("Bye!");
        frog.dispose();
    }
}

输出结果:

Creating Characteristic +is alive
Creating Description Basic living Creature
LivingCreature()
Creating Characteristic +has heart
Creating Description Animal not Vegetable
Animal()
Creating Characteristic +Can live in water
Creating Description Both water and land
Amphibian()
Creating Characteristic +Croaks
Creating Description Eats Bugs
Frog()
Bye!
Frog dispose
Disposing Description Eats Bugs
disposing Characteristic Croaks
Amphibian dispose
Disposing Description Both water and land
disposing Characteristic Can live in water
Animal dispose
Disposing Description Animal not Vegetable
disposing Characteristic has heart
LivingCreature dispose
disposing Characteristic is alive
Disposing Description Basic living Creature

层次结构中每个类都包含Characteristic和Description这两种类型的成员对象,并且他们必须销毁。所以万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化顺序相反。对于字段,则意味着与声明的顺序相反(因为字段的初始化是按照声明的顺序进行的)。对于基类,应该首先对其导出类进行清理,然后才是基类。这是因为导出类的清理可能会调用基类的某些方法,所以需要是基类中的构件仍起作用而不因过早地销毁它们。出输出结果可以看到,Frog对象的所有部分都是按照构建的逆序进行销毁的。

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

package com.jackson.polymorphism;

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);
        }
    }

    @Override
    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("Composing " + this);
        this.shared = shared;
        this.shared.addRef();
    }
    protected void dispose() {
        System.out.println("disposing Composing " + this);
        this.shared.dispose();
    }

    @Override
    public String toString() {
        return "Composing " + id;
    }
}

public class ReferenceCounting {
    public static void main(String[] args) {
        Shared shared = new Shared();
        Composing [] composings = {new Composing(shared),new Composing(shared),
        new Composing(shared), new Composing(shared)};

        for (Composing composing: composings) {
            composing.dispose();
        }
    }
}

输出结果:

Creating Shared 0
Composing Composing 0
Composing Composing 1
Composing Composing 2
Composing Composing 3
disposing Composing Composing 0
disposing Composing Composing 1
disposing Composing Composing 2
disposing Composing Composing 3
disposing Shared 0

static long counter跟踪所创建的Share的实例的数量,还可以为id提供数值。counter类型是long而不是int,这样可以防止溢出(这只是一个良好的实践)。id是final的,因为我们不希望它的值在对象生命周期中被改变。

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

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

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

在一般的方法内部,动态绑定的调用是在运行时才决定的,因为对象无法知道它是属于方法所在的那个类,还是属于那个内的导出类。

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

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

package com.jackson.polymorphism;

class Glyph {
    public void draw() {
        System.out.println("Glyph.draw()");
    }

    public Glyph() {
        System.out.println("Glyph() before invoke draw()");
        
        draw();
        System.out.println("Glyph() before invoke draw()");
    }
}
class RoundGlyph extends Glyph{
    private int radius = 1;

    public RoundGlyph(int radius) {
        this.radius = radius;
        System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
    }
    @Override
    public void draw() {
        System.out.println("RoundGlyph.draw(), radius = " + radius);
    }
}
public class PolyConstructors {
    public static void main(String[] args) {
        new RoundGlyph(10);
    }
}

输出结果:

Glyph() before invoke draw()
RoundGlyph.draw(), radius = 0
Glyph() before invoke draw()
RoundGlyph.RoundGlyph(), radius = 10

在上述例子中Glyph.draw()方法设计为将要被覆盖,这种覆盖是在RoundGlyph中发生的。但是Glyph构造器会调用这个方法,结果导致了调用RoundGlyph.draw()方法,(这就是上述说过的“如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义”的含义。读者可以调试该示例代码可以更加清晰地理解)这看起来似乎是我们的目的。但是如果看到输出,我们会发现当Glyph的构造函数调用draw()方法时,radius不是默认的初始值1,而是0。这可能导致在屏幕上只画了一个点,或者根本什么东西都没有;我们只能干瞪眼,并试图找出程序无法运行的原因所在。

由此可见之前讲述的初始化顺序并不十分完整,而这正是解决这一谜题的关键所在。初始化的实际过程是:

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

这样做有一个优点,那就是所有东西都至少初始化成零(或者某些特殊数据类型中与“零”等价的值),而不是仅仅留作垃圾。其中包括通过“组合”而嵌入一个类内部的对象引用,其值是null。所以如果忘记为该引用初始化,就会在运行时出现异常。查看输出结果时,会发现其他所有东西的值都会是零,这通常也是发现问题的证据。

另一方面,我们应该对这个程序结果相当震惊。在逻辑方面,我们做的已经十分完美,而它的行为却不可思议地错了,并且编译器也没有报错。(在这种情况下,C++语言会产生合理的行为)诸如此类的错误很容易被人忽略,而且要花很长时间才能发现。

因此,编写构造器时有一条有效的准则:“用尽可能简单的方法是对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器中唯一能够调用的那些方法是基类中的final方法(也适用于private方法,它们自动属于final方法)。这些方法不能够覆盖,因此也就不会出现上述令人惊讶的问题。你可能无法总是能够遵循这条规则,但是应该朝它努力。

如下是在构造器中调用private和final方法的例子:

package com.jackson.polymorphism;

class GlyphWithFinal {
    private void draw() {
        System.out.println("GlyphWithFinal.draw()");
    }

    public final void move() {
        System.out.println("GlyphWithFinal.move()");
    }

    public GlyphWithFinal() {
        System.out.println("GlyphWithFinal() before invoke draw() and move()");
        draw();
        move();
        System.out.println("GlyphWithFinal() before invoke draw() and move()");
    }
}

class RoundGlyphWithFinal extends GlyphWithFinal{
    private int radius = 1;

    public RoundGlyphWithFinal(int radius) {
        this.radius = radius;
        System.out.println("RoundGlyphWithFinal.RoundGlyphWithFinal(), radius = " + radius);
    }
    public void draw() {
        System.out.println("RoundGlyphWithFinal.draw(), radius = " + radius);
    }
}

public class FinalPolyConstructors {
    public static void main(String[] args) {
        new RoundGlyphWithFinal(10);
    }
}

输出结果:

GlyphWithFinal() before invoke draw() and move()
GlyphWithFinal.draw()
GlyphWithFinal.move()
GlyphWithFinal() before invoke draw() and move()
RoundGlyphWithFinal.RoundGlyphWithFinal(), radius = 10

输出结果正是我们期望的结果。

四、协变返回类型

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

package com.jackson.polymorphism;

class Grain {
    @Override
    public String toString() {
        return "Grain";
    }
}
class Wheat extends Grain {
    @Override
    public String toString() {
        return "Wheat";
    }
}
class Mill {
    Grain process() {
        return new Grain();
    }
}
class WheatMill extends Mill{
    @Override
    // 在Java SE5之前,这里不能返回Wheat,只能返回Grain
    Wheat process() {
        return new Wheat();
    }
}
public class CovariantReturn {
    public static void main(String[] args) {
        Mill mill = new Mill();
        Grain grain = mill.process();
        System.out.println(grain);
        mill = new WheatMill();
        grain = mill.process();
        System.out.println(grain);
    }
}

输出结果:

Grain
Wheat

Java SE5与Java较早版本的主要区别就是较早的版本强制process()的覆盖版本必须返回Grain,而不能返回Wheat,尽管Wheat是Grain的导出类,因而也应该是一种合法的返回类型。协变返回类型允许返回更具体的Wheat(导出)类型。

五、用继承进行设计

学习了多态之后,看起来似乎所有东西都可以被继承,因为多态是一种如此巧妙的工具。事实上,当我们使用现有的类来建立新类时,如果首先就考虑使用继承技术,反倒会加重我们的设计负担,使事情变得不必要的复杂起来。

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

package com.jackson.polymorphism;

class Actor{
    public void act() {
        System.out.println("super act");
    }
}

class HappyActor extends Actor {
    @Override
    public void act() {
        System.out.println("Happy Actor");
    }
}

class SadActor extends Actor {
    @Override
    public void act() {
        System.out.println("Sad Actor");
    }
}

class Stage {
    private Actor actor = new HappyActor();
    public void change() {
        actor = new SadActor();
    }
    public void performPlay() {
        actor.act();
    }
}

public class Transmogrify {
    public static void main(String[] args) {
        Stage stage = new Stage();
        stage.performPlay();
        stage.change();
        stage.performPlay();
    }
}

输出结果:

Happy Actor
Sad Actor

在该例中,Stage对象包含一个Actor引用,而Actor被初始化为HappyActor对象。这意味着pertormPlay()会产生某种特殊的行为。既然引用可以在运行时可以与另一个不同的对象重新绑定起来,所以Sadactor对象引用可以在actor中被替代,然后由performPlay()产生的行为也随之改变。这样一来,我们在运行期间获得动态灵活性(这也称作状态模式)。与此相反,我们不能在运行期间决定继承不同的对象,因为它要求在编译期完全确定下来。

一条通用的准则是:“用继承表达行为间的差异,并用字段表示状态上的变化”。在该例中,两者都使用到了:通过继承得到了两个不同的类,用于表达act()方法的差异;而Stage通过运用组合自己的状态发生改变。在这种情况下,这种状态的改变也就产生了行为的改变。

5.1 纯继承与扩展

采取“纯粹”的方法来创建继承层次结构似乎是最好的方式。也就是说,只有在基类中已经建立的方法才可以在导出类中被覆盖,如下图:

这中称为纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了它应该是什么。继承可以确保所有导出类具有基类的接口,且绝对不会少。按上图这么做,导出类也将具有和基类一样的接口。

也可以认为这是一种存替代,因为导出类可以完全替代基类,而在使用它们时,完全不需要需要关于子类的任何额外信息。也就是说,基类可以接受发送给导出类的任何消息,因为二者有着完全相同的接口。我们只需要从导出类向上转型,永远不需要知道正在处理的对象的确切类型。所有的这一切,都是通过多态来处理的。

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

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

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

5.2 向下转型与运行时类型识别(RTTI)

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

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

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

package com.jackson.polymorphism;

class Useful {
    public void f() {
        System.out.println("Useful.f()");
    }
    public void g() {
        System.out.println("Useful.g()");
    }
}
class MoreUseful extends Useful {
    @Override
    public void f() {
        System.out.println("MoreUseful.f()");
    }

    @Override
    public void g() {
        System.out.println("MoreUseful.g()");
    }
    public void u() {
        System.out.println("MoreUseful.u()");
    }
    public void v() {
        System.out.println("MoreUseful.v()");
    }
    public void w() {
        System.out.println("MoreUseful.w()");
    }
}
public class RTTI {
    public static void main(String[] args) {
        Useful [] usefuls = {new Useful(), new MoreUseful()};
        usefuls[0].f();
        usefuls[1].g();
        // compile time : method not found in Useful
        // usefuls[1].u()

        // 向下转型/RTTI
        ((MoreUseful)(usefuls[1])).u();
        // 下面的转换将会抛出异常
        ((MoreUseful)(usefuls[0])).u();
    }
}

输出结果:

Useful.f()
MoreUseful.g()
MoreUseful.u()
Exception in thread "main" java.lang.ClassCastException: com.jackson.polymorphism.Useful cannot be cast to com.jackson.polymorphism.MoreUseful
	at com.jackson.polymorphism.RTTI.main(RTTI.java:41)

正如前面的一个示意图所示,MoreUseful接口扩展来Useful接口;但是由于它是继承而来的,所以可以向上转型到Useful类型。我们在main()方法中对数组usefuls进行初始化是可以看到这种情况(向上转型)的发生。既然数组中的两个对象都属于Useful类,所以可以调用f()和g()方法。如果我们试图调用u()、w()或v()方法(它们只存在与MoreUseful),就会返回一个编译时出错消息。

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值