目录
组合
继承
委托
组合和继承的结合
确保正确的清理
名称隐藏
在组合和继承之间选择
protected关键字
向上转型
final关键字
final数据
final方法
final类
初始化及类的重载
本笔记参考自: 《On Java 中文版》
对面向对象的编程语言而言,复用意味着可以在新类中使用其他人已经构建和调试过的类,而不必从头开始编写它们。要在不污染原有代码的基础上使用新类,可以通过两种方式:
- 组合:在新类中创建现有类的对象;
- 继承:直接复制原有类的形式,然后向其中添加代码,不修改原有类。
组合
组合,意味着将对象引用放入到新类中。
class WaterSource {
private String s;
WaterSource() {
System.out.println("一个WaterSource类的构造器");
s = "构造器";
}
@Override
public String toString() {
return s;
}
}
public class SprinklerSystem {
private String value1, value2, value3, value4;
private WaterSource source = new WaterSource();
private int i;
private float f;
@Override
public String toString() {
return "value1 = " + value1 + ", " +
"value2 = " + value2 + ", " +
"value3 = " + value3 + ", " +
"value4 = " + value4 + "\n" +
"i = " + i + ", " +
"f = " + f + "\n" +
"source = " + source; // 为了将对象source转换为字符串,会调用toString()方法
}
public static void main(String[] args) {
SprinklerSystem sprinklers = new SprinklerSystem();
System.out.println(sprinklers);
}
}
程序执行的结果如下:
在上述定义的方法中,存在一个特殊的方法:toString()。每个非基本类型的对象都有一个toString()方法,这个方法会在一些特殊情况下(比如将对象转换为字符串的时候)被调用。
@Override被用在了toString()方法的修饰中,这种用法有助于检测问题,比如拼写错误。
一致,编译器会初始化类中的基本类型字段,将其全部置为0。但是对于引用而言,编译器并不会简单地为其创建默认对象,这会产生不必要的开销。因此,引用的初始化一般有4种方式:
- 在定义对象时进行初始化,这会使得它们在调用构造器前被初始化;
- 在类的构造器中进行初始化;
- 延迟初始化,即在对象被实际使用之前进行初始化,减少开销;
- 实例初始化。
4中初始化方式的使用例如下:
class Soap {
private String s;
Soap() {
System.out.println("Soap()的构造器");
s = "构造器";
}
@Override
public String toString() {
return s;
}
}
public class Bath {
private String s1 = "Hello", s2 = "World"; // 在定义时进行初始化
private String s3, s4;
private Soap castile;
private int i;
private float toy;
public Bath() {
System.out.println("在构造器Bath()中:");
s3 = "Begin"; // 在构造器中进行初始化
toy = 3.14f;
castile = new Soap();
}
{ // 实例初始化
i = 47;
}
@Override
public String toString() {
if (s4 == null) // 延迟初始化
s4 = "Java";
return "s1 = " + s1 + ", " +
"s2 = " + s2 + ", " +
"s3 = " + s3 + ", " +
"s4 = " + s4 + "\n" +
"i = " + i + ", " +
"toy = " + toy + "\n" +
"castile = " + castile;
}
public static void main(String[] args) {
Bath b = new Bath();
System.out.println(b);
}
}
程序运行的结果是:
若在对象初始化之前,向对象引用发送消息,就会得到一个运行时异常。
继承
事实上,当创建一个类时,继承总是在发生:若没有明确指定要继承的类,那么就会隐式继承Java的标准根类Object。
继承使用的是一种特殊的语法,要求在类主体的左花括号之前,使用extends关键字(后跟基类的名称)进行声明。这会使新类自动获得基类的所有字段和方法:
class Cleanser {
private String s = "Cleanser";
public void append(String a) {
s += a;
}
public void scrub() {
append("+scrub()");
}
@Override
public String toString() {
return s;
}
public static void main(String[] args) {
Cleanser x = new Cleanser();
x.scrub();
System.out.println(x);
}
}
public class Detergent extends Cleanser {
@Override
public void scrub() { // 修改方法
append("+Detergent.scrub()");
super.scrub(); // 调用当前方法的基类版本
}
public void foam() { // 添加新的方法
append("+foam()");
}
public static void main(String[] args) {
Detergent x = new Detergent();
x.scrub();
x.foam();
System.out.println(x);
System.out.println("\n调用基类:");
Cleanser.main(args);
}
}
程序运行的结果如下:
在上述的程序中,Cleanser和Detergent都有一个main()方法。每个类都可以有一个main(),方便后继的测试。但是,唯一能够运行的main()是在命令行中被调用的那一个。
Cleanser中的方法都是public的,因此在同一个包中的所有类都可以访问这些方法。这种做法就是考虑了继承:作为一般规则,将所有字段设为private,将所有方法设为public(或protected)。
最后,可以在子类中对基类中定义的方法进行修改,例如上述例子中的scrub()。
使用super关键字来指代当前类继承的基类(又称“超类”)。因此super.scrub()调用的是基类的scrub()方法。
初始化基类
基类和子类的关系并不简单。当创建了一个子类的时候,其中就会包含一个基类的子对象(和直接通过积累创建的对象是一样的)。但从外面看,基类的子对象被包裹在了子类的对象中。为了正确初始化基类的自对象,只能在子类构造器中调用基类构造器来执行初始化。Java会自动往子类构造器中插入基类构造器。
class Art {
Art() {
System.out.println("类Art的构造器");
}
}
class Drawing extends Art {
Drawing() {
System.out.println("Drawing的构造器");
}
}
public class Cartoon extends Drawing {
public Cartoon() {
System.out.println("Cartoon的构造器");
}
public static void main(String[] args) {
Cartoon x = new Cartoon();
}
}
程序运行的结果如下:
从上述程序可以总结出构造过程的顺序:即从基类开始,“向外”进行。因此基类会在子类构造器访问它之前被初始化。另外,即使子类没有构造器,编译器也会自动合成一个可以调用基类构造器的无参构造器。
---
带参数的构造器
若基类没有无参构造器,或者必须调用具有参数的构造器,则需要使用super关键字和其对应的参数列表,显式地调用基类构造器(否则会报错):
class Game {
Game(int i) {
System.out.println("含参的Game构造器,传入参数为:" + i);
}
}
class BoardGame extends Game {
BoardGame(int i) {
super(1);
System.out.println("含参的BoardGame构造器,传入参数为:" + i);
}
}
public class Chess extends BoardGame {
Chess() {
super(11);
System.out.println("Chess的构造器");
}
public static void main(String[] args) {
Chess x = new Chess();
}
}
程序运行的结果如下:
注意:对基类构造器的调用必须是子类构造器的第一个操作。
委托
尽管Java没有提供直接支持,但是Java中确实存在除组合和继承以外的第三种关系:委托。这种关系介于组合和继承之间,有两者的一些特点:
- 类似组合:将成员对象放在正在构建的类中;
- 类似继承:在新类中公开了成员对象的所有方法。
例如,现在有一个飞船控制模块:
public class SapceShipControls {
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 DerivedSpaceShip extends SapceShipControls {
private String name;
public DerivedSpaceShip(String name) {
this.name = name;
}
@Override
public String toString() {
return name;
}
public static void main(String[] args) {
DerivedSpaceShip protector = new DerivedSpaceShip("保护器");
protector.forward(100);
}
}
但是,这里存在着一个问题。当这种类的继承发生的时候,也就意味着基类的所有方法都会因为这个子类而被暴露给了外部。因此,就需要使用委托进行解决:
public class SpaceShipDelegation {
private String name;
private SapceShipControls controls = new SapceShipControls();
public SpaceShipDelegation(String name) {
this.name = name;
}
// 下面开始使用委托
public void up(int velocity) {
controls.up(velocity);
}
public void down(int velocity) {
controls.down(velocity);
}
public void left(int velocity) {
controls.left(velocity);
}
public void right(int velocity) {
controls.right(velocity);
}
public void forward(int velocity) {
controls.forward(velocity);
}
public void back(int velocity) {
controls.back(velocity);
}
public void turboBoost() {
controls.turboBoost();
}
public static void main(String[] args) {
SpaceShipDelegation protector = new SpaceShipDelegation("保护器");
protector.forward(100);
}
}
通过委托,方法调用会被转发到隐藏的controls对象,这样接口可以得到的就和使用继承得到的是相同的。当然,更好的委托控制的方式是仅提供部分的方法。
尽管Java本身不支持委托,但是开发工具通常支持。
组合和继承的结合
在实际的编程中,会将继承和组合同时进行使用。例如:
class Plate {
Plate(int i) {
System.out.println("Plate的构造器");
}
}
class DinnerPlate extends Plate {
DinnerPlate(int i) {
super(i); // 调用基类构造器
System.out.println("DinnerPlate的构造器");
}
}
class Custom {
Custom(int i) {
System.out.println("Custom的构造器");
}
}
public class PlaceSetting extends Custom {
private DinnerPlate pl;
public PlaceSetting(int i) {
super(i + 1);
pl = new DinnerPlate(i + 2);
System.out.println("PlaceSetting的构造器");
}
public static void main(String[] args) {
PlaceSetting x = new PlaceSetting(3);
}
}
程序执行的结果如下:
虽然编译器会强制要求对基类的初始化,但对于类的成员对象,编译器不会进行监督。
确保正确的清理
Java没有C++的析构函数,或许是因为通过垃圾收集器就可以在需要时回收内存。但在类的生命周期中,也可能存在一些需要清理的行为。因为不知道垃圾收集器合适被调用,所以为了清理某些东西,就必须在finally子句中设置清理活动。
class Shape {
Shape(int i) {
System.out.println("开始Shape的构造");
}
void dispose() {
System.out.println("进行Shape的清理工作");
}
}
class Circle extends Shape {
Circle(int i) {
super(i);
System.out.println("画一个圆");
}
@Override
void dispose() {
System.out.println("擦除圆");
super.dispose();
}
}
class Line extends Shape {
private int start, end;
Line(int start, int end) {
super(start);
this.start = start;
this.end = end;
System.out.println("画一条线:" + start + ", " + end);
}
@Override
void dispose() {
System.out.println("擦除线:" + start + ", " + end);
super.dispose();
}
}
public class CADSystem extends Shape {
private Circle c;
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);
System.out.println("合并构造器");
}
@Override
public void dispose() {
System.out.println();
System.out.println("开始总的清理工作");
// 清理的顺序和初始化的顺序相反
c.dispose();
for (int i = lines.length - 1; i >= 0; i--)
lines[i].dispose();
super.dispose();
}
public static void main(String[] args) {
CADSystem x = new CADSystem(47);
try {
// 代码及异常处理...
} finally {
x.dispose();
}
}
}
上述程序的运行结果是:
在上述程序中,每一个类都有自己的dispose()方法,用来将非内存相关的事物回复到原本的状态。
上述程序中出现了这样的结构:
try {
// ...
} finally {
// ...
}
其中,try关键字后面的代码块是应该保护区域,用来进行特殊处理。这种特殊处理之一,就是无论try以何种形式推出,保护区域后面的finally子句都会执行(这种设定允许以各种非常规的方式退出try代码块)。
除此之外,在调用清理方法时,应该注意调用顺序,防止子对象依赖另一个的情况出现。类的特定清理工作,顺序应该和创建顺序相反。
除了内存回收,其他情况并不建议依赖垃圾收集。
名称隐藏
若Java基类的方法名称被多次重载,那么子类中重新定义的该方法名称将不会隐藏任何基类版本。
class Homer {
char doh(char c) {
System.out.println("方法的形式是doh(char)");
return 'd';
}
float doh(float f) {
System.out.println("方法的形式是doh(float)");
return 1.0f;
}
}
class Milhouse {
}
class Bart extends Homer {
void doh(Milhouse m) {
System.out.println("方法的形式是doh(Milhouse)");
}
}
public class Hide {
public static void main(String[] args) {
Bart b = new Bart();
b.doh(1);
b.doh('x');
b.doh(1.0f);
b.doh(new Milhouse());
}
}
程序执行的结果如下:
上述程序中,Homer类所有重载的doh()方法都可以在Bart中被使用,并且Bart中也引入了一个新的重写方法。为了区分重写和重载,在重写同名方法时,应该使用与基类完全相同的签名和返回类型。
@Override可以帮助检测,分析一个方法是重载或是重写。例如,如果不小心进行了重载:
public class Lisa extends Homer {
@Override
void doh(Milhouse m) {
System.out.println("方法的形式是doh(Milhouse)");
}
}
尝试编译,发生报错:
在组合和继承之间选择
一般,可以参考下列意见进行选择:
- 使用组合时:往往希望在新类中使用现有类的功能,而不是其接口。对于新类中通过组合得到的成员,有时可以允许类的使用者直接访问它们(可将这种成员设为public)。因为成员对象的实现是隐藏的,所以这种做法也是安全的。
- 使用继承时:需要通过现有类生成一个其的特殊版本。也就是说,需要对通用类进行“定制”,使其满足特定需求。
除此之外,也可以这样说:继承表示的是“is-a”的关系,而组合表示的是“has-a”的关系。以汽车为例,汽车是(is)一种交通工具,因此使用“交通工具”来组合(has)一部“汽车”是无意义的。
继承的使用应该是谨慎的,因此只有在继承能够明显发挥作用时再使用它。确定使用继承或是组合,除了上述标准外,还可以通过判断是否需要从新类向上转型到基类来进行。
protected关键字
protected关键字,对于类的用户而言,这是private的,但对于继承该类的任何类或同一个包中的其他类而言,这是可用的(protected会提供包访问权限)。
虽然字段也可以是protected的,但是最好将字段设置为private(保证修改的权利),而将方法设置为protected,以此来控制继承者的访问权限。例如:
class Output {
private String name;
protected void set(String nm) {
name = nm;
}
Output(String name) {
this.name = name;
}
@Override
public String toString() {
return "这是Output函数,即将输出 " + name;
}
}
public class Orc extends Output {
private int orcNumber;
public Orc(String name, int orcNumber) {
super(name);
this.orcNumber = orcNumber;
}
public void change(String name, int orcNumber) {
set(name); // 可以使用protected的方法
this.orcNumber = orcNumber;
}
@Override
public String toString() {
return "Orc " + orcNumber + ": " + super.toString();
}
public static void main(String[] args) {
Orc orc = new Orc("Chicken", 12);
System.out.println(orc);
orc.change("Duck", 22);
System.out.println(orc);
}
}
程序运行的结果是:
向上转型
继承除了用来为新类提供方法,更重要的是表达了新类和基类之间的关系,这种关系具体可以概括为:新类是现有类的一种类型。例如:
class Instrument {
public void play() {
System.out.println("调用play方法");
}
static void tune(Instrument i) {
// ...
i.play();
}
}
public class Wind extends Instrument {
public static void main(String[] args) {
Wind flute = new Wind();
// Wind方法也是Instrument,它们有相同的接口
// 下方语句发生了向上转型
Instrument.tune(flute);
}
}
tune()方法接受一个Instrument引用。但是在上述程序中,它也接受了Wind对象,这是因为Wind对象也是一个Instrument对象,Wind拥有所有的Instrument接口。因此,tune()对Instrument及其的任何子类起作用。这种将子类引用转换为基类引用的行为就是向上转型:
也可以将子类称为基类的超集。
final关键字
final关键字一般表示“这是无法更改的”。之所以需要阻止更改,可能的原因有两个:设计或是效率。final关键字可以在三个地方进行使用:数据、方法和类。
final数据
在此之前需要先讨论常量,常量会有用,有两个原因:
- 这种数据可以是一个永远不会改变的编译时常量;
- 这种数据可以是在运行时初始化的值,并且程序员不希望数据被修改。
在Java中,常量必须是基本类型,并且使用final关键字表示,必须在初始化时为其提供一个值。
一个即是static又是final的字段只会分配一块不能改变的储存空间。
若一个非基本类型(对象引用)使用了final关键字,那么final会使得这一引用无法改变,但是对象本身是可以进行修改的(Java没有提供使对象恒定不变的方法)。
final字段的使用例:
import java.util.*;
class Value {
int i; // 拥有包访问权限的字段
Value(int i) {
this.i = i;
}
}
public class FinalData {
private static Random rand = new Random(47);
private String id;
public FinalData(String id) {
this.id = id;
}
// 编译时常量
private final int valueOne = 9;
private static final int VALUE_TWO = 99;
// 典型的公共常量
public static final int VALUE_THREE = 39;
// 不可作为编译时常量
private final int i4 = rand.nextInt(20);
static final int INT_5 = rand.nextInt(20);
private Value v1 = new Value(11);
private final Value v2 = new Value(11);
// 数组
private final int[] a = { 1, 2, 3, 4, 5, 6 };
@Override
public String toString() {
return id + ": " + "i4 = " + i4 + ", " + "INT_5 = " + INT_5;
}
public static void main(String[] args) {
FinalData fd1 = new FinalData("fd1");
// final修饰,无法改变值
// fd1.valueOne++;
// fd1.VALUE_TWO++;
// 非恒定不变的对象
fd1.v2.i++;
fd1.v1 = new Value(9);
for (int i = 0; i < fd1.a.length; i++)
fd1.a[i]++;
// 对象引用无法修改
// fd1.v2 = new Value(0);
// fd1.a = new int[3];
System.out.println(fd1);
System.out.println();
System.out.println("创建一个新的FinalData对象");
FinalData fd2 = new FinalData("fd2");
System.out.println(fd1);
System.out.println(fd2);
}
}
程序执行的结果如下:
在上述程序中,创建第二个对象并没有改变INT_5的值,因为这个数据是静态的,它只会在加载时初始化一次。
按照惯例,具有常量初始值的final static基本类型(编译时常量)全部使用大写字母命名,单词之间使用下划线分隔。
空白final
空白final,即没有初始值的final字段。编译器会确保这种空白final字段在使用前被初始化。这种做法可以让类中的final字段对每个对象而言都是不同的,同时保持其不可改变的特性:
class Poppet {
private int i;
Poppet(int i2) {
i = i2;
}
}
public class BlankFinal {
private final int i = 0; // 进行了初始化的final字段
private final int j; // 空白final字段
private final Poppet p; // 空白final引用
// 空白final必须在构造器中进行初始化
public BlankFinal() {
j = 1;
p = new Poppet(1);
}
public BlankFinal(int x) {
j = x;
p = new Poppet(x);
}
public static void main(String[] args) {
new BlankFinal();
new BlankFinal(3);
}
}
final的赋值操作只能发生在:
- 字段定义处;
- 构造器中。
---
final参数
在参数列表中也可以创建final参数。在方法内部无法改变final参数指向的内容,而只能进行参数的读取。
final方法
使用final方法有两个原因:
- 防止继承的类通过重写改变该方法的含义;
- 为了提高效率(但一般不推荐这样考虑)。
类中的任何一个private方法都是隐式的final。因为private方法即不可以访问,也不能被重写。但若尝试重写一个private方法,会发现编译器不会进行报错:
class WithFinal {
private final void f() { // final使用与否没有区别
System.out.println("类WithFinal的方法f()");
}
private void g() {
System.out.println("类WithFinal的方法g()");
}
}
class HavePrivate extends WithFinal {
private final void f() {
System.out.println("类HavePrivate的方法f()");
}
private void g() {
System.out.println("类HavePrivate的方法g()");
}
}
class OverridingPrivate extends HavePrivate {
public final void f() {
System.out.println("类OverridingPrivate的方法f()");
}
public void g() {
System.out.println("类OverridingPrivate的方法g()");
}
}
public class FinalOverridingIllusion {
public static void main(String[] args) {
OverridingPrivate op = new OverridingPrivate();
op.f();
op.g();
// 可以使用向上转型
HavePrivate hp = op;
// 但是hp的方法是不可被调用的
// hp.f();
// hp.g();
// 基类的方法也无法使用
WithFinal wf = op;
// wf.f();
// wf.g();
}
}
程序运行的结果如下:
注意:重写只有在方法是基类接口的一部分时才会发生。
若一个方法是private的,那么它就不是基类接口的一部分。它是隐藏在类中的代码,只是恰好具有相同的名称罢了。即使在子类中创建了具有相同名称的方法,这个方法也与基类中被隐藏的代码无关。
使用@Override可以产生有用的报错信息。
final类
将一个类定义为final时,会阻止该类的所有继承。这么做往往是不希望类的设计被修改,或者不允许该类存在子类。
class SmallBrain {
}
final class Dinosaur {
int i = 7;
int j = 1;
SmallBrain x = new SmallBrain();
void f() {
};
}
// class Further extends Dinosaur{} // 无法继承final类
public class Jurassic {
public static void main(String[] args) {
Dinosaur n = new Dinosaur(); // 可以创建final类的对象
n.f();
n.i++;
n.j++;
}
}
由于上述Dinosaur类的方法都是隐式的,所以无法被继承,也就无法进行重写了。
无论类是否被定义为final,相同的规则都会适用于字段的final定义。
初始化及类的重载
在Java中,每个类的编译代码都存在于自己的单独文件中,只有在需要使用时才会加载。一般认为“类的代码在第一次使用时才加载”(在构造类的第一个对象或访问静态成员时)。因为这种更加灵活的加载方式,Java变得更容易操作。在这里需要说明的是:
- 构造器是一个静态方法,当一个类的任何静态成员被访问时,都会触发其的加载。
- 静态初始化发生在初次使用时,所有静态对象和静态代码块在加载时按照文本顺序进行初始化。静态成员只初始化一次。
class Insect {
private int i = 0;
protected int j;
Insect() {
System.out.println("i = " + i + ", j = " + j);
j = 39;
}
private static int x1 = printInit("静态成员Insect.x1初始化完毕");
static int printInit(String s) {
System.out.println(s);
return 47;
}
}
public class Beetle extends Insect {
private int k = printInit("成员Beetle.k初始化完毕");
public Beetle() {
System.out.println("k = " + k);
System.out.println("j = " + j);
}
private static int x2 = printInit("静态成员Beetle.x2初始化完毕");
public static void main(String[] args) {
System.out.println("调用Beetle的构造器");
Beetle b = new Beetle();
}
}
程序执行的结果是:
从程序的输出可以看出加载器加载的规律:当运行java Beetle时,加载器会在Beetle.class中找到Beetle的编译代码。在编译过程中,加载器发现还有一个基类,就会先去加载这一基类。而若基类还有自己的基类,那么第二个基类也会被加载,以此类推。直到执行完根基类(上述程序中是Insect)的静态初始化,然后继加载根基类的子类。
这种加载方式的合理性在于,子类的静态初始化可能会依赖于基类成员的正确初始化。
而对象的创建规律是:① 子类Beetle中的所有基本类型先被设为默认值,对象置为null。② 然后基类构造器会被调用,重复子类构造器相同的过程。③ 基类构造器完毕后,子类的实例变量按文本顺序初始化。④ 执行子类构造器的剩余部分。