读书笔记-《ON JAVA 中文版》-摘要9[第九章 多态]

文章目录

  • 第九章 多态
    • 1. 向上转型回顾
      • 1.1 忘掉对象类型
      • 1.2 转机
      • 1.3 方法调用绑定
      • 1.4 产生正确的行为
      • 1.5 可扩展性
      • 1.6 陷阱:“重写”私有方法
      • 1.7 陷阱:属性与静态方法
    • 2. 构造器和多态
      • 2.1 构造器调用顺序
      • 2.2 继承和清理
      • 2.3 构造器内部多态方法的行为
    • 3. 协变返回类型
    • 4. 使用继承设计
      • 4.1 替代 vs 扩展
      • 4.2 向下转型与运行时类型信息
    • 5. 本章小结

第九章 多态

多态是面向对象编程语言中,继数据抽象和继承之外的第三个重要特性。

多态提供了另一个维度的接口与实现分离,以解耦做什么和怎么做。多态不仅能改善代码的组织,提高代码的可读性,而且能创建有扩展性的程序——无论在最初创建项目时还是在添加新特性时都可以“生长”的程序。

封装通过合并特征和行为来创建新的数据类型。隐藏实现通过将细节私有化把接口与实现分离。而多态是消除类型之间的耦合。

多态(也称为动态绑定或后期绑定或运行时绑定)。

1. 向上转型回顾

在上一章中,你看到了如何把一个对象视作它的自身类型或它的基类类型。这种把一个对象引用当作它的基类引用的做法称为向上转型,因为继承图中基类一般都位于最上方。

public enum Note {
    MIDDLE_C, C_SHARP, B_FLAT;
}
class Instrument {
    public void play(Note n) {
        System.out.println("Instrument.play()");
    }
}

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

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

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

在 main() 中你看到了 tune() 方法传入了一个 Wind 引用,而没有做类型转换。这样做是允许的

—— Instrument 的接口一定存在于 Wind 中,因此 Wind 继承了 Instrument。从 Wind 向上转型为 Instrument 可能“缩小”接口,但不会比 Instrument 的全部接口更少。

1.1 忘掉对象类型

如果 tune() 接受的参数是一个 Wind 引用会更为直观。这会带来一个重要问

题:如果你那么做,就要为系统内 Instrument 的每种类型都编写一个新的 tune() 方法。

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(Wind i) {
        i.play(Note.MIDDLE_C);
    }

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

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

    public static void main(String[] args) {
        Wind flute = new Wind();
        Stringed violin = new Stringed();
        Brass frenchHorn = new Brass();
        tune(flute);
        tune(violin);
        tune(frenchHorn);
    }
}

这样行得通,但是有一个主要缺点:必须为添加的每个新 Instrument 类编写特定的方法。

—PS:子子孙孙无穷匮也,不对,应该是葫芦娃
读书笔记-《ON JAVA 中文版》-摘要9[第九章 多态]_第1张图片
(图片来源于网络,侵删)

1.2 转机

Music.java 中 tune() 它接受一个 Instrument 引用。那么编译器是如何知道这里的 Instrument 引用指向的是 Wind,而不是 BrassStringed 呢?编译器无法得知。为了深入理解这个问题,有必要研究一下绑定这个主题。

1.3 方法调用绑定

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

上述程序让人困惑的地方就在于前期绑定,因为编译器只知道一个 Instrument 引用,它无法得知究竟会调用哪个方法。

解决方法就是后期绑定,意味着在运行时根据对象的类型进行绑定。后期绑定也称为动态绑定或运行时绑定。

Java 中除了 staticfinal 方法(private 方法也是隐式的 final)外,其他所有方法都是后期绑定。这意味着通常情况下,我们不需要判断后期绑定是否会发生——它自动发生。

—PS:static 修饰的属于类,在类加载时就确定了,final 修饰的不能更改,也是确定了的

1.4 产生正确的行为

Java 中所有方法都是通过后期绑定来实现多态时,就可以编写只与基类打交道的代码,而且代码对于派生类来说都能正常地工作。

—PS:这句话是有限定的,所有方法-后期绑定-多态

—PS:下面是基类 Shape

public class Shape {
    public void draw() {
    }

    public void erase() {
    }
}

—PS:下面是3个派生类 Circle/Square/Triangle,都重写了 draw() 和 erase()

public class Circle extends Shape{
    @Override
    public void draw() {
        System.out.println("Circle.draw()");
    }

    @Override
    public void erase() {
        System.out.println("Circle.erase()");
    }
}
public class Square extends Shape {
    @Override
    public void draw() {
        System.out.println("Square.draw()");
    }

    @Override
    public void erase() {
        System.out.println("Square.erase()");
    }
}
public class Triangle extends Shape {
    @Override
    public void draw() {
        System.out.println("Triangle.draw()");
    }

    @Override
    public void erase() {
        System.out.println("Triangle.erase()");
    }
}

—PS:下面是工厂类 RandomShapes ,它的 get() 可获取一个随机的形状,array() 可获取一个指定长度的 Shape[]

import java.util.Random;

public class RandomShapes {
    private Random rand = new Random(47);

    /**
     * PS:此处为自加的注释,原文没有
     * get() 获取一个随机的形状
     * rand.nextInt(3) 生成一个随机整数n, 0<=n<3
     */
    public Shape get() {
        switch (rand.nextInt(3)) {
            default:
            case 0:
                return new Circle();
            case 1:
                return new Square();
            case 2:
                return new Triangle();
        }
    }

    public Shape[] array(int sz) {
        Shape[] shapes = new Shape[sz];
        for (int i = 0; i < sz; i++) {
            shapes[i] = get();
        }
        return shapes;
    }
}

—PS:下面是入口类 Shapes

public class Shapes {
    public static void main(String[] args) {
        RandomShapes gen = new RandomShapes();
        for (Shape shape : gen.array(9)) {
            shape.draw();
        }
    }
}

输出:

Triangle.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Circle.draw()

随机生成形状是为了让大家理解:在编译时,编译器不需要知道任何具体信息以进行正确的调用。所有对方法 draw() 的调用都是通过动态绑定进行的。

1.5 可扩展性

只与基类接口通信。这样的程序是可扩展的,因为可以从通用的基类派生出新的数据类型,从而添加新的功能。

代码中的修改不会破坏程序中其他不应受到影响的部分。换句话说,多态是一项“将改变的事物与不变的事物分离”的重要技术。

1.6 陷阱:“重写”私有方法

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

    public static void main(String[] args) {
        PrivateOverride po = new Derived();
        po.f();
    }
}
public class Derived extends PrivateOverride {
    public void f() {
        System.out.println("public f()");
    }
}

输出:

private f()

你可能期望输出是 public f(),然而 private 方法可以当作是 final 的,对于派生类来说是隐蔽的。因此,这里 Derived 的 f() 是一个全新的方法;因为基类版本的 f() 屏蔽了 Derived ,因此它都不算是重写方法。

—PS:不算重写,同名而已
读书笔记-《ON JAVA 中文版》-摘要9[第九章 多态]_第2张图片

(图片来源于网络,侵删)
如果使用了 @Override 注解,就能检测出问题:

读书笔记-《ON JAVA 中文版》-摘要9[第九章 多态]_第3张图片

1.7 陷阱:属性与静态方法

只有普通的方法调用可以是多态的。

如果你直接访问一个属性,该访问会在编译时解析:

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

输出:

sup.field = 0, sup.getField() = 1
sub.field = 1, sub.getField() = 1, sub.getSuperField() = 0

Sub 对象向上转型为 Super 引用时,任何属性访问都被编译器解析,因此不是多态的。在这个例子中,Super.fieldSub.field 被分配了不同的存储空间,因此,Sub 实际上包含了两个称为 field 的属性:它自己的和来自 Super 的。然而,在引用 Subfield 时,默认的 field 属性并不是 Super 版本的field 属性。为了获取 Superfield 属性,需要显式地指明 super.field

尽管这看起来是个令人困惑的问题,实际上基本不会发生。首先,通常会将所有的属性都指明为private,因此不能直接访问它们,只能通过方法来访问。此外,你可能也不会给基类属性和派生类属性起相同的名字,这样做会令人困惑。

—PS:要听劝,不要给基类属性和派生类属性起相同的名字

如果一个方法是静态(static)的,它的行为就不具有多态性。

静态的方法只与类关联,与单个的对象无关。

—PS:所以啊,还是前面的那句话:只有普通的方法调用可以是多态的

2. 构造器和多态

构造器不具有多态性(事实上人们会把它看作是隐式声明的静态方法)

2.1 构造器调用顺序

class Meal {
    static {
        System.out.println("Meal static");
    }
    Meal() {
        System.out.println("Meal()");
    }
}

class Bread {
    static {
        System.out.println("Bread static");
    }
    Bread() {
        System.out.println("Bread()");
    }
}

class Cheese {
    static {
        System.out.println("Cheese static");
    }
    Cheese() {
        System.out.println("Cheese()");
    }
}

class Lettuce {
    static {
        System.out.println("Lettuce static");
    }
    Lettuce() {
        System.out.println("Lettuce()");
    }
}

class Lunch extends Meal {
    static {
        System.out.println("Lunch static");
    }
    Lunch() {
        System.out.println("Lunch()");
    }
}

class PortableLunch extends Lunch {
    static {
        System.out.println("PortableLunch static");
    }
    PortableLunch() {
        System.out.println("PortableLunch()");
    }
}

public class Sandwich extends PortableLunch {
    static {
        System.out.println("Sandwich static");
    }
    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) {
        System.out.println("start");
        new Sandwich();
        System.out.println("end");
    }
}

输出:

Meal static
Lunch static
PortableLunch static
Sandwich static
start
Meal()
Lunch()
PortableLunch()
Bread static
Bread()
Cheese static
Cheese()
Lettuce static
Lettuce()
Sandwich()
end

—PS:以上代码中 static 块是我自己加的,为了复习以前章节的技术点,现在解析下

  1. 执行 Sandwich main() 之前需要加载 Sandwich ,发现它继承了 PortableLunch ,就先加载了 PortableLunch ,PortableLunch 又继承了 Lunch ,Lunch 继承了 Meal 。
  2. 类第一次加载时,会执行 static 修饰的代码,所以在步骤1的所有类加载过程中,从最上面的基类开始,执行 static 代码,依次输出了结果集中的第1-4行。
  3. 接着进入 Sandwich main() ,执行 System.out.println(“start”); 输出结果集中的第5行。
  4. 接着执行 new Sandwich(); 需要先进行 Sandwich 初始化,这时需要先初始化从它那开始的一串基类,故依次输出了结果集中的第6-8行。
  5. 接着依次初始化 Sandwich 的成员变量
  6. private Bread b = new Bread();
  7. 成员变量的 Bread 是第一次使用,需要先加载,然后 new Bread() 再调用 Bread 的构造方法,故输出了结果集中的第9-10行。
  8. 同样的道理
  9. private Cheese c = new Cheese(); 输出了结果集中的第11-12行。
  10. private Lettuce l = new Lettuce(); 输出了结果集中的第13-14行。
  11. 接着是派生类的构造方法,输出了结果集中的第15行。
  12. 最后执行 System.out.println(“end”); 输出了结果集中的第16行。

从创建 Sandwich 对象的输出中可以看出对象的构造器调用顺序如下:

  1. 基类构造器被调用。这个步骤被递归地重复,这样一来类层次的顶级父类会被最先构造,然后是

它的派生类,以此类推,直到最底层的派生类。

  1. 按声明顺序初始化成员。

  2. 调用派生类构造器的方法体。

构造器的调用顺序很重要。当使用继承时,就已经知道了基类的一切,并可以访问基类中任意 publicprotected 的成员。这意味着在派生类中可以假定所有的基类成员都是有效的。在一个标准方法中,构造动作已经发生过,对象其他部分的所有成员都已经创建好。在构造器中必须确保所有的成员都已经构建完。唯一能保证这点的方法就是首先调用基类的构造器。接着,在派生类的构造器中,所有你可以访问的基类成员都已经初始化。

—PS:这就是上面那段代码输出结果的缘由

2.2 继承和清理

在使用组合和继承创建新类时,大部分时候你无需关心清理。子对象通常会留给垃圾收集器处理。如果你存在清理问题,那么必须用心地为新类创建一个 dispose() 方法(这里用的是我选择的名称, 你可以使用更好的名称)。由于继承,如果有其他特殊的清理工作的话,就必须在派生类中重写 dispose() 方法。当重写 dispose() 方法时,记得调用基类的 dispose() 方法,否则基类的清理工作不会发生。

类中变量销毁的顺序应该与初始化的顺序(声明的顺序相反)相反,以防一个对象依赖另一个对象。对于基类(遵循 C++ 析构函数的形式),首先进行派生类的清理工作,然后才是基类的清理。这是因为派生类的清理可能调用基类的一些方法,所以基类组件这时得存活,不能过早地被销毁。

—PS:所以在派生类的清除方法中,最后一行才调用基类的清除方法

尽管通常不必进行清理工作,但万一需要时,就得谨慎小心地执行。

—PS:通常不必,相信GC

Frog 对象拥有自己的成员对象,它创建了这些成员对象,并且知道它们能存活多久,所以它知道何时调用 dispose() 方法。然而,一旦某个成员对象被其它一个或多个对象共享时,问题就变得复杂了,不能只是简单地调用 dispose() 。这里,也许就必须使用引用计数来跟踪仍然访问着共享对象的对象数量

—PS:这个就是垃圾清除算法的东西了,有个概念就好

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

如果在构造器中调用了动态绑定方法,就会用到那个方法的重写定义。然而,调用的结果难以预料因为被重写的方法在对象被完全构造出来之前已经被调用,这使得一些 bug 很隐蔽,难以发现。

—PS:这个和初始化顺序有关,基类的构造方法一定是早于派生类执行的,如果在基类构造方法中调用了动态绑定的方法,就会调用到这个方法在派生类中重写的方法。如果这个重写的方法中涉及到了派生类的成员变量(此时成员变量还未初始化),就会出现问题

编写构造器有一条良好规范:做尽量少的事让对象进入良好状态。如果有可能的话,尽量不要调用类中的任何方法。在基类的构造器中能安全调用的只有基类的 final 方法(这也适用于可被看作是 finalprivate 方法)。这些方法不能被重写,因此不会产生意想不到的结果。你可能无法永远遵循这条规范,但应该朝着它努力。

—PS:构造器纯粹一点好

3. 协变返回类型

Java 5 中引入了协变返回类型,这表示派生类的被重写方法可以返回基类方法返回类型的派生类型

4. 使用继承设计

学习过多态之后,一切看似都可以被继承,因为多态是如此巧妙的工具。这会给设计带来负担。事实上,如果利用已有类创建新类首先选择继承的话,事情会变得莫名的复杂。

更好的方法是首先选择组合,特别是不知道该使用哪种方法时。组合不会强制设计是继承层次结构,而且组合更加灵活,因为可以动态地选择类型(因而选择相应的行为),而继承要求必须在编译时知道确切类型。

有一条通用准则:使用继承表达行为的差异,使用属性表达状态的变化。

4.1 替代 vs 扩展

纯粹的替代(纯粹的“is - a”关系)意味着派生类可以完美地替代基类,当使用它们时,完全不需要知道这些子类的信息。也就是说,基类可以接收任意发送给派生类的消息,因为它们具有完全相同的接口。只需将派生类向上转型,不要关注对象的具体类型。所有一切都可以通过多态处理。

—PS:这里的派生类是仅仅重写了基类的方法,没有属于自己的额外方法

这可以称为“is - like - a” 关系,因为派生类就像是基类——它有着相同的基本接口,但还具有需要额外方法实现的其他特性虽然这是一种有用且明智的方法(依赖具体情况),但是也存在缺点。派生类中接口的扩展部分在基类中不存在(不能通过基类访问到这些扩展接口),因此一旦向上转型,就不能通过基类调用这些新方法。

4.2 向下转型与运行时类型信息

由于向上转型(在继承层次中向上移动)会丢失具体的类型信息,那么为了重新获取类型信息,就需要在继承层次中向下移动,使用向下转型。

向上转型永远是安全的,因为基类不会具有比派生类更多的接口。因此,每条发送给基类接口的消息都能被接收。但是对于向下转型,你无法知道一个形状是圆,它有可能是三角形、正方形或其他一些类型。

为了解决这个问题,必须得有某种方法确保向下转型是正确的。

class Father {
    void a() {
        System.out.println("i am father");
    }
}

public class Son extends Father {
    @Override
    void a() {
        System.out.println("i am son");
    }

    void b() {
        System.out.println("son b");
    }

    public static void main(String[] args) {
        Father f1 = new Father();
        Father f2 = new Son();// PS:这是老朋友-向上转型
//        Son s1 = (Son) f1;// PS:这是新朋友-向下转型,但是 f1 原本是 new Father(),强转为 Son 时,报错:ClassCastException
        Son s2 = (Son) f2;// PS:这是新朋友-向下转型
//        s1.b();
        s2.b();
    }
}

—PS:上面的代码是我自己写的,不是原文的代码

5. 本章小结

多态意味着“不同的形式”。在面向对象编程中,我们持有从基类继承而来的相同接口和使用该接口的不同形式:不同版本的动态绑定方法。

如果不使用数据抽象和继承,就不可能理解甚至创建多态的例子。

为了在程序中有效地使用多态乃至面向对象的技术,就必须扩展自己的编程视野,不能只看到单一类中的成员和消息,而要看到类之间的共同特性和它们之间的关系。它能带来更快的程序开发、更好的代码组织、扩展性更好的程序和更易维护的代码。但是记住,多态可能被滥用。

自我学习总结:

  1. Java 中所有方法都是通过后期绑定来实现多态
  2. 向上转型:父类的引用指向子类的对象
    向上转型后调用的方法为子类的方法
  3. 基类的构造方法先于派生类执行,派生类的成员变量初始化先于构造方法
  4. 使用组合和继承时,不用关注清理,交给垃圾收集器即可
  5. 构造方法要纯粹,方法体中尽量不要调用任何方法,不要动态绑定方法
  6. 向下转型时需要确定转型的类型,如果转型失败会报出 ClassCastException
  7. 继承时多态的基础
  8. 尽量使用组合,非必要不使用继承,不滥用多态
    读书笔记-《ON JAVA 中文版》-摘要9[第九章 多态]_第4张图片
    (图片来源于网络,侵删)

你可能感兴趣的:(读书笔记,java)