上一章,我们谈到了如何将现实中的概念映射为程序中的概念,我们谈了类以及类之间的组合,现实中的概念间还有一种非常重要的关系,就是分类。分类有个根,然后向下不断细化,形成一个层次分类体系。这种例子是非常多的:
1)在自然世界中,生物有动物和植物,动物有不同的科目,食肉动物、食草动物、杂食动物等,食肉动物有狼、狗、虎等,这些又分为不同的品种 。
2)打开电商网站,在显著位置一般都有分类列表,比如家用电器、服装,服装有女装、男装,男装有衬衫、牛仔裤等 …
计算机程序经常使用类之间的继承关系来表示对象之间的分类关系。在继承关系中,有父类和子类,比如动物类Animal和狗类Dog,Animal是父类,Dog是子类。父类也叫基类,子类也叫派生类,父类子类是相对的,一个类B可能是类A的子类,是类C的父类。
之所以叫继承,是因为子类继承了父类的属性和行为,父类有的属性和行为子类都有。但子类可以增加子类特有的属性和行为,某些父类有的行为,子类的实现方式可能与父类也不完全一样。
使用继承一方面可以复用代码,公共的属性和行为可以放到父类中,而子类只需要关注子类特有的就可以了;另一方面,不同子类的对象可以更为方便的被统一处理。
本问详细学习继承。我们先介绍继承的基本概念,然后详述继承的一些细节,理解了继承的用法之后,我们谈论继承的注意事项,解释为什么说继承是把双刃剑,以及如何正确地使用继承。
#一.基础概念
##1.根父类Object
在Java中,所有类都有一个父类,即使没有声明父类,也有一个隐含的父类,这个父类叫Object。Object没有定义属性,但定义了一些方法,如下图所示:
本节我们会介绍toString()方法,其他方法我们会在后续章节中逐步介绍。toString()方法的目的是返回一个对象的文本描述,这个方法可以直接被所有类使用。
比如说,对于我们之前介绍的Point类,可以这样使用toString方法:
Point p = new Point(2,3);
System.out.println(p.toString());
输出类似这样:
Point@76f9aa66
这是什么意思呢?@之前是类名,@之后的内容是什么呢?我们来看下toString的代码:
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
getClass().getName()返回当前对象的类名,hashCode()返回一个对象的哈希值,哈希我们会在后续章节中介绍,这里可以理解为是一个整数,这个整数默认情况下,通常是对象的内存地址值,Integer.toHexString(hashCode())返回这个哈希值的shiliu进制表示。
为什么要这么写呢?写类名是可以理解的,表示对象的类型,而写哈希值则是不得已的,因为Object类并不知道具体对象的属性,不知道怎么用文本描述,但又需要区分不同对象,只能是写一个哈希值。
但子类是知道自己的属性的,子类可以重写父类的方法,以反映自己的不同实现。所谓重写,就是定义和父类一样的方法,并重新实现。
##2.方法重写
上一节,我们介绍了一些图形处理类,其中有Point类,这次我们重写了toString()方法。代码如下(Point.class):
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
@Override
public String toString() {
return "(" + x + "," + y + ")";
}
}
toString方法前面有一个 @Override,这表示toString这个方法是重写的父类的方法(当然,也可以是父类的父类),重写后的方法返回Point的x和y坐标的值。重写后,将调用子类的实现。比如,如下代码的输出就变成了:(2,3)
Point p = new Point(2,3);
System.out.println(p.toString());
##3.图形类继承体系
接下来,我们以一些图形处理中的例子来进一步解释,先来看幅图:
这都是一些基本的图形,图形有线、正方形、三角形、圆形等,图形有不同的颜色。接下来,我们定义以下类来说明关于继承的一些概念:
- 父类Shape,表示图形。
- 类Circle,表示圆。
- 类Line,表示直线。
- 类ArrowLine,表示带箭头的直线,
1.图形
所有图形(Shape)都有一个表示颜色的属性,有一个表示绘制的方法,代码如下(Shape.class):
public class Shape {
private static final String DEFAULT_COLOR = "black";
private String color;
public Shape() {
this(DEFAULT_COLOR);
}
public Shape(String color) {
this.color = color;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public void draw(){
System.out.println("draw shape");
}
}
以上代码基本没什么可解释的,实例变量color表示颜色,draw方法表示绘制,我们不会写实际的绘制代码,主要是演示继承关系。
2.圆
圆(Circle)继承自Shape,但包括了额外的属性,中心点和半径,以及额外的方法area,用于计算面积,另外,重写了draw方法,代码如下:
public class Circle extends Shape {
//中心点
private Point center;
//半径
private double r;
public Circle(Point center, double r) {
this.center = center;
this.r = r;
}
@Override
public void draw() {
System.out.println("draw circle at "
+ center.toString() + " with r " + r
+ ", using color : " + getColor());
}
public double area() {
return Math.PI * r * r;
}
}
说明:
- Java使用extends关键字标明继承关系,一个类最多只能有一个父类;
2)子类不能直接访问父类的私有属性和方法,比如,在Circle中,不能直接访问shape的私有实例变量color;
3)除了私有的外,子类继承了父类的其他属性和方法,比如,在Circle的draw方法中,可以直接调用getColor()方法。
我们来验证一下。代码如下(chapter_4Activity.class):
Point center = new Point(2,3);
//创建圆,赋值给circle
Circle circle = new Circle(center,2);
//调用draw方法,会执行Circle的draw方法
circle.draw();
//输出圆面积
System.out.println(circle.area());
程序的输出为:
draw circle at (2,3) with r 2.0, using color : black
12.566370614359172
这里比较奇怪的是,color是什么时候赋值的?在new的过程中,父类的构造方法也会执行,且会优先于子类先执行。在这个例子中,父类Shape的默认构造方法会在子类Circle的构造方法之前执行。关于new过程的细节,我们会在后续章节进一步介绍。
3.直线
线(Line)继承自Shape,但有两个点,有一个获取长度的方法,另外,重写了draw方法,代码如下(Line.class):
public class Line extends Shape {
private Point start;
private Point end;
public Line(Point start, Point end, String color) {
super(color);
this.start = start;
this.end = end;
}
public double length() {
return start.distance(end);
}
public Point getStart() {
return start;
}
public Point getEnd() {
return end;
}
@Override
public void draw() {
System.out.println("draw line from "
+ start.toString() + " to " + end.toString()
+ ",using color " + super.getColor());
}
}
这里我们要说明的是super这个关键字,super用于指代父类,可用于调用父类构造方法,访问父类方法和变量。
1)在Line构造方法中,super(color)表示调用父类的带color参数的构造方法,调用父类构造方法时,super(…)必须放在第一行。
2)在draw方法中,super.getColor()表示调用父类的getColor方法,当然不写super.也是可以的,因为这个方法子类没有同名的,没有歧义,当有歧义的时候,通过super.可以明确表示调用父类的。
3)super同样可以引用父类非私有的变量。
可以看出,super的使用与this有点像,但super和this是不同的,this引用一个对象,是实实在在存在的,可以作为函数参数,可以作为返回值,但super只是一个关键字,不能作为参数和返回值,它只是用于告诉编译器访问父类的相关变量和方法。
4.带箭头直线
带箭头直线 (ArrowLine)继承自Line,但多了两个属性,分别表示两端是否有箭头,也重写了draw方法,代码如下(ArrowLine.class):
public class ArrowLine extends Line {
private boolean startArrow;
private boolean endArrow;
public ArrowLine(Point start, Point end, String color,
boolean startArrow, boolean endArrow) {
super(start, end, color);
this.startArrow = startArrow;
this.endArrow = endArrow;
}
@Override
public void draw() {
super.draw();
if(startArrow){
System.out.println("draw start arrow");
}
if(endArrow){
System.out.println("draw end arrow");
}
}
}
ArrowLine继承自Line,而Line继承自Shape,ArrowLine的对象也有Shape的属性和方法。
注意draw方法的第一行,super.draw()表示调用父类的draw()方法,这时候不带super.是不行的,因为当前的方法也叫draw()。
需要说明的是,这里ArrowLine继承了Line,也可以直接在类Line里加上属性,而不需要单独设计一个类ArrowLine,这里主要是演示继承的层次性。
5.图形管理器
使用继承的一个好处是可以统一处理不同子类型的对象。比如,我们来看一个图形管理者类,它负责管理画板上的所有图形对象并负责绘制,在绘制代码中,只需要将每个对象当做Shape并调用draw方法就可以了,系统会自动执行子类的draw方法。代码如下(ShapeManager.class):
public class ShapeManager {
private static final int MAX_NUM = 100;
private Shape[] shapes = new Shape[MAX_NUM];
private int shapeNum = 0;
public void addShape(Shape shape) {
if (shapeNum < MAX_NUM) {
shapes[shapeNum++] = shape;
}
}
public void draw() {
for (int i = 0; i < shapeNum; i++) {
shapes[i].draw();
}
}
}
ShapeManager使用一个数组保存所有的shape,在draw方法中调用每个shape的draw方法。ShapeManager并不知道每个shape具体的类型,也不关心,但可以调用到子类的draw方法。
我们来看下使用ShapeManager的一个例子。代码如下(chapter_4Activity.class):
ShapeManager manager = new ShapeManager();
manager.addShape(new Circle(new Point(4,4),3));
manager.addShape(new Line(new Point(2,3),new Point(3,4),"green"));
manager.addShape(new ArrowLine(new Point(1,2),new Point(5,5),"black",false,true));
manager.draw();
新建了三个shape,分别是一个圆、直线和带箭头的线,然后加到了shape manager中,然后调用manager的draw方法。
需要说明的是,在addShape方法中,参数Shape shape,声明的类型是Shape,而实际的类型则分别是Circle,Line和ArrowLine。子类对象赋值给父类引用变量,这叫向上转型,转型就是转换类型,向上转型就是转换为父类类型。
变量shape可以引用任何Shape子类类型的对象,这叫多态,即一种类型的变量,可引用多种实际类型对象。这样,对于变量shape,它就有两个类型,类型Shape,我们称之为shape的静态类型,类型Circle/Line/ArrowLine,我们称之为shape的动态类型。在ShapeManager的draw方法中,shapes[i].draw()调用的是其对应动态类型的draw方法,这称之为方法的动态绑定。
为什么要有多态和动态绑定呢?创建对象的代码 (ShapeManager以外的代码)和操作对象的代码(ShapeManager本身的代码),经常不在一起,操作对象的代码往往只知道对象是某种父类型,也往往只需要知道它是某种父类型就可以了。
可以说,多态和动态绑定是计算机程序的一种重要思维方式,使得操作对象的程序不需要关注对象的实际类型,从而可以统一处理不同对象,但又能实现每个对象的特有行为。
##4.小结
上面我们学习了继承和多态的基本概念。
1)每个类有且只有一个父类,没有声明父类的其父类为Object,子类继承了父类非private的属性和方法,可以增加自己的属性和方法,可以重写父类的方法实现。
2)new过程中,父类先进行初始化,可通过super调用父类相应的构造方法,没有使用super的话,调用父类的默认构造方法。
3)子类变量和方法与父类重名的情况下,可通过super强制访问父类的变量和方法。
4)子类对象可以赋值给父类引用变量,这叫多态,实际执行调用的是子类实现,这叫动态绑定。
#二.继承的细节
##1.构造方法
前面我们说过,子类可以通过super调用父类的构造方法,如果子类没有通过super调用,则会自动调动父类的默认构造方法,那如果父类没有默认构造方法呢?如下所示:
public class Base {
private String member;
public Base(String member){
this.member = member;
}
}
这个类只有一个带参数的构造方法,没有默认构造方法。这个时候,它的任何子类都必须在构造方法中通过super调用Base的带参数构造方法,如下所示,否则,Java会提示编译错误。
public class Child extends Base {
public Child(String member) {
super(member);
}
}
另外需要注意的是,如果在父类构造方法中调用了可被重写的方法,则可能会出现意想不到的结果,我们来看个例子,下面是基类代码(Base.class):
public class Base {
public Base(){
test();
}
public void test(){
}
}
构造方法调用了test()。这是子类代码(Child.class):
public class Child extends Base {
private int a = 123;
public Child(){
}
public void test(){
Log.e(TAG, "构造方法............" + a);
}
}
子类有一个实例变量a,初始赋值为123,重写了test方法,输出a的值。看下使用的代码(chapter_4Activity.class):
Child c = new Child();
c.test();
输出结果是:
构造方法............0
构造方法............123
第一次输出为0,第二次为123。第一行为什么是0呢?第一次输出是在new过程中输出的,在new过程中,首先是初始化父类,父类构造方法调用test(),test被子类重写了,就会调用子类的test()方法,子类方法访问子类实例变量a,而这个时候子类的实例变量的赋值语句和构造方法还没有执行,所以输出的是其默认值0。
像这样,在父类构造方法中调用可被子类重写的方法,是一种不好的实践,容易引起混淆,应该只调用private的方法。
##2.重名与静态绑定
前面我们说到,子类可以重写父类非private的方法,当调用的时候,会动态绑定,执行子类的方法。那实例变量、静态方法、和静态变量呢?它们可以重名吗?如果重名,访问的是哪一个呢?
重名是可以的,重名后实际上有两个变量或方法。对于private变量和方法,它们只能在类内被访问,访问的也永远是当前类的,即在子类中,访问的是子类的,在父类中,访问的父类的,它们只是碰巧名字一样而已,没有任何关系。
但对于public变量和方法,则要看如何访问它,在类内访问的是当前类的,但子类可以通过super.明确指定访问父类的。在类外,则要看访问变量的静态类型,静态类型是父类,则访问父类的变量和方法,静态类型是子类,则访问的是子类的变量和方法。我们来看个例子:
这是基类代码(Base1.class):
public class Base1 {
private static final String TAG = "Base1";
public static String s = "static_base";
public String m = "base";
public static void staticTest() {
Log.e(TAG, "重名与静态绑定....."+"base static: " + s);
}
}
定义了一个public静态变量s、一个public实例变量m、一个静态方法staticTest。
这是子类代码(Child1.class):
public class Child1 extends Base1 {
private static final String TAG = "Base1";
public static String s = "child_base";
public String m = "child";
public static void staticTest(){
Log.e(TAG, "重名与静态绑定....."+"child static: " + s);
}
}
子类定义了和父类重名的变量和方法。对于一个子类对象,它就有了两份变量和方法,在子类内部访问的时候,访问的是子类的,或者说,子类变量和方法隐藏了父类对应的变量和方法,下面看一下外部访问的代码(chapter_4Activity.class):
Child1 c1 = new Child1();
Base1 b1 = c1;
Log.e(TAG, "重名与静态绑定....."+"外部调用: " + b1.s);
Log.e(TAG, "重名与静态绑定....."+"外部调用: : " + b1.m);
b1.staticTest();
Log.e(TAG, "重名与静态绑定....."+"外部调用: " + c1.s);
Log.e(TAG, "重名与静态绑定....."+"外部调用: : " + c1.m);
c1.staticTest();
以上代码创建了一个子类对象,然后将对象分别赋值给了子类引用变量c1和父类引用变量b1,然后通过b1和c1分别引用变量和方法。这里需要说明的是,静态变量和静态方法一般通过类名直接访问,但也可以通过类的对象访问。程序输出为:
E/chapter_4Activity: 重名与静态绑定.....外部调用: static_base
重名与静态绑定.....外部调用: : base
E/Base1: 重名与静态绑定.....base static: static_base
E/chapter_4Activity: 重名与静态绑定.....外部调用: child_base
重名与静态绑定.....外部调用: : child
E/Base1: 重名与静态绑定.....child static: child_base
当通过b1 (静态类型Base) 访问时,访问的是Base的变量和方法,当通过c1 (静态类型Child)访问时,访问的是Child的变量和方法,这称之为静态绑定,即访问绑定到变量的静态类型。静态绑定在程序编译阶段即可决定,而动态绑定则要等到程序运行时。实例变量、静态变量、静态方法、private方法,都是静态绑定的。
##2.重载和重写
重载是指方法名称相同但参数签名不同(参数个数或类型或顺序不同),重写是指子类重写父类相同参数签名的方法。对一个函数调用而言,可能有多个匹配的方法,有时候选择哪一个并不是那么明显,我们来看个例子:
这里基类代码:
public class Base {
public int sum(int a, int b){
System.out.println("base_int_int");
return a+b;
}
}
它定义了方法sum,下面是子类代码:
public class Child extends Base {
public long sum(long a, long b){
System.out.println("child_long_long");
return a+b;
}
}
以下是调用的代码:
public static void main(String[] args){
Child c = new Child();
int a = 2;
int b = 3;
c.sum(a, b);
}
这个调用的是哪个sum方法呢?每个sum方法都是兼容的,int类型可以自动转型为long,当只有一个方法的时候,那个方法就会被调用。但现在有多个方法可用,子类的sum方法参数类型虽然不完全匹配但是是兼容的,父类的sum方法参数类型是完全匹配的。程序输出为:
base_int_int
父类类型完全匹配的方法被调用了。如果父类代码改成下面这样呢?
public class Base {
public long sum(int a, long b){
System.out.println("base_int_long");
return a+b;
}
}
父类方法类型也不完全匹配了。程序输出为:
base_int_long
调用的还是父类的方法。父类和子类的两个方法的类型都不完全匹配,为什么调用父类的呢?因为父类的更匹配一些。现在修改一下子类代码,更改为:
public class Child extends Base {
public long sum(int a, long b){
System.out.println("child_int_long");
return a+b;
}
}
程序输出变为了:
child_int_long
终于调用了子类的方法。可以看出,当有多个重名函数的时候,在决定要调用哪个函数的过程中,首先是按照参数类型进行匹配的,换句话说,寻找在所有重载版本中最匹配的,然后才看变量的动态类型,进行动态绑定。
父子类型转换
之前我们说过,子类型的对象可以赋值给父类型的引用变量,这叫向上转型,那父类型的变量可以赋值给子类型的变量吗?或者说可以向下转型吗?语法上可以进行强制类型转换,但不一定能转换成功。我们以上面的例子来示例:
Base b = new Child();
Child c = (Child)b;
Child c = (Child)b就是将变量b的类型强制转换为Child并赋值为c,这是没有问题的,因为b的动态类型就是Child,但下面代码是不行的:
Base b = new Base();
Child c = (Child)b;
语法上Java不会报错,但运行时会抛出错误,错误为类型转换异常。
一个父类的变量,能不能转换为一个子类的变量,取决于这个父类变量的动态类型(即引用的对象类型)是不是这个子类或这个子类的子类。
给定一个父类的变量,能不能知道它到底是不是某个子类的对象,从而安全的进行类型转换呢?答案是可以,通过instanceof关键字,看下面代码:
public boolean canCast(Base b){
return b instanceof Child;
}
这个函数返回Base类型变量是否可以转换为Child类型,instanceof前面是变量,后面是类,返回值是boolean值,表示变量引用的对象是不是该类或其子类的对象。
protected
变量和函数有public/private修饰符,public表示外部可以访问,private表示只能内部使用,还有一种可见性介于中间的修饰符protected,表示虽然不能被外部任意访问,但可被子类访问。另外,在Java中,protected还表示可被同一个包中的其他类访问,不管其他类是不是该类的子类,后续章节我们再讨论包。
我们来看个例子,这是基类代码:
public class Base {
protected int currentStep;
protected void step1(){
}
protected void step2(){
}
public void action(){
this.currentStep = 1;
step1();
this.currentStep = 2;
step2();
}
}
action()表示对外提供的行为,内部有两个步骤step1()和step2(),使用currentStep变量表示当前进行到了哪个步骤,step1、step2和currentStep是protected的,子类一般不重写action,而只重写step1和step2,同时,子类可以直接访问currentStep查看进行到了哪一步。子类的代码是:
public class Child extends Base {
protected void step1(){
System.out.println("child step "
+this.currentStep);
}
protected void step2(){
System.out.println("child step "
+this.currentStep);
}
}
使用Child的代码是:
public static void main(String[] args){
Child c = new Child();
c.action();
}
输出为:
child step 1
child step 2
基类定义了表示对外行为的方法action,并定义了可以被子类重写的两个步骤step1和step2,以及被子类查看的变量currentStep,子类通过重写protected方法step1和step2来修改对外的行为。
这种思路和设计在设计模式中被称之为模板方法,action方法就是一个模板方法,它定义了实现的模板,而具体实现则由子类提供。模板方法在很多框架中有广泛的应用,这是使用protected的一个常用场景。关于更多设计模式的内容我们暂不介绍。
可见性重写
重写方法时,一般并不会修改方法的可见性。但我们还是要说明一点,重写时,子类方法不能降低父类方法的可见性,不能降低是指,父类如果是public,则子类也必须是public,父类如果是protected,子类可以是protected,也可以是public,即子类可以升级父类方法的可见性但不能降低。如下所示:
基类代码为:
public class Base {
protected void protect(){
}
public void open(){
}
}
子类代码为:
public class Child extends Base {
//以下是不允许的的,会有编译错误
// private void protect(){
// }
//以下是不允许的,会有编译错误
// protected void open(){
// }
public void protect(){
}
}
为什么要这样规定呢?继承反映的是"is-a"的关系,即子类对象也属于父类,子类必须支持父类所有对外的行为,将可见性降低就会减少子类对外的行为,从而破坏"is-a"的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。
防止继承 (final)
上节我们提到继承是把双刃剑,具体原因我们后续章节解说,带来的影响就是,有的时候我们不希望父类方法被子类重写,有的时候甚至不希望类被继承,实现这个的方法就是final关键字。之前我们提过final可以修饰变量,这是final的另一个用法。
一个Java类,默认情况下都是可以被继承的,但加了final关键字之后就不能被继承了,如下所示:
public final class Base {
//....
}
一个非final的类,其中的public/protected实例方法默认情况下都是可以被重写的,但加了final关键字后就不能被重写了,如下所示:
public class Base {
public final void test(){
System.out.println("不能被重写");
}
}
小结
本节我们讨论了Java继承概念引入的一些细节,有些细节可能平时遇到的比较少,但我们还是需要对它们有一个比较好的了解,包括构造方法的一些细节,变量和方法的重名,父子类型转换,protected,可见性重写,final等。
#三.继承是把双刃剑
继承其实是把双刃剑:一方面继承是非常强大的,另一方面是因为继承的破坏力也是很强的。
继承被广泛应用于各种Java API、框架和类库之中,一方面它们内部大量使用继承,另一方面,它们设计了良好的框架结构,提供了大量基类和基础公共代码。使用者可以使用继承,重写适当方法进行定制,就可以简单方便的实现强大的功能。
但,继承为什么会有破坏力呢?主要是因为继承可能破坏封装,而封装可以说是程序设计的第一原则 ;另一方面,继承可能没有反映出"is-a"关系。下面我们详细来说明。
1.继承破坏封装
什么是封装呢?封装就是隐藏实现细节,提供简化接口。使用者只需要关注怎么用,而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。函数是封装,类也是封装。通过封装,才能在更高的层次上考虑和解决问题。可以说,封装是程序设计的第一原则,没有封装,代码之间到处存在着实现细节的依赖,则构建和维护复杂的程序是难以想象的。
继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。我们通过一些例子来说明。这些例子主要用于演示,可以基本忽略其实际意义。
2.封装是如何被破坏的
我们来看一个简单的例子,这是基类代码:
public class BaseV1 {
private static final int MAX_NUM = 1000;
private int[] arr = new int[MAX_NUM];
private int count;
public void add(int number) {
if (count < MAX_NUM) {
arr[count++] = number;
}
}
public void addAll(int[] numbers) {
for (int num : numbers) {
add(num);
}
}
}
Base提供了两个方法add和addAll,将输入数字添加到内部数组中。对使用者来说,add和addAll就是能够添加数字,具体是怎么添加的,应该不用关心。
子类代码Child(ChildV1.class)如下:
public class ChildV1 extends BaseV1 {
private long sum;
@Override
public void add(int number) {
super.add(number);
sum+=number;
}
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
for(int i=0;i<numbers.length;i++){
sum+=numbers[i];
}
}
public long getSum() {
return sum;
}
}
子类重写了基类的add和addAll方法,在添加数字的同时汇总数字,存储数字的和到实例变量sum中,并提供了方法getSum获取sum的值。
使用Child的代码如下所示(chapter_4Activity.class):
ChildV1 cv1=new ChildV1();
cv1.addAll(new int[]{1,2,3});
Log.e(TAG, "继承是把双刃剑....... " + cv1.getSum());
使用addAll添加1,2,3,期望的输出是1+2+3=6,实际输出为12!为什么是12呢?查看代码不难看出,同一个数字被汇总了两次。子类的addAll方法首先调用了父类的addAll方法,而父类的addAll方法通过add方法添加,由于动态绑定,子类的add方法会执行,子类的add也会做汇总操作。
可以看出,如果子类不知道基类方法的实现细节,它就不能正确的进行扩展。知道了错误,现在我们修改子类实现,修改addAll方法为:
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
}
也就是说,addAll方法不再进行重复汇总。这下,程序就可以输出正确结果6了。
但是,基类Base决定修改addAll方法的实现,改为下面代码:
public void addAll(int[] numbers){
for(int num : numbers){
if(count<MAX_NUM){
arr[count++] = num;
}
}
}
也就是说,它不再通过调用add方法添加,这是Base类的实现细节。但是,修改了基类的内部细节后,上面使用子类的程序却错了,输出由正确值6变为了0。
从这个例子,可以看出,子类和父类之间是细节依赖,子类扩展父类,仅仅知道父类能做什么是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随意修改,否则可能影响子类。
更具体地说,子类需要知道父类的可重写方法之间的依赖关系,具体到上例中,就是add和addAll方法之间的关系,而且这个依赖关系,父类不能随意改变。
但即使这个依赖关系不变,封装还是可能被破坏。还是以上面的例子,我们先将addAll方法改回去,这次,我们在基类Base中添加一个方法clear,这个方法的作用是将所有添加的数字清空,代码如下:
public void clear(){
for(int i=0;i<count;i++){
arr[i]=0;
}
count = 0;
}
基类添加一个方法不需要告诉子类,Child类不知道Base类添加了这么一个方法,但因为继承关系,Child类却自动拥有了这么一个方法!因此,Child类的使用者可能会这么使用Child类:
Child c = new Child();
c.addAll(new int[]{1,2,3});
c.clear();
c.addAll(new int[]{1,2,3});
System.out.println(c.getSum());
先添加一次,之后调用clear清空,又添加一次,最后输出sum,期望结果是6,但实际输出呢?是12。为什么呢?因为Child没有重写clear方法,它需要增加如下代码,重置其内部的sum值:
@Override
public void clear() {
super.clear();
this.sum = 0;
}
可以看出,父类不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性。
总结一下:对于子类而言,通过继承实现,是没有安全保障的,父类修改内部实现细节,它的功能就可能会被破坏,而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由。
3.继承没有反映 is-a 关系
继承关系是被设计用来反映 is-a 关系的,子类是父类的一种,子类对象也属于父类,父类的属性和行为也一定适用于子类。就像橙子是水果一样,水果有的属性和行为,橙子也必然都有。
但现实中,设计完全符合 is-a 关系的继承关系是困难的。比如说,绝大部分鸟都会飞,可能就想给鸟类增加一个方法fly()表示飞,但有一些鸟就不会飞,比如说企鹅。
在is-a关系中,重写方法时,子类不应该改变父类预期的行为,但是这是没有办法约束的。比如说,还是以鸟为例,你可能给父类增加了fly()方法,对企鹅,你可能想,企鹅不会飞,但可以走和游泳,就在企鹅的fly()方法中,实现了有关走或游泳的逻辑。
继承是应该被当做 is-a关系使用的,但是,Java并没有办法约束,父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。
但通过父类引用操作子类对象的程序而言,它是把对象当做父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,结果是什么呢?混乱。
4.如何应对继承的双面性?
继承既强大又有破坏性,那怎么办呢?
1)避免使用继承
2)正确使用继承
我们先来看怎么避免继承,有三种方法:
- 使用final关键字
- 优先使用组合而非继承
- 使用接口
(1)使用final避免继承
在上节,我们提到过final类和final方法,final方法不能被重写,final类不能被继承,我们没有解释为什么需要它们。通过上面的介绍,我们就应该能够理解其中的一些原因了。
给方法加final修饰符,父类就保留了随意修改这个方法内部实现的自由,使用这个方法的程序也可以确保其行为是符合父类声明的。
给类加final修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心的使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。
(2)优先使用组合而非继承
使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该被优先使用。还是上面的例子,我们使用组合来重写一下子类,代码如下(ChildV2.class):
public class ChildV2 {
private BaseV1 mBaseV1;
private long sum;
public ChildV2() {
mBaseV1 = new BaseV1();
}
public void add(int number) {
mBaseV1.add(number);
sum += number;
}
public void addAll(int[] numbers) {
mBaseV1.addAll(numbers);
for (int i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
}
public long getSum() {
return sum;
}
}
这样,子类就不需要关注基类是如何实现的了,基类修改实现细节,增加公开方法,也不会影响到子类了。但组合的问题是,子类对象不能被当做基类对象,被统一处理了。解决方法是使用接口。
(3)正确使用继承
如果要使用继承,怎么正确使用呢?使用继承大概主要有三种场景:
1)基类是别人写的,我们写子类。
2)我们写基类,别人可能写子类。
3)基类、子类都是我们写的。
第1种场景中,基类主要是Java API,其他框架或类库中的类,在这种情况下,我们主要通过扩展基类,实现自定义行为,这种情况下需要注意的是:
- 重写方法不要改变预期的行为。
- 阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的调用关系。
- 在基类修改的情况下,阅读其修改说明,相应修改子类。
第2种场景中,我们写基类给别人用,在这种情况下,需要注意的是:
- 使用继承反映真正的 is-a 关系,只将真正公共的部分放到基类。
- 对不希望被重写的公开方法添加final修饰符。
- 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写。
在基类修改可能影响子类时,写修改说明。
第3种场景,我们既写基类、也写子类,关于基类,注意事项和第2种场景类似,关于子类,注意事项和第1种场景类似,不过程序都由我们控制,要求可以适当放松一些。
4.小结
上述我们介绍了继承为什么是把双刃剑,继承虽然强大,但继承可能破坏封装,而封装可以说是程序设计第一原则,继承还可能被误用,没有反映真正的is-a关系。
我们也介绍了如何应对继承的双面性,一方面是避免继承,使用final避免、优先使用组合、使用接口。如果要使用继承,我们也介绍了使用继承的三种场景下的注意事项。