目录
组合
继承
委托
组合和继承的结合
确保正确的清理
名称隐藏
在组合和继承之间选择
protected关键字
向上转型
final关键字
final数据
final方法
final类
初始化及类的重载
本笔记参考自: 《On Java 中文版》
对面向对象的编程语言而言,复用意味着可以在新类中使用其他人已经构建和调试过的类,而不必从头开始编写它们。要在不污染原有代码的基础上使用新类,可以通过两种方式:
- 组合:在新类中创建现有类的对象;
- 继承:直接复制原有类的形式,然后向其中添加代码,不修改原有类。
组合
组合,意味着将对象引用放入到新类中。
class WaterSource {private String s;WaterSource() {System.out.println("一个WaterSource类的构造器");s = "构造器";}@Overridepublic String toString() {return s;}
}public class SprinklerSystem {private String value1, value2, value3, value4;private WaterSource source = new WaterSource();private int i;private float f;@Overridepublic 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 = "构造器";}@Overridepublic 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;}@Overridepublic 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()");}@Overridepublic 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 {@Overridepublic 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;}@Overridepublic 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("画一个圆");}@Overridevoid 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);}@Overridevoid 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("合并构造器");}@Overridepublic 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 {@Overridevoid 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;}@Overridepublic 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;}@Overridepublic 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 };@Overridepublic 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。② 然后基类构造器会被调用,重复子类构造器相同的过程。③ 基类构造器完毕后,子类的实例变量按文本顺序初始化。④ 执行子类构造器的剩余部分。