文章目录
5.1 类、超类和子类
现在让我们回忆一下在前一章讨论过的Employee类。假设你在某个公司工作,这个公司里经理的待遇与普通员工的待遇存在着一些差异。不过,他们之间也存在着很多相同的地方,例如,他们都领取薪水。只是普通员工在完成本职任务后仅仅领取薪水,而经理在完成了预期的业绩之后还能得到奖金。这种情形就需要用到继承。为什么呢?因为需要为经理定义一个新类Manager,并增加一些新功能。但可以重用Employee中已经编写的部分代码,并保留原来Employee类中的所有字段。从理论上讲,在Manager与Employee之间存在着明显的"is-a"关系,每个经理都是一个员工。"is-a"关系是继承的一个明显特征。
注释:这一章中,我们使用了员工和经理的经典示例,不过必须提醒你的是对这个例子要有所保留。在真实的世界里,员工也可能会成为经理,所以你建模时可能希望经理也是员工,而不是员工的一个子类。不过,在我们的例子中,假设公司里只有两类人:一些人永远是员工,另一些人一直是经理。
5.1.1 定义子类
可以如下继承Employee类来定义Manager类,这里使用关键字extends表示继承。
public class Manager extends Employee{
added methods and fields
}
关键字extends表明正在构造的新类派生于一个已存在的类。这个已存在的类称为超类、基类或父类。新类称为子类、派生类或孩子类。超类和子类是Java程序员最常用的两个术语,而了解其他语言的程序员可能跟家偏爱使用父类和孩子类,这也能很贴切的体现继承。
尽管Employee类是一个超类,但并不是因为它优于子类或者拥有比子类更多的功能。实际上恰恰相反,子类比超类拥有的功能更多。例如,看过Manager类的源代码之后就会发现,Manager类比超类Employee封装了更多的数据,拥有更多的功能。
注释,前缀超(super)和子(sub)来源于计算机科学与数学理论中集合语言的术语。所有员工组成的集合包含所有经理组成的集合。可以这样说,员工集合是经理集合的超集,也可以说,经理集合是员工集合的子集。
在Manager类中,增加了一个用于存储奖金信息的字段,以及一个用于设置这个字段的新方法:
public class Manager extends Employee{
private double bonus;
...
public void setBonus(double bonus){
this.bonus=bonus;
}
}
这里定义的方法和字段并没有什么特别之处。如果有一个Manager对象,就可以使用setBouns方法。
Manager boss=...;
boss.setBonus(5000);
当然,由于setBouns方法不是在Employee中定义的,所以属于Employee类的对象不能使用它。
然而,尽管在Manager类中没有显式地定义getName和getHireDay等方法,但是可以对Manager对象使用这些方法,这是因为Manager类自动地继承了超类Employee中的这些方法。
类似地,从超类中还继承了name、salary和hireDay这三个字段。这样一来,每个Manager对象就包含四个字段:name,salary,hireDay和bonus。
通过扩展超类定义子类的时候,只需要指出子类与超类的不同之处。因此在设计类的时候,应该将最一般的方法放在超类中,而将更特殊的方法放在子类中,这种将通用功能抽取到超类的做法在面向对象程序设计中十分普遍。
5.1.2 覆盖方法
超类中的有些方法对子类Manager并不一定适用。具体来说,Manager类中的getSalary方法就应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖(override)超类中的这个方法:
public class Manager extends Employee{
...
public double getSalary(){
...
}
...
}
应该如何实现这个方法呢?乍看起来似乎很简单,只要返回salary和bonus字段的总和就可以了:
public double getSalary(){
return salary+bouns; //这是无效的
}
不过,这样做是不行的。回想一下,只有Employee方法能直接访问Employee类的私有字段。这意味着,Manager类的getSalary方法不能直接访问salary字段。如果Manager类的方法要访问那些私有字段,就要像其他所有方法一样使用公共接口,在这里就是要使用Employee类中的公共方法getSalary。
现在,再试一下。你需要调用getSalary方法而不是直接访问salary字段:
public double getSalary(){
double baseSalary=getSalary(); //仍然无效
return baseSalary+bonus;
}
上面的这段代码仍然有问题。问题出现在调用getSalary的语句上,它只是在调用自身,这是因为Manager类也有一个getSalary方法(就是我们正在实现的这个方法),所以这条语句将会导致无限次的调用自己,直到整个程序最终崩溃。
这里需要指出:我们希望调用超类Employee中的getSalary方法,而不是当前类的这个方法。为此,可以使用特殊的关键字super解决这个问题:
super.getSalary();
这个语句调用的是Employee类中的getSalary方法。下面是Manager类中getSalary方法的正确版本:
public double getSalary(){
double baseSalary =super.getSalary();
return baseSalary+bonus;
}
注释:有些人认为super和this引用是类似的概念,实际上,这样比较并不恰当。这是因为super不是一个对象的引用,例如,不能将值super赋给另一个对象变量,它只是一个指示编译器调用超类方法的特殊关键字。
正像前面所看到的那样,在子类中可以增加字段、增加方法或者覆盖超类的方法,不过,继承绝对不会删除任何字段或方法。
5.1.3 子类构造器
在例子的最后,我们来提供一个构造器。
public Manager(String name,double salary,int year,int month,int day){
super(name,salary,year,month,day);
bonus=0;
}
这里的关键字super具有不同的含义。语句:
super(name,salary,year,month,day);
是"调用超类Employee中带有n,s,year,month,day参数的构造器"的简写形式。
由于Manager类的构造器不能访问Employee类的私有字段,所以必须通过一个构造器来初始化这些私有字段。可以利用特殊的super语法调用这个构造器。使用super调用构造器的语句必须是子类构造器的第一条语句。
如果子类构造器没有显式地调用超类的构造器,将自动地调用超类的无参数的构造器。如果超类没有无参的构造器,并且在子类的构造器中又没有显式地调用超类的其他构造器,Java编译器就会报告一个错误。
注释,回想一下,关键字this有两个含义:一是指示隐式参数的引用,二是调用该类的其他构造器。类似地,super关键字也有两个含义:一是调用超类的方法,二是调用超类的构造器。在调用构造器的时候,this和super这两个关键字紧密相关。调用构造器的语句只能作为另一个构造器的第一条语句出现。构造器参数可以传递给当前类的另一个构造器(this),也可以传递给超类的构造器(super)。
重新定义Manager对象的getSalary方法之后,奖金就自动地添加到经理的薪水中了。
下面给出一个一个例子来说明这个类的使用。我们要创建一个新经理,并设置他的奖金:
Manager boss=new Manager("Carl Cracker",5000,1989,10,1);
boss.setBonus(5000);
下面定义一个包含3个员工的数组:
var staff=new Employee[3];
在数组中混合填入经理和员工:
staff[0]=boss;
staff[1]=new Employee("Harry Hacker",50000,1989,10,1);
staff[2]=new Employee("Tony Tester",40000,1990,3,15);
输出每个人的薪水:
for(Employee e : staff){
System.out.println(e.getName+""+e.getSalary());
}
运行的结果:
Carl Cracker 85000.0 //这里是80000+5000,基本工资加奖金
Harry Hacker 50000.0
Tommy Tester 40000.0
这里的staff[1]和staff[2]仅仅输出了基本薪水,这是因为它们是Employee对象,而staff[0]是一个Manager对象,它的getSalary方法会将奖金和基本薪水相加。
需要提醒大家的是,以下调用
e.getSalary();
能够选出应该执行的正确的getSalary方法。请注意,尽管这里将e声明为Employee类型,但实际上e既可以引用Employee类型的对象,也可以引用Manager类型的对象。
当e引用Employee对象时,e.getSalary()
调用的是Manager类中的getSalary()方法;当e引用Manager对象时,e.getSalary()
调用的是Manager类中的getSalary方法。虚拟机知道e实际引用的对象类型,因此能够正确的调用相应的方法。
一个对象变量可以指示多种实际类型的现象称为多态(polymorphism)。在运行时能够自动地选择适当的方法,称为动态绑定。在本章中将详细地讨论这两个概念。
程序清单5-1的程序展示了Employee对象与Manager对象在薪水计算上的区别。
package inheritance;
/**
* This program demonstrates inheritance
* @version 17:54 2021-04-24
* @author Jie Han
*/
public class ManagerTest {
public static void main(String[] args) {
//construct a Manager object
var boss=new Manager("Carl Cracker", 80000, 1987, 12, 15);
boss.setBonus(5000);
var staff=new Employee[3];
//fill the staff array with Manager and Employee objects
staff[0]=boss;
staff[1]=new Employee("Harry Hacker", 50000, 1989, 10, 1);
staff[2]=new Employee("Tommy Tester", 40000, 1990, 3, 15);
//print out information about all Employee objects
for (Employee e:staff) {
System.out.println("name="+e.getName()+",salary="+e.getSalary());
}
}
}
package inheritance;
import java.time.*;
public class Employee {
private String name;
private double salary;
private LocalDate hireDay;
public Employee(String name,double salary,int year,int month,int day){
this.name=name;
this.salary=salary;
hireDay=LocalDate.of(year, month, day);
}
public String getName() {
return name;
}
public double getSalary() {
return salary;
}
public LocalDate getHireDay() {
return hireDay;
}
public void raiseSalary(double byPercent){
double raise=salary*byPercent/100;
salary=salary+raise;
}
}
package inheritance;
public class Manager extends Employee {
private double bonus;
/**
* @param name the employee's name
* @param salary the salary
* @param year the hire year
* @param month the hire month
* @param day the hire day
*/
public Manager(String name,double salary,int year,int month,int day){
super(name, salary, year, month, day);
bonus=0;
}
@Override
public double getSalary() {
double baseSalary=super.getSalary();
return baseSalary+bonus;
}
public void setBonus(double b){
bonus=b;
}
}
5.1.4 继承层次
继承并不仅限于一个层次。例如,可以由Manager类派生Executive类。由一个公共超类派生出来的所有类的集合称为继承层次。在继承层次中,从某个特定的类到其祖先的路径称为该类的继承链。
通常,一个祖先类可以有多个子孙链。例如,可以由Employee类派生出子类Programmer和Secretary,它们与Manager类没有任何关系。它们彼此之间也没有关系。必要的话,可以将这个过程一直延续下去。
5.1.5 多态
有一个简单规则可以用来判断是否应该将数据设计为继承关系,这就是"is-a"规则,它指出子类的每一个对象也是超类的对象。例如,每个经理都是员工,因此,将Manager类设计为Employee类的子类是有道理的;反之则不然,并不是每一名员工都是经理。
"is-a"规则的另一种表述是替换原则。它指出程序中出现超类对象的任何地方都可以使用子类对象替换。
例如,可以将子类的对象赋给超类变量。
Employee e;
e=new Employee(...); //Employee object expected(期待)
e=new Manager(...); //OK, Manager can be used as well
在Java程序设计语言中,对象变量是多态的。一个Employee类型的变量既可以引用一个Employee类型的对象,也可以引用Employee类的任何一个子类的对象。
在程序清单5-1中,我们就利用了这个替换原则:
Manager boss=new Manager(...);
Employee[] staff =new Employee[3];
staff[0]=boss;
在这个例子中,变量staff[0]与boss引用同一个对象。但编译器只将staff[0]看成是一个Employee对象。
这意味着,可以这样调用
boss.setBonus(5000); //OK
但不能这样调用
staff[0].setBonus(5000); //ERROR
这是因为staff[0]声明的类型是Employee,而setBonus不是Employee的方法。
不过,不能将超类的引用赋给子类变量。例如,下面的赋值是非法的:
Manager m=staff[i]; //ERROR
原因很清楚:不是所有员工都是经理。如果赋值成功,m有可能引用了一个不是经理的Employee对象,而在后面有可能会调用m.setBonus(…),这就会发生运行时错误。
警告,在Java中,子类引用的数组可以转换成超类引用的数组,而不需要使用强制类型转换。例如,下面是一个经理数组
Manager[] managers =new Manager[10];
将它转换成Employee[]数组完全是合法的:
Employee[] staff =managers; //OK
这样做肯定不会有问题,请思考一下其中的缘由。毕竟,如果manager[i]是一个Manager,它也一定是一个Employee。不过,实际上将会发生一些令人惊讶的事情。要切记managers和staff引用的是同一个数组。现在来看一下这条语句:
staff[0]=new Employee("Harry Hacker",...);
编译器竟然接纳了这个赋值操作。但在这里,staff[0]与manager[0]是相同的引用,似乎我们把一个普通员工擅自归入经理行列中了。这是一种很不好的情形,当调用manager[0].setBonus(1000)的时候,将会试图调用一个不存在的实例字段,进而搅乱相邻存储空间的内容。
为了确保不发生这类破坏,所有数组都要牢记创建时的元素类型,并负责监督仅将类型兼容的引用存储到数组中。例如,使用
new managers[10]
创建的数组是一个经理数组。如果试图存储一个Employee类型的引用就会引发ArrayStoreException异常。
5.1.6 理解方法调用
准确地理解如何在对象上应用方法调用非常重要。下面假设要调用x.f(args)
,隐式参数x声明为类C 的一个对象。下面是调用过程的详细描述:
- 首先,编译器查看对象的声明类型和方法名。需要注意的是,有可能存在多个名字为f但参数类型不一样的方法。例如存在方法
f(int)
和方法f(String)
。编译器将会一一列举C类中所有名为f的方法和其超类中所有名为f而且可以访问的方法。超类的私有方法不可访问。至此,编译器已经知道所有可能被调用的候补方法。 - 接下来,编译器要确定方法调用中提供的参数类型。如果在所有名为f的方法中存在一个与所提供参数类型完全匹配的方法,就选择这个方法。这个过程称为重载解析。例如,对于调用
x.f("Hello")
,编译器将会挑选f(String)
,而不是f(int)
。由于允许类型转换,比如:int可以转换成double,Manager可以转换成Employee,等。所以情况可能会变得很复杂。如果编译器没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个方法与之匹配,编译器就会报告一个错误。至此,编译器已经知道需要调用的方法的名字和参数类型。
注释,前面学过,方法的名字和参数列表称为方法的签名。例如,f(int)
和f(String)
是两个有相同名字、不同签名的方法。如果在子类中定义了一个与超类签名相同的方法,那么子类中的这个方法就会覆盖超类中这个相同签名的方法。
返回类型不是签名的一部分。不过在覆盖一个方法时,需要保证返回类型的兼容性。**允许子类将覆盖方法的返回类型改为原返回类型的子类型。**例如,Employee类有以下方法:
public Employee getBuddy{
...
}
经理不会想找这种底层员工做搭档。为了反映这一点,在子类Manager中,可以覆盖这个方法:
public Manager getBuddy{
...
}
- 如果是private方法、static方法、final方法或者构造器,那么编译器将可以准确地知道吗应该调用哪个方法。这称为静态绑定。与此对应的是,如果要调用的方法依赖于隐式参数的实际类型,那么必须在运行时动态绑定。在我们的示例中,编译器会利用动态绑定生成一个调用
f(String)
的指令。 - 程序运行并采用动态绑定调用方法时,虚拟机必须调用与x所引用对象的实际类型对应的那个方法。假设x的实际类是D,它是C类的子类。如果D类定义了
f(String)
,就会调用这个方法;否则,将在D类的超类中寻找f(String
),以此类推。
每次调用方法都要完成这个搜索,时间开销相当大。因此,虚拟机预先为每个类计算了一个方法表,其中列出了所有方法的签名和要调用的实际方法。这样一来,在真正调用方法的时候,虚拟机仅查找这个表就行。在前面的例子中,虚拟机搜索D类的方法表,寻找与调用f(String)
相匹配的方法。(小心!x声明为C的一个对象,实际上是D类。)这个方法既有可能是D.f(String)
,也有可能是X.f(String)
。这里X是D的某个超类。需要提醒一点,如果调用是super.f(param)
,那么编译器将对隐式参数超类的方法进行搜索。
现在来详细分析程序清单5-1中调用e.getSalary()
的过程。e声明为Employee类型。Employee类只有一个名为getSalary的方法,这个方法没有参数。因此,在这里不必担心重载解析的问题。
由于getSalary不是private方法、static方法或者final方法,所以将采用动态绑定。虚拟机为Employee和Manager类生成方法表。在Employee的方法表中列出了这个Employee类本身定义的所有方法:
//Employee:
//getName()---Employee.getName()
//getSalary()---Employee.getSalary()
//getHireDay()---Employee.getHireDay()
//raiseSalary(double)---Employee.raiseSalary(double)
实际上,上面列出的方法并不完整,稍后会看到Employee类有一个超类Object,Employee类从这个超类中还继承了大量方法,在此,省去了Object方法。
Manager方法表稍微有些不同。其中有三个方法是继承而来的,一个方法是重新定义的,还有一个方法是新增加的。
//Manager:
//getName()---Employee.getName()
//getSalary()---Manager.getSalary()
//getHireDay()---Employee.getHireDay()
//raiseSalary(double)---Employee.raiseSalary(double)
//setBonus(double)---Manager.setBonus(double)
在运行时,调用e.getSalary()
的解析过程为:
- 首先,虚拟机获取e的实际类型的方法表。这可能是Employee、Manager的方法表,也可能是Employee类的其他子类的方法表。
- 接下来,虚拟机查找定义了
getSalary()
签名的类。此时,虚拟机已经知道应该调用哪个方法。 - 最后,虚拟机调用这个方法。
动态绑定有一个非常重要的特性:无须对现有的代码进行修改就可以对程序进行扩展。假设增加一个新类Executive,并且变量e有可能引用这个类的对象,我们不需要对包含调用e.getSalary()
的代码重新进行编译。如果e恰好引用了一个Executive类的对象,就会自动地调用Executive.getSalary()
方法。
警告:在覆盖一个方法的时候,子类方法不能低于超类方法的可见性。特别是,如果超类方法是public,子类方法必须声明为public。经常会发生这类错误:即子类方法不小心遗漏了public修饰符。因此,编译器就会报错,指出你试图提供更严格的访问权限。
5.1.7 阻止继承:final类和方法
有时候,我们可能希望阻止人们利用某个类定义子类。不允许扩展的类被称为final类。如果在定义类的时候使用了final修饰符就表明这个类是final类。例如,假设希望阻止人们派生Executive类的子类,就可以在声明这个类的时候使用final修饰符。声明格式如下:
public final class Executive extends Manager{
...
}
类中的某个特定方法也可以被声明为final。如果这样做,子类就不能覆盖这个方法。final类中的所有方法自动地称为final方法。例如:
public class Employee{
...
public final String getName()
{
return name;
}
...
}
注释:前面曾经说过,字段也可以声明为final。对于final字段来说,构造对象之后就不允许改变它们的值了。不过,如果将一个类声明为final,只有其中的方法自动地称为final,而不包括字段。
将方法或类声明为final的主要原因是:确保它们在子类中不会改变语义。例如,Calendar类中的getTime和setTime方法都声明为final。这表明Calendar类的设计者负责实现Date类与日历状态之间的转换,而不允许子类来添乱。同样地,String类也是final类,这意味着不允许任何人定义String的子类。换言之,如果有一个String引用,它引用的一定是一个String对象,而不可能是其他类的对象。
有些程序员认为:除非有足够的理由使用多态,否则应该将所有的方法都声明为final。事实上,C++和C#中,如果没有特别地说明,所有的方法都不使用多态性。这两种做法可能有些偏激。我们提倡在设计类层次时,要仔细地思考应该将哪些方法和类声明为final。
在早期的Java中,有些程序员为了避免动态绑定带来的系统开销而使用final关键字。如果一个方法没有被覆盖并且很短,编译器就能够对它进行优化处理,这个过程称为内联。(???突然上概念?)例如,内联调用e.getName()
将被替换为访问字段e.name
。这是一项很有意义的改进,CPU在处理当前指令时,分支会扰乱预取指令的策略。所以,CPU不喜欢分支。然而,如果getName在另一个类中被覆盖,那么编译器就无法知道覆盖的代码将会做什么操作,因此也就不能对它进行内联处理。
幸运的是,虚拟机中的即时编译器比传统编译器能力强得多。这种编译器可以准确地知道类之间的继承关系,并能够检测出是否有类覆盖了给定的方法。如果方法很简短、被频繁调用且没有被覆盖,即时编译器就会对这个方法进行内联处理。如果虚拟机加载了另外一个子类,而这个子类覆盖了一个内联方法,将会怎样呢?优化器会取消对这个方法的内联。这个过程很慢,不过很少会发生这种情况。
5.1.8 强制类型转换
第三章讲过,将一个类型强制转换为另一个类型的过程叫强制类型转换。Java程序设计语言为强制类型转换提供了一种特殊的表示法。例如:
double x=3.405;
int nx=(int)x;
将表达式x的值转换成整数类型,舍弃了小数部分。
正像有时候需要将浮点数转换成整数一样,有时候也可能需要将某个类的对象引用转换成另一个类的对象引用。要完成对象引用的强制类型转换,转换语法与数值表达式的强制类型转换类似,仅需要用一对圆括号将目标类名包括起来,并放置在需要转换的对象引用之前。例如:
Manager boss = (Manager)staff[0];
进行强制类型转换的唯一原因是:要在暂时忽视对象的实际类型之后使用对象的全部功能。例如,在managerTest类中,由于某些元素是普通员工,所以staff数组必须是Employee对象的数组。我们需要将数组中引用经理的元素复原成Manager对象,以便能够访问新增加的所有变量。(需要注意,在第一节的示例代码中,为了避免强制类型转换,我们做了一些特别的处理。将boss变量存入数组之前,先将他初始化为一个Manager对象。为了设置经理的奖金,必须使用正确的类型。)
在Java中,每个对象变量都有一个类型。类型描述了这个变量所引用的以及能够引用的对象类型。例如,staff[i]引用一个Employee对象。它还可以引用Manager对象。
将一个值存入变量时,编译器将检查你是否承诺过多。如果将一个子类的引用赋给一个超类变量,编译器是允许的。但将一个超类引用赋给一个子类变量时,就承诺过多了。必须进行强制类型转换,才能通过运行时检查。
如果试图在继承链上进行向下的强制类型转换,并且“谎报”对象包含的内容,会发生什么情况呢?
Manager boss=(Manager) staff[1]; //ERROR
运行这个程序时,Java运行时系统将注意到你的承诺不符,并产生一个ClassCastException异常。如果没有捕获这个异常,那么程序就会终止。因此,应该养成一个良好的程序设计习惯:在进行强制类型转换之前,先查看是否能够成功地转换。为此只需要使用instanceof操作符就可以实现。例如:
if(staff[1] instanceof Manager)
{
boss=(Manager) staff[1];
}
最后,如果这个类型转换不可能成功,编译器就不会让你完成这个转换。例如,下面这个强制类型转换:
String c=(String) staff[1];
将会产生编译错误,这是因为String不是Employee的子类。
综上,
- 只能在继承层次内进行强制类型转换
- 在将超类强制转换成子类之前,应该先使用instanceof进行检查
注释:
如果x为null,进行以下测试:
x instanceof C
不会产生异常,只是返回false。之所以这样处理是因为null没有引用任何对象,当然也不会引用C类型的对象。
实际上,通过强制类型转换来转换对象的类型通常并不是一种好的做法。在我们的示例中,大多数情况下并不需要将Employee对象强制转换成Manager对象,两个类的对象都能够正确的调用getSalary方法,这是因为实现多态性的动态绑定机制能够自动地找到正确的方法。
只有在使用Manager中特有的方法时才需要强制类型转换。例如,setBonus方法。如果出于某种原因发现需要在Employee对象上调用setBonus方法,那么就应该自问超类的设计是否合理。可能需要重新设计超类,并添加setBonus方法。请记住,只要没有捕获ClassCastException异常,程序就会终止执行。一般情况下,最好尽量少用强制类型转换和instanceof运算符。
理解: 强制类型转换指的是在继承链上向下进行引用变量的转换。就是说只有将父类的引用变量转换为子类的引用变量才叫强制类型转换。但是,将父类引用转换成子类引用时,父类引用指向的对象必须是子类引用所能够引用的。假设,父类引用指向的是一个父类的对象,然后将这个引用转换成子类引用。那么就产生了一个奇怪的现象,就是子类引用指向父类对象,这是不符合Java继承的设计思想的,是错误的。
5.1.9 抽象类
如果自下而上在类继承层次结构中上移,位于上层的类更具有一般性,可能更加抽象。从某种角度看,祖先类更有一般性,人们只将它作为派生其他类的基类,而不是用来构造你想使用的特定的实例。例如,考虑扩展Employee类的层次结构。员工是一个人,学生也是一个人。下面扩展我们的类层次结构来加入类Person和类Student。图5-2显示了这三个类之间的继承关系。
为什么要那么麻烦提供这样一个高层次的抽象呢?每个人都有一些属性,如姓名。学生与员工都有姓名属性,因此通过引入一个公共的超类,我们就可以把getName方法放在继承层次结构中更高的一层。
现在,再增加一个getDescription方法,它可以返回对一个人的简短描述。例如:
an employee with a salary of $50,000.00
a student majoring in computer science
在Employee类和Student类中实现这个方法很容易。但是在Person类中应该提供什么内容呢?除了姓名之外,Person类对这个人一无所知。当然,可以让Person.getDescription()
返回一个空字符串。不过还有一个更好的方法,就是使用abstract关键字,这样就完全不需要实现这个方法了。
public abstract String getDescription();
// no implementation required
为了提高程序的清晰度,包含一个或多个抽象方法的类本身必须被声明为抽象的。
public abstract class Person{
...
public abstract String getDescription();
}
除了抽象方法之外,抽象类还可以包含字段和具体方法。例如,Person类还保存着一个人的姓名,另外有一个返回姓名的具体方法。
public abstract class Person{
private String name;
public Person(String name){
this.name=name;
}
public abstract String getDescription();
public String getName(){
return name;
}
}
提示:有些程序员认为,在抽象类中不能包含具体方法。建议尽量将通用的字段和方法(不管是否是抽象的)放在超类(不管是否是抽象类)中。
抽象方法充当着占位方法的角色,它们在子类中具体实现。扩展抽象类可以有两种选择。一种是在子类中保留抽象类中的部分或所有抽象方法仍未定义,这样就必须将子类也标记为抽象类;另一种做法是定义全部方法,这样一来子类就不是抽象的了。
例如,通过扩展抽象Person类,并实现getDescription方法来定义Student类。由于Student类中不再含有抽象方法,所以不需要将这个类声明为抽象类。
即使不含抽象方法,也可以将类声明为抽象类。
抽象类不能实例化。就是说,如果将一个类声明为abstract,就不能创建这个类的对象。例如,表达式:
new Person("Vince Vu");
是错误的。但,可以创建一个具体子类的对象。
需要注意,可以定义一个抽象类的对象变量,但是这样一个变量只能引用非抽象子类的对象。例如,
Person p=new Student("Vince Vu","Economics");
这里的p是一个抽象类型Person的变量,它引用了一个非抽象子类Student的实例。
下面定义一个扩展抽象类Person的具体子类Student:
public class Student extends Person{
private String major;
public Student(String name,String major){
super(name);
this.major=major;
}
public String getDescription(){
return "a student majoring in "+major;
}
}
Student类定义了getDescription方法。因此,在Student类中的全部方法都是具体的,这个类不再是抽象类。
程序清单5-4的程序中定义了抽象超类Person和两个具体子类Employee和Student。下面将员工和学生对象填充到一个Person引用数组。
var people=new Person[2];
people[0]=new Employee(...);
people[1]=new Student(...);
然后,输出这些对象的姓名和信息描述:
for(Person p:people)
{
System.out.println(p.getName()+", "+p.getDescription());
}
有些人可能对下面这个调用感到困惑:
p.getDescription()
这不是调用了一个没有定义的方法吗?请牢记,由于不能构造Person抽象类的对象,所以变量p永远不会引用Person对象,而是引用诸如Employee和Student这样的具体子类的对象,而这些对象中都定义了getDescription对象。
是否可以干脆省略Person超类中的抽象方法,而仅在Employee和Student子类中定义getDescription方法呢?如果这样做,就不能在变量p上调用getDescription方法了。编译器只允许调用在类中声明的方法。
在Java程序设计语言中,抽象方法是一个重要的概念。在接口(interface)中将会看到更多的抽象方法。
程序清单5-4至5-7:
package abstractClasses;
/**
* This program demonstrates abstract classes.
* @version 1.01 2021-5-4
* @author XJTU_ANTS_Jayhan
*/
public class PersonTest {
public static void main(String[] args) {
var people=new Person[2];
people[0]=new Employee("Harry Hacker", 50000, 1989, 10, 1);
people[1]=new Student("Maria Morris", "computer science");
for (Person p:people
) {
System.out.println(p.getName()+", "+p.getDescription());
}
}
}
package abstractClasses;
public abstract class Person {
public abstract String getDescription();
private String name;
public Person(String name){
this.name=name;
}
public String getName() {
return name;
}
}
package abstractClasses;
import java.time.*;
public class Employee extends Person {
private double salary;
private LocalDate hireDay;
public Employee(String name,double salary,int year,int month, int day){
super(name);
this.salary=salary;
hireDay=LocalDate.of(year, month, day);
}
public double getSalary() {
return salary;
}
public LocalDate getHireDay() {
return hireDay;
}
@Override
public String getDescription() {
return String.format("an employee with a salary of $%.2f", salary);
}
public void raiseSalary(double byPercent){
double raise=salary*byPercent/100;
salary=salary+raise;
}
}
package abstractClasses;
public class Student extends Person {
private String major;
/**
* @param name the student's name
* @param major the student's major
*/
public Student(String name,String major){
// pass name to superclass constructor
super(name);
this.major=major;
}
@Override
public String getDescription() {
return "a student majoring in "+major;
}
}
5.1.10 受保护访问
大家都知道,最好将类中的字段标记为private,而方法标记为public。任何声明为private的内容对其他类都是不可见的。前面已经看到,这对于子类来说也完全适用,即子类也不能访问超类的私有字段。
不过,在有些时候,你可能希望限制超类中的某个方法只允许子类访问,或者更少见地,可能希望允许子类的方法访问超类的某个字段。为此,需要将这些类方法或字段声明为protected。例如,如果将超类Employee中的hireDay字段声明为protected,而不是private,Manager方法就可以直接访问这个字段。
在Java中,保护字段只能由同一个包中的类访问。现在考虑一个Administrator子类,这个子类在另一个不同的包中。Administrator类中的方法只能查看Administrator对象自己的hireDay字段,而不能查看其他Employee对象的这个字段。有了这个限制,就能避免滥用保护机制,不能通过派生子类来访问受保护的字段。
在实际应用中,要谨慎使用受保护字段。假设你的类要提供给其他程序员使用,而你在设计这个类时设置了一些受保护字段。你不知道的是,其他程序员可能会由这个类再派生出新类,并开始访问你的受保护字段。这种情况下,如果你要修改你的类的实现,就会影响那些程序员。就违背了OOP数据封装的精神。
受保护的方法更具有实际意义。如果需要限制某个方法的使用,就可以将它声明为protected。这表明子类得到了信任,可以正确的使用这个方法,而其他类则不行。
这种方法的一个很好的示例就是Object类中的clone方法,第六章详细解释。
下面对Java中的4个访问控制修饰符做一个小结:
- 仅对本类可见——private
- 对外部完全可见——public
- 对本包和所有子类可见——protected
- 对本包可见——默认,无需修饰符