第4章 类的继承
4.1 类、超类和子类
4.1.1 什么是继承,有什么用?
继承:在现实世界当中也是存在的,例如:父亲很有钱,儿子不用努力也很有钱。
继承的作用:
- 基本作用:子类继承父类,代码可以得到复用。
- 主要作用:因为有了继承关系,才有了后期的方法覆盖和多态机制。
4.1.2 继承的相关特性
- B类继承 A类,则称 A类为超类(superclass)、父类、基类,B类则称为子类(subclass)、派生类、扩展类。
- 虽然 Java 中不支持多继承,但有的时候会产生间接继承的效果,例如:A继承B,但是B又继承C,A与C间接继承。
- Java 中规定,子类继承父类,除构造方法和被 private 修饰的数据不能继承外,剩下都可以继承。
- Java 中的类没有显式的继承任何类,则默认继承 Object 类。Object 类是 java 语言提供的根类,也就是说,一个对象与生俱来就有 Object 类型中所有的特征。
- 继承也存在一些缺点,例如:CreditAccount 类继承 Account 类会导致它们之间的耦合度非常高,Account 类发生改变之后会马上影响到 CreditAccount 类。
4.1.3 覆盖方法
超类中的有些方法对子类并不一定适用。举例体来说,Manager类中getSalary方法应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖超类中的这个方法,乍看起来似乎很简单,只要返回salary和bonus字段的总和就可以了:
public class Manager extends Employee{
private double bonus;
public double getSalary(){
return salary + bonus;
}
}
不过这样做是不行的,只有Employee方法能直接访问Employee类的私有字段。这意味着,Manager类的getSalary方法不能直接访问salary字段。如果Manager类的方法想要访问那些私有字段,就要像所有方法一样使用公共接口,在这里就是要使用Employee类中的公共方法getSalary。
我们希望调用超类Employee中的getSalary方法,而不是当前类的这个方法。为此,可以使用特殊的关键字super解决这个问题:
public double getSalary(){
double baseSalary = super.getSalary();
return baseSalary + bonus;
}
4.1.4 方法覆盖的条件及注意事项
1.方法覆盖的条件
- 方法覆盖发生在具有继承关系的父子类之间,这是首要条件;
- 覆盖之后的方法与原方法具有相同的返回值类型、相同的方法名、相同的形式参数列表;
2.注意事项
- 私有的方法不能被继承,所以不能被覆盖;
- 构造方法不能被继承,所以也不能被覆盖;
- 覆盖之后的方法不能比原方法拥有更低的访问权限,可以更高;
- 覆盖之后的方法不能比原方法抛出更多的异常,可以相同或更少;
- 方法覆盖只是和方法有关,和属性无关;
- 静态方法不存在覆盖(方法覆盖只是针对于“实例方法”,“静态方法覆盖”没有意义)。
4.1.5 子类构造器
public Manager(String name,double salary,int year,int month,int day){
super(name,salary,year,month,day);
bonus = 0;
}
由于Manager类的构造器不能访问Employee类的私有字段,所以必须通过一个构造器来初始化这些私有字段。可以利用特殊的super语法调用这个构造器。使用super调用构造器的语句必须是子类构造器的第一条语句。
如果子类的构造器没有显式地调用超类的构造器,将自动地调用超类的无参数构造器。如果超类没有无参数的构造器,并且在子类的构造器中又没有显式地调用超类的其他构造器,Java编译器就会报告一个错误。
4.1.6 继承层次
继承并不仅限于一个层次。由一个公共超类派生出来的所有类的集合称为继承层次。在继承层次中,从某个特定的类到其祖先的路径称为该类的继承链。
不允许扩展的类被称为final类。如果在定义类的时候使用了final修饰符就表明这个类是final类。
类中的某个特定的方法也可以被声明为final。如果这样做,子类就不能覆盖这个方法。
前面曾经说过,字段也可以声明为final。对于final字段来说,构造对象之后就不允许改变他们的值了。不过,如果将一个类声明为final,只有其中的方法自动地成为final,而不包括字段。
总结final关键字如下:
- final修饰的类无法继承。
- final修饰的方法无法覆盖。
- final修饰的变量只能赋一次值。
- final修饰的引用一旦指向某个对象,则不能再重新指向其它对象,但该引用指向的对象内部的数据是可以修改的。
- final修饰的实例变量必须手动初始化,不能采用系统默认值。
- final修饰的实例变量一般和static联合使用,称为常量。例:public static final double PI = 3.1415926;
4.1.8 受保护访问
大家都知道,最好将类中的字段标记为private,而方法标记为public。任何声明为private的内容对其他类都是不可见的。前面已经看到,这对于子类来说也完全适用,即子类也不能访问超类的私有字段。
不过,在有些时候,可能希望限制超类中的某个方法只允许子类访问,或者更少见的,可能希望允许子类的方法访问超类的某个字段。为此,需要将这些类方法或字段声明为受保护(protectd)。
在Java中,保护字段只能由同一个包中的类访问。
下面对Java中的4个访问控制修饰符做个小结:
修饰符 | 类的内部 | 同一个包里 | 子类 | 任何地方 |
private | √ | |||
default | √ | √ | ||
protected | √ | √ | √ | |
public | √ | √ | √ | √ |
4.2 多态
4.2.1 多态基础语法
一个对象变量(例如,变量e)可以指示多重实际类型的现象称为多态。在运行时能够自动地选择适当的方法,称为动态绑定。
在Java程序设计语言中,对象变量是多态的。一个Employee类型的变量既可以引用一个Employee类型的对象,也可以引用Employee类的任何一个子类的对象(例如Manager)。
Manager boss = new Manager(...);
Employee[] staff = new Employee[3];
staff[0] = boss;
在这个例子中,变量staff[0]与boss引用同一个对象。但编译器只将staff[0]看成是一个Employee对象。
这意味着,可以这样调用:
boss.setBonus(5000);
但不能这样调用:
staff[0].setBonus(5000);
这是因为staff[0]声明的类型是Employee,而setBonus不是Employee类的方法。不过,不能将超类的引用赋给子类变量。例如,下面的赋值是非法的:
Manager m = staff[i];//ERROR
原因很清楚:不是所有的员工都是经理。如果赋值成功,m有可能引用了一个不是经理的Employee对象,而在后面有可能会调用m.getBonus(...),这就会发生运行时错误。
4.2.2 向上转型和向下转型
在 Java中允许这样的两种语法出现,一种是向上转型(Upcasting),一种是向下转型(Downcasting),向上转型是指子类型转换为父类型,又被称为自动类型转换,向下转型是指父类型转换为子类型,又被称为强制类型转换。
多态就是“同一个行为(move)”作用在“不同的对象上”会有不同的表现结果。Java中之所以有多态机制,是因为 Java允许一个父类型的引用指向一个子类型的对象。
也就是说允许这种写法:
Animal a2 = new Bird();
因为 Bird is a Animal是能够说通的。其中 Animal a1 = new Cat();或者 Animal a2 = new Bird();都是父类型引用指向了子类型对象,都属于向上转型(Upcasting),或者叫做自动类型转换。
解释一下这段代码片段:
Animal a1 = new Cat();
a1.move();
Java程序包括编译和运行两个阶段,分析 Java程序一定要先分析编译阶段,然后再分析运行阶段,在编译阶段编译器只知道 a1 变量的数据类型是 Animal,那么此时编译器会去 Animal.class字节码中查找 move() 方法,发现 Animal.class 字节码中存在 move()方法,然后将该 move()方法绑定到 a1 引用上, 编译通过了,这个过程我们可以理解为“静态绑定”阶段完成了。
紧接着程序开始运行,进入运行阶段,在运行的时候实际上在堆内存中 new的对象是 Cat 类型,也就是说真正在 move 移动的时候,是 Cat 猫对象在移动,所以运行的时候就会自动执行 Cat 类当中的 move()方法,这 个过程可以称为“动态绑定”。但无论是什么时候,必须先“静态绑定”成功之后才能进入“动态绑定”阶段。
public class Test03 {
public static void main(String[] args) {
Animal a = new Cat();
a.catchMouse(); //报错
}
}
有人认为 Cat 猫是可以抓老鼠的呀,为什么会编译报错呢?那是因为“Animal a = new Cat();”在编译的时候,编译器只知道 a 变量的数据类型是 Animal,也就是说它只会去 Animal.class 字节码中查找 catchMouse()方法,结果没找到,自然“静态绑定”就失败了,编译没有通过。就像以上描述的错误信息一样:在类型为 Animal 的变量 a 中找不到方法 catchMouse()。
那么,假如说我就是想让这只猫去抓老鼠,以上代码应该如何修改呢?请看以下代码:
public class Test04 {
public static void main(String[] args) {
//向上转型
Animal a = new Cat();
//向下转型:为了调用子类对象特有的方法
Cat c = (Cat)a;
c.catchMouse();
}
}
我们可以看到直接使用 a 引用是无法调用 catchMouse()方法的,因为这个方法属于子类 Cat 中特有的行为,不是所有 Animal 动物都可以抓老鼠的,要想让它去抓老鼠,就必须做向下转型,也就是使用强制类型转换将 Animal 类型的 a 引用转换成 Cat 类型的引用 c(Cat c = (Cat)a;),使用 Cat 类型的 c 引用调用 catchMouse()方法。
4.2.3 ClassCastException类型转换异常
通过这个案例,可以得出:只有在访问子类型中特有数据的时候,需要先进行向下转型。 其实向下转型就是用在这种情形之下。那么向下转型会存在什么风险吗?请看以下代码:
public class Test05 {
public static void main(String[] args) {
Animal a = new Bird();
Cat c = (Cat)a;
}
}
以上代码可以编译通过吗?答案是可以的,为什么呢?那是因为编译器只知道 a 变量是 Animal 类型,Animal 类和 Cat 类之间存在继承关系,所以可以进行向下转型(前面提到过, 只要两种类型之间存在继承关系,就可以进行向上或向下转型),语法上没有错误,所以编译通过了。但是运行的时候会出问题,因为毕竟 a 引用指向的真实对象是一只小鸟,出现ClassCastException异常。
4.2.4 instanceof
instanceof 运算符的语法格式是这样的: (引用 instanceof 类型)
instanceof 运算符的运算结果是布尔类型,有了 instanceof 运算符,向下转型就可以这样写了:
Animal a = new Bird();
if(a instanceof Cat){
Cat c = (Cat)a;
c.catchMouse();
}
假设结果是 true 则表示在运行阶段 a 引用指向的对象是 Cat 类型,如果结果是 false 则表示在运行阶段 a 引用指向的对象不是 Cat 类型。
在实际开发中,在进行任何向下转型的操作之前,要使用 instanceof 进行判断, 这是一个很好的编程习惯。
4.2.5 多态在开发中的作用
降低程序的耦合度,提高程序的扩展力。
public class Master{
public void feed(Dog d){...}
public void feed(Cat c){...}
}
以上的代码中表示:Master和Dog以及Cat的关系很紧密(耦合度高)。导致扩展力很差。
public class Master{
public void feed(Pet pet){
pet.eat();
}
}
以上的代表中表示:Master和Dog以及Cat的关系就脱离了,Master关注的是Pet类。这样Master和Dog以及Cat的耦合度就降低了,提高了软件的扩展性。
下面给出完整的代码自我感受一下多态在开发中的作用:
public class Master {
public void feed(Pet p){
p.eat();
}
public static void main(String[] args) {
Master master = new Master();
master.feed(new Dog("阿拉斯加"));//阿拉斯加在吃骨头
master.feed(new Cat("加菲猫"));//加菲猫在吃鱼
}
}
class Pet {
private String name;
public Pet(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void eat(){
System.out.println(name + "在吃饭");
}
}
class Dog extends Pet{
public Dog(String name) {
super(name);
}
@Override
public void eat() {
System.out.println(super.getName() + "在吃骨头");
}
}
class Cat extends Pet{
public Cat(String name) {
super(name);
}
@Override
public void eat() {
System.out.println(super.getName() + "在吃鱼");
}
}
4.3 super
4.3.1 super概述
严格来说,super 其实并不是一个引用,它只是一个关键字,super 代表了当前对象中从父类继承过来的那部分特征。super 并不是指向某个“独立”的对象,假设张大明是父亲,张小明是儿子,有这样一句话:大家都说张小明的眼睛、鼻子和父亲的很像。那么也就是说儿子继承了父亲的眼睛和鼻子特征,那么眼睛和鼻子肯定最终还是长在儿子的身上。假设this 指向张小明,那么 super 就代表张小明身上的眼睛和鼻子。换句话说 super 其实是 this 的一部分。
如下图所示:张大明和张小明其实是两个独立的对象,两个对象内存方面没有联系,super 只是代表张小明对象身上的眼睛和鼻子,因为这个是从父类中继承过来的,在内存方面使用了 super 关键字进行了标记,对于下图来说“this.眼睛”和“super.眼睛”都是访问的同一块内存空间。
super 和 this 都可以使用在实例方法当中。
super 不能使用在静态方法当中,因为 super 代表了当前对象上的父类型特征, 静态方法中没有 this,肯定也是不能使用 super 的。 super的语法是:“super.”、“super()”。
super 也有这种用法:“super(实际参数列表);”,只能出现在构造方法第一行,通过当前的构造方法去调用“父类”中的构造方法,目的是:创建子类对象的时候,先初始化父类型特征。
4.3.2 super的原理
4.3.3 super在构造方法中
super 使用在构造方法中,语法格式为:super(实际参数列表),这行代码和“this(实际参数列表)”都是只允许出现在构造方法第一行,所以这两行代码是无法共存的。“super(实际参数列表)”这种语法表示子类构造方法执行过程中调用父类的构造方法。
“super(实际参数列表);”语法表示调用父类的构造方法,代码复用性增强了,另外一方面也是模拟现实世界当中的“要想有儿子,必须先有父亲”的道理。不过这里的“super(实际参数列表)”在调用父类构造方法的时候,从本质上来说并不是创建一个“独立的父类对象”,而是为了完成当前对象的父类型特征的初始化操作。
当一个构造方法第一行没有显示的调用“super(实际参数列表)”的话,系统默认调用父类的无参数构造方法“super()”。当然前提是“this(实际参数列表)”也没有显示的去调用。
4.3.4 super在实例方法中
父类和子类中有同名实例变量或者有同名的实例方法,想在子类中访问父类中的实例变量或实例方法,则super 是不能省略的,其它情况都可以省略。
4.4 抽象类
- 抽象类怎么定义?在class前添加abstract关键字就行了。在 Java中采用 abstract 关键字定义的类就是抽象类,采用 abstract 关键字定义的方法就是抽象方法 。抽象的方法只需在抽象类中,提供声明,不需要实现。
- 抽象类是无法实例化的,无法创建对象的,所以抽象类是用来被子类继承的。
- final和abstract不能联合使用,这两个关键字是对立的。
- 抽象类的子类可以是抽象类。也可以是非抽象类。如果这个类是抽象的,那么这个类被子类继承,抽象方法必须被重写。如果在子类中不复写该抽象方法,那么必须将此类再次声明为抽象类。
- 抽象类虽然无法实例化,但是抽象类有构造方法,这个构造方法是供子类使用的。
- 抽象类中不一定有抽象方法,抽象方法必须出现在抽象类中。
- 一个非抽象的类,继承抽象类,必须将抽象类中的抽象方法进行覆盖/重写/实现。
面试题(判断题):Java语言中凡是没有方法体的方法都是抽象方法。不对,错误的。
Object类中就有很多方法都没有方法体,都是以“;”结尾的,但他们都不是抽象方法,例如:public native int hashCode();这个方法底层调用了C++写的动态链接库程序。前面修饰符列表中没有:abstract。有一个native。表示调用JVM本地程序。
4.5 包装类
有没有这种需求:调用doSome()方法的时候需要传一个数字进行。但是数字属于基本数据类型,而doSome()方法参数的类型是Object。可见doSome()方法无法接收基本数据类型的数字。那怎么办呢?可以传一个数字对应的包装类进去。
8种包装类属于引用数据类型。
4.5.1 Number抽象类
Number中有这些方法byteValue()、shortValue()、intValue()……这些方法是负责拆箱的。例如:
Integer i = new Integer(100); //基本数据类型 —装箱—> 引用数据类型
float f = i.floatValue(); //引用数据类型 —拆箱—> 基本数据类型
4.5.2 Integer
1.构造方法
Integer(int value) 构造一个新分配的 Integer对象,该对象表示指定的 int值。
Integer(String s) 构造一个新分配 Integer对象,表示 int由指示值 String参数。
2.通过常量获取最大值和最小值
int a = Integer.MAX_VALUE; //2147483647
int b = Integer.MIN_VALUE; //-2147483648
3.自动装箱与自动拆箱(JDK5以后)
有了自动拆箱之后,Number类中的方法就用不着了。==这个运算符不会触发自动拆箱机制(只有+、-、×、/等运算的时候才会)。
自动装箱:
Integer x = 100; //int类型自动转换为Integer
自动拆箱:
int y = 2; //Integer自动转换为int
4.整数型常量池
Integer a = 128;
Integer b = 128;
System.out.println(a == b); //false
Integer x = 127;
Integer y = 127;
System.out.println(x == y); //true
5.String、Integer、int三种类型的相互转换
4.6 Object:所有类的超类
4.6.1 Object类型的变量
可以使用Object类型的变量引用任何类型的对象:
Object obj = new Employee(“Tom”,35000);
当然,Object类型的变量只能用于作为各种值的一个泛型容器。要想对其中的内容进行具体的操作,还需要清楚对象的原始类型,并进行相应的强制类型转换。
在Java中,只有基本类型不是对象,例如:数字、字符和布尔类型的值都不是对象。所有的数组类型,不管是对象数组还是基本类型的数组都扩展了Object类型。
4.6.2 equals方法与==
“==”可以比较基本类型和引用类型,等号比较的是值;特别是比较引用类型,比较的是引用的内存地址(确切的说,是堆内存地址)。
Object类中的equals方法用于检测一个对象是否等于另外一个对象。Object类中实现的equals方法将确定两个对象引用是否相等。
以后所有类的equals方法也需要重写,因为Object中的equals方法比较的是两个对象的内存地址,我们应该比较内容,所以需要重写。重写规则:自己定,主要看是什么和什么相等时表示两个对象相等。
基本数据类型比较适用:==
对象和对象比较:调用equals方法
String类是SUN编写的,所以String类的equals方法重写了。以后判断两个字符串是否相等,最好不要使用==,要调用字符串对象的equals方法。
使用Object.equals方法,可以防备比较的两个字段可能为null的情况。如果两个参数都为null,Object.equals(a,b)调用返回true;如果其中一个参数为null,则返回false;否则,如果两个参数都不为null,则调用a.equals(b)。
在子类定义equals方法时,首先调用超类的equals。如果检测失败,对象就不可能相同。如果超类中的字段都相等,就需要比较子类中的实例字段。
4.6.3 toString方法
返回该对象的字符串表示。通常 toString 方法会返回一个“以文本方式表示”此对象的字符串,Object 类的 toString 方法返回一个字符串,该字符串由类名加标记@和此对象哈希码的无符号十六进制表示组成,Object 类 toString 源代码如下:
getClass().getName() + '@' + Integer.toHexString(hashCode())
4.6.4 finalize方法(了解)
这个方法是protected修饰,垃圾回收器(Garbage Collection),也叫 GC,垃圾回收器主要有以下特点:
- 当对象不再被程序使用时,垃圾回收器将会将其回收。
- 垃圾回收是在后台运行的,我们无法命令垃圾回收器马上回收资源,但是我们可以告诉他,尽快回收资源(System.gc 和 Runtime.getRuntime().gc())。
- 垃圾回收器在回收某个对象的时候,首先会调用该对象的 finalize 方法 。
- GC 主要针对堆内存 。
- 单例模式的缺点:当垃圾收集器将要收集某个垃圾对象时将会调用 finalize,建议不要使用此方法,因为此方法的运行时间不确定,如果执行此方法出现错误,程序不会报告,仍然继续运行。
4.6.5 hashCode方法
散列码(hash code)是由对象导出的一个整型值。散列码是没有规律的。
如果重新定义了equals方法,就必须为用户可能插入散列表的对象重新定义hashCode方法。
equals与hashCode的定义必须相容:如果x.equals(y)返回true,那么x.hashCode()就必须与y.hashCode()返回相同的值。例如,如果定义Employee.equals比较员工的ID,那么hashCode方法就需要散列ID,而不是员工的姓名或存储地址。
4.6.6 相等测试与继承
Java语言规范要求equals方法具有下面的特性:
- 自反性:对于任何非空引用x,x.equals(x)应该返回true。
- 对称性:对于任何引用x和y,当且仅当y.equals(x)返回true时,x.equals(y)返回true
- 传递性:对于任何引用x、y、z,如果x.equals(y)返回true,y.equals(z)返回truex,equals(z)也应该返回true。
- 一致性:如果x和y引用的对象没有发生变化,反复调用x.equals(y)应该返回同样的结果。
- 对于任意非空引用x,x.equals(null)应该返回false。
下面给出编写一个完美的equals方法的建议:
1.显式参数命名为otherObject,稍后需要将它强制转换为另一个名为other的变量。
2.检测this与otherObject是否相等:
if(this == otherObject) return true;
这条语句只是一个优化,实际上,这是一种经常采用的形式。因为检测身份要比逐个比较字段开销小。
3.检测otherObject是否为null,如果为null,返回false。这项检测是很有必要的。
if(otherObject == null)return false;
4.比较this与otherObject的类。如果equals的语义可以在子类中改变,就是用getClass检测:
if(getClass() != otherObject.getClass()) return false;
如果所有的子类都有相同的相等性语义,可以使用instanceof检测:
if(!(otherObject instanceof ClassName) return false;
5.将otherObject强制转换为响应类类型的变量:
ClassName ohter = (ClassName)otherObject
6.现在根据相等性概念的要求来比较字段。使用==比较基本类型字段,使用Objects.equals比较对象字段。如果所有的字段都匹配,就返回true;否则返回false。
return field == other.field1 && Objects.equals(field2,other.field2) && ...;
如果在子类中重新定义equals,就要在其中包含一个super.equals(other)调用。
对于数组类型的字段,可以使用静态的Arrays.equals方法检测相应的数组元素是否相等。
下面给出一个例子完美的equals方法的比较例子:
public boolean equals(Object otherObject) {
//1.快速测试是否为同一对象(比较内存地址)
if (this == otherObject){
return true;
}
//2.如果显式参数为null,则必须返回false
if (otherObject == null){
return false;
}
//3.如果不是同一类,他们就不可能相等
if (this.getClass() != otherObject.getClass()){
return false;
}
//4.将otherObject强制转换为响应类类型的变量
Employe eother = (Employee)otherObject;
//5.测试字段是否有相同的值
return Objects.equals(this.name,other.name)&& salary == other.salary && Objects.equals(hireDay,other.hireDay);
}
4.7 继承的设计技巧
- 将公共操作和字段放在超类中。
- 不要使用受保护的字段。究其原因:protected机制并不能够带来更多的保护,这有两方面的原因。第一,子类集合是无限制的,任何一个人都能够由你的类派生一个子类,然后编写代码直接访问protected实例字段,从而破坏了封装性。第二,在Java中,在同一个包中的所有类都可以访问protected字段,而不管他们是否为这个类的子类。不过,protected方法对于指示那些不提供一般用途而应在子类中重新定义的方法很有用。
- 使用继承实现“is-a”关系。
- 除非所有继承的方法都有意义,否则不要使用继承。
- 在覆盖方法时,不要改变预期的行为。
- 使用多态,而不要使用类型信息。