初识Java 7-1 多态

目录

向上转型

难点

方法调用绑定

产生正确的行为

可扩展性

陷阱:“重写”private方法

陷阱:字段与静态方法

构造器和多态

构造器的调用顺序

继承和清理

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

协变返回类型

使用继承的设计

替换和扩展

向下转型和反射


本笔记参考自: 《On Java 中文版》


        多态,是面向对象编程语言的一个基本特性,也被称为动态绑定后期绑定运行时绑定。这一特性分离了做什么(接口)和怎么做(实现)。到目前为止,已经可以总结:

  • 封装,通过组合特征和行为来创建新的数据类型;
  • 隐藏实现,通过把实现细节设为private来分离接口和实现。

        而多态则是根据类型来进行解耦的。多态方法调用允许一种类型表现出和另一种相似类型之间的区别,而只要求它们都继承相同的基类。

向上转型

        获取对象引用并把其当作基类型的引用称为向上转型,这是因为继承层次结构是以基类在顶部的方式进行绘制的。

        以乐器为例,先创建一个枚举:

package music;

public enum Note {
    MIDDLE_C, C_SHARP, B_FLAT;
}

        已知,管乐器(Wind)是一种乐器(Instrument):

package music;

public class Instrument {
    public void play(Note n) {
        System.out.println("这是方法Instrument.play");
    }
}

        那么,Wind就可以继承Instrument

package music;

public class Wind extends Instrument { // Wind方法是一种Instrument,它们有相同的接口
    @Override
    public void play(Note n) {
        System.getProperty("这是方法Wind.play() " + n);
    }
}

        现在就可以使用这些子类和基类了:

package music;

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

    public static void main(String[] args) {
        Wind flute = new Wind();
        tune(flute); // 向上转型
    }
}

        虽然Music.tune()方法接收的是一个Instrument的引用,但是也可以接收任何继承了Instrument的类。在上述程序中,Music.tune()就接收了一个Wind类。程序执行的结果是:

        在上述程序中,将Wind引用传递给tune()方法不需要任何强制类型转换。因为Instrument中的接口必定存在于Wind中,Wind向上转型是缩小了自己的接口。

忘记对象类型

        在向上转型的过程中,会出现如上这种忘记了对象类型的情况。

        如果反过来,向上转型无法发生的话,我们就得为系统内每种类型的乐器(Instrument)编写一个tune()方法,这就意味着更多的编程工作,并且在进行重载的管理时,会遇到不少的困难。

package music;

class Stringed extends Instrument {
    @Override
    public void play(Note n) {
        System.out.println("这是方法Stringed.play() " + n);
    }
}

class Brass extends Instrument {
    @Override
    public void play(Note n) {
        System.out.println("这是方法Brass.play() " + n);
    }
}

public class Music2 {
    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) {
        Stringed violin = new Stringed();
        Brass frenchHorn = new Brass();

        tune(violin);
        tune(frenchHorn);
    }
}

        如果能够通过编写一个以基类为参数的方式,而不必在意任何的子类,或者说忘记子类的存在,那么整个程序就会变得更加直观和简单。这就是由多态进行实现的工作了。

难点

        在上面的例子中,Music.tune()方法接收了一个Wind类型的参数,但这里存在着一个问题:tune()只有一个Instrument类型的参数,这个方法是怎么知道其接收的是一个Wind()类型的参数,而不会是一个Stringed或者是Brass

public static void tune(Instrument i) { // tune()方法的形式

        解答这个问题的关键,就在于绑定

方法调用绑定

        绑定,就是将一个方法调用和一个方法体关联在一起。如果在程序运行之前执行绑定(若存在编译器和链接器,由它们完成),则称之为前期绑定

        与前期绑定相对的,后期绑定意味着绑定发生在运行时,并且基于对象的类型。这种绑定往往会通过某种机制确定对象的类型,并调用恰当的方法(后期绑定的实现会因为语言的不同产生差异,但可以认为,这些机制都需要将某种类型信息放入对象中)

    Java中的所有方法都是后期绑定,除非方法是staticfinal的(private是隐式的final)。例如,如果把Instrument.play()方法设为final的,那么在编译Music.java时就会报错。


产生正确的行为

        利用多态,就可以编写直接与基类互动的代码了。并且所有子类都可以通过这个相同的代码进行正确工作。

        在面向对象中,有一个经典的示例:“形状”。这个示例包括基类Shape及其的各种子类:Circle(圆形)、Square(正方形)、Triangle(三角形)等。它们的关系如图所示:

        向上转型的实现十分简单:

Shape s = new Circle() // 将Circle向上转型为Shape

        这条语句创建了一个Circle对象,并且把这个对象赋给了一个Shape引用。通过继承,Circle被认为是一种Shape。编译器认可这种语句。

        现在,假设存在一个基类方法draw(),这一方法在子类中已经进行了重写:

s.draw();

这条语句将不会调用Shapedraw(),由于后期绑定(即多态),Circle.draw()会被正确地调用。

    实际上,编译器不需要任何可以让其在编译时进行正确调用的特殊信息。这些都是动态绑定的工作。


可扩展性

        多态允许我们向系统内添加任意数量的新类型,而不需要修改基类的方法。在一个设计良好的OOP程序中,许多方法会遵循基类方法的模型,即只与基类接口通信。这样,程序就有了可扩展性。

        以之前的乐器(Instrument)为例,可以向其中添加更多的方法和类:

        这些后来的新方法可以和旧方法和谐相处。比如原本的tune()方法,它并不需要了解周围的代码变更,而可以正常工作。可以说,多态是程序员“将变化的事物和不变的事物分离”的一项重要技术。


陷阱:“重写”private方法

        若在无意之中,我们一个private的方法进行了“重写”,如:

public class PrivateOverride {
    private void f() {
        System.out.println("隐藏的f()方法");
    }

    public static void main(String[] args) {
        PrivateOverride po = new Derives();
        po.f();
    }
}

class Derives extends PrivateOverride {
    public void f() { // 尝试性的“重写”
        System.out.println("公开的f()方法");
    }
}

        若没有注意到被重写的方法是private的,我们可能会认为输出的是“公开的f()方法”。但实际上的输出结果是:

        这是因为private方法也是final的,这种方法对子类隐藏。所以,在Derived中的f()是一个全新的方法,这个方法没有重载,因为f()的基类版本对Derived而言,是不可见的。所以,只有private的方法才能被重写。为此,最好在子类中使用与基类的private方法不同的名称。

        若使用@Override,就可以发现异常:

    @Override public void f() {
        System.out.println("公开的f()方法");
    }

        尝试编译,会发生报错:


陷阱:字段与静态方法

        与方法调用不同,字段并不存在多态。在直接访问一个字段时,该访问会在编译时解析:

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.getField() = " + sub.getField() +
                ", sub.getSuperField() = " + sub.getSuperField());
    }
}

        程序执行的结果是:

        在上述程序中,Sub对象向上转型为Super引用时,其字段访问都会被编译器解析(得到的field字段是属于Super对象的)。因此,这不是多态。

        注意Super.fieldSub.field被分配了不同的存储空间。

        因此,Sub实际上包含了两个名称是field的字段:Sub自己的和Super的。而上述例子可以表明,当直接使用Sub.field时,不会获得基类的字段。要使用Superfield,就需要明确使用super.field

    为了防止混淆,一般不会让子类字段和基类字段使用相同的名称。

        除了字段,静态方法的行为也不是多态的:

class StaticSuper {
    public static String staticGet() {
        return "属于基类的staticGet()方法";
    }

    public String dynamicGet() {
        return "属于基类的dynamicGet()方法";
    }
}

class StaticSub extends StaticSuper {
    public static String staticGet() { // 静态方法直接与类关联
        return "派生的staticGet()方法";
    }

    @Override
    public String dynamicGet() {
        return "派生的dynamicGet()方法";
    }
}

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

        程序执行的结果如下:

        静态方法直接和类关联,不会与单个的对象关联。

构造器和多态

        构造器不同于其他方法,这点在涉及多态时也是如此。构造器是隐式的static方法,理解其在复杂层次结构和多态中的工作方式也很重要。

构造器的调用顺序

        基类的构造器总是在子类的构造过程中被调用。这是因为构造器需要保证对象的正确调用。由于字段通常是private的,因此一般必须假设子类只能访问自己的成员,而不能访问基类的成员。通过一个例子展示组合、继承及多态对构造顺序的影响:

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

        程序执行的结果是:

        根据上述的输出结果,可以得出一个复杂对象的构造器调用顺序:

  1. 基类的构造器被调用:
    1. 重复调用基类构造器,直到到达根基类。
    2. 根基类构造完毕,构造根基类的子类。
    3. 以此类推,直到最底层的子类构造完毕。
  2. 然后,按声明的顺序初始化成员。
  3. 最后,执行子类构造器的方法体。

        构造器的调用顺序是十分重要的。如果能够理清上述的顺序,就可以假定在子类中,基类的所有成员都是有效的

    为了使得所有成员在构造器中都是有效的,应该在类的定义处(如上述的bcl)来初始化所有的成员对象。


继承和清理

        大多时候,Java的清理可以交给垃圾收集器来处理。但若确有清理的必要,就需要为自己创建的新类创建一个清理方法(方法名可以自拟,本篇章中统一使用dispose()方法表示)。

        在继承时,若有特殊清理必须作为垃圾收集的一部分,那么也应该在子类中重写dispose()方法来执行该操作。并且,记住要调用基类的dispose()

class Characteristic {
    private String s;

    Characteristic(String s) {
        this.s = s;
        System.out.println("特征创建:" + s);
    }

    protected void dispose() {
        System.out.println("特征清理:" + s);
    }
}

class Description {
    private String s;

    Description(String s) {
        this.s = s;
        System.out.println("特征创建:" + s);
    }

    protected void dispose() {
        System.out.println("特征清理:" + s);
    }
}

class LivingCreature {
    private Characteristic p = new Characteristic("有活力的");
    private Description t = new Description("是一个活着的生物");

    LivingCreature() {
        System.out.println("构造器LivingCreature()");
    }

    protected void dispose() {
        System.out.println("清理LivingCreature");
        t.dispose();
        p.dispose();
    }
}

class Animal extends LivingCreature {
    private Characteristic p = new Characteristic("有一颗心脏");
    private Description t = new Description("是动物而不是植物");

    Animal() {
        System.out.println("构造器Animal()");
    }

    @Override
    protected void dispose() {
        t.dispose();
        p.dispose();
        super.dispose();
    }
}

class Amphibian extends Animal {
    private Characteristic p = new Characteristic("能在水中生存");
    private Description t = new Description("水陆两栖");

    Amphibian() {
        System.out.println("构造器Amphibian()");
    }

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

public class Frog extends Amphibian {
    private Characteristic p = new Characteristic("呱呱叫");
    private Description t = new Description("吃虫子");

    public Frog() {
        System.out.println("构造器Frog()");
    }

    @Override
    protected void dispose() {
        t.dispose();
        p.dispose();
        super.dispose();
    }

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

        上述程序执行的结果是:

        上述程序中,清理的顺序刚好和初始化顺序相反。对于字段而言,这意味着与声明顺序相反(字段是按顺序初始化的)。对于基类,首先进行子类的清理,然后再进行基类的清理。

        Frog对象拥有其余的成员对象,并且能够控制对这些成员的清理。但是,如果其中的某个成员被其他成员共享,情况就会变得更加复杂,此时不能简单地调用dispose()。一个方法是使用引用计数的方式。例如:

class Shared {
    private int refcount = 0;
    private static long counter = 0;
    private final long id = counter++;

    Shared() {
        System.out.println("创建:" + this);
    }

    public void addRef() {
        refcount++;
    }

    protected void dispose() {
        if (--refcount == 0)
            System.out.println("清理:" + this);
    }

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

class Compsoing {
    private Shared shared;
    private static long counter = 0;
    private final long id = counter++;

    Compsoing(Shared shared) {
        System.out.println("创建:" + this);
        this.shared = shared;
        this.shared.addRef();
    }

    protected void dispose() {
        System.out.println("清理:" + this);
        shared.dispose();
    }

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

public class ReferenCounting {
    public static void main(String[] args) {
        Shared shared = new Shared();
        Compsoing[] compsoings = {
                new Compsoing(shared),
                new Compsoing(shared),
                new Compsoing(shared),
                new Compsoing(shared),
                new Compsoing(shared)
        };

        System.out.println();
        for (Compsoing c : compsoings) {
            c.dispose();
        }
    }
}

        程序执行的结果如下:

        对于这个程序而言,如果想要在类中使用共享对象,就需要调用addRef()。通过这种方式进行引用计数的跟踪,以此来判断是否进行清理。


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

        对一个普通的方法而言,动态绑定调用是在运行时解析的。这是为了确定被调用的方法到底属于子类还是基类。

        若在一个构造器内部调用动态绑定方法,就会得到该方法被重写后的定义。由于此时对象还没有被构造完毕,这个被重写的方法可能会带来一些难以被发现的错误

        构造器用于对象的创建工作,因此在构造器中,对象往往处于部分形成的状态,只有基类对象是已知被初始化的。若正在构造一个子类对象,那么当其基类构造器被调用时,这一子类对象还没有被全部初始化。但是,动态绑定可以跳出这一层次,直接调用子类(还未被初始化完毕的)中的方法。

        这就是一个有问题的例子:

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

    Glyph() {
        System.out.println("构造器Glyph:在调用draw()之前");
        draw();
        System.out.println("构造器Glyph:在调用draw()之后");
    }
}

class RoundGlyph extends Glyph {
    private int radius = 1;

    RoundGlyph(int r) {
        radius = r;
        System.out.println("调用构造器RoundGlyph(),radius = " + radius);
    }

    @Override
    void draw() {
        System.out.println("调用方法RoundGlyph.draw(),radius = " + radius);
    }
}

public class PolyConstructors {
    public static void main(String[] args) {
        new RoundGlyph(5);
    }
}

        程序运行的结果如下:

        上述程序中,Glyph.draw()是为了重写而设计的方法,重写发生在RoundGlyph中。但Glyph()调用了该方法,实际上被调用的是RoundGlyph.draw()。或许有些人确实想要这个效果,但除此之外,红框所指的部分中,radius的值很明显是不对的。这就是初始化不完整导致的。

        补充并复习一下初始化的顺序:

  1. 在所有动作发生之前,为对象分配的储存空间会被初始化为二进制零。
  2. 基类构造器按层次被调用。此时被重写的draw()方法会被调用,而由于第1步的关系,radius是0
  3. 按声明顺序初始化成员。
  4. 执行子类构造器的主体代码。

        这就是为什么上述程序会出现问题。

    在编写构造器时的一个准则:使用尽可能少的操作使对象进入正常状态,并尽可能避免调用此类中的任何其他方法。

        注意:只有基类中的final方法(及隐式的finalprivate方法)可以在构造器中被安全调用。

协变返回类型

        Java 5加入的协变返回类型,使得子类中重写方法的返回值可以是基类方法返回值的子类型

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
    Wheat process() {
        return new Wheat();
    }
}

public class CovariantReturn {
    public static void main(String[] args) {
        Mill m = new Mill();
        Grain g = m.process();
        System.out.println(g);

        m = new WheatMill();
        g = m.process();
        System.out.println(g);
    }
}

        程序执行的结果是:

        协变返回类型允许process()的重写版本返回Wheat引用。但是在Java 5之前,process()会被强制要求返回Grain。也就是说,协变返回类型允许更具体的Wheat返回类型。

使用继承的设计

        事实上,在创建新类时,更好的选择是使用组合。因为组合不会强制要求程序设计使用继承层次结构,它更加灵活,可以动态选择类型(和随后的行动),而继承在编译时就需要知道确定的类型。例如:

class Actor {
    public void act() {
    }
}

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

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

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

        程序执行的结果是:

        上述的Stage.performPlay()会根据引用的不同而产生不同的行为,因为引用可以在运行时绑定到不同的对象上。这就在运行中获得了动态灵活性(状态模式)。相反,不能在运行时决定使用不同的方式进行继承。

    通用的原则:使用继承表达行为上的差异,使用字段表达状态的变化。

替换和扩展

        在继承中,最简洁的关系是“is-a”关系,即只有来自基类的方法会在子类中被重写:

        在这种方法中,子类的接口不会比基类的多。这时,使用子类对象不会需要额外的信息。完全相同的接口使得基类可以接收任何发送给子类的信息。

        但是,在一些时候我们会需要通过扩展接口来解决特定问题。这种关系被称为“is-like-a”,也就是说,子类像基类——子类拥有和基类相同的基本接口,同时也有用于实现特性的额外方法。

        这种扩展的部分在基类中是不可用的。因此,一旦发生向上转型,就无法调用这些扩展方法了:


向下转型和反射

        在进行向上转型时会丢失特定类型的信息,此时就可以通过向下转型来重新获取类型信息,即在继承层次结构中向下移动。

        尽管向上转型是安全的,因为基类只有那些通用的接口。但是向下转型却不一样,这是有危险的。

    打个比方,我们实际上无法知道一个形状是不是一个圆形。因为这个形状也可以是正方形、三角形或是其他类型。

        为此,就必须要有某种方法来保证向下转型的安全性。在Java中,每次的转型都会被检查。即使只是一次最普通的强制类型转换,都会在运行时被检查。这种运行时检查类型的行为是Java反射的一部分。

class Useful {
    public void f() {
    }

    public void g() {
    }
}

class MoreUseful extends Useful {
    @Override
    public void f() {
    }

    @Override
    public void g() {
    }

    public void u() {
    }

    public void v() {
    }

    public void w() {
    }
}

public class Reflect {
    public static void main(String[] args) {
        Useful[] x = {
                new Useful(),
                new MoreUseful()
        };

        x[0].f();
        x[1].g();

        // 下方这行语句触发编译时错误:无法在Useful中找到对应方法
        // x[1].u();

        ((MoreUseful) x[1]).u(); // 向下转型,触发反射
        ((MoreUseful) x[0]).u(); // 该条语句会抛出运行时异常
    }
}

        编译正常通过,但是若试图运行该程序,会发生异常:

        在尝试向下转型时,若类型正确就会直接通过,反之会得到一个异常。另外,反射并不仅仅包括简单的转型,但笔者尚未学到,此处就不做涉及。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值