图源:
类是OOP编程中的代码组织单元,无论是OOP的类还是面向过程的函数,其目的都是为了实现代码复用。
通过类实现代码复用的两大途径是:继承和组合。
组合,简单地说就是将一个类地实例以属性地方式存在于另一个类中。
package ch4.compose;
class Compose {
}
public class MyClass {
private Compose compose;
...
}
当然,这样地compose
仅仅是一个引用,要让它有用必须进行初始化。和普通类型的属性一样,对组合的对象初始化有多种方式。
-
声明的同时初始化:
package ch4.compose2;
class Compose {
}
public class MyClass {
private Compose compose = new Compose();
...
}
这样做的好处是,初始化行为是在所有构造函数调用之前,换句话说,即使存在多个重载的构造函数,compose
属性也能得到有效的初始化。
-
在构造函数中初始化:
package ch4.compose3;
class Compose {
}
public class MyClass {
private Compose compose;
public MyClass() {
compose = new Compose();
}
...
}
这也是一种常见方式。
-
由外部传入:
package ch4.compose4;
class Compose {
}
public class MyClass {
private Compose compose;
public MyClass(Compose compose) {
this.compose = compose;
}
...
}
可以通过构造函数或者其它方法传参的方式传入一个compose
的引用,在设计模式的相关实现中这种方式非常常见。
-
惰性初始化:
package ch4.compose5;
class Compose {
}
public class MyClass {
private Compose compose;
public MyClass() {
}
public Compose getCompose() {
if (compose == null) {
compose = new Compose();
}
return compose;
}
...
}
这种初始化方式的优点在于可以将比较消耗性能的对象初始化工作延后到真正需要对象的时候,这也是单例模式常用的方式。但需要注意的是,在单进程下这样做并没有什么问题,如果在编写并发程序的时候,要实现这种效果就要付出更大的努力(比如使用资源锁)。
继承
Java的继承语法与其它主流语言类似,不过因为Java采用单根继承,所以所有没有指定继承的类,实际上都是隐式继承自Object
,也就是说所有类都是Object
类的子类。
package ch4.inheritence;
class Parent{
}
public class Child extends Parent {
public static void main(String[] args) {
Child c = new Child();
if (c instanceof Object){
System.out.println("c is Object.");
}
if (c instanceof Parent){
System.out.println("c is Parent.");
}
if (c instanceof Child){
System.out.println("c is Child.");
}
// c is Object.
// c is Parent.
// c is Child.
}
}
可以使用instanceof
操作符判断某个对象是否为某个类的实例,很多语言中都有类似的用法。
通过上边的示例可以看到,通过继承,c
这个实例可以具备多种“身份”,所以通常会说继承是一种"is"关系。
super
如果存在继承关系,并且需要在当前类中使用父类的引用,可以使用super
关键字,该关键字通常用于在子类中调用父类的方法:
package ch4.inheritence2;
class Parent{
public void display(){
System.out.println("Parent is displayed.");
}
public void desdroy(){
System.out.println("parent is desdroyed.");
}
}
public class Child extends Parent {
public void display() {
super.display();
System.out.println("Child is desplayed.");
}
public void desdroy() {
System.out.println("Child is desdroyed.");
super.desdroy();
}
public static void main(String[] args) {
Child c = new Child();
c.display();
c.desdroy();
// Parent is displayed.
// Child is desplayed.
// Child is desdroyed.
// parent is desdroyed.
}
}
在重写(覆盖)父类方法的时候,的确需要考虑是否要使用super
调用父类同名方法,但需要注意的是,调用顺序很重要。比如像上面的例子,display
代表一种创建行为,所以子类的display
动作可能需要在父类的display
动作完成后才能进行,所以要先调用super.display()
。而destroy
方法代表一种销毁动作,是display
的反面,所以子类的destroy
动作需要在父类的destroy
动作执行前进行,这样才能确保某些关联关系在父类销毁前都被正确销毁。
和this
类似,super
还可以用于调用父类的构造函数:
package ch4.inheritence3;
import util.Fmt;
class Parent {
private int number;
public Parent(int number) {
this.number = number;
Fmt.printf("Parent(%d) is called.\n", number);
}
}
public class Child extends Parent {
public Child(int number) {
super(number);
Fmt.printf("Child(%d) is called.\n", number);
}
public static void main(String[] args) {
Child c = new Child(1);
// Parent(1) is called.
// Child(1) is called.
}
}
这种情况下super
同样存在着类似于this
的限制,即必须在子类构造函数的第一行使用,且单个构造函数内只能使用一次。
初始化基类
存在继承关系的类,其实例的构建过程是自内向外的,有点像是“洋葱结构”。
这里用学习OOP时常用于举例的Shape
、Rectangle
、Square
来说明。
Shape
是所有图形的基类,代表一种抽象的图形,Rectangle
是矩形,意味着有两条可能相等也可能不相等的边,以及二维坐标上的一个点(可以是中心点,也可以是某个角),Square
是正方形,是某种“特殊的矩形”,它的四条边是相等的。
package ch4.inheritence4;
class Pointer {
private int x;
private int y;
public Pointer(int x, int y) {
this.x = x;
this.y = y;
}
}
class Shape {
public Shape() {
System.out.println("Shape is build");
}
}
class Rectangle extends Shape {
private int xEdge;
private int yEdge;
private Pointer center;
public Rectangle(int xEdge, int yEdge, Pointer center) {
super();
this.xEdge = xEdge;
this.yEdge = yEdge;
this.center = center;
System.out.println("Rectangle is build");
}
}
public class Square extends Rectangle {
private int edge;
private Pointer center;
public Square(int edge, Pointer center) {
super(edge, edge, center);
this.edge = edge;
this.center = center;
System.out.println("Square is build");
}
public static void main(String[] args) {
Pointer center = new Pointer(0, 0);
int edge = 10;
Square s = new Square(edge, center);
// Shape is build
// Rectangle is build
// Square is build
}
}
可以看到,要创建子类实例,需要“由内向外”,先创建基类的实例,再创建父类的实例,最后再创建子类的实例。
需要说明的是,其实Rectangle
的构造方法中不需要显式调用super()
,因为其父类构造方法是一个不带参数的默认构造方法,这种情况下即使不显式调用super()
,Java编译器也会自动调用。但是显式调用父类构造方法依然是一个良好习惯,可以减少一些阅读代码时的歧义。
代理
实际上代理是一种概念,一种设计模式,关于它的详细说明可以见。
如果不那么严谨地定义代理,实际上当一个对象持有另一个对象时,某种程度上说,当前对象就可以充当所持有的对象的代理。换言之,我们可以通过组合来实现一种简单的代理关系:
package ch4.proxy;
class MyClass{
public void print(String str){
System.out.println("print() is called.");
System.out.println(str);
}
}
class MyClassProxy{
MyClass mc = new MyClass();
public void print(String str){
mc.print(str);
}
}
public class Main {
public static void main(String[] args) {
MyClassProxy mcp = new MyClassProxy();
mcp.print("hello");
// print() is called.
// hello
}
}
实际上,完整代理的核心实现也是通过组合的方式,只不过可能需要用到接口或抽象基类。
组合和继承
确保正确清理
既然创建实例时是“由内向外”的,是有依赖性的,自然的,销毁实例时也应当是“由外向内”的,只有这样才能确保销毁内部实例时不会依然存在外部实例对内部实例的引用关系。
这里依然用“图形家族”进行说明:
package ch4.inheritence5;
import util.Fmt;
class Pointer {
private int x;
private int y;
public Pointer(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return Fmt.sprintf("Pointer(%d,%d)", this.x, this.y);
}
public void destory() {
String thisStr = this.toString();
this.x = 0;
this.y = 0;
Fmt.printf("%s is destroy.\n", thisStr);
}
}
class Shape {
public Shape() {
System.out.println("Shape is build");
}
public void desdroy() {
System.out.println("Shape is destroy.");
}
}
class Rectangle extends Shape {
private int xEdge;
private int yEdge;
private Pointer center;
public Rectangle(int xEdge, int yEdge, Pointer center) {
super();
this.xEdge = xEdge;
this.yEdge = yEdge;
this.center = center;
Fmt.printf("%s is build.\n", this.getName());
}
private String getName() {
return Fmt.sprintf("Rectangle(xEdge:%s,yEdge:%s,center:%s)", this.xEdge, this.yEdge, this.center);
}
@Override
public void desdroy() {
String thisStr = this.getName();
this.xEdge = 0;
this.yEdge = 0;
this.center.destory();
Fmt.printf("%s is destory.\n", thisStr);
super.desdroy();
}
}
public class Square extends Rectangle {
private int edge;
private Pointer center;
public Square(int edge, Pointer center) {
super(edge, edge, center);
this.edge = edge;
this.center = center;
Fmt.printf("%s is build.\n", this.getName());
}
@Override
public void desdroy() {
String thisStr = this.getName();
this.edge = 0;
this.center.destory();
Fmt.printf("%s is destory.\n", thisStr);
super.desdroy();
}
private String getName() {
return Fmt.sprintf("Squre(edge:%d,center:%s)", this.edge, this.center);
}
public static void main(String[] args) {
Pointer center = new Pointer(5, 5);
int edge = 10;
Square s = new Square(edge, center);
s.desdroy();
// Shape is build
// Rectangle(xEdge:10,yEdge:10,center:Pointer(5,5)) is build.
// Squre(edge:10,center:Pointer(5,5)) is build.
// Pointer(5,5) is destroy.
// Squre(edge:10,center:Pointer(5,5)) is destory.
// Pointer(0,0) is destroy.
// Rectangle(xEdge:10,yEdge:10,center:Pointer(0,0)) is destory.
// Shape is destroy.
}
}
这个例子说明了如何通过destroy
方法“从外向内”将实例进行“注销”。
主要注意的是,在Square
和Rectangle
类中,我使用了一个私有的getName()
方法作为获取当前实例字符串形式的方法,而非是一般性的重写toString
方法,原因是后者是public
的,意味着重写后会被“多态调用”,也就是说输出的结果中并不会出现Rectangle(...) is build
的字样,只有Square(...) is build
。
此外,输出结果中Pointer(0,0) is destroy.
也很奇怪,这是因为虽然Sqaure
和Rectangle
的center
属性都是私有的,不存在继承关系,但实际上它们都是指向同一个Pointer
对象的引用,所以实际调用destory
方法的时候,是Square
的center
属性先被销毁,此时Pointer
对象就变成了Pointer(0,0)
,相当于Rectangle
中的center
实例被提前销毁了,这显然是一个bug。
像这种可能会同时被多个对象持有引用的对象,销毁时应当检查是否所有引用都已失效,只有在所有引用都失效的时候才能真正销毁:
package ch4.inheritence6;
import util.Fmt;
class Pointer {
private int x;
private int y;
private int referenceCounter = 0;
public Pointer(int x, int y) {
this.x = x;
this.y = y;
}
public void addReference() {
this.referenceCounter++;
}
@Override
public String toString() {
return Fmt.sprintf("Pointer(%d,%d)", this.x, this.y);
}
public void destory() {
referenceCounter--;
if (referenceCounter == 0) {
String thisStr = this.toString();
this.x = 0;
this.y = 0;
Fmt.printf("%s is destroy.\n", thisStr);
}
}
}
...
class Rectangle extends Shape {
private int xEdge;
private int yEdge;
private Pointer center;
public Rectangle(int xEdge, int yEdge, Pointer center) {
super();
this.xEdge = xEdge;
this.yEdge = yEdge;
this.center = center;
center.addReference();
Fmt.printf("%s is build.\n", this.getName());
}
...
}
public class Square extends Rectangle {
private int edge;
private Pointer center;
public Square(int edge, Pointer center) {
super(edge, edge, center);
this.edge = edge;
this.center = center;
center.addReference();
Fmt.printf("%s is build.\n", this.getName());
}
...
public static void main(String[] args) {
Pointer center = new Pointer(5, 5);
int edge = 10;
Square s = new Square(edge, center);
s.desdroy();
// Shape is build
// Rectangle(xEdge:10,yEdge:10,center:Pointer(5,5)) is build.
// Squre(edge:10,center:Pointer(5,5)) is build.
// Squre(edge:10,center:Pointer(5,5)) is destory.
// Pointer(5,5) is destroy.
// Rectangle(xEdge:10,yEdge:10,center:Pointer(5,5)) is destory.
// Shape is destroy.
}
}
当然,这里只是说明如何来正确清理会出现“多重引用”的对象,实际编码中往往会采用更简单的方式:
package ch4.inheritence7;
import util.Fmt;
...
class Rectangle extends Shape {
...
@Override
public void desdroy() {
String thisStr = this.getName();
this.xEdge = 0;
this.yEdge = 0;
this.center = new Pointer(0, 0);
Fmt.printf("%s is destory.\n", thisStr);
super.desdroy();
}
}
public class Square extends Rectangle {
...
@Override
public void desdroy() {
String thisStr = this.getName();
this.edge = 0;
this.center = new Pointer(0, 0);
Fmt.printf("%s is destory.\n", thisStr);
super.desdroy();
}
private String getName() {
return Fmt.sprintf("Squre(edge:%d,center:%s)", this.edge, this.center);
}
public static void main(String[] args) {
Pointer center = new Pointer(5, 5);
int edge = 10;
Square s = new Square(edge, center);
s.desdroy();
// Shape is build
// Rectangle(xEdge:10,yEdge:10,center:Pointer(5,5)) is build.
// Squre(edge:10,center:Pointer(5,5)) is build.
// Squre(edge:10,center:Pointer(5,5)) is destory.
// Rectangle(xEdge:10,yEdge:10,center:Pointer(5,5)) is destory.
// Shape is destroy.
}
}
只要将center
引用指向new Pointer(0,0)
或者null
,就相当于释放了对原来对象的引用,而原始的Pointer
对象也会在所有引用都失效后,由垃圾回收器来进行回收和清理。如果Pointer
的destroy
必须在清理时得到执行,可以通过在中提到的Cleaner
类来实现。
名称屏蔽
需要注意的是,对父类方法重写时,必须函数签名完全一致,否则不算是重写:
package ch4.override1;
import util.Fmt;
class Parent{
public void func(int i){
Fmt.printf("func(int %d) is called.\n", i);
}
public void func(char c){
Fmt.printf("func(char %s) is called.\n", c);
}
}
class Child extends Parent{
public void func(String str) {
Fmt.printf("func(String %s) is called.\n", str);
}
}
public class Main {
public static void main(String[] args) {
Child c = new Child();
c.func("123");
c.func(123);
c.func('a');
// func(String 123) is called.
// func(int 123) is called.
// func(char a) is called.
}
}
上面这个示例,Child
中的func
方法实际上并不是对父类func
方法的重写,只是添加了一种新的重载而已。
这在有些时候会带来一些潜在bug,比如说你打算重写一个方法,但实际上因为参数类型不同,只是进行了重载。
对此,Java提供一个@override
标签:
package ch4.override2;
import util.Fmt;
...
class Child extends Parent{
@Override
public void func(String str) {
Fmt.printf("func(String %s) is called.\n", str);
}
}
...
被@override
标签标注的方法,必须是父类方法的重写,否则的话不会通过编译检查。像上边这种情况就会编译报错。
要说明的是,这个标签并非必须,没有也是不影响程序正常执行的,但使用它可以避免重写方法时可能的bug。
组合or继承
组合和继承的取舍,即什么时候使用组合,什么时候使用继承,是一个相当深入OOP的问题,这往往需要开发者有一定的经验。
开发模式对此有一条准则:多使用组合,少使用继承。原因是虽然他们都有着不错的扩展性,但相比之下,组合更加灵活,而继承往往会受到“基类修改接口后所有的子类不得不做出相应修改”这种困境的影响。而且这种风险会随着继承层次增多后越来越麻烦,所以对于继承,开发模式的建议是不要使用超过3层的继承。
更多设计模式方面的内容推荐阅读《Head First设计模式》一书,或者我的系列博文。
protected
为了能修改基类的设计,应当尽可能将基类的属性设置为private
的。如果子类需要使用父类的private
属性,可以为其添加一个protected
权限的访问器和修改器:
package ch4.protected1;
import util.Fmt;
class Parent{
private int num;
protected int getNum() {
return num;
}
protected void setNum(int num) {
this.num = num;
}
}
class Child extends Parent{
public Child(int num) {
super();
super.setNum(num);
}
public void display(){
Fmt.printf("num:%d\n", super.getNum());
}
public void clean(){
super.setNum(0);
}
}
public class Main {
public static void main(String[] args) {
Child c = new Child(10);
c.display();
c.clean();
c.display();
// num:10
// num:0
}
}
当然,这个例子中看不出继承的必要性,仅作为protected
使用方式的一种说明。
向上转型
向上转型描述的是在某些情况下,一个导出类的对象可以被当做基类的对象看待。
之所以叫做“向上转型”,是因为在UML图中,通常会将基类放在上方,导出类位于下方,比如之前提到的形状相关类族,其继承关系可以用以下的UML图表示:
因为继承关系是is,所以向上转型是安全的,一个基类的对象是可以在合适的时候当做一个父类对象使用的:
package ch4.cast;
public class Main {
private static void testShape(Shape shape) {
shape.display();
shape.desdroy();
}
public static void main(String[] args) {
Pointer center = new Pointer(5, 5);
int edge = 10;
Square s = new Square(edge, center);
Shape[] shapes = new Shape[3];
shapes[0] = s;
shapes[1] = center;
shapes[2] = new Line(new Pointer(5, 5), new Pointer(1, 1));
for (Shape shape : shapes) {
testShape(shape);
}
}
}
这里我将Pointer
和新添加的Line
类也作为了Shape
的子类。
篇幅起见这里仅展示部分代码,完整代码见。
除了这种函数调用传参时的向上转型以外,还可以在任意位置“手动”进行转型:
package ch4.cast;
public class Main2 {
private static void testShape(Shape shape) {
shape.display();
shape.desdroy();
}
public static void main(String[] args) {
Pointer center = new Pointer(5, 5);
int edge = 10;
Shape s = new Square(edge, center);
testShape(s);
}
}
这里直接使用Shape
句柄来承接了一个子类型Square
的对象,这同样是可以的。
final
final
关键字类似于C/C++中的const
关键字,不过略有区别。
final数据
被声明为final
的数据,如果是基本数据,其内容不能更改,如果是对象,则不能指向新的引用:
package ch4.final1;
class MyInteger{
private int num;
public MyInteger(int num) {
this.setNum(num);
}
public int getNum() {
return num;
}
public void setNum(int num) {
this.num = num;
}
}
public class Main {
public static void main(String[] args) {
final int num = 123;
// num = 222;
final String msg = "hello";
// msg = "world";
final MyInteger mi = new MyInteger(10);
mi.setNum(20);
System.out.println(mi.getNum());
// 20
}
}
需要注意的是,虽然final String
和final int
看上去很相似,但实际上前者是对象,只不过String
对象比较特殊,创建后没法修改。
声明为final
的对象内容能否修改,取决于对象的具体设计(比如String
就不行),final
只会限制引用无法重新指向。
final
更常见的用途是与static
结合,创建“类常量”:
package ch4.final2;
class Colors {
public static final int RED = 1;
public static final int BLUE = 2;
public static final int YELLOW = 3;
public static final int BLACK = 4;
public static final int GREEN = 5;
public static boolean isLight(int code) {
switch (code) {
case BLUE:
case YELLOW:
case RED:
case GREEN:
return true;
default:
return false;
}
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Colors.isLight(Colors.GREEN));
}
}
这在JavaSE5之前很有用,但在有了enum
之后就不再那么有用了。
有些语言要求被const
或final
声明的类属性必须在声明的同时初始化,但Java并没有这种限制,只声明但没有初始化的final
属性可以被称作“空白final”,这样的“空白final”可以在稍后的任意时刻完成初始化,但必须保证在使用前被初始化过,否则就会产生一个编译错误。
package ch4.final3;
class MyClass {
private final int num;
public MyClass(int num) {
this.num = num;
}
}
public class Main {
public static void main(String[] args) {
MyClass mc = new MyClass(10);
}
}
类似的,也可以将方法的参数指定为final
,这样就无法在方法中进行更改:
package ch4.final4;
class MyClass {
...
public void passNum(final int num){
// num = 123;
}
}
...
实例中注释的部分无法通过编译。
final方法
被声明为final
的方法将无法被重写:
package ch4.final5;
class Parent {
public final void test() {
System.out.println("test() is called.");
}
}
class Child extends Parent {
// public void test() {
// }
}
public class Main {
public static void main(String[] args) {
}
}
final
和private
的方法都无法被父类重写,但它们有一些区别。从定义上讲,private
定义的方法完全对子类是不可见的,对子类而言就像是不存在,所以子类完全可以定义一个同样签名的方法,这是允许的:
package ch4.final6;
class Parent {
private void test() {
System.out.println("Parent.test() is called.");
}
}
class Child extends Parent {
private void test(){
System.out.println("Child.test() is called.");
}
public void callTest(){
this.test();
}
}
public class Main {
public static void main(String[] args) {
Child c = new Child();
c.callTest();
// Child.test() is called.
}
}
final
则不同,它仅仅用于声明方法本身不能被子类重写,当然,final
只有和public
或protected
结合使用才有意义,如果一个方法被声明为private final
,那么没有任何意义。
final
的主要用途是将一些“骨架”代码所在的方法设置为不能重写的,比如:
package ch4.final7;
class Controller {
public Controller() {
}
public final void request() {
this.preRequest();
this.doRequest();
this.afterRequest();
}
protected void preRequest() {
}
protected void afterRequest() {
}
protected void doRequest() {
}
}
class IndexController extends Controller {
@Override
protected void preRequest() {
super.preRequest();
//检查登录状态
}
@Override
protected void doRequest() {
super.doRequest();
//加载首页html
}
}
public class Main {
public static void main(String[] args) {
IndexController ic = new IndexController();
ic.request();
}
}
这里是一个简易的Web开发中常见的MVC框架中的Control
层代码,其中Controller
基类的request
就是一个包含了“骨架”代码的方法,在这个方法中调用了三个可以被子类继承的方法preRequest
、doRequest
、afterRequest
,这样子类就可以根据需要通过继承相应的方法实现在执行HTTP请求前、后执行一些必要操作,比如读写Cookie或者进行登录检查。
当然这里的示例相当简单,通常这些处理HTTP请求的方法还会传递一些包含HTTP请求信息的参数。
事实上上面这种做法在设计模式中被称为“模版方法模式”,关于这个设计模式的更多说明见。
final类
final
作用于类只有一个用途——让类不能被继承。
这样做或许并不常见,因为继承本身就是为了让程序设计更具“弹性”,换句话说让一个类变成final
的,无疑是自废武功。所以如果你的确打算这么做,要先经过谨慎思考。
对象初始化顺序
在之前简单提到过对象的初始化顺序:
-
加载类,并执行
static
类变量的初始化。 -
执行普通属性的初始化。
-
执行构造函数。
可以用以下示例验证:
package ch4.init;
import java.util.Random;
class MyClass{
private int num;
private static int snum;
private static Random random;
static{
random = new Random();
snum = random.nextInt(100);
System.out.println("static members inilize is executed.");
}
{
num = random.nextInt(100);
System.out.println("normal members inilize is executed.");
}
public MyClass(){
System.out.println("constructor is called.");
}
}
public class Main {
public static void main(String[] args) {
MyClass mc = new MyClass();
// static members inilize is executed.
// normal members inilize is executed.
// constructor is called.
}
}
如果涉及继承关系,这个初始化过程会更复杂一些:
package ch4.init2;
import java.util.Random;
...
class Child extends MyClass {
private int num;
private static int snum;
private static Random random;
static {
random = new Random();
snum = random.nextInt(100);
System.out.println("Child's static members inilize is executed.");
}
{
num = random.nextInt(100);
System.out.println("Child's normal members inilize is executed.");
}
public Child() {
super();
System.out.println("Child's constructor is called.");
}
}
public class Main {
public static void main(String[] args) {
Child mc = new Child();
// static members inilize is executed.
// Child's static members inilize is executed.
// normal members inilize is executed.
// constructor is called.
// Child's normal members inilize is executed.
// Child's constructor is called.
}
}
可以看到,同样是先加载类定义,不同的是如果有继承关系,需要将所有父类的定义都进行加载。加载完类定义后,需要初始化类变量,初始化过程是“从内向外”,因为子类的类变量可以基于父类的类变量进行定义。所以父类的类变量必须先初始化。然后是初始化父类的普通属性,并调用父类的构造函数。这样父类的实例就初始化完毕,然后再初始化子类的普通属性,并调用子类的构造函数。
文章评论