Java学习笔记——多态

时间:2023-02-25 18:36:37

本文为Java编程思想第四版的学习笔记,在此感谢作者Bruce Eckel给我们带来这样一本经典著作,也感谢该书的翻译及出版人员。本文为原创笔记,转载请注明出处,谢谢。


在面向对象的程序设计语言中,多态是继数据抽象和继承之后的第三种基本特征。多态通过分离做什么和怎么做,从另一个角度将接口和实现分离开来。多态不但能够改善代码的组织结构和可读性,还能创建可扩展的程序——即无论在项目最初创建时还是在需要添加新功能时都可以“生长”的程序。“封装”通过合并特征和行为来创建新的数据类型,“实现隐藏”则通过将细节“私有化”把接口和实现分离开来。这种类型的组织机制对那些拥有国成华程序设计背景的人来说,更容易理解。而多态的作用则是消除类型之间的耦合关系。在前一章中我们已经知道,继承语序将对象视为它自己本身的类型或基本类型来加以处理。这种能力极为重要,因为它允许将多种类型(从同一基类导出的)视为同一类型来处理,而同一份代码也就可以毫无差别地运行在这些不同类型之上了。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出而来的。这种区别是根据方法行为的不同而表示出来的,虽然这些方法都可以通过同一个基类来调用。


1.再论向上转型

对象既可以作为它自己本身的类型使用,也可以作为他的基类型使用。而这种把对某个对象的引用视为对其基类型的引用的做法被称为向上转型。但是这样做也有一个问题。我们来看这个有关乐器的例子。我们创建了乐器的基类Instrument,然后继承它创建了Wind类,之后我们创建一个Music类并在其中添加main方法用来执行程序。在Music中我们添加一个tune()方法,接收一个Instrument类型的引用,用来播放相关的乐声。这是tune()方法同时也可以接收任何导出自Instrument的类,并且可以正确地播放不同的乐声,而不需要任何类型转换。这样做是允许的——因为Wind从Instrument继承而来,所以Instrument的接口必定存在于Wind中。从Wind向上转型到Instrument可能会“缩小”接口,但不会比Instrument的全部接口更窄。

Music类看起来似乎有些奇怪,为什么所有人都故意忘记对象的类型呢?在进行向上转型时就会产生这种情况;并且如果让tune()方法直接接受一个WInd的引用作为自己的参数,似乎会更为直观。但这样引发的一个重要的问题是:如果那样做,就需要为系统内Instrument的每种类型都编写一个新的tune()方法。这样做行得通,但有一个主要缺点:必须为添加的每一个新的Instrument类编写特定类型的方法。这意味着在开始时就需要更多的编程,这也意味着如果以后想添加类似tune()的新方法,或者添加子Instrument导出的新类,仍需要做大量的工作。此外,如果我们忘记重载某个方法,编译器不会返回任何错误信息,这样关于类型的整个处理过程就变得难以操纵。

如果我们只写这样一个简单方法,它仅接收基类作为参数,而不是那些特殊的导出类。这样做情况会变得更好么?也就是说,如果我们不管导出类的存在,编写的代码只是与基类打交道,会不会更好呢?这正是多台所允许的。然而,大多数程序员具有面向过程程序设计的背景,对多态的运作方式可能会有一点迷惑。


2.转机

运行上述程序后,我们便会发现Music.java的难点所在。Wind.play()方法将产生输出结果。这无疑使我们所期望的输出结果,但它看起来似乎又没有什么意义。我们观察一下tune()方法:它接收一个Instrument引用。那么在这种情况下,编译器怎样才能知道这个Instrument引用指向的是Wind对象,而不是其他的Instrument的到处对象呢?实际上,编译器无法得知。为了深入理解这个问题,有必要研究一下绑定这个话题。


2.1 方法调用绑定

讲一个方法调用同一个方法主题关联起来被称作绑定。若在程序执行前绑定(若果有的话,由编译器和连接程序实现),叫做前期绑定。这是面向过程语言中不需要选择就默认的绑定方式。上述程序之所以令人迷惑,主要是因为前期绑定。因为,当编译器只有一个Instrument引用时,它无法知道如何调用哪个方法才对。解决的办法就是后期绑定,它的含义就是在运行时根据对象的类型进行绑定。后期绑定也叫做动态绑定或运行时绑定。如果一种语言想实现后期绑定,就必须具有某种机制,以便在运行时能够判断对象的类型,从而调用恰当的方法。也就是说,编译器一直不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定机制随编程语言的不用而有所不同,但是只要想一下就会的值,不管怎样都必须在对象中安置某种“类型信息”。

Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。这意味通常情况下,我们不必判定是否应该进行后期绑定——它会自动发生。但为什么要将某个方法声明为final呢?正如前一章提到的那样,它可以防止其他人覆盖该方法。但更重要的一点或许是:这样做可以有效地”关闭“动态绑定,或者说,告诉编译器不需要对其进行动态绑定。这样,编译器就可以为final方法嗲用生成更有效的代码。然而,大多数情况下,这样做对程序的整体性能不会有什么改观。所以,最好根据设计来决定是否使用final。而不是出于试图提高性能的目的来使用final。


2.2 产生正确的行为

一旦知道Java中所有方法都是通过动态绑定实现多态这个事实之后,我们就可以编写与基类打交道的程序代码了,并且这些代码对所有的导出类都可以正确与百姓。或者换一种说法,发送消息给某个对象,让该对象去断定应该做什么事。面向对象程序设计中,有一个经典的例子就是”几何形状(Shape)“。在这个例子中,有一个基类Shape,以及多个导出类——如Circle,Square、Triangle等。Shape s = new Circle();这里创建了一个Circle对象,并把得到的引用立即赋值给Shape,这样做看似错误(将一种类型赋值给另一种类型);但实际上是没问题的,因为通过继承,Circle就是一种Shape。因此,编译器认可这条语句,也就不会产生错误信息。假设你调用一个基类方法(它已在导出类中被覆盖):s.draw();你可能在此认为调用的是Shape的draw(),因为这毕竟是一个Shape引用。由于后期绑定(多态),还是正确调用了Circle.draw()方法。


2.3 可扩展性

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


2.4  缺陷:”覆盖“私有方法
我们试图向下面这样做也是无可厚非的:
public class PrivateOverride {
    private void f () { print("private f()");}
    public static void main(String []args){
        PrivateOverride po = new Derived();
        po.f()
    }
}


class Derived extends PrivateOverride {
    public void f(){print("public f()");}
}


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

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


2.5 缺陷:域与静态方法
一旦你了解了多态机制,可能就会开始认为所有事物都可以多态地发生。然而,只有普通的方法调用可以是多态的。例如,如果你直接访问某个域,这个访问就将在编译期进行解析。
class Super {
    public int field = 0;
    public int getField() { return field; }
}


class Sub extends Super {
    public int field = 1;
    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());
    }
}


以上程序的输出结果为:
sup.field = 0, sup.getField() = 1
sub.field = 1, sub.getField() = 1, sub.getSuperField = 0
当Sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super.field和Sub.field分配了不同的存储空间。这样,Sub实际上包含两个称为field的域:他自己的和它从Super处得到的。然而,在引用Sub中的field时所产生的默认域并非Super版本的field与。因此,为了得到Super.field,必须显式地指明super.field。
尽管这看起来好像会成为一个容易令人混淆的问题,但是在实践中,它是实际上从来不会发生。首先,你通常会将所有的域设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问。另外,你可能不会对积累中的域和导出类中的域赋予相同的名字,因为这种做法容易令人混淆。
如果某个方法是静态的,它的行为就不具有多态性:
class StaticSuper {
    public static String staticGet() {
        return "Base staticGet()";
    }
    public String dynamicGet() {
        retrun "Base dynamicGet():'
    }
}


class StaticSub extends StaticSuper {
    public static String staticGet() {
        return "Deriver staticGet()";
    }
    public String dynamicGet() {
        return "Derived dynamicGet()";
    }
}


public class StaticPolymorphism {
    public static void main (String [] args) {
        StaticSuper sup = new StaticSub();
        System.out.println(sup.staticGet());
        System.out.println(sup.dynamicGet());
    }
}
这段程序的输出结果为:
Base staticGet()
Derived dynamicGet()
静态方法是与类,而并非与单个对象相关联的。


3. 构造器和多态
通常,构造器不同于其他种类的方法。涉及到多态时仍是如此。尽管构造器并不具有多态性(它们实际上是static方法,只不过该static声明是隐式的),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作,这一理解将有助于大家避免一些令人不快的困扰。


3.1 构造器的调用顺序
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐想让连接,以使每个积累的构造器都能得到调用。这样做时有意义的,因为构造器具有一项耶稣任务:检查对象是否被正确地构造。导出类只能访问它自己的成员,不能访问基类中的成员(几类成员通常是private的)。只有基类的构造器才具有恰当的只是和权限来对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。这正是编译器为什么要强制每个导出类部分都必须调用构造器的原因。在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,它就会“默默“地调用默认构造器。如果不存在默认构造器,编译器就会报错(若某个类某个构造,编译器会自动合成出一个默认构造器)。复杂对象调用构造器要遵照下面的顺序:
1)调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,知道最底层的导出类。
2)按声明书序调用成员的初始化方法。
3)调用导出类构造器的主体。
构造器的调用顺序是很重要的。当进行继承时,我们已经知道基类的一切,并且可以访问积累中任何声明为public和protected的成员。这意味着在导出类中,必须嘉定积累的所有成员都是有效的。一种标准的方法是,构造动作一经发生,那么对象所有部分的全体成员都会得到构建。然而,在构造器内部,我们必须确保所要使用的成员都已构建完毕。为确保这一目的,唯一的办法就是首先调用基类构造器。那么在进入导出类构造器时,在基类中可供我们访问的成员都已得到初始化。此外,知道构造器中的所有成员都有效也是因为,当成员对象在类内进行定义的时候,只要有可能,就应该对他们进行初始化(也就是说,通过组合方法将对象至于类内)。若遵循这一规则,那么就能保证所有基类成员以及当前对象的成员对象被初始化了。但遗憾的是,这种做法并不适用于所有情况,这一点我们会在下一节中看到。


3.2 继承与清理
通过组合和继承方法来创建新类是,永远不必担心对象的清理问题,子对象通常都会留给垃圾回收器进行处理。如果确实遇到清理的问题,那么必须用心为新类创建dispose()方法,并且由于继承的缘故,如果我们由其他作为垃圾回收一部分的特殊清理动作,就必须在导出类中覆盖dispose()方法。当覆盖被继承类的dispose()方法时,务必记住调用基类版本dispose()方法;否则,积累的清理动作不会发生。万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化顺序相反。这对字段,则意味着与声明的顺序相反(因为字段的初始化是按照声明的顺序进行的)。对于基类(遵循C++中析构函数的形式),应该首先对其导出类进行清理,然后才是基类。这是因为导出类的清理可能会调用基类中的某些方法,所以需要使用基类中的构建仍起作用而不是过早地销毁它们。另外,如果A对象创建它自己的成员对象,这些成员对象中存在其他一个或多个对象共享的情况,也许就必须使用引用计数来跟踪仍旧访问者共享独享的对象数量了。


3.3 构造器内部的多态方法的行为
构造器调用的层次结构带来了一个有趣的两难问题。如果在一个构造器的内部调用正在够早的对象的某个动态绑定方法,这会发生什么情况呢?在一般的方法内部,动态绑定的调用是在运行时才能决定的,因为对象无法知道它是属于方法所在的那个类,还是属于那个类的导出类。如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,这个调用的效果可能可能箱单难于预料,因为被覆盖的方法在对象被完全构造之前就会被调用。这可能会造成一些南愈发显得隐藏错误。从概念上讲,构造器的工作实际上是创建对象(这并非是一件平常的工作)。在任何构造器内部,整个独享可能只是部分形成——我们只知道基类对象已经进行初始化。如果构造器只是在构建对象过程中的一个步骤,并且该对象所述的类是从这个构造器所属的类导出的,那么导出部分在当前构造器正在被调用的时刻仍旧是没有被初始化的。然而,一个动态绑定的方法调用却会向外深入到继承层次结构内部,他可以调用导出类里的方法。如果我们是在构造器内部这样做,那么就可能会调用某个方法,而这个方法所操纵的成员可能还未进行初始化——这肯定会招致灾难。
class Glyph {
    void draw() { print("Glyph.draw()");}
    Glyph() {
        print("Glyph() before draw()");
        draw();
        print(”Glyph() after draw()");
    }
}


class RoundGlyph extends Glyph {
    private int radius = 1;
    RoundGlyph (int r) {
        radius = r;
        print("Glyph() after draw()");
    }
    void draw() {
        print("RoundGlyph.draw(),radius = " + radius);
    }
}


public class PolyConstructors {
    public static void main(String [] args) {
        new RoundGlyph(5);
    }
}
以上程序的输出结果为:
Glyph() befor draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
Glyph.draw()方法设计为将要被覆盖,这种覆盖是在RoundGlyph中发生的。但是Glyph构造器会调用则个方法,结果导致了对RoundGlyph.draw()的调用,这看起来似乎是我们的目的。但是如果看到输出结果,我们会发现Glyph的构造器调用draw()方法时,radius不是默认初始值1而是0.


前一节述的初始化顺序并不十分完整,而这正是解决这一谜题的关键所在。初始化的实际过程是:
1)在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零
2)如前所述那样调用基类构造器。此时,调用被覆盖后的draw()方法(要在调用RoundGlyph构造器之前调用)由于步骤1的缘故,我们此时会发现radius的值为0
3)按照声明顺序嗲用成员的初始化方法
4)调用导出类的构造器主体
这样做有一个有点,那就是所有东西都至少初始化成零(或者是某些特殊数据类型中与“零”等价的值),而不是仅仅留作垃圾。其中包括通过“组合”而嵌入一个类内部的对象引用,其值时null。所以如果忘记为该引用进行初始化,就会在运行时出现异常。另一方面,我们应该对这个程序的结果相当震惊。在逻辑方面,我们做的已经十分完美,而他的行为却不可思议地错了,并且编译器也没有报错。(在这红情况下,C++语言会产生更合理的行为)诸如此类的错误会很容易被人忽略,而且要花很长时间才能发现。
因此,便也构造器时有一条有效的准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的哪些方法是基类中的final方法(也适用于private方法,它们自动是与final方法)。这些方法不能被覆盖,因此也就不会出现上述令人惊讶的问题。你可能无法总是遵循这条准则,但是应该朝着它努力。


4.协变返回类型
Java SE5中添加了协变返回类型,它表示在导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型。
class Grain {
    public String toString() { return "Grain"; }
}


class wheat extends Grain {
    public String toString() { return "Wheat"; }
}


class Mill {
    Grain process () { return new Wheat(); }


class WheatMill extends Mill {
    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);
    }
}


本程序的输出结果为:
Grain
Wheat


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


5.用继承进行设计
学习了多态之后,看起来似乎所有的东西都可以被继承,因为多态是一种如此巧妙的工具。事实上,当我们使用线程的类来创建新类时,如果首先考虑使用继承技术,反倒会家中我们的设计负担,使事情变得不必要地复杂起来。更好的方式是首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。组合不会强制我们的程序设计进入集成的层次结构中。而且,组合更加灵活,因为它可以动态选择类型(因此也就选择了行为);相反继承在编译时就需要知道确切类型。
class Actor {
    public void act(){}
}


class HappyActor extends Actor {
    public void act() { print("HappyActor"); }
}


class SadActor extends Actor {
    public void act() { print("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();
    }
}
本程序输出结果为:
HappyActor
SadActor
在这里,Stage对象包含一个对Actor的引用,而Actor被初始化为HappyActor对象。这意味着performPlay()会产生某种特殊行为既然引用在运行时可以与另一个不同的对象重新绑定起来,所以SadActor对象的引用可以在actor中被替代,然后由performPlay()产生的行为也随之改变。这样一来,我们在运行期间获得了动态灵活性。与此相反,我们不能在运行期间决定继承不同的对象,因为它要求在编译期间完全确定下来。
一条通用的准则是:“用继承表达行为之间的差异,并用字段表达状态上的变化”。在上述例子中,两者都用到了:通过继承得到了两个不同的类,用于表达act()方法的差异;而Stage通过运用组合是自己的状态发生了变化。在这种情况下,这种状态的改变也就产生了行为的改变。


5.1 纯继承与扩展
采取“纯粹”的方式来创建继承层次结构似乎是最好的方式。也就是说,只有在积累中已经建立的方法才可以在导出类中被覆盖。这被称作是纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了它应该是什么。继承可以确保所有的导出类具有积累的接口,且却对不会少。那么,按照纯粹的继承方式,到粗雷也将具有和基类一样的接口。也可以认为这是一种纯替代,因为导出类可以完全替代基类,而在使用它们时,完全不需要知道关于子类的任何额外信息。也就是说,基类可以寄售发给导出类的任何消息,因为二者有着完全相同的接口。我们只需从导出类 向上转型,永远不需知道正在处理的对象的确切类型。所有这一切,都是通过多态来处理。按照这种方式考虑,似乎只有纯粹的is-a关系才是唯一明智的做法,而所有其他的设计都只会导致混乱和注定会失败。这其实也是一个陷阱,因为只要开始考虑,就会转向,并发现扩展接口(遗憾的是,extends关键字似乎在怂恿我们这样做)才是解决特定问题的完美方案。这可以成为“is-like-a”(像一个)关系,因为导出类就像是一个基类——它有着相同的基本接口,但是它还具有额外方法实现的其他特性。
虽然这是一种有用切明智的方法(依赖于具体情况),但它也有缺点,导出类中接口的扩展部分不能被基类访问,因此,一旦我们向上转型,就不能调用那些新方法:这种情况下,如果我们不进行向上转型,这样的问题也就不会出现。但是通常情况下,我们需要重新查明对象的确切类型,以便能够访问该类型所扩充的方法。


5.2 向下转型与运行时类型识别
由于向上转型(在继承层次中向上移动)会丢失具体的类型信息,所以我们就像,通过向下转型——也就是在继承层次中向下移动——应该能够获取类型信息。然而,我们知道向上转型是安全的,因为基类不会具有大于导出类的接口。因此,我们通过基类接口发送的消息保证都能被接受。但是对于向下转型,例如,我们无法知道一个“几何形状”它确实就是一个“圆”,它可以是一个三角形、正方形或其他一些类型。
要解决这个问题,必须有某种方法来确保向下转型的正确性,使我们不至于贸然转型到一种错误类型,进而发出该对象无法接受的消息。这样做是极其不安全的。在某些程序设计语言(如C++)中,我们必须执行一个特殊的操作来获得安全的向下转型。但是在Java语言中,所有转型都会得到检查!所以即使我们知识进行一次普通的加括弧形式的类型转换,在进入运行时仍然会对其进行检查,一边保证它的确使我们希望的那种类型。如果不是,就会返回一个ClassCastException(类型转换异常)。这种在运行期间对类型进行检查的行为称作”运行时类型识别(RTTI)。RTTI的内容不仅仅包括转型处理。例如它还提供一种方法,使你可以在试图向下转型之前,查看你所要处理的类型。


总结:本章作者详细的介绍了多态的相关知识。本菜鸟起初对于多态的理解很简单——其实就是将子类的引用存储在基类型的引用的存储空间中,当调用的时候,从基类型引用存储空间取出的引用的值,实际指向的是一个子类的对象,所以对于这种方法调用的多态看起来似乎是理所当然的。然而通过今天的学习,本菜鸟认识到似乎事情不是我想象的那样,上述结论中,我忽略了所有讲解多态的地方都会讲到的动态绑定的问题,而且也忽略了本书中所讲的域和静态方法、以及私有方法等的情况。由此,我产生了一个新的问题——Java中到底是如何实现多态的,换句话说,就是Java虚拟机是如何找到运行时应该执行的方法代码段和属性的呢?这看起来似乎很复杂,这涉及到了Java虚拟机的实现。但到底是因为需要这种多态而在虚拟机中实现了相应的功能,还是虚拟机本身就应该是这样工作的,而导致Java中多态的产生呢?结合Java、C++、C#这些面向对象的编程语言中都有多态这一事实,本菜鸟得出这样的结论:面向对象编程是一种思想,一种方法论,一种技术,而Java、C++、C#这些编程语言只不过是面向对象思想的不同形式的具体实现,一种我们能够采用面向对象的思想与机器交流的工具,因此,是因为面向对象思想中多态的存在,导致面向对象编程语言工具Java的运行环境——Java虚拟机实现了这种对于多态的支持的绑定方式。总之,今天的学习不但巩固和深化了我对多态的理解、弥补了只是漏洞,同时,本菜鸟得出了两个有待考证的结论:
1)Java中成员对象、静态方法是按照引用变量生命的类型静态绑定;实例方法按照引用变量引用的实例动态绑定;(“静态”二字有待考究,这似乎与之前的Java中都是动态绑定的说法有些相悖)
2)是因为面向对象思想中多态的存在,导致面向对象编程语言工具Java的运行环境——Java虚拟机实现了这种对于多态的支持的绑定方式

我相信随着学习的深入,这些结论最终会得到验证,在Java虚拟机规范中,这些应该能得到解答。
<pre name="code" class="java">