Java继承相关知识以及新的理解

Java的继承

继承是OOP里最为基本的概念之一,在OO的世界里,万物皆可为对象,对象间的关系组织很大一部分就是靠继承来实现.子类继承父类构成了is-a关系,通过继承子类可以继承父类属性和方法,也可以在此基础上扩展自己的属性和方法,可以重写(override)或者重载(overload)父类方法.

继承的概念

继承在本职上是特殊-一般的关系,即常说的is-a关系。子类继承父类,表明子类是一种特殊的父类,并且具有父类所不具有的一些属性或方法。

继承中的初始化顺序

从类的结构上而言,其内部可以有如下四种常见形态:属性(包括类属性和实例属性)、方法(包括类方法和实例方法)、构造器初始化块(包括类的初始化块和实例的初始化块)。对于继承中的初始化顺序,又具体分为类的初始化和对象的初始化。

JVM中类的加载过程包括

  1. 分配内存保存类的信息
  2. 给类变量赋默认值
  3. 加载父类
  4. 设置父子关系
  5. 执行类初始化代码

类初始化

在JVM装载类的准备阶段,首先为类的所有类属性和类初始化块分配内存空间。并在类首次初始化阶段中为其进行初始化,类属性和类初始化块之间的定义时的顺序决定了其初始化的顺序。若类存在父类,则首先初始化父类的类属性和类初始化块,一直上溯到Object类最先执行。

对象初始化

在new创建对象时,首先对对象属性和初始化块分配内存,并执行默认初始化。如果存在父类,则先为父类对象属和初始化块先分配内存并执行初始化。

然后执行父类构造器中的初始化程序,接着才开始对子类的对象属性和初始化块执行初始化。

注:

  1. 在对象初始化阶段,属性和方法均针对子类可以从父类继承过来的属性和方法而言,一般而言,都是针对父类中非private而言的。因为private修饰的为父类所特有的,子类没有继承过来,当new子类时,无须为其分配空间并执行初始化。当然了,父类的构造器子类也是不继承过来的,但构造器另当别论。

  2. 类的初始化只执行一次,当对同一个类new多个对象时,类属性和类初始化块只初始化一次。

继承中的隐藏

隐藏含义:实际上存在,但是对外不可见(不可直接用对象运算符 . 操作)。

Java类具有三种访问控制符:private、protected和public,同时当不写这三个访问控制符时,表现为一种默认的访问控制状态(default)。因此,一共具有四种访问控制级别。

具体访问控制表现如下

  • private修饰的属性或方法为该类所特有,在任何其他类中都不能直接访问

  • default修饰的属性或方法具有包访问特性,同一个包中的其他类可以访问

  • protected修饰的属性或方法在同一个包中的其他类可以访问,同时对于不在同一个包中的子类中也可以访问

  • public修饰的属性或方法外部类中都可以直接访问

当子类继承父类,子类可以继承父类中具有访问控制权限的属性和方法(一般来说是非private修饰的),对于private修饰的父类所特有的属性和方法,子类是不继承过来的。

当子类需要改变继承过来的方法时,也就是常说的重写父类的方法。一旦重写后,父类的此方法对子类来说表现为隐藏。以后子类的对象调用此方法时,都是调用子类重写后的方法,但子类对象中想调用父类原来的此方法时,可以通过如下两种方式:

  1. 将子类对象类型强制转化为父类类型,进行调用

  2. 通过super调用

同样的,如果在子类中定义父类中相同名称的属性时,父类属性在子类中表现为隐藏。

继承中的this和super

构造器中的this表示当前正在初始化的对象引用,方法中的this表示当前正在调用此方法的对象引用(可能是子类的实例)。this具体用法表现在一下几个方面:

  1. 当具多个重载的构造器时,且一个构造器需要调用另外一个构造其,在其第一行使用this(param)形式调用,且只能在第一行

  2. 当对象中一个方法需要调用本对象中其他方法时(可能实际调用的是子类方法),使用this作为主调,也可以不写,实际上默认就是this作为主调

  3. 当对象属性和方法中的局部变量名称相同时,在该方法中需要显式的使用this作为主调,以表示对象的属性,若不存在此问题,可以不显式的写this。

其实,其牵涉到的一个问题就是变量的查找规则:先局部变量 => 当前类中定义的变量 => 其父类中定义的可以被子类继承的变量 => 父类的父类=>…

super表示调用父类中相应的属性和方法。在方法中,若需要调用父类的方法时,也一定要写在第一行.

继承与组合

从单纯的实现效果上看,继承和组合都能达到同样的目的。并且都是实现代码复用的有效方式。

但在一般性的概念层次中,两者具有较为明显的差别。

继承表现为一般——特殊的关系,子类是一个特殊的父类,是is-a的关系。父类具有所有子类的一般特性。

组合表现为整体——部分关系,即has-a关系。在组合中,通过将“部分”单独抽取出来,用其中这部分的功能来形成自己的类定义,并且在“整体”中复用这部分的代码.

但是继承将子类当做基类对象来统一处理流程,而组合不存在继承关系,因而不能统一处理.

这个类定义中,将部分定义为其中的一个属性,并通过get和set方法,以此可以调用“部分”类中的属性和方法。

继承对封装的破坏

以前使用继承的时候感觉很自然地按照类之间的层次关系来组织,但是在看<<Java编程的逻辑>>的时候,发现了继承的一些之前没注意的地方,直接放码.

继承是如何破坏封装

何为封装

封装是指将复杂的功能都在内部实现,对外提供接口,这个接口并不是Java语法中的接口(interface),可以理解成一种调用方式,外部对象只需要调用这个接口,就可以实现复杂的功能,而不用关心它是如何实现的.

继承破坏封装实例

基类代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Base {
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就是能够添加数字,具体是怎么添加的,不用关心.

子类代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Child extends Base {
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的值.

使用代码
1
2
3
4
5
public static void main(String[] args) {
Child c = new Child();
c.addAll(new int[]{1, 2, 3});
System.out.println(c.getSum());
}

“奇怪”的错误

使用addAll添加1,2,3,期望的输出是1+2+3=6,实际输出为12!为什么是12呢?查看代码不难看出,同一个数字被汇总了两次.子类的addAll方法首先调用了父类的addAll方法,而父类的addAll方法通过add方法添加,由于动态绑定,子类的add方法会执行,子类的add动作也会做汇总操作.

可以看出,如果子类不知道基类方法的实现细节,它就不能正确地进行扩展.知道了错误,现在我们修改子类实现,修改addAll方法为:

1
2
3
4
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
}

也就是说,addAll方法不再进行重复汇总.这次,程序就可以输出正确结果6了.

但是,基类Base决定修改addAll方法的实现,如下

1
2
3
4
5
6
7
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,这个方法的作用是将所有添加的数字清空,代码如下

1
2
3
4
5
6
public void clear() {
for (int i = 0; i < count; i++) {
arr[i] = 0;
}
count = 0;
}

基类添加一个方法不需要告诉子类,Child类不知道Base类添加了这么一个方法.但因为继承关系,Child类却自动拥有了这么一个方法.因此,Child类的使用者可能这么使用Child类

1
2
3
4
5
6
7
public static void main(String[] args) {
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方法,那么这里的clear调用的就是Base的clear方法,操作的是Base.arr和Base.count,它需要增加如下代码,重置其内部的sum值:

1
2
3
4
5
@Override
public void clear() {
super.clear();
this.sum = 0;
}

可以看出,父类不能随意増加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性。

总结一下:对于子类而言,通过继承实现是没有安全保障的,因为父类修改内部实现细节,它的功能就可能会被破坏;而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由。

组合和接口的结合使用

继承和组合都能实现代码复用,前面已经举例说明了继承是如何实现代码复用的,使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合.下面给一个组合实现代码复用的例子

重写子类使用组合实现复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Child {
private Base base;
private long sum;

public Child() {
base = new Base();
}

public void add(int number) {
base.add(number);
sum += number;
}

public void addAll(int[] numbers) {
base.addAll(numbers);
for (int i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
}

public long getSum() {
return sum;
}
}

可以看到,Child类不再继承Base,而是把Base当做内部一个变量,用Base类的实例base来调用Base的功能,这样复用了Base类的代码又不依赖Base的具体实现,但组合的问题是,子类对象不能当做基类对象来统一处理了.

组合和接口一起使用

定义接口

1
2
3
4
public interface IAdd {
void add(int number);
void addAll(int[] numbers);
}

Base类实现接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Base implements IAdd {
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);
}
}
}

Child类实现接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Child implements IAdd {

private Base base;
private long sum;

public Child() {
base = new Base();
}

public void add(int number) {
base.add(number);
sum += number;
}

public void addAll(int[] numbers) {
base.addAll(numbers);
for (int i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
}

public long getSum() {
return sum;
}
}

Base和Child都实现了IAdd接口,这样可以统一用接口声明的变量来统一处理,同时Child类中利用Base实例来复用功能而不是通过继承来复用,这样不用考虑Base内部功能具体实现的细节.

引用资料

数额什么的无所谓,主要是能让我真正知道哪篇文章真的对你有用(๑*◡*๑)