Java编程思想(第四版)

——第八章 多态

Posted by yuchen on October 22, 2016

1. 关于动态绑定

  • Java中除了static方法和final方法(private方法属于final方法)之外,其他所有方法都是后期绑定。这意味着通常情况下,我们不必判定是否应该进行后期绑定-它会自动发生。
  • 为什么要将某个方法声明为final呢?
    1)可以防止其他人覆盖该方法(子类不能重写父类的final方法,但是可以重载父类的final方法);
    2)更重要的一点或许是:这样做可以有效地“关闭”动态绑定,或者说,告诉编译器不需要对其进行动态绑定。这样编译器就可以为final方法调用生成更有效的代码。然而,大多数情况下,这样做对程序的整体性能不会有什么改观。所以,最好根据设计来决定是否使用final,而不是出于试图提高性能的目的来使用final。
    3)动态绑定只针对于方法,而对域没有作用。即通过向上转型将子类转为基类,再访问子类继承于基类的域(对于静态变量和实例变量都一样)时,访问得到的值是基类中的值。
小结

多态的作用:程序员所做的代码的修改,不会对程序中其他不应收到影响的部分产生破坏。换句话说,多态是一项让程序员“将改变的事物与未变的事物分离开来”的重要技术。

2. 关于继承

  • 具有public、protected和default(包访问权限)访问权限的域或方法都是可以被子类直接继承的,即使是具有final修饰的域或方法也可以被继承。
  • 注意:
    1)对于具有default访问权限的域或方法,仅当子类与父类在同一个包中,才可以被继承(在不同包中,因为不可见,所以也不能被继承,更不能被重载);
    2)final作用于域,被继承的域不可用被重新赋值;final作用于方法,被继承的方法不可被覆盖(cannot override,即不可修改它的含义);
    3)final置于类的定义之前表示该类不允许被继承。由于final类禁止继承,所以final类中所有的方法都隐式指定为final的。final类的非final域是可以被重新赋值的。
    4)访问权限是针对类来说的,不是针对对象。示例如下:

1)ClassA与ClassB在不同包中:

package top.wzzju.test;

/**
 * Created by yuchen on 16-10-22.
 */
public class ClassA {
    protected String strF = "hello";
    protected static String strS = "world";
}
package top.wzzju;

import top.wzzju.test.ClassA;

/**
 * Created by yuchen on 16-10-22.
 */
public class ClassB extends ClassA{
    public static void main(String[] args) {
        ClassA a = new ClassA();
        ClassB b = new ClassB();
        System.out.println(a.strF);//error:strF has protected access
        System.out.println(a.strS);//OK
        System.out.println(ClassA.strS);//OK
        System.out.println(b.strF);//OK
        System.out.println(b.strS);//OK
        System.out.println(ClassB.strS);//OK
    }
}

2)ClassA与ClassB在同一包中,上述代码的error处便消失了。

3.陷阱:“覆盖“私有方法

只有非private方法才可以被覆盖。因此,在子类中,对于基类的private方法,最好采用不同的名字。

4.陷阱:域和静态方法

  • 当子类对象转型为父类引用时,任何域访问操作都将由编译器解析,因此不是多态的。子类的域和父类的域分配在不同的存储空间中。子类实际上包含两个版本的域,它自己的和从父类中得到的。
    注意:尽量不要将基类中的域和导出类中的域赋予相同的名字,以免造成混淆。
  • 如果某个方法是静态的,它的行为就不具有多态性。

  • 构造器实际上是static方法,只不过该static声明是隐式的。

5.继承与构造器——初始化顺序(上)

基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。 注意:在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,它就会调用默认构造器。如果不存在默认构造器,编译出错(若某个类没有构造器,编译器会自动生成一个默认构造器。但是如果有了其他的构造器(带参数的构造器),编译器就不会生成默认构造器(即无参数的构造器))。
下面的例子展示组合、继承以及多态在构建顺序上的作用:

//: polymorphism/Sandwich.java
// Order of constructor calls.
package polymorphism;
import static net.mindview.util.Print.*;

class Meal {
  Meal() { print("Meal()"); }
}

class Bread {
  Bread() { print("Bread()"); }
}

class Cheese {
  Cheese() { print("Cheese()"); }
}

class Lettuce {
  Lettuce() { print("Lettuce()"); }
}

class Lunch extends Meal {
  Lunch() { print("Lunch()"); }
}

class PortableLunch extends Lunch {
  PortableLunch() { print("PortableLunch()");}
}

public class Sandwich extends PortableLunch {
  private Bread b = new Bread();
  private Cheese c = new Cheese();
  private Lettuce l = new Lettuce();
  public Sandwich() { print("Sandwich()"); }
  public static void main(String[] args) {
    new Sandwich();
  }
} /* Output:
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()
*///:~

构造顺序如下:

  1. 调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最底层的导出类;
  2. 按声明顺序调用成员的初始化方法(注意:如果父类中也有组合对象,那么在调用父类的构造器前也要先对其中的组合对象进行初始化);
  3. 调用导出类构造器的主体。

6.继承与清理

虽然通常不需要执行清理工作,但是一旦选择一进行清理,就必须按照如下顺序(销毁的顺序应该和初始化的顺序相反):
1)对于字段,则意味着与声明顺序相反(因为字段的初始化是按照声明的顺序进行的);
2)对于基类(遵循C++中析构函数的形式),应该首先对其导出类进行清理,然后才是基类。

原因:导出类的清理可能会调用基类中的某些方法,所以需要使基类中的构建仍起作用而不应该过早地销毁它们。

7.构造器内部的多态方法的行为——初始化顺序(下)

package polymorphism;//: polymorphism/PolyConstructors.java
// Constructors and polymorphism
// don't produce what you might expect.
import static net.mindview.util.Print.*;

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("RoundGlyph.RoundGlyph(), radius = " + radius);
  }
  void draw() {
    print("RoundGlyph.draw(), radius = " + radius);
  }
}	

public class PolyConstructors {
  public static void main(String[] args) {
    new RoundGlyph(5);
  }
} /* Output:
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
*///:~

产生上述输出结果的原因,是因为初始化顺序实际过程如下:
1)在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
2) 调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最底层的导出类【注意上面只是构造器调用顺序,而构造器执行顺序如下:根基类构造器->下一个导出类构造器->…->当前类构造器,并且每个类的构造器执行前都必须先执行其内部成员变量的初始化步骤(初始化顺序按照步骤三)】。在上述代码中,调用基类构造器时,调用了被覆盖的draw()方法(是在调用RoundGlyph构造器之前调用的),由于步骤1)的缘故,此时的radius的值为0。
3)按照声明的顺序调用成员的初始化方法(再次注意:如果父类中也有成员变量,那么在调用父类的构造器前也要先对其的组成员变量进行初始化)。
4)调用导出类的构造器。

小结

编写构造器的一条有效准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是基类中的finanl方法(也试用于private方法,它们属于final方法)。这些方法不能被覆盖,因此也就不会出现上述令人惊讶的问题。

8.协变返回类型

在面向对象程序设计中,协变返回类型指的是子类中的成员函数的返回值类型不必严格等同于父类中被重写的成员函数的返回值类型,而可以是更 “狭窄” 的类型。
Java 5.0添加了对协变返回类型的支持,即子类覆盖(即重写)基类方法时,返回的类型可以是基类方法返回类型的子类。协变返回类型允许返回更为具体的子类型。

示例代码如下:

package polymorphism;//: polymorphism/CovariantReturn.java

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

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

class Mill {
  Grain process() { return new Grain(); }
  int fun(){ return 1; }
}

class WheatMill extends Mill {
  Wheat process() { return new Wheat(); }//OK,因为Wheat是Grain的子类,符合协变返回类型
  double fun(){ return 3.14; }//error,因为double不是int的子类,不符合协变返回类型
}

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);
  }
} /* Output:
Grain
Wheat
*///:~

9.Java中的逆变与协变1

9.1里氏替换原则(Liskov替换原则)

LSP (Liskov Substitution Principle) is a fundamental principle of OOP and states that derived classes should be able to extend their base classes without changing their behavior. In other words, derived classes should be replaceable for their base types, i.e., a reference to a base class should be replaceable with a derived class without affecting the behavior. The Liskov Substitution Principle represents a strong behavioral subtyping and was introduced by Barbara Liskov in the year 1987.
里氏替换原则的内容可以描述为: “派生类(子类)对象能够替换其基类(超类)对象被使用。”或“所有引用基类(父类)的地方必须能透明地使用其子类的对象。”

LSP包含以下四层含义:

  • 子类完全拥有父类的方法,且具体子类必须实现父类的抽象方法。
  • 子类中可以增加自己的方法。
  • 当子类覆盖或实现父类的方法时,方法的形参要比父类方法的更为宽松。
  • 当子类覆盖或实现父类的方法时,方法的返回值要比父类更严格。
9.2逆变与协变的定义

逆变与协变用来描述类型转换(type transformation)后的继承关系,其定义:如果A、B表示类型,f(⋅)表示类型转换,≤表示继承关系(比如,A≤B表示A是由B派生出来的子类);

  • f(⋅)是逆变(contravariant)的,当A≤B时有f(B)≤f(A)成立;
  • f(⋅)是协变(covariant)的,当A≤B时有f(A)≤f(B)成立;
  • f(⋅)是不变(invariant)的,当A≤B时上述两个式子均不成立,即f(A)与f(B)相互之间没有继承关系。

f(.)在java中的示例:

  • 泛型:f(A)=ArrayList<A>
  • 数组:f(A)=[]A

在Java中泛型是不变的,数组是协变的。

9.3实现泛型的协变与逆变

Java中泛型是不变的,但是使用通配符可以实现逆变与协变:

  • <? extends>实现了泛型的协变,比如:
    List<? extends Number> list = new ArrayList<Integer>();
  • <? super>实现了泛型的逆变,比如:
    List<? super Number> list = new ArrayList<Object>();
9.4 PECS原则

究竟是使用extends还是使用super,这要遵循PECS原则:

PECS: producer-extends, consumer-super.

比如,在Stack API中,设计如下方法:

public void pushAll(Iterable<? extends E> src) {
    for (E e : src)
        push(e);
}

public void popAll(Collection<? super E> dst) {
    while (!isEmpty())
        dst.add(pop());
}

pushAll方法中的参数src的类型中之所以使用extends,是因为src是一个producer,需要从src中读取数据;
popAll方法中的参数dst的类型中之所以使用super,是因为dst是一个consumer,需要向dst中写入数据。

9.5小结

逆变和协变的目的就是为了满足里氏替换原则,即满足upcasting,只有upcasting是安全的,downcasting是不安全的。

10.关于设计

用继承表达行为间的差异,并用域(组合)表达状态上的变化。

11.向下转型

使用普通的加圆括号形式进行向下转型。
示例如下:

package polymorphism;//: polymorphism/RTTI.java
// Downcasting & Runtime type information (RTTI).
// {ThrowsException}

class Useful {
  public void f() {}
  public void g() {}
}

class MoreUseful extends Useful {
  public void f() {}
  public void g() {}
  public void u() {}
  public void v() {}
  public void w() {}
}	

public class RTTI {
  public static void main(String[] args) {
    Useful[] x = {
      new Useful(),
      new MoreUseful()
    };
    x[0].f();
    x[1].g();
    // Compile time: method not found in Useful:
    //! x[1].u();
    ((MoreUseful)x[1]).u(); // Downcast/RTTI
    ((MoreUseful)x[0]).u(); // Exception thrown
  }
} ///:~

在java语言中,所有转型都会得到检查!即使只是进行一次普通的加括号形式的类型转换,在进入运行期时仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastExcept(类转型异常)。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RTTI)。

12.参考资料


本文总阅读量