• 初识Java 7-1 多态


    目录

    向上转型

    难点

    方法调用绑定

    产生正确的行为

    可扩展性

    陷阱:“重写”private方法

    陷阱:字段与静态方法

    构造器和多态

    构造器的调用顺序

    继承和清理

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

    协变返回类型

    使用继承的设计

    替换和扩展

    向下转型和反射


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


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

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

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

    向上转型

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

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

    1. package music;
    2. public enum Note {
    3. MIDDLE_C, C_SHARP, B_FLAT;
    4. }

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

    1. package music;
    2. public class Instrument {
    3. public void play(Note n) {
    4. System.out.println("这是方法Instrument.play");
    5. }
    6. }

            那么,Wind就可以继承Instrument

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

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

    1. package music;
    2. public class Music {
    3. public static void tune(Instrument i) {
    4. // ...
    5. i.play(Note.MIDDLE_C);
    6. }
    7. public static void main(String[] args) {
    8. Wind flute = new Wind();
    9. tune(flute); // 向上转型
    10. }
    11. }

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

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

    忘记对象类型

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

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

    1. package music;
    2. class Stringed extends Instrument {
    3. @Override
    4. public void play(Note n) {
    5. System.out.println("这是方法Stringed.play() " + n);
    6. }
    7. }
    8. class Brass extends Instrument {
    9. @Override
    10. public void play(Note n) {
    11. System.out.println("这是方法Brass.play() " + n);
    12. }
    13. }
    14. public class Music2 {
    15. public static void tune(Stringed i) {
    16. i.play(Note.MIDDLE_C);
    17. }
    18. public static void tune(Brass i) {
    19. i.play(Note.MIDDLE_C);
    20. }
    21. public static void main(String[] args) {
    22. Stringed violin = new Stringed();
    23. Brass frenchHorn = new Brass();
    24. tune(violin);
    25. tune(frenchHorn);
    26. }
    27. }

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

    难点

            在上面的例子中,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的方法进行了“重写”,如:

    1. public class PrivateOverride {
    2. private void f() {
    3. System.out.println("隐藏的f()方法");
    4. }
    5. public static void main(String[] args) {
    6. PrivateOverride po = new Derives();
    7. po.f();
    8. }
    9. }
    10. class Derives extends PrivateOverride {
    11. public void f() { // 尝试性的“重写”
    12. System.out.println("公开的f()方法");
    13. }
    14. }

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

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

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

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

            尝试编译,会发生报错:


    陷阱:字段与静态方法

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

    1. class Super {
    2. public int field = 0;
    3. public int getField() {
    4. return field;
    5. }
    6. }
    7. class Sub extends Super {
    8. public int field = 1;
    9. @Override
    10. public int getField() {
    11. return field;
    12. }
    13. public int getSuperField() {
    14. return super.field;
    15. }
    16. }
    17. public class FieldAccess {
    18. public static void main(String[] args) {
    19. Super sup = new Sub(); // 向上转型
    20. System.out.println("sup.field = " + sup.field +
    21. ", sup.getField() = " + sup.getField());
    22. Sub sub = new Sub();
    23. System.out.println("sub.field = " + sub.field +
    24. ", sub.getField() = " + sub.getField() +
    25. ", sub.getSuperField() = " + sub.getSuperField());
    26. }
    27. }

            程序执行的结果是:

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

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

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

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

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

    1. class StaticSuper {
    2. public static String staticGet() {
    3. return "属于基类的staticGet()方法";
    4. }
    5. public String dynamicGet() {
    6. return "属于基类的dynamicGet()方法";
    7. }
    8. }
    9. class StaticSub extends StaticSuper {
    10. public static String staticGet() { // 静态方法直接与类关联
    11. return "派生的staticGet()方法";
    12. }
    13. @Override
    14. public String dynamicGet() {
    15. return "派生的dynamicGet()方法";
    16. }
    17. }
    18. public class StaticPolymorphism {
    19. public static void main(String[] args) {
    20. StaticSuper sup = new StaticSub();
    21. System.out.println(StaticSuper.staticGet());
    22. System.out.println(sup.dynamicGet());
    23. }
    24. }

            程序执行的结果如下:

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

    构造器和多态

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

    构造器的调用顺序

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

    1. class Meal {
    2. Meal() {
    3. System.out.println("构造器Meal()");
    4. }
    5. }
    6. class Bread {
    7. Bread() {
    8. System.out.println("构造器Bread()");
    9. }
    10. }
    11. class Cheese {
    12. Cheese() {
    13. System.out.println("构造器Cheese()");
    14. }
    15. }
    16. class Lettuce {
    17. Lettuce() {
    18. System.out.println("构造器Lettuce()");
    19. }
    20. }
    21. class Lunch extends Meal {
    22. Lunch() {
    23. System.out.println("构造器Lunch()");
    24. }
    25. }
    26. class PortableLunch extends Lunch {
    27. PortableLunch() {
    28. System.out.println("构造器PortableLunch()");
    29. }
    30. }
    31. public class Sandwich extends PortableLunch {
    32. private Bread b = new Bread();
    33. private Cheese c = new Cheese();
    34. private Lettuce l = new Lettuce();
    35. public Sandwich() {
    36. System.out.println("构造器SandWich()");
    37. }
    38. public static void main(String[] args) {
    39. new Sandwich();
    40. }
    41. }

            程序执行的结果是:

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

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

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

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


    继承和清理

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

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

    1. class Characteristic {
    2. private String s;
    3. Characteristic(String s) {
    4. this.s = s;
    5. System.out.println("特征创建:" + s);
    6. }
    7. protected void dispose() {
    8. System.out.println("特征清理:" + s);
    9. }
    10. }
    11. class Description {
    12. private String s;
    13. Description(String s) {
    14. this.s = s;
    15. System.out.println("特征创建:" + s);
    16. }
    17. protected void dispose() {
    18. System.out.println("特征清理:" + s);
    19. }
    20. }
    21. class LivingCreature {
    22. private Characteristic p = new Characteristic("有活力的");
    23. private Description t = new Description("是一个活着的生物");
    24. LivingCreature() {
    25. System.out.println("构造器LivingCreature()");
    26. }
    27. protected void dispose() {
    28. System.out.println("清理LivingCreature");
    29. t.dispose();
    30. p.dispose();
    31. }
    32. }
    33. class Animal extends LivingCreature {
    34. private Characteristic p = new Characteristic("有一颗心脏");
    35. private Description t = new Description("是动物而不是植物");
    36. Animal() {
    37. System.out.println("构造器Animal()");
    38. }
    39. @Override
    40. protected void dispose() {
    41. t.dispose();
    42. p.dispose();
    43. super.dispose();
    44. }
    45. }
    46. class Amphibian extends Animal {
    47. private Characteristic p = new Characteristic("能在水中生存");
    48. private Description t = new Description("水陆两栖");
    49. Amphibian() {
    50. System.out.println("构造器Amphibian()");
    51. }
    52. @Override
    53. protected void dispose() {
    54. System.out.println("清理Amphibian");
    55. t.dispose();
    56. p.dispose();
    57. super.dispose();
    58. }
    59. }
    60. public class Frog extends Amphibian {
    61. private Characteristic p = new Characteristic("呱呱叫");
    62. private Description t = new Description("吃虫子");
    63. public Frog() {
    64. System.out.println("构造器Frog()");
    65. }
    66. @Override
    67. protected void dispose() {
    68. t.dispose();
    69. p.dispose();
    70. super.dispose();
    71. }
    72. public static void main(String[] args) {
    73. Frog frog = new Frog();
    74. System.out.println("结束");
    75. System.out.println();
    76. frog.dispose();
    77. }
    78. }

            上述程序执行的结果是:

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

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

    1. class Shared {
    2. private int refcount = 0;
    3. private static long counter = 0;
    4. private final long id = counter++;
    5. Shared() {
    6. System.out.println("创建:" + this);
    7. }
    8. public void addRef() {
    9. refcount++;
    10. }
    11. protected void dispose() {
    12. if (--refcount == 0)
    13. System.out.println("清理:" + this);
    14. }
    15. @Override
    16. public String toString() {
    17. return "Shared " + id;
    18. }
    19. }
    20. class Compsoing {
    21. private Shared shared;
    22. private static long counter = 0;
    23. private final long id = counter++;
    24. Compsoing(Shared shared) {
    25. System.out.println("创建:" + this);
    26. this.shared = shared;
    27. this.shared.addRef();
    28. }
    29. protected void dispose() {
    30. System.out.println("清理:" + this);
    31. shared.dispose();
    32. }
    33. @Override
    34. public String toString() {
    35. return "Composing " + id;
    36. }
    37. }
    38. public class ReferenCounting {
    39. public static void main(String[] args) {
    40. Shared shared = new Shared();
    41. Compsoing[] compsoings = {
    42. new Compsoing(shared),
    43. new Compsoing(shared),
    44. new Compsoing(shared),
    45. new Compsoing(shared),
    46. new Compsoing(shared)
    47. };
    48. System.out.println();
    49. for (Compsoing c : compsoings) {
    50. c.dispose();
    51. }
    52. }
    53. }

            程序执行的结果如下:

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


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

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

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

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

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

    1. class Glyph {
    2. void draw() {
    3. System.out.println("方法Glyph.draw()");
    4. }
    5. Glyph() {
    6. System.out.println("构造器Glyph:在调用draw()之前");
    7. draw();
    8. System.out.println("构造器Glyph:在调用draw()之后");
    9. }
    10. }
    11. class RoundGlyph extends Glyph {
    12. private int radius = 1;
    13. RoundGlyph(int r) {
    14. radius = r;
    15. System.out.println("调用构造器RoundGlyph(),radius = " + radius);
    16. }
    17. @Override
    18. void draw() {
    19. System.out.println("调用方法RoundGlyph.draw(),radius = " + radius);
    20. }
    21. }
    22. public class PolyConstructors {
    23. public static void main(String[] args) {
    24. new RoundGlyph(5);
    25. }
    26. }

            程序运行的结果如下:

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

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

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

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

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

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

    协变返回类型

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

    1. class Grain {
    2. @Override
    3. public String toString() {
    4. return "Grain";
    5. }
    6. }
    7. class Wheat extends Grain {
    8. @Override
    9. public String toString() {
    10. return "Wheat";
    11. }
    12. }
    13. class Mill {
    14. Grain process() {
    15. return new Grain();
    16. }
    17. }
    18. class WheatMill extends Mill {
    19. @Override
    20. Wheat process() {
    21. return new Wheat();
    22. }
    23. }
    24. public class CovariantReturn {
    25. public static void main(String[] args) {
    26. Mill m = new Mill();
    27. Grain g = m.process();
    28. System.out.println(g);
    29. m = new WheatMill();
    30. g = m.process();
    31. System.out.println(g);
    32. }
    33. }

            程序执行的结果是:

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

    使用继承的设计

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

    1. class Actor {
    2. public void act() {
    3. }
    4. }
    5. class HappyActor extends Actor {
    6. @Override
    7. public void act() {
    8. System.out.println("HappyActor");
    9. }
    10. }
    11. class SadActor extends Actor {
    12. @Override
    13. public void act() {
    14. System.out.println("SadActor");
    15. }
    16. }
    17. class Stage {
    18. private Actor actor = new HappyActor();
    19. public void change() {
    20. actor = new SadActor();
    21. }
    22. public void performPlay() {
    23. actor.act();
    24. }
    25. }
    26. public class Transmogrify {
    27. public static void main(String[] args) {
    28. Stage stage = new Stage();
    29. stage.performPlay();
    30. stage.change();
    31. stage.performPlay();
    32. }
    33. }

            程序执行的结果是:

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

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

    替换和扩展

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

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

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

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


    向下转型和反射

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

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

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

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

    1. class Useful {
    2. public void f() {
    3. }
    4. public void g() {
    5. }
    6. }
    7. class MoreUseful extends Useful {
    8. @Override
    9. public void f() {
    10. }
    11. @Override
    12. public void g() {
    13. }
    14. public void u() {
    15. }
    16. public void v() {
    17. }
    18. public void w() {
    19. }
    20. }
    21. public class Reflect {
    22. public static void main(String[] args) {
    23. Useful[] x = {
    24. new Useful(),
    25. new MoreUseful()
    26. };
    27. x[0].f();
    28. x[1].g();
    29. // 下方这行语句触发编译时错误:无法在Useful中找到对应方法
    30. // x[1].u();
    31. ((MoreUseful) x[1]).u(); // 向下转型,触发反射
    32. ((MoreUseful) x[0]).u(); // 该条语句会抛出运行时异常
    33. }
    34. }

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

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

  • 相关阅读:
    python入门基础知识·二
    分支结构相关
    常用中间件分类
    scikit-learn机器学习算法封装
    【DSP程序升级】程序升级/OTA/BootLoader开发
    《 公共关系学 》综合复习资料
    前端面试题(14)|求职季面试题分享|答案
    【SG滤波】三阶滤波、五阶滤波、七阶滤波(Matlab代码实现)
    word文档删除了还能找回来吗?这里分享可行方法
    k8s笔记20--基于 K8S 的 cicd 概述
  • 原文地址:https://blog.csdn.net/w_pab/article/details/132730789