在面向对象的程序设计语言中,多态是继数据抽象、继承之后的第三种基本特征。
多态通过分离做什么和怎么做,从另一个角度将接口和实现分离开来。封装通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过将细节“私有化”把接口和实现分离开来。而多态的作用则是消除类型之间的耦合关系。继承允许将对象视为它自己本身的类型或其基类型来加以处理。这种能力极其重要,因为它允许将多种类型(从同一基类导出的)视为同一类型来处理,而同一份代码也就可以毫无差别的运行在这些不同类型之上。多态允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出而来的。
1. 再论向上转型
对象既可以作为它自己本身的类型使用,也可以作为它的基类型使用。而这种把对某个对象的引用视为对其基类型的引用的做法称为向上转型,因为在继承树的画法中,基类是放置在上方的。
例子:
public enum Note {
MIDDLE_C, C_SHARP, B_FLAT; // Etc.
}
class Instrument {
public void play(Note n) {
print("Instrument.play()");
}
}
public class Wind extends Instrument {
// Redefine interface method:
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 flute = new Wind();
tune(flute); // Upcasting
}
}
/* Output:
* Wind.play() MIDDLE_C
*/
Music.tune()方法接受一个Instrument引用,同时也接受任何导出自Instrument的类。在main方法中,当一个Wind引用传递到tune()方法时,就会出现这种情况,而不需要任何类型转换。这样做是允许的,因为Wind是从Instrument继承而来,所以Instrument的接口必定存在于Wind中。从Wind向上转型到Instrument可能会“缩小”接口,但不会比Instrument的全部接口更窄。
1.1 忘记对象类型
Music.java看起来似乎有些奇怪。为什么所有人都故意忘记对象的类型呢?在进行向上转型时,就会产生这种情况;并且如果让tune()方法直接接受一个Wind引用作为自己的参数,似乎会更为直观。但这样引发的一个重要问题是:如果那样做,就需要创建多个tune()方法来接受不同类型的参数。假设按这种推理,现在再加入Stringed(弦乐)和Brass(管乐)这两种Instrument(乐器):
class Stringed extends Instrument {
public void play(Note n) {
print("Stringed.play() " + n);
}
}
class Brass extends Instrument {
public void play(Note n) {
print("Brass.play() " + n);
}
}
public class Music2 {
public static void tune(Wind i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Stringed i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Brass 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); // No upcasting
tune(violin);
tune(frenchHorn);
}
} /* Output:
Wind.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
*/
这样做行得通,但是有一个主要缺点:必须为添加的每一个新Instrument类编写特定类型的方法,这意味着在开始时就需要更多的编程。
如果我们只写这样一个简单的方法,它仅接受基类作为参数,而不是那些特殊的导出类,这样做情况就会变得更好,也就是说,如果我们不管导出类的存在,编写的代码只是与基类打交道,是更好的方式。这也正是多态多允许的。
2. 转机
运行这个程序后,我们便会发现Music.java的难点所在。
public static void tune(Instrument i){
//...
i.play(Note.MIDDLE_C);
}
tune()方法它接受一个Instrument引用。那么在这种情况下,编译器怎么样才能知道这个Instrument引用指向的是Wind对象,而不是Brass对象或Stringed对象呢?实际上编译器无法得知。为了理解这么问题,有必要说明下有关绑定的问题。
2.1 方法调用绑定
将一个方法调用同一个方法主体关联起来被称为绑定。若在程序执行前进行绑定,叫作前期绑定。在运行时根据对象的类型进行绑定,叫作后期绑定(也叫作动态绑定或运行时绑定)。
Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。这意味着通常情况下,我们不必判定是否应该进行后期绑定,它会自动发生。
为什么要将某个方法声明为final呢?它可以防止其他人覆盖该方法,但更重要的一点或许是:这样做可以有效地“关闭”动态绑定,或者说,告诉编译器不需要对其进行动态绑定。
2.2 产生正确的行为
一旦知道Java中所有方法都是通过动态绑定实现多态这个事实之后,我们就可以编写只与基类打交道的代码了,并且这些代码对所有的导出类都可以正确运行。或者换一种说法,发送消息给某个对象,让该对象去断定应该做什么事。
面向对象程序设计中,有一个经典例子就是“几何形状”。这个例子中,有一个基类Sharp,以及多个导出类,如Circle、Square、Triangle等。
向上转型可以像下面这条语句这么简单:Sharp s = new Cicle();这里创建了一个Circle对象,并把得到的引用立即赋值给Sharp,这样做看似错误(将一种类型赋值给另一种类型);但实际上是没问题的,因为通过继承,Circle就是一种Shape。因此,编译器认可这条语句;
假设你调用一个基类方法(它已在导出类中被覆盖):
s.draw();
你可能再次认为调用的是Shape的draw();因为这比较是一个Sharp引用,那么编译器是怎么知道去做其他的事情呢?由于后期绑定(多态),还是正确调用了Circle.draw();
例子:
public class Shape {
public void draw() {}
public void erase() {}
}
public class Circle extends Shape {
public void draw() { print("Circle.draw()"); }
public void erase() { print("Circle.erase()"); }
}
public class Square extends Shape {
public void draw() { print("Square.draw()"); }
public void erase() { print("Square.erase()"); }
}
public class Triangle extends Shape {
public void draw() { print("Triangle.draw()"); }
public void erase() { print("Triangle.erase()"); }
}
public class RandomShapeGenerator {
private Random rand = new Random(47);
public Shape next() {
switch(rand.nextInt(3)) {
default:
case 0: return new Circle();
case 1: return new Square();
case 2: return new Triangle();
}
}
}
public class Shapes {
private static RandomShapeGenerator gen =
new RandomShapeGenerator();
public static void main(String[] args) {
Shape[] s = new Shape[9];
// Fill up the array with shapes:
for(int i = 0; i < s.length; i++)
s[i] = gen.next();
// Make polymorphic method calls:
for(Shape shp : s)
shp.draw();
}
} /* Output:
Triangle.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Circle.draw()
*/
Shape基类为自它那里继承而来的所有导出类建立一个公用的接口,也就是说,所有的形状都可以描绘和擦除。导出类通过覆盖这些定义,来为每种特殊的几何形状提供单独的行为。
RandomShapeGenerator 是一种“工厂”,在我们每次调用next()方法时,它可以为随机选择的Shape对象产生一个引用。请注意向上转型是在return语句里发生的。每个return语句取得一个指向某个Circle、Square、或者Triangle的引用,并将其以Shape类型从next()方法出去。所以无论我们在什么时候调用next()方法时,是绝对不可能知道具体类型到底是什么的,因为我们总是只能获得一个通用的Shape引用。
main()包含了一个Shape引用组成的数组,通过调用RandomShapeGenerator.next()来填入数据。此时,我们只知道自己拥有一些Shape,除此之外不会知道更具体的情况(编译器也不知道)。然而,当我们遍历这个数组,并为每个数组元素调用draw()方法时,与类型有关的特定行为会神奇般地正确发生,我们可以从运行该程序时所产生的输出结果中发现这一点。
随机选择几何形状是为了让大家理解:在编译时,编译器不需要获得任何特殊信息就能进行正确的调用。对draw()方法的所有调用都是通过动态绑定进行的。
2.3 可扩展性
现在,让我们返回到“乐器”(Instrument)示例。由于有多态机制,我们可根据自己的需求对系统添加任意多的新类型,而不需要更改tune()方法。在一个设计良好的OOP程序中,大多数或者所有的方法都会遵循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 缺陷“覆盖”私有方法
我们试图像下面这样做也是无可厚非的:
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()"); }
} /* Output:
private f()
*/
我们所期望的输出是public f(),但是由于private方法被自动认为是final方法,而且对导出类是屏蔽的。因此,在这种情况下,Derived类中的f()方法就是一个全新的方法。
结论:只有非public的方法才可以被覆盖。只有在导出类中是覆盖了基类的方法这种情况时,才会有所谓的基类引用调用指向的导出类的方法。
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对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super.field和Sub.field分配了不同的存储空间。这样Sub实际上包含两个称为field的域:它自己和它从super处得到的。然而,在引用Sub中的默认域并非Super版本的field域。因此,为了得到Super.field,必须显式的指明super.field。
尽管这看起来好像会成为一个容易令人混淆的问题,但是在实践中,它实际上从来不会发生。首先, 你通常会将所有的域都设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问。
如果某个方法是静态的,它的行为就不具备多态性:
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. 构造器和多态
通常,构造器不用于其他种类的方法。涉及到多态时仍是如此。尽管构造器并不具有多态性(它们实际上是static方法,只不过该static声明是隐式的),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作。
3.1 构造器的调用顺序
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以便每个基类的构造器都能得到调用。这样做是有意义的,因为构造器具有一项特殊任务:检查对象是否被正确的构造。导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。只有基类的构造器才具有恰当的知识和权限来对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。这正是编译器为什么要强制每个导出类部分都必须调用构造器的原因。在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,它就会“默默”调用默认构造器。如果不存在默认构造器,编译器就会报错(若某个类没有构造器,编译器会自动合成出一个默认构造器)。
例子:
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()
*/
在这个例子中,用其他类创建了一个复杂的类,而且每个类都有一个声明它自动构造器,其中最重要的类是Sandwich,它反映了三层继承(若将自Object的隐含继承也算在内,就是四层)以及三个成员对象。当在main里创建一个Sandwich对象后,就可以看到输出结果。这也表明了这一复杂对象调用构造器要遵照下面的顺序:
1. 调用基类构造器。这个步骤会不断的反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最低层的导出类。
2. 按声明顺序调用成员的初始化方法。
3. 调用导出类构造器的主体。
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
*/
Glyph.draw()方法设计为将要被覆盖,这种覆盖是在RoundGlyph中发生。但是Glyph构造器会调用这个方法,结果导致了对RoundGlyph.draw()的调用,这看起来似乎是我们的目的。但是如果看到输出结果,我们会发现当Glyph的构造器调用draw()方法时,radius不是默认初始值1,而是0。
前一节讲述的初始化顺序并不是很完整,初始化的实际顺序是:
1. 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
2. 如前所述那样调用基类构造器。此时,调用被覆盖后的draw()方法(要在调用RoundGlyph构造器之前调用),由于步骤1的缘故,我们此时会发现radius的值为0.
3. 按照声明的顺序调用成员的初始化方法。
4. 调用导出类的构造器主体。
这样做有一个优点,那就是所有东西都只是初始化成零(或者是某些特殊数据类型中与零等价的值),而不是仅仅留作垃圾。其中包括通过“组合”而嵌入一个类内部的对象引用,其值是null。所以,如果忘记为该引用进行初始化,就会在运行时出现异常。查看输出结果时,会发现其他所有东西的值都会是零,这通常也正是发现问题的证据。
因此,编写构造器时有一条有效的准则;“用尽可能简单的方法使对象进入正常状态,如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是基类中的final方法(也适用于private方法,它们自动属于final方法)。这些方法不能被覆盖,也就不会出现上面令人惊讶的结果。
4. 协变返回类型
在JavaSE5中添加了协变返回类型,它表示在导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型:
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
*/
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();
}
} /* Output:
HappyActor
SadActor
*/
在这里,Stage对象包含一个对 Actor的引用,而Actor被初始化为HappyActor对象。这意味着performPlay()会产生某种特殊行为。既然引用在运行时可以与另一个不同的对象重新绑定起来,所以,SadActor对象的引用可以在actor中被替代,然后由performPlay()产生的行为也随之改变。与此相反,我们不能在运行期间决定继承不同的对象,因为它要求在编译期间完全确定下来。
一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。在上述例子中,两者都用到了:通过继承得到了两个不同的类,用于表达act()方法的差异;而Stage通过运用组合使自己的状态发生变化。在这种情况下,这种状态的变化也就产生了行为的变化。
5.1 纯继承与扩展
采取“纯粹”的方式来创建继承层次结构似乎是做好的方式。也就是说,只有在基类中已经建立的方法才可以在导出类中被覆盖。如下图所示:
这被称作是纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了它应该是什么。继承可以确保所有的导出类具有基类的接口,且绝对不会少。按上图那么做,导出类也将具有和基类一样的接口。
也可以认为这是一种纯替代,因为导出类可以安全替代基类,而在使用它们时,完全不需要知道关于子类的任何额外信息。
也就是说,基类可以接收发送给导出类的任何消息,因为二者有着完全相同的接口。我们只需从导出类向上转型,永远不需知道正在处理的对象的确切类型。所有这一切,都是通过多态来处理的。
按这种方式考虑,似乎只有纯粹的is-a关系才是唯一明智的做法,而所有其他的设计都只会导致混乱和注定会失败。这其实也是一个陷阱,因为只要开始考虑,就会转向,并发现扩展接口(遗憾的是,extends关键字似乎在怂恿我们这么做)才是解决特定问题的完美方案。这可以称为“is-like-a”(像一个)关系,因为导出类就像是一个基类,它有着相同的基本接口,但是它还具有由额外方法实现的其他特性。
虽然这是一种有用且明智的方法(依赖于具体情况),但是它也有缺点。导出类中接口的扩展部分不能被基类访问,因此,一旦我们向上转型,就不能调用那些新方法。
在这种情况下,如果我们不进行向上转型,这样的问题也就不会出现。但是通常情况下,我们需要重新查明对象的确切类型,以便能够访问该类型所扩展的方法。
5.1 向下转型与运行时类型识别
由于向上转型(在继承层次中向上移动)会丢失具体的类型信息,所以我们就想,通过向下转型,也就是在继承层次中向下移动,应该能够获取类型信息。然而,我们知道,我们知道向上转型是安全的,因为基类不会具有大于导出类的接口。因此,我们通过基类接口发生的消息保证都能被接受。但是对于向下转型,例如,我们无法知道一个“几何形状”它确实就是一个“圆”,它可以是一个三角形、正方形或其他一些类型。
要解决这个问题,必须有某种方法来确保向下转型的正确性,使我们不至于贸然转型到一种错误的类型,进而发出该对象无法接受的消息。这样做是及其不安全的。
在Java中,所有转型都会得到检查!所以即使我们只是进行一次普通的加括弧形式的类型转换,在进入运行期时仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException(类转型异常)。这种在运行期间对类型进行检查的行为称为“运行时类型识别(RTTI)”:
例子:
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
}
}
正如前一个示意图中所示,MoreUseful(更有用的)接口扩展了Userful(有用的)接口;但是由于它是继承而来的,所以它也可以向上转型到Userful类型。我们在main()方法中对数组x进行初始化时可以看到这种情况的发生。既然数组中的两个对象都属于Userful类型,所以我们可以调用f()和g()这两个方法。如果我们试图调用u()方法(它只存在于MoreUseful),就会返回一条编译时出错信息。如果想访问MoreUseful对象的扩展接口,就可以尝试进行向下转型。如果所转类型是正确的类型,那么转型成功;否则,就会返回一个ClassCastException异常。