章七 继承和组合
前言
随着编程语言的发展,项目规模的日益增大,代码的复用愈加重要。程序语言始终朝着越来越抽象,越来越易用的方式发展;从汇编,到C语言,到OOP语言。在C语言时代,代码复制和函数是复用的主要途径,在OOP中,通过复用类,使得代码复用更加方便和自然。
本章讲述了三种复用类的方式:组合,继承和代理。虽然继承是众所周知的OOP三大特性之一且是另一大特性多态的基础,但在实际使用中,继承很容易被滥用,组合往往代表着更好的实现。Java并不直接支持代理,但可以通过组合来模拟代理。本章的主要内容即为组合和继承。
1. 组合语法
对于组合的语法,并没有什么好说的,因为创建类的域就是在不停的使用组合,只需将对象引用置于新的类中即可。下面举例说明对对象引用进行初始化的四种途径:
public class Bath {
private String s1 = "Happy",s2, s3, s4;// Way 1:Initializing at point of definition:
public Bath() {
s2 = "New"; // Way 2: Initializing in the constructor
}
{ s3= "Year"; } // Way 3: Instance initialization:
public String toString() {
if(s4 == null) // Way 4: Delayed initialization:
s4 = "Everybody";
return
"s1 = " + s1 + "\n" +
"s2 = " + s2 + "\n" +
"s3 = " + s3 + "\n" +
"s4 = " + s4 + "\n" +
}
}
如上述程序所示,可以在程序的四个位置初始化对象引用:
- 在定义对象的地方,则其可以在构造器调用之前被初始化
- 在构造器中初始化
- 实例初始化
- 惰性初始化,在实际使用时才初始化
2.继承语法
继承是所有OOP语言中不可缺少的组成部分。需要注意的是,与C++不同,Java存在标准根类Object。也就是说,在创建类时,总是使用了继承,要么是显示继承,要么是隐式继承Object。
继承通过使用关键字extends实现,通过继承,可以自动得到基类中所有的域和方法,Java不支持多继承。为了方便继承,一般的规则是将所有的域指定为private,将所有的方法指定为public或protected。在子类中,可以用super关键字调用父类的方法。此外,在每个类中都设置一个main()有助于类的调试。
初始化基类
继承并不只是复制基类的接口。当创建一个子类的对象时,其内含了一个父类的子对象。因此,需要通过调用父类构造器对父类子对象进行初始化。若在子类构造器中未显示调用基类构造器,则编译器会自动调用基类的默认构造器对父类部分进行初始化。若基类没有默认构造器,或者需要调用其他含参构造器进行初始化,需要用super关键字进行显式初始化。
class Father{
Father(int i){
System.out.println("Father Constructor");
}
}
class Son extends Father{
Son(int j){
super(j);
System.out.println("Son Constructor");
}
}
如上述代码所示,super()语句必须位于子类构造器中的第一句。
3.代理
Java中并不直接支持代理,但可以用组合实现代理的效果。代理一般用于当两个类不符合继承的关系,但是又想在一个类中取得对另一个类方法的直接使用权(一般只有子类可以),书中用SpaceShip做了一个很好的示例:
public class SpaceShipControls {
void up(int velocity) {}
void down(int velocity) {}
void left(int velocity) {}
void right(int velocity) {}
void forward(int velocity) {}
void back(int velocity) {}
void turboBoost() {}
} ///:~
public class SpaceShipDelegation {
private String name;
private SpaceShipControls controls =
new SpaceShipControls();
public SpaceShipDelegation(String name) {
this.name = name;
}
// Delegated methods:
public void back(int velocity) {
controls.back(velocity);
}
public void down(int velocity) {
controls.down(velocity);
}
public void forward(int velocity) {
controls.forward(velocity);
}
public void left(int velocity) {
controls.left(velocity);
}
public void right(int velocity) {
controls.right(velocity);
}
public void turboBoost() {
controls.turboBoost();
}
public void up(int velocity) {
controls.up(velocity);
}
public static void main(String[] args) {
SpaceShipDelegation protector =
new SpaceShipDelegation("NSEA Protector");
protector.forward(100);
}
} ///:~
如上述程序所示,通过使用代理,将组合使用出了继承的效果,取得了与成员完全一致的接口。另外,使用代理可以选择该成员的方法的一个子集,选择余地较直接继承大。
4. 组合使用继承和组合
这种应用场景很常见,需要注意的是构造顺序:首先执行父类初始化,然后执行对象引用在定义处的初始化(包括实例初始化),最后执行子类构造器。父类和引用对象成员存在引用对象和父类的时候,顺序同上,形成树状结构。
另一点需要注意的是,编译器只监督必须初始化基类,但并不监督对成员对象引用的初始化,可以会错误使用未经初始化的成员对象引用。
确保正确清理
如第五章所述,因为Java中没有析构函数,所以清理工作需要由程序员自己定义,这里需要注意两点。一是需要将清理语句放置于try-catch-finally语句中,以确保其能被正确执行。二是要确保清理与构建的顺序完全相反,如析构函数一样,在Java中,只能靠程序员本身提供这种保证,下面是示例程序。
public class CADSystem extends Shape {
private Circle c;
private Triangle t;
private Line[] lines = new Line[3];
public CADSystem(int i) {
super(i + 1);
for(int j = 0; j < lines.length; j++)
lines[j] = new Line(j, j*j);
c = new Circle(1);
t = new Triangle(1);
print("Combined constructor");
}
public void dispose() {
print("CADSystem.dispose()");
// The order of cleanup is the reverse
// of the order of initialization:
t.dispose();
c.dispose();
for(int i = lines.length - 1; i >= 0; i--)
lines[i].dispose();
super.dispose(); //dispose() executes exactly the opposite order of constructor
}
public static void main(String[] args) {
CADSystem x = new CADSystem(47);
try {
// Code and exception handling...
} finally {
x.dispose(); // The clean work should be put in the finally-sentence
}
}
}
名字屏蔽
若Java中的父类含有某个被多次重载的函数,在子类中重新定义该方法名称并不会屏蔽父类中的任何版本(这点与C++不同)。这样更符合习惯,但容易引起函数重载和函数覆盖之间的混淆,Java引入@Override
注解来解决这一问题。通过对需要覆写的函数加上该注解,若是不小心写成重载则会报错。
5. 在组合和继承之间选择
组合和继承都允许在新的类中放置子对象,组合是显式这样做,而继承则是隐式这样做。组合适用于想在新类中使用现有类的功能而非它的接口这种情形,可以通过加入现有类的一个private对象引用来实现。偶尔,会需要允许类的用户直接访问类中的组合成分,则需要将其成员对象设置为public。
继承则适用于开发现有类的一个特殊版本,表达is-a的关系。需要说明的是,Java中的继承相当于C++中的共有继承。根据Effective C++书中的观点,Java中的继承和组合才是正确的复用方式。
6.再论protected关键字
这里补充一下protected的一般使用方法:将父类中的域设置成private,再将对应的access routines设置为protected,从而方便子类访问和设置域,又实现了封装。
7. 向上转型
继承最重要的方面是表现新类和基类之间的关系:新类是现有类的一种类型。
向上转型名字的由来
在UML的类图中,子类位于父类的下方。将子类看做父类,在继承图上是向上移动的,故称为向上转型。因为向上转型是从一个专用类型向一个通用类型转化,所以很安全。考虑到子类是父类的超集,向上转型可能会丢失方法。向下转型将在下面的章节说明。
再论组合和继承
最清晰的判断是用组合还是继承的方法是:询问自己是否需要向上转型。在需要的情况下再考虑继承。
8. final关键字
final通常用以说明无需改变,出于两种理由:设计和效率。在三种情形下可能用到final:数据,方法和类。
final数据
final数据一种应用情形是编译期常量,该常量必须是基本数据类型,由final修饰,在定义时初始化。由于计算在编译期完成,会减少运行期开销。
对于基本类型,final使其数值恒定不变;对于对象引用,final使其引用恒定不变。Java中并未提供使对象本身恒定不变的技术(包括数组,数组也是对象)。
空白final是指被声明为final但又初始值的域,空白final必须在该类的所有构造器中被执行初始化。
**final参数**Java中可以通过把函数参数设置为final,使得无法在函数体中更改参数的值或其指向的对象
final方法
使用final方法的原因有两个:一个防止继承类覆写;二是提高效率。随着JVM技术提高,二的情况基本不复存在,主要看原因一。
final和private
所有的private方法都是隐式final的。因为private方法被完美隐藏,在新类中覆写private方法相对于添加新方法。
final类
final类表示不能被继承的类,表示对该类的设计不变,或者出于安全不希望其有子类。
最后,使用final需慎重。
9.初始化及类加载
在Java中,每个类的编译代码都存在于其独立的文件中,该文件只有当需要使用其程序代码时才会被加载。总而言之,类是在其任意static元素(static域,static方法,构造器)被访问时加载的。加载按照基类、成员和子类的顺序进行。其中,成员对象引用仅在其在定义时被初始化或被实例初始化时被加载。