文章目录
- java 继承
- 3.1 根父类Object
- 3.2 方法重写
- 3.3 继承案例:图形类继承体系
- 3.4 继承的细节
- 3.4.1 构造方法
- 3.4.2 重名与静态绑定
- 3.4.3 重载和重写
- 3.4.4 父子类型转换
- 3.4.5 继承访问权限protected
- 3.4.6 可见性重写
- 3.4.7 防止继承final
- 3.5 继承是把双刃剑
- 3.5.1 继承破坏封装性
- 3.5.2 继承没有反映is-a关系
java 继承
本文为书籍《Java编程的逻辑》1和《剑指Java:核心原理与应用实践》2阅读笔记
我们知道,java
中有类和对象的概念,当我们研究类与类之间的关系时,会发现类与类之间有一种继承的关系。比如动物类Animal
和狗类Dog
,Animal
是父类,Dog
是子类。父类也叫基类,子类也叫派生类。父类、子类是相对的,一个类B
可能是类A
的子类,但又是类C
的父类。之所以叫继承,是因为子类继承了父类的属性和行为,父类有的属性和行为会继承给子类。但子类也可以增加子类特有的属性和行为,某些父类有的行为,子类的实现方式可能与父类也不完全一样。使用继承一方面可以复用代码,公共的属性和行为可以放到父类中,而子类只需要关注子类特有的就可以了;另一方面,不同子类的对象可以更为方便地被统一处理。
3.1 根父类Object
在Java
中,即使没有声明父类,也有一个隐含的父类,这个父类叫Object
。Object
没有定义属性,但定义了一些方法,如下图所示。
上图中定义了一些方法,Object
中的方法我们先学习toString()
方法,toString()
方法的目的是返回一个对象的文本描述,这个方法可以直接被所有类使用,toString
代码如下:
public String toString() {
return this.getClass().getName() + "@" + Integer.toHexString(this.hashCode());
}
现在有一个Point point对象,可以这样使用
toString`方法:
System.out.println(point.toString());
输出类似这样:com.ieening.Point@2e5c649
结合toString
和输出结果,我们知道,@
之前是类名,之后是该对象哈希值的十六进制表示。为什么这么设计呢?写类名是可以理解的,表示对象的类型,而写哈希值则是不得已的,因为Object
类并不知道具体对象的属性,不知道怎么用文本描述,但又需要区分不同对象,只能是写一个哈希值。
但子类是可以知道自己的属性和值的,显然Object
的toString
不适合子类,为解决该问题,java
允许子类重写父类继承的方法,以适合自己的实际和需求。所谓重写,就是定义和父类一样的方法,并重新实现。
3.2 方法重写
设计一个Point
类,并重写toString
方法。
class Point {
private int x;
private int y;
Point() {
this(0, 0);
}
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;
}
public double distance() {
return Math.sqrt(x * x + y * y);
}
@Override
public String toString() {
return "Point [x=" + x + ", y=" + y + "]";
}
}
toString
方法前面有一个@Override
,这表示toString
这个方法是重写的父类的方法,重写后的方法返回Point
的x
和y
坐标的值。重写后,将调用子类的实现。比如,如下代码的输出就变成了:Point [x=3, y=4]
。
public static void main(String[] args) {
Point point = new Point(3, 4);
System.out.println(point);
}
3.3 继承案例:图形类继承体系
接下来,我们以一些图形处理中的例子来进一步解释。先来看一些图形的例子,如下图所示:
这都是一些基本的图形,图形有直线、正方形、三角形、圆形等,图形有不同的颜色。接下来,我们定义以下类来说明关于继承的一些概念和使用:
- 父类
Shape
,表示图形。 - 类
Circle
,表示圆。 - 类
Line
,表示直线。 - 类
ArrowLine
,表示带箭头的直线。 - 图形管理者类
ShapeManager
,它负责管理画板上的所有图形对象并负责绘制。
1、Shape
package com.ieening.learninheritshape;
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
方法,代码如下所示。
package com.ieening.learninheritshape;
public class Circle extends Shape { // 注释1
// 中心点
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()); // 注释2、注释3
}
public double area() {
return Math.PI * r * r;
}
@Override
public String toString() {
return "Circle [center=" + center + ", r=" + r + "]";
}
public static void main(String[] args) {
Point center = new Point(3, 4);
// 创建圆,赋值给circle
Circle circle = new Circle(center, 2);
// 调用draw方法,会执行Circle的draw方法
circle.draw();
// 输出圆面积
System.out.println(circle.area());
}
}
程序输出:
12.566370614359172
draw circle at Point [x=3, y=4] with r 2.0, using color : black // 注释
- 注释1:
Java
使用extends
关键字表示继承关系,一个类最多只能有一个父类; - 注释2:子类不能直接访问父类的私有属性和方法。比如,在
Circle
中,不能直接访问Shape
的私有实例变量color
,而是调用共有方法getColor
; - 注释3:除了私有的外,子类继承了父类的其他属性和方法。比如,在
Circle
的draw
方法中,可以直接调用getColor
方法;
这里比较奇怪的是,color
是什么时候赋值的?在new
的过程中,父类的构造方法也会执行,且会优先于子类执行。在这个例子中,父类Shape
的默认构造方法会在子类Circle
的构造方法之前执行。
3、直线
线(Line
)继承自Shape
,但有两个点,以及一个获取长度的方法,并重写了draw
方法,代码如下所示。
package com.ieening.learninheritshape;
public class Line extends Shape {
private Point start;
public Point getStart() {
return start;
}
public void setStart(Point start) {
this.start = start;
}
private Point end;
public Point getEnd() {
return end;
}
public void setEnd(Point end) {
this.end = end;
}
public Line(Point start, Point end, String color) {
super(color);
this.start = start;
this.end = end;
}
public double length() {
return start.distance(end);
}
@Override
public void draw() {
System.out.println(
"draw line from " + start.toString() + " to " + end.toString() + ", using color " + super.getColor());
}
public static void main(String[] args) {
Line line = new Line(new Point(0, 0), new Point(1, 1), "red");
System.out.println(line.length());
line.draw();
}
}
运行结果:
1.0
draw line from Point [x=0, y=0] to Point [x=1, y=1], using color red
这里我们要说明的是super
这个关键字,super
用于指代父类,可用于调用父类构造方法,访问父类方法和变量。
- 在
Line
构造方法中,super(color)
表示调用父类的带color
参数的构造方法。调用父类构造方法时,super
必须放在第一行。 - 在
draw
方法中,super.getColor()
表示调用父类的getColor
方法,当然不写super.
也是可以的,因为这个方法子类没有同名的,没有歧义,当有歧义的时候,通过super.
可以明确表示调用父类的方法。 super
同样可以引用父类非私有的变量。可以看出,super
的使用与this
有点像,但super
和this
是不同的,this
引用一个对象,是实实在在存在的,可以作为函数参数,可以作为返回值,但super
只是一个关键字,不能作为参数和返回值,它只是用于告诉编译器访问父类的相关变量和方法。
4、带箭头直线
带箭头直线(ArrowLine
)继承自Line
,但多了两个属性,分别表示两端是否有箭头,也重写了draw
方法,代码如下所示:
package com.ieening.learninheritshape;
public class ArrowLine extends Line {
private boolean startArrow;
public boolean isStartArrow() {
return startArrow;
}
public void setStartArrow(boolean startArrow) {
this.startArrow = startArrow;
}
private boolean endArrow;
public boolean isEndArrow() {
return endArrow;
}
public void setEndArrow(boolean endArrow) {
this.endArrow = 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() {
if (startArrow) {
System.out.print("draw start arrow, ");
}
if (endArrow) {
System.out.print("draw end arrow");
}
super.draw();
}
public static void main(String[] args) {
ArrowLine arrowLine = new ArrowLine(new Point(0, 0), new Point(2, 2), "blue", true, true);
arrowLine.draw();
}
}
运行结果:
draw start arrow, draw end arrowdraw line from Point [x=0, y=0] to Point [x=2, y=2], using color blue
ArrowLine
继承自Line
,而Line
继承自Shape
, ArrowLine
的对象也有Shape
的属性和方法。注意draw
方法的最后一行,super.draw()
表示调用父类的draw()
方法,这时候不带super.
是不行的,因为当前的方法也叫draw()
。
5、图形管理器
使用继承的一个好处是可以统一处理不同子类型的对象。比如,我们来看一个图形管理者类,它负责管理画板上的所有图形对象并负责绘制,在绘制代码中,只需要将每个对象当作Shape
并调用draw
方法就可以了,系统会自动执行子类的draw
方法。代码如下所示。
package com.ieening.learninheritshape;
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();
}
}
public static void main(String[] args) {
ShapeManager shapeManager = new ShapeManager();
shapeManager.addShape(new Circle(new Point(4, 4), 3));
shapeManager.addShape(new Line(new Point(2, 3), new Point(3, 4), "green"));
shapeManager.addShape(new ArrowLine(new Point(1, 2), new Point(5, 5), "black", false, true));
shapeManager.draw();
}
}
运行结果如下:
draw circle at Point [x=4, y=4] with r 3.0, using color : black
draw line from Point [x=2, y=3] to Point [x=3, y=4], using color green
draw end arrowdraw line from Point [x=1, y=2] to Point [x=5, y=5], using color black
ShapeManager
使用一个数组保存所有的shape
,在draw
方法中调用每个shape
的draw
方法。ShapeManager
并不知道每个shape
具体的类型,也不关心,但可以调用到子类的draw
方法。如上面代码main
方法中,使用ShapeManager
的一个例子,新建了三个shape
,分别是一个圆、直线和带箭头的线,然后加到了shapeManager
中,然后调用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
本身的代码),经常不在一起,操作对象的代码往往只需要知道对象是某种父类型,也往往只需要知道它是某种父类型就可以了。可以说,多态和动态绑定是计算机程序的一种重要思维方式,使得操作对象的程序不需要关注对象的实际类型,从而可以统一处理不同对象,但又能实现每个对象的特有行为。
3.4 继承的细节
3.4.1 构造方法
我们已经知道,子类可以通过super
调用父类的构造方法,如果子类没有通过super
调用,则会自动调动父类的默认构造方法,那如果父类没有默认构造方法呢?如下所示:
package com.ieening;
public class Base {
private String member;
public Base(String member) {
this.member = member;
}
}
这个类只有一个带参数的构造方法,没有默认构造方法。这个时候,它的任何子类都必须在构造方法中通过super
调用Base
的带参数构造方法,如下所示,否则,Java
会提示编译错误:Implicit super constructor Base() is undefined for default constructor. Must define an explicit constructorJava(134217868)
。
另外需要注意的是,如果在父类构造方法中调用了可被重写的方法,则可能会出现意想不到的结果。我们来看个例子,下面是基类和子类代码:
package com.ieening;
public class Base {
private String member;
public Base(String member) {
this.member = member;
}
public Base() {
test();
}
public void test() {
}
}
/**
* Child
*/
class Child extends Base {
private int a = 123;
@Override
public void test() {
System.out.println(a);
}
public static void main(String[] args) {
Child child = new Child();
child.test();
}
}
运行结果:
0
123
构造方法调用了test
方法。子类Child`有一个实例变量`a`,初始赋值为`123`,重写了`test()
方法,输出a
的值。输出结果中第一次输出为0
,第二次输出为123
。第一行为什么是0
呢?第一次输出是在new
过程中输出的,在new
过程中,首先是初始化父类,父类构造方法调用test()
方法,test()
方法被子类重写了,就会调用子类的test()
方法,子类方法访问子类实例变量a
,而这个时候子类的实例变量的赋值语句和构造方法还没有执行,所以输出的是其默认值0
。
像这样,在父类构造方法中调用可被子类重写的方法,是一种很不好的实践,容易引起混淆,应该只调用private
的方法。
3.4.2 重名与静态绑定
子类可以重写父类非private
的方法,当调用的时候,会动态绑定,执行子类的方法。那实例变量、静态方法和静态变量呢?它们可以重名吗?如果重名,访问的是哪一个呢?重名是可以的,重名后实际上有两个变量或方法。private
变量和方法只能在类内访问,访问的也永远是当前类的,即:在子类中访问的是子类的;在父类中访问的是父类的,它们只是碰巧名字一样而已,没有任何关系。public
变量和方法,则要看如何访问它。在类内,访问的是当前类的,但子类可以通过super.
明确指定访问父类的。在类外,则要看访问变量的静态类型:静态类型是父类,则访问父类的变量和方法;静态类型是子类,则访问的是子类的变量和方法。
public class Base {
public static String s = "static_base";
public String m = "base";
public static void staticTest(){
System.out.println("base static: "+s);
}
}
base
类中定义了一个public
静态变量s
,一个public
实例变量m
,一个静态方法staticTest
。子类Child
中定义了和父类重名的变量和方法。对于一个子类对象,它就有了两份变量和方法,在子类内部访问的时候,访问的是子类的,或者说,子类变量和方法隐藏了父类对应的变量和方法。
public class Child extends Base {
public static String s = "child_base";
public String m = "child";
public static void staticTest(){
System.out.println("child static: "+s);
}
}
观察下面外部访问代码,创建了一个子类对象,然后将对象分别赋值给了子类引用变量c
和父类引用变量b
,然后通过b
和c
分别引用变量和方法。程序输出为:
public static void main(String[] args) {
Child c = new Child();
Base b = c;
System.out.println(b.s);
System.out.println(b.m);
b.staticTest();
System.out.println(c.s);
System.out.println(c.m);
c.staticTest();
}
运行结果如下:
static_base
base
base static: static_base
child_base
child
child static: child_base
当通过b
(静态类型Base
)访问时,访问的是Base
的变量和方法,当通过c
(静态类型Child
)访问时,访问的是Child
的变量和方法,这称之为静态绑定,即访问绑定到变量的静态类型。静态绑定在程序编译阶段即可决定,而动态绑定则要等到程序运行时。实例变量、静态变量、静态方法、private
方法,都是静态绑定的。
3.4.3 重载和重写
重载是指方法名称相同但参数签名不同(参数个数、类型或顺序不同),重写是指子类重写与父类相同参数签名的方法。对一个函数调用而言,可能有多个匹配的方法,有时候选择哪一个并不是那么明显。总体的规律就是:
- 当有多个重名函数的时候,在决定要调用哪个函数的过程中,首先是按照参数类型进行匹配的,换句话说,寻找在所有重载版本中最匹配的;
- 当没有最匹配时,看变量的动态类型,进行动态绑定;
来看个例子,这是基类代码:
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);
}
Child
和Base
都定义了sum
方法,这里调用的是哪个sum
方法呢?子类的sum
方法参数类型虽然不完全匹配但是是兼容的,父类的sum
方法参数类型是完全匹配的。程序输出为:
0
base_int_int
父类类型完全匹配的方法被调用了。如果父类代码改成下面这样呢?
public class Base {
public long sum(int a, long b){
System.out.println("base_int_long");
return a+b;
}
}
父类方法类型也不完全匹配了。程序输出为:
0
base_int_long
调用的还是父类的方法。父类和子类的两个方法的类型都不完全匹配,为什么调用父类的呢?因为父类的更匹配一些。现在修改一下子类代码,更改为:
public class Child extends Base {
public long sum(int a, long b){
System.out.println("child_int_long");
return a+b;
}
}
程序输出变为了:
0
child_int_long
终于调用了子类的方法。
3.4.4 父子类型转换
之前我们说过,子类型的对象可以赋值给父类型的引用变量,这叫向上转型,向上转型不会出现问题,那父类型的变量可以赋值给子类型的变量吗?或者说可以向下转型吗?语法上可以进行强制类型转换,但不一定能转换成功。我们以前面的例子来看:
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
值,表示变量引用的对象是不是该类或其子类的对象。
3.4.5 继承访问权限protected
变量和函数有public/private
修饰符,public
表示外部可以访问,private
表示只能内部使用,还有一种可见性介于中间的修饰符protected
,表示虽然不能被外部任意访问,但可被子类访问。另外,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
的一种常见场景。
3.4.6 可见性重写
重写方法时,一般并不会修改方法的可见性。但我们还是要说明一点,重写时,子类方法不能降低父类方法的可见性。不能降低是指,父类如果是public
,则子类也必须是public
,父类如果是protected
,子类可以是protected
,也可以是public
,即子类可以升级父类方法的可见性但不能降低。
为什么要这样规定呢?继承反映的是is-a
的关系,即子类对象也属于父类,子类必须支持父类所有对外的行为,将可见性降低就会减少子类对外的行为,从而破坏is-a
的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。
3.4.7 防止继承final
有的时候我们不希望父类方法被子类重写,有的时候甚至不希望类被继承,可以通过final
关键字实现。final
关键字可以修饰变量,而这是final
的另一种用法。一个Java
类,默认情况下都是可以被继承的,但加了final
关键字之后就不能被继承了,如下所示:
public final class Base {
//主体代码
}
一个非final
的类,其中的public/protected
实例方法默认情况下都是可以被重写的,但加了final
关键字后就不能被重写了,如下所示:
public class Base {
public final void test(){
System.out.println("不能被重写");
}
}
3.5 继承是把双刃剑
一方面继承是非常强大的;另一方面继承的破坏力也是很强的。继承为什么会有破坏力呢?主要是因为继承可能破坏封装,而封装可以说是程序设计的第一原则;另外,继承可能没有反映出is-a关系。
3.5.1 继承破坏封装性
什么是封装呢?封装就是隐藏实现细节,提供简化接口。使用者只需要关注怎么用,而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。函数是封装,类也是封装。通过封装,才能在更高的层次上考虑和解决问题。可以说,封装是程序设计的第一原则,没有封装,代码之间会到处存在着实现细节的依赖,则构建和维护复杂的程序是难以想象的。继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。子类和父类之间是细节依赖,子类扩展父类,仅仅知道父类能做什么是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随意修改,否则可能影响子类。更具体地说,子类需要知道父类的可重写方法之间的依赖关系。
3.5.2 继承没有反映is-a关系
继承关系是设计用来反映is-a
关系的,子类是父类的一种,子类对象也属于父类,父类的属性和行为也适用于子类。就像橙子是水果一样,水果有的属性和行为,橙子也必然都有。但现实中,设计完全符合is-a
关系的继承关系是困难的。比如,绝大部分鸟都会飞,可能就想给鸟类增加一个方法fly()
表示飞,但有一些鸟就不会飞,比如企鹅。在is-a
关系中,重写方法时,子类不应该改变父类预期的行为,但是这是没有办法约束的。还是以鸟为例,你可能给父类增加了fly()
方法,对企鹅,你可能想,企鹅不会飞,但可以走和游泳,就在企鹅的fly()
方法中,实现了有关走或游泳的逻辑。继承是应该被当作is-a
关系使用的,但是,Java
并没有办法约束,父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。但对于通过父类引用操作子类对象的程序而言,它是把对象当作父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,结果是什么呢?混乱。
马俊昌.Java编程的逻辑[M].北京:机械工业出版社,2018. ↩︎
尚硅谷教育.剑指Java:核心原理与应用实践[M].北京:电子工业出版社,2023. ↩︎