多态 是继 数据抽象 和 继承 之后的第三种基本特征。
多态也称作动态绑定、后期绑定或运行时绑定。
多态的一些具象表现。
- 允许不同类的对象对同一消息做出响应
- 同一个行为具有多个不同表现形式或形态的能力
- 只有在运行时才会知道引用变量所指向的具体实例对象
封装:通过合并特征和行为创建新的数据类型。
实现隐藏:通过将细节私有化,把接口和实现分离开来
多态:消除类型之间的耦合关系。
1. 再论向上转型
上一章中我们提到了,对象及可以作为自己本身的类型使用,也可以作为ita的基类型来使用。而这种把对某个对象的引用视为对其基类型的引用的做法被称作 向上转型。
我们看下面这个例子:
class Instrument {
public void play(String song){
System.out.println("Instrument.play() " + song);
}
}
class Piano extends Instrument{
//重写方法
public void play(String song){
System.out.println("Piano.play() " + song);
}
}
public class Music {
public static void tune(Instrument i){
//...
i.play("Fade");
}
public static void main(String[] args){
Piano piano = new Piano;
tune(piano);
}
}
//输出结果为 Piano.play() Fade
我们通过向上转型,将子类 Paino 类型的引用传入了接收 父类 Instrument 类型引用参数的 tune() 方法中,这是没有什么问题的,不过有一个疑问:为什么不让 tune() 方法直接接收一个 Piano 参数呢?
乍一看下,似乎让 tune() 方法接收一个 Piano 引用作为参数更符合常理,但是这样会导致一个严重的问题:
- 如果这样做的话,那么每个 Instrument 的子类型都要写一个新的 tune() 方法
- 这会造成大量多余的编程
- 同时,假如某个子类忘记修改某个方法,编译器不会报任何错误,此时会出现一些隐患
结论是:这种情况下如果我们只写一个简单方法,它仅仅接收基类作为参数,而不是特殊的导出类,事情就迎刃而解了。当然这正式 多态 所允许的。
但是不能只知其然不知其所以然,下面就仔细分析一下上述结论的依据是什么。
2. 绑定
我们再来分析一下 tune() 方法:
public static void tune(Instrument i){
//...
i.play("Fade");
}
前面我们提到 tune() 传参的时候,将 Piano 向上转型作为 Instrument 使用,但与此同时问题出现了:编译器是如何知道这个 Instrument 引用指向的是 Piano 对象?嗯,实际上,编译器无法得知(WTF?)。
为了深入理解这个问题,我们需要研究一下 绑定。
2.1 什么是绑定
绑定 即将一个方法调用同一个方法主题关联起来
-
若在程序执行前进行绑定,叫做 前期绑定
如果有前期绑定的话,是由编译器和连接程序实现
-
如果在运行时根据对象的类型进行绑定,叫做 后期绑定/动态绑定/运行时绑定
后期绑定使得编译器一直不知道对象的类型,但是方法调用机制通过安置的某种“类型信息”,能找到正确的方法体,并加以调用。
在 Java 中,除了 static 方法和 final 方法以外,其他所有的方法都是后期绑定。这点很关键。
关于把一个方法声明为 final 的作用,在前面一章也提过了:
- 防止被覆盖
- 出于性能考虑 -- 有效的关闭动态绑定
但是应该从设计方面考虑是否使用 final 方法,而非出于性能的考虑。
2.2 多态的正确实践
从上面分析,我们得知:"Java 中的方法都是通过动态绑定来实现多态的",这样一来,我们编写代码时就只需要和基类打交道了,这些代码自然适用于所有导出类。换个说法,发送消息给某个对象,让该对象去断定应该做什么事。
我们来举个“几何形状”的例子:有一个基类 Shape,以及多个导出类,如 Circle、Square、Triangle:
向上转型:Shape s = new Circle()
这里创建了一个 Circle 对象,并把得到的引用立即赋值给 Shape,能这么做是因为通过继承,Circle 就是一种 Shape。
继承表示 is-a 的关系
此时如果调用基类方法 s.draw(),虽然 s 是一个 Shape 引用,但是编译器实际上并非调用 Shape.draw(),而是由于后期绑定(多态),会正确的调用 Circle.draw() 方法。
在编译时,编译器不需要获得人为添加的任何特殊信息就能进行正确的调用。后期绑定 会替我们进行正确调用。
多态方法调用允许一种类型表现出与其他相似类型之间的区别,这种区别根据方法行为的不同而表示出来。
2.3 良好的可扩展性
在一个设计良好的 OOP 程序中,大多数或者所有方法都会只与基类接口通信。这样的程序是可扩展的,因为可以从通用的基类继承出新的数据类型,从而新添一些功能,那些操纵基类接口的方法不需要任何改动就可以应用于新类。
回到上面的 “乐器”(Instrument) 示例。由于多态机制,我们可根据自己需求添加任意多的新类型,而无需改变 tune() 方法。
class Instrument {
void play(Note n) { print("Instrument.play() " + n); }
String what() { return "Instrument"; }
void adjust() { print("Adjusting Instrument"); }
}
class Wind extends Instrument {
void play(Note n) { print("Wind.play() " + n); }
String what() { return "Wind"; }
void adjust() { print("Adjusting Wind"); }
}
class Percussion extends Instrument {
void play(Note n) { print("Percussion.play() " + n); }
String what() { return "Percussion"; }
void adjust() { print("Adjusting Percussion"); }
}
class Stringed extends Instrument {
void play(Note n) { print("Stringed.play() " + n); }
String what() { return "Stringed"; }
void adjust() { print("Adjusting Stringed"); }
}
class Brass extends Wind {
void play(Note n) { print("Brass.play() " + n); }
void adjust() { print("Adjusting Brass"); }
}
class Woodwind extends Wind {
void play(Note n) { print("Woodwind.play() " + n); }
String what() { return "Woodwind"; }
}
public class Music3 {
// Doesn't care about type, so new types
// added to the system still work right:
public static void tune(Instrument i) {
// ...
i.play(Note.MIDDLE_C);
}
public static void tuneAll(Instrument[] e) {
for(Instrument i : e)
tune(i);
}
public static void main(String[] args) {
// Upcasting during addition to the array:
Instrument[] orchestra = {
new Wind(),
new Percussion(),
new Stringed(),
new Brass(),
new Woodwind()
};
tuneAll(orchestra);
}
} /* Output:
Wind.play() MIDDLE_C
Percussion.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
Woodwind.play() MIDDLE_C
*/
在 main 方法中,我们将引用至于 orchestra 数组中,会自动转型到 Instrument。通过多态机制,tune() 方法完全忽略了它周围代码的全部变化,依旧运行正常。话句话说,多态使得程序员能够 “将改变的事物和未变的事物分离开来”。
2.4 多态的适用范围
只有普通的方法调用可以是多态的,静态方法/域 不具有多态性。
-
域访问操作会由编译器解析,因此不是多态的
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(); // Upcast 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()); } } /* Output: * sup.field = 0, sup.getField() = 1 * sub.field = 1, sub.getField() = 1, sub.getSuperField() = 0 */
上面的例子中,Sub 包含两个 field(本身的 和 从父类Super 继承来的),如果需要得到父类的的 field,必须显式指明 Super.field。
-
静态方法的行为不具有多态性 -- 静态方法是与类,而非单个对象相关联的
class StaticSuper { public static String staticGet() { return "Base staticGet()"; } public String dynamicGet() { return "Base dynamicGet()"; } } class StaticSub extends StaticSuper { public static String staticGet() { return "Derived staticGet()"; } public String dynamicGet() { return "Derived dynamicGet()"; } } public class StaticPolymorphism { public static void main(String[] args) { StaticSuper sup = new StaticSub(); // Upcast System.out.println(sup.staticGet()); System.out.println(sup.dynamicGet()); } } /* Output: Base staticGet() Derived dynamicGet() */
3. 构造器和多态
构造器是个很特殊的方法,尽管构造器不具备多态性[1],但还是有必要理解构造器怎样通过多态[2]在复杂的层次结构中运作。
1.构造器实际上是 static 方法,隐式声明了 static,因此不支持多态。
2.指构造器内部调用的方法,而非构造器本身。
3.1 再再论构造器的调用顺序
关于构造器的调用顺序在第五章进行了简要说明(最基本),并在第七章再论(加入继承), 下一节就再结合 多态 来进一步补充,本节先来做一下回顾并讨论该顺序的意义所在。
Java 之路 (五) -- 初始化和清理(构造器与初始化、方法重载、this、垃圾回收器、枚举类型)
Java 之路 (七) -- 复用类(组合、继承、代理、向上转型、final、再谈初始化和类的加载)
给出以下例子:
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()
*/
从结果中我们可以看出这调用构造器遵循一下的顺序:
- 调用基类构造器
- 调用成员的初始化方法
- 调用导出类构造器的
这一过程会反复递归,首先构造根基类,然后是下一层导出类,等等,知道最底层的导出类。
3.1.1 基类的构造器总是在导出类的构造器中被调用
如标题所言,这么做的意义何在?
首先构造器担负着检查对象是否被正确构造的任务,同时导出类只能访问自己的成员,而不能访问基类的成员(基类成员通常为 private),这就导致了只有基类的构造器能够对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则不可能正确构造完整对象。
这也是为什么编译器强制每个导出类构造器都必须调用基类构造器的原因。
如果导出类没有明确指定调用基类构造器,就会自动调用默认构造器;如果不存在默认构造器,编译器会报错。
其次,当进行继承时,我们获取了基类的一切,并可以访问基类中 public 和 protected 的成员。这就意味着导出类中,必须假定基类的所有成员都有效。通常做法就是在构造器内部确保所要使用的成员都构建完毕,因此,唯一的办法就是首先调用基类构造器,这样在进入导出类构造器时,积累中可供我们访问的成员就都已被初始化。
3.1.2 清理顺序
如果某一子对象依赖于其他对象,销毁的顺序应该和初始化顺序相反
- 对于类的成员,意味着与声明的顺序相反,因为成员的初始化是按照声明的顺序进行的
- 对于基类,应该首先销毁其导出类,然后才是基类。这是因为导出类的清理可能会调用基类的某些方法,所以需要使基类中的构建仍起作用。
关于上述第2条,进行补充说明:
例子:假定父类 A 的清理方法为 clean(),用来清理 A 中的数据,然后子类 B 继承 A,由于继承的原因,需要重写 clean() 方法,用来清理 B 中的数据。此时,我们需要 B.clean() 方法中调用 super.clean()。这样在清理子类时,也会清理父类。反之,如果没有调用父类的 clean() 方法,父类的清理动作就不会 发生。
将上面的例子一般化:子类覆盖父类的某个方法后(比如为 method()),如果需要调用父类的 method() 方法,必须显式调用 super.method()。
method() -> 子类.method()
super.method() -> 父类.method()
3.2 构造器内部的多态方法
虽然前面关于调用顺序已经分析的很清楚了,但是加入多态之后,新的问题又产生了:如果一个构造器的内部 调用正在构建的对象的某个动态绑定方法,会发生什么情况?
由于动态绑定的调用在运行时才决定,因此对象无法知道它是属于方法所在的那个类,还是属于其导出类。如果要调用构造器内部的一个动态绑定方法,那么就要用到那个方法的被覆盖之后的定义。但是被覆盖的方法在对象被完全构造之前就会被调用,这就会造成一些错误。
对以上再进行补充:任何构造器内部,整个对象可能只是部分形成,我们只能保证基类对象已经进行初始化。如果构造器只是在构建对象过程中的一个步骤,并且该对象所属的类是从这个构造器所属的类导出的,那么导出部分在当前构造器被调用的时刻仍旧是没有被初始化的。然而,一个动态绑定的方法调用却会向外深入到继承层次结构内部,它可以调用导出类那里的方法。如果我们在构造器内部这样做,那么就可能会调用某个方法,而这个方法所操作的成员可能还未初始化,这肯定会招致灾难。
上面这段话读起来可能很拗口,举个例子:
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
*/
可以看到,RoundGlyph 覆盖了 draw() 方法,在 Glyph 的构造器中调用 RoundGlyph.draw() 方法时发生了错误:输出时 radius 的值不是默认初始值 1 ,而是 0。
Glyph 构造器调用 draw() 方法时,RoundGlyph 的成员还未进行初始化。
实际上一节的初始化顺序并不完整。初始化的实际过程是:
- 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
- 如前所述那样调用基类构造器。此时,调用被覆盖后的draw()方法(要在调用RoundGlyph构造器之前调用),由于步骤 1 的缘故,我们此时会发现 radius 的值为 0。
- 按照声明的顺序调用成员的初始化方法。
- 调用导出类的构造器
这样的好处就是所有东西至少初始化为零,而不是仅仅留作垃圾
因此,我们得出以下结论:构造器内唯一能够安全调用的方法是基类中的 final 方法(包含 private 方法,因为 private 属于 final 方法),这些方法无法被覆盖,也就不会出现上述问题。
4. 协变返回类型
协变返回类型指的是:导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型。
换个说法:导出类 覆盖(即重写) 基类 方法时,返回的类型可以是基类方法返回类型的子类。
举个例子:
class Grain {
public String toString() { return "Grain"; }
}
class Wheat extends Grain {
public String toString() { return "Wheat"; }
}
class Mill {
Grain process() { return new Grain(); }
}
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);
}
} /* Output:
Grain
Wheat
*/
5. 用继承进行设计
5.1 再论组合与继承
进行设计时,首选组合。组合可以动态选择类型(因此也就选择了行为);相反,继承再编译时就需要知道确切类型。
看一个例子:
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();
}
} /* Output:
HappyActor
SadActor
*/
设计的通用准则:用继承表达行为间的差异,用成员表达状态上的变化。
上例中,通过继承得到两个不同的类,用于表达 act() 方法的差异;而 Stage 通过组合是自己的状态发生变化。这种情况下,这种状态的改变也就产生了行为的改变。
5.2 纯继承与扩展
纯粹的"is-a"关系:基类和导出类具有相同的接口
- 图示:
- 也可以认为这是一种纯替代,即导出类可以完全代替基类,基类可以接收发送给导出类的任何消息。
- 我们只需从导出类向上转型,永远不要知道正在处理的对象的确切类型。
扩展的"is-like-a"关系:导出类有着和基类相同的基本接口,同时还具有由额外方法实现的其他特性
- 是更为有用且明智的方法
- 问题在于,当进行向上转型时,不能使用扩展部分(基类无法访问导出类的扩展部分)
5.3 向下转型与运行时类型识别
5.3.1 运行时类型识别
运行时类型识别(RTTI)指的是在运行期间对类型进行检查的行为。
Java 语言中,所有转型都会在运行期时对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个 ClassCastException。
RTTI 不仅仅包括转型处理。比如它提供一种方法,使我们在试图向下转型之前,查看索要处理的类型。
5.3.2 向下转型
由于向上转型会丢失具体的类型信息,所以希望通过向下转型(在继承层次中向下移动)重新获取类型信息。
通过例子来讲解向下转型的要点:
class Fruit {
void name(){
System.out.println("Fruit");
}
}
class Apple extends Fruit{
@Override
void name(){
System.out.println("Apple");
}
void color(String c){
System.out.println("This apple's color is " + c);
}
}
-
正确的向下转型:
先进行向上转型,然后再进行向下转型。此时会转型成功,可以调用子类的特殊方法。Fruit a = new Apple();//先向上转型 a.name(); Apple apple = (Apple)a;//再向下转型,不会出错(正确的) apple.name(); apple.color("red"); //输出: //Apple //Apple //This apple's color is red
-
不安全的向下转型:
不经过向上转型,直接向下转型。此时编译不会报错,但运行时会抛出ClassCastException 异常Fruit f = new Fruit(); Apple apple = (Apple)f;//此处异常
总结
这一张内容看起来比较多,实际上都是围绕多态展开。
多态是一种不能单独来看待的特性,相反它只能作为类关系”全景“的一部分,与其他特性协同工作。
另外,面向对象的编程思想还需打磨。
就这样吧,共勉。