一个看起来很吸引人的解决方案是为Employee类添加一个名为isSalary的布尔型成员变量:对拿年薪的员工,该值为true;对计时付酬员工,该值为false。而salary成员变量在isSalary为true时,代表年薪;在isSalary为false时,代表按小时付费。这样,在computePay()方法中,我们可以用isSalary成员变量来决定采用哪种计算方法,因为拿年薪的员工的薪酬计算方法与计时付酬的员工的薪酬计算方法不同。例如:
public float computePay() {
if(isSalary) {
//对拿年薪的员工,执行薪酬计算
}
else {
//对拿时薪的员工,执行薪酬计算
}
}
然而,添加一个诸如isSalary的成员变量,并试图用一个类来代表两个不同类型的对象,并不是一个好的OOP设计方法。虽然这样做也可以正常运行,并得到正确的结果,但是我们没有利用继承的优点。
如果我们使用一个成员变量来决定一个对象的类型,最终结果是:类看起来是面向对象的,而实际上是面向过程的。例如,一个Employee对象要知道员工的类型,就必须要将检查这个添加的布尔型成员变量,从而导致程序设计的焦点不在对象上。
当需求发生改变时,就会引发更大的问题。试想一下:当需要添加一个新类型的员工时,isSalary成员变量会发生什么?假设公司开始雇用按天付费的合同工,那么布尔类型的isSalary就没法工作了,因为它不可能区分三种类型。这样,我们就得把该成员变量改为整型,命名为employeeType,0代表拿年薪的员工,1代表按时付酬的员工,2代表合同工。
现在,程序又可以正常运行了,但是我们又不得不对Employee类进行大的更改了。computePay()方法必须完全重写为如下样式:
public float computePay(){
switch(employeeType) {
case 0:
//对拿固定工资的员工,执行薪酬计算
break;
case 1:
//对计时付酬的员工,执行薪酬计算
break;
case 2:
//对合同工,执行薪酬计算
break;
//如果有必要,继续添加case语句
}
}
随着不同类型员工的增加,computePay()方法会变得越来越长。这就是结构化编程的弱点。在任何时候,只要我们发现正在编写的类不知道是哪一种类型,也许就要重新考虑程序的设计。如果使用继承,所有这些问题都可以避免。
如果使用继承,雇员的共同特征出现在一个父类中。每个不同的类型用一个子类代表。当需要一个新类型的雇员时(例如,合同工),通过继承父类编写一个新的子类。这样,已经写好的代码就不用再修改了。
这种好处在结构化编程以及糟糕的OOP设计中是很难达到的。在面向对象编程中,为避免上面所出现的问题,我们需要根据一个简单的规则不断地测试我们的设计:一个对象“有(has a)”一个属性,一个对象“执行(does)”一个行为。
在我们的员工发工资示例中,如果一个员工“有”年薪是不正确的,那么Employee类就不应该有salary成员变量,我们必须重新设计我们的程序。
我们需要认识到,虽然员工都是我们的问题域中的对象,但是实际上有两种不同类型的员工对象:拿年薪的员工和按计时付酬的员工。因此,我们需要编写两个类Salary和Hourly,分别代表这两种类型的员工。其中,Salary类必须有成员变量可以代表员工的年薪,Hourly类必须有成员变量可以代表员工的每小时报酬以及工作的小时数。
代码清单7.2演示如何编写Salary类。仔细研究该类的属性和行为。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14: 15: 16: 17: /* 代码清单7.2 Salary.java
Salary类,代表拿固定年薪的员工类型
*/
public class Salary{
public String name;
public String address;
public int SSN;
public int number;
public float salary;
public void mailCheck() {
System.out.println(“邮寄支票给员工” + name + “的地址为” + address);
}
public float computePay() {
return (float) salary/52.0;
}
}
代码清单7.3演示如何编写Hourly类。仔细研究该类的属性和行为。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16: 17: 18: /* 代码清单7.3 Hourly.java
Hourly类代表按小时付酬的员工类型
*/
public class Hourly{
public String name;
public String address;
public int SSN;
public int number;
public float hourlyRate;
public float hoursWorked
public void mailCheck() {
System.out.println(“邮寄支票给员工” + name + “的地址为” + address);
}
public float computePay() {
return (float) hoursWorked * hourlyRate;
}
}
研究Salary类和Hourly类的代码,我们会发现:虽然Salary和Hourly员工是不同的类型,但是又不是完全不同。实际上,这两种类型的员工有很多共同的地方,正如我们所看到的两个类中重复的成员变量和方法。如果我们要添加一个类型的员工,就必须在新的类中再次重复这些成员变量和方法。这显然不是一个令人满意的解决方案。
在面向对象编程中,当两个或多个类是不相同的,但是共享相同特征的时候,将共同的元素从类中拿出来,放到一个父类中。这些类继承父类,因此就继承了所有父类的特征,同时,每个子类中又保留了不同的特征。使用继承不仅可以重用已有的代码,从而避免代码重复,还可以创建一个更容易维护和修改代码的程序。
对于上面的发工资示例,我们可以使用继承,让员工的一般特征出现在父类中,每个不同类型的员工用子类来代替。当一个新类型的员工(例如合同工)出现时,我们就可以通过继承父类来编写一个新的子类,而已有的代码不需要更改。
对于Salary类和Hourly类来说,更好的设计就是将它们共同的元素取出来,放到父类中,而将独特的元素留在子类中。例如,mailCheck()方法可以出现Employee父类中,computePay()方法可以出现在每个子类中。这是因为邮寄支票对于所有员工都是相同的,但是计算报酬直接受付酬方式影响。
员工不共享一个共同的computePay()方法,通过将computePay()方法放在每个子类中,方法将被重写两次。这不是重复的代码,因为Salary类中的computePay()方法代码与Hourly类中的computePay()方法代码有很大的不同。
7.2 使用"is a"关系判断继承
“is a”关系是一个简单而强大的规则,用于判断父类和子类的继承关系是否正确。当使用继承时,我们必须能够说子类“ is a(是一个)” 父类。如果这个语句是真的,那么继承关系就是对的。例如,“一个拿年薪的员工是一个员工”是真的。同样,一个按小时付酬的员工是一个员工。因此,可以推断Salary类和Hourly类与Employee类之间的继承关系是正确的。
现在我们来看一个不应该使用继承的例子。假如有一个Date类来表示日历日期,我们想使用该类来跟踪员工入职的日期。
因为继承有如此多的好处,我们决定用Employee类继承Date类。当实例化Employee对象时,我们也将得到一个Date对象,以存储员工的入职日期。但是,“员工是一个日期”正确么?很显然,这里这种关系失败了。虽然结果可能是正确的,但是Employee类继承Date类不是一个好的设计方法,因此不能用。
对Employee和Date类不正确地使用继承的解决方案是认识到员工有一个入职日期,而不是员工是一个入职日期。如果对象有一个(has a)属性,这个属性就必须是类中的一个成员变量。Employee类必须添加一个Date类型的成员变量来代表员工的入职日期,而不是继承Date类。
7.3 Java中继承的实现
我们已经知道为什么在面向对象编程中要使用继承。现在,让我们来看看Java中是如何实现继承的。
在Java中,一个类使用关键字extends继承其它类。关键字extends出现在类声明时的类名后,extends后面跟着的是要继承的类的名称。
例如,下面的语句用于声明Salary类是Employee类的子类:
public class Salary extends Employee
同样,Hourly类可以用如下的语句继承Employee类:
public class Hourly extends Employee
代码清单7.4中的Employee类可以用作Salary和Hourly类的父类。注意,我们不需要添加任何特殊的代码来说明Employee是一个父类。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10: 11: 12: 13: /*代码清单7.4 Employee.java
重新设计的Employee类,做为父类。共性的特征和行为放在Employee类中。
*/
public class Employee{
public String name;
public String address;
public int SSN;
public int number;
public void mailCheck(){
System.out.println(“邮寄支票给员工” + name + “的地址为” + address);
}
}
代码清单7.5中的Salary类使用extends关键字,来描述Salary类是Employee类的子类。
1:
2:
3:
4:
5:
6:
7:
8:
9: 10: 11: /*代码清单7.5 Salary.java
重新设计的Salary类,做为Employee类的子类。
*/
public class Salary extends Employee{
public float salary; //年薪
public float computePay(){
System.out.println(“计算拿固定工资的员工” + name +“的工资”);
return salary/52;
}
}
同样,代码清单7.6中的Hourly类也使用extends关键字继承Employee类。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16: 17: 18: /*代码清单7.6 Hourly.java
重新设计的Hourly类,做为Employee的子类。
*/
public class Hourly extends Employee{
public float hourlyRate; //每小时报酬
public float hoursWorked; //每周工作时间
public float computePay(){
System.out.println(“计算按时付酬员工” + name + “的薪水”);
float pay = 0.0F;
if(hoursWorked <= 40){
pay = hourlyRate * hoursWorked;
}
else { //需要计算加班
pay = (hourlyRate * 40) + (hourlyRate * (hoursWorked - 40) * 1.5F);
}
return pay;
}
}
子类是否可以访问父类中的成员变量和方法,取决于访问修饰符,我们将在后一章中讨论。
Salary类中的computePay()方法将输出员工的姓名,但是Salary类总没有name这个成员变量。成员变量name是在Salary类的父类Employee类中。在上例中,因为name的访问修饰符是public,所以,Salary类可以访问该成员变量,并且可以在Salary类内的任何地方使用它。
同样,Hourly类使用从Employee类中继承的name成员变量,在其computePay()方法内输出员工的名字。
7.4 实例化子对象
在前面,我们已经定义了Employee、Salary、Hourly三个类,现在我们编写一个程序来实例化和使用这些类。代码清单7.7所示的InheritDemo程序创建了一个Employee、Salary和Hourly对象。仔细研究程序代码,判断程序运行的输出结果。
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:
26:
27:
28:
29:
30:
31:
32:
33:
34: 35: 36: /*代码清单7.7 InheritDemo.java
演示如何实例化子对象
*/
public class InheritDemo{
public static void main(String [] args){
System.out.println(“实例化一个Employee对象”);
Employee e = new Employee();
e.name = “张三”;
e.address = “111 Java Street”;
e.SSN = 999001111;
e.number = 1;
System.out.println(“实例化一个Salary对象”);
Salary s = new Salary();
s.name = “李四”;
s.address = “222 Oak Drive”;
s.SSN = 111009999;
s.number = 2;
s.salary = 100000.00F;
System.out.println(“实例化一个Hourly对象”);
Hourly h = new Hourly();
h.name = “王二”;
h.address = “333 Espresso Lane”;
h.SSN = 111990000;
h.number = 3;
h.hourlyRate = 10.00F;
h.hoursWorked = 50;
System.out.println(“发工资”);
//e.computePay(); //不能通过编译!
System.out.println(s.number + " " + s.computePay());
System.out.println(h.number + " " + h.computePay());
System.out.println(“邮寄支票”);
e.mailCheck();
s.mailCheck();
h.mailCheck();
}
}
代码详细解析如下:
第6~27行,实例化了三个员工类型,并初始化成员变量。输出:
实例化一个Employee对象
实例化一个Salary对象
实例化一个Hourly对象
发工资
第28行,注释掉一行语句。如果不注释掉,程序将不能通过编译。这里Employee类型的e引用不能调用computePay()方法,因为该对象没有computePay()方法。实际上,员工张三的工资是如何发的、发多少等信息是没有办法存储的。也就是说,没有员工是Employee类型的。但是,这并不意味着Employee类没有用处。即使我们不需要Employee类型的对象,Employee类也是我们在程序设计中的子类的基础。
第29~30行,salary和hourly对象的computePay()方法被调用。注意,在main()方法内,salary对象可以按照s.number的方式访问从父类继承的number成员变量,同样,hourly对象可以用h.number的方式访问从父类继承的number成员变量。这两行产生如下输出:
计算拿固定工资的员工李四的工资
2 1923.0769
计算按时付酬员工王二的工资
3 550.0
第31~34行,分别执行三个对象的mailCheck()方法,产生如下输出:
邮寄支票
邮寄支票给员工张三的地址为111 Java Street
邮寄支票给员工李四的地址为222 Oak Drive
邮寄支票给员工王二的地址为333 Espresso Lane
InheritDemo程序的全部输出如图7.1所示。
图7.1 InheritDemo程序的输出
7.5 单继承与多继承
某些OOP语言(例如C++)允许子类有多个父类。但是,在Java中这是不允许的。在Java中,一个类只能有一个父类。例如,Salary类不能同时继承Employee类和Manager类。多继承在Java中是不允许的。Java语言的目标之一是创建一个易于使用和理解的面向对象编程语言。而多继承只能给编程语言带来混淆。
一个Java类只能有一个父类。但是这并不意味着一个类没有祖父、曾祖父等等。一个Java类可以有父类、父类也可以父类,依此类推。例如,前面讨论的Salary类继承了Employee类。Salary类也可以成为一个父类。任何Salary的子类都要继承Salary和Employee类的属性和方法。
假如我们需要用一个类来代表兼职的雇员,这类员工有年薪,但是需要记录他们工作的时间。我们用新类PartTimeSalary来继承Salary类。这样,PartTimeSalary类就成为Salary类的子类,Salary类成为Employee类的子类。PartTimeSalary对象就继承了Salary和Employee类的一切。
代码清单7.8是PartTimeSalary类的定义。关键字extends用于继承Salary类,但是我们没有指定它也继承了Employee类。而编译器和JVM知道Salary继承了Employee。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10: 11: 12: /*代码清单7.8 PartTimeSalary.java
代表兼职雇员。
*/
public class PartTimeSalary extends Salary{
public int hoursWorked;
public int getHoursWorked(){
System.out.println("Getting hours for " + this.name + " earning " + salary);
return hoursWorked;
}
}
从上述代码,我们可以看到PartTimeSalary类只有一个方法以及一个成员变量。但是,一个PartTimeSalary对象有name、 adress、SSN、number、salary和hoursWorked共六个成员变量,getHoursWorked()、 computePay()和mailCheck()共三个方法。
注意,PartTimeSalary 类中的getHoursWorked()方法访问了从Employee类继承来的name成员变量,从Salary类继承的salary成员变量,以及该类本身的hoursWorked成员变量。
PartTimeSalary类中的getHoursWorked()方法中用this引用访问了this.name,以强调name成员变量是该对象的一个成员,即使该对象不是该类的一个成员变量。
7.6 所有类的根类Object
Java语言API中包含了一个名为Object的特殊类,它是整个Java类层次中的根类。Object类在java.lang包中,是每个Java类的父类,要么是直接的父类,要么就是间接父类。
例如,假如如下的Employee类没有声明父类:
public class Employee{
//类定义
}
因为这个Employee类没有显式地继承其它类,所以它隐式地继承了Object类。实际上,我们可以有如下的一个多余的extends关键字:
public class Employee extends Object{
//类定义
}
如果我们编写了一个类,并且没有显式地继承另一个类,那么编译器就会在我们的类声明中添加“extends Object”。如果我们编写的类继承了除Object以外的其它类,那么该类仍然是Object的子类,因为最终它的上代还是要继承Object类。
假如Salary类继承了Employee类,例如:
public class Salary extends Employee{
//类定义
}
Salary类继承Employee类,因为Java类只能有一个父类,所以Salary没有直接继承Object。而Employee继承了Object类,所以Salary实际上也是Object类的一个间接子类。
因为Object类是所有类的父类,所以Object类中的方法被每个Java对象所继承。也就是说,不管该对象是什么类类型,Object类中的方法都可以在任何Java对象中调用。
下面列出了Object类中的方法签名以及每个方法的描述:
public final Class getClass():Java程序中用到的每个类都是由JVM装载的,类的信息保存在一个Class对象中。使用该方法可以得到我们调用方法的对象的Class对象的引用。
public int hashCode():该方法返回对象的哈希值码。当使用哈希表以及使用哈希的其它数据结构时,可以用到该方法。
public boolean equals(Object x):使用该方法可以检测两个对象是否相等。这个方法经常被重写(方法重写将在下一节讨论)。注意,如果两个对象通过该方法判断是相等的,那么两个对象的hashCode() 方法应该产生相同的哈希码。
protected Object clone() throws CloneNotSupportedException:clone()方法用于创建对象的一份拷贝。当被克隆的对象不支持克隆时,就会出现异常。
public String toString():该方法返回对象的字符串表示。用字符串来表示对象有助于调试或测试。Java文档推荐对所有编写的类加上toString()方法,这是Java编程中的一个广泛使用的技巧。
protected void finalize() throws Throwable:该方法在对象将要被垃圾回收时调用。finalize()方法允许在从内存中移除对象之前,释放对象所占用的资源以及执行必需的清理工作。
public final void wait() throws InterruptedException.:在Object类中,wait()方法还有其它两个重载版本。在一个对象上调用wait()方法将导致当前线程停止执行,直到其它线程在相同对象上调用notify()为止。wait()和notify()方法用于线程同步。
public final void notify():在Object类中还有一个notifyAll()方法。这些方法用于重启在对象上调用wait()方法时被阻塞的任何线程。
Object类的静态方法,例如wait()和notify(),不能被Object的子类修改;但是非静态方法可以被Object的子类修改。例如,Object类的toString()方法的默认行为是输出类名,后跟一个@符号和哈希码值。如果我们不想使用默认行为,那么就要在我们的类中添加toString()方法,以输出我们想要的任何字符串。
例如,代码清单7.9所示的Radio类不包含toString()方法,但是它继承了Object类默认的toString()方法。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12: 13: 14: /*代码清单7.9 Radio.java
Radio类
*/
public class Radio{
public int volume;
public double channel;
public char band;
public Radio(int v, double c, char b){
volume = v;
channel = c;
band = b;
}
}
代码清单7.10所示的ToStringDemo程序调用Radio类中的toString()方法。仔细研究ToStringDemo程序,判断输出结果。
1:
2:
3:
4:
5:
6:
7:
8:
9: 10: 11: /*代码清单7.10 ToStringDemo.java
演示toString()方法。
*/
public class ToStringDemo{
public static void main(String [] args){
Radio radio = new Radio(7, 100.3, ‘F’);
System.out.println("toString 返回 " + radio.toString());
System.out.println("只是打印出引用: " + radio);
}
}
分别编译Radio.java、ToStringDemo.java,并运行程序ToStringDemo,输出结果如图7.2所示。
图7.2 ToStringDemo程序的输出
第8行,调用radio对象上的toString()方法,并输出字符串“Radio@c17164”。
第9行,打印radio对象的引用,输出字符串“只是打印出引用:Radio@c17164”。
在Java中,当引用与一个字符串连接时,JVM将隐式地调用toString()方法。当radio引用与字符串“只是打印出引用:”相连时,在连接发生前,引用必须转换为字符串。这里,toString()方法是自动调用的。因为Java中每个对象都是Object的子类,每个对象都有一个toString()方法,于是在每次需要将对象转换为字符串时,JVM都会自动调用toString()。
默认的toString()方法虽然很有用,但是有时候不能满足要求。为了让toString()使用我们自己写的类,在Radio类中,我们可以添加一个toString()方法,用Radio类本身的toString()方法来替换默认的toString()方法,这就是方法重写。
7.7 方法重写
子类可以重写从父类继承的方法,从而允许子类添加或者改变父类中方法的行为。这称为方法重写,是OOP的特征之一。
当子类重写父类的方法时,必须遵循如下的规则:
子类的方法的返回值的类型、方法名和形式参数列表,必须和父类中的是相同的。如果子类中的方法与父类中的方法同名,但是子类的方法改变了形式参数列表,那么这是方法重载,而不是方法重写。不要混淆这两个概念,二者的用法是完全不同的。
访问修饰符必须不小于父类中的访问修饰符。例如,如果父类的方法是public,那么子类的必须是public。如果父类的方法是protected,那么子类必须是protected或public(public比protected访问权更大)。
子类中重写的异常不能抛出比父类更多的异常,其原因我们将在后面异常处理一章中讲解。
前面我们看到的Radio类继承了Object类的所有方法,但是没有重写它们。代码清单7.11中的Radio类覆盖了toString()方法,因而替换了Object类的toString()方法的行为。
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20: 21: 22: /*代码清单7.11 Radio.java
演示重写toString()方法
*/
public class Radio{
public int volume;
public double channel;
public char band;
public Radio(int v, double c, char b){
volume = v;
channel = c;
band = b;
}
public String toString(){
System.out.println(“在Radio的toString方法内部”);
String rep = "收音机音量 = " + volume + ", 频道 = " + channel + ", 波段 = " + band;
return rep;
}
}
Object类中的toString()方法是声明为public的,因此,在Radio中的toString()方法必须声明为public。如果Radio中的toString()的访问修饰符的访问优先权更小,Radio 类是无法通过编译的。
现在,当一个Radio对象上的toString()方法被调用时,是调用Radio类中的toString()方法,而不是Object类中的toString()方法。也就是说,Object类中的toString()方法被隐藏了,这也是我们想要的结果。
现在我们使用新的Radio类,重新编译它,然后再运行ToStringDemo程序,输出结果如图7.3所示。
图7.3 Radio类中重写的toString()方法被调用
7.7.1 equals()方法
Object类有一个用于判断两个对象是否相等的equals()方法。我们编写的每一个类应该覆盖equals()方法,以让类的用户判断什么时候类的实例是相等的。
如果两个Employee对象是相等的,这意味着什么?我们需要做出决定。例如,如果两个员工在同一个部门工作、有相同的部门经理、发同等数额的报酬,那么这两个员工可能是相等的。更肯定地,如果两个员工有相同的员工号,那么这两个员工就是相等的。选定了哪种逻辑,那么我们就应该通过重写Employee类中的equals()方法来执行那种逻辑。
代码清单7.12中的Employee类重写了equals()方法,如果两个对象有相同的员工号number,那么二者就是相等的。
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:
26:
27: 28: 29: /*代码清单7.12 Employee.java
演示重写equals()方法
*/
public class Employee{
public String name;
public String address;
public int SSN;
public int number;
public void mailCheck(){
System.out.println(“将员工” + name + "的支票寄到 " + address);
}
public boolean equals(Object x){
if(x == null) return false;
Employee other = (Employee) x;
if(this.number == other.number){
return true;
}
else{
return false;
}
}
public int hashCode(){
return this.number;
}
}
分析上述代码中重写的equals()方法,我们可以看到方法体内首先测试传入的参数x是否等于null。引用为null是可能的,因此进行测试是一个好的习惯。随后,传递进来的引用被强制转换为一个Employee类型。这是必须的,因为我们想将这个对象看作是一个Employee对象。注意,沿着类继承层次树向下强制转换是相当危险的,通常我们应该使用instanceof运算符来确保x是Employee类型的。关于沿着继承层次树向下强制转换的概念,我们将在《多态和抽象》一章中讲解。
我们还可以注意到类中还添加了一个hashCode()方法。一个必须遵循的常用规则就是:如果两个对象是相等的,那么它们必须产生相同的哈希码。因此,如果一个类重写了equals()方法,通常也需要重写hashCode()方法。
下面的语句实例化了两个Employee对象,并测试二者是否相等:
Employee e1 = new Employee();
Employee e2 = new Employee();
e1.number = 101;
e2.number = 102;
if(e1.equals(e2)) System.out.println(“这条语句不会输出.”);
e2.number = 101;
if(e2.equals(e1)) System.out.println(“这条语句会输出.”);
if(e1 == e2) System.out.println(“这条语句也不会输出.”);
在上述语句中,实例化了两个Employee对象,所以对我们来说就有了两个equals()方法,一个是e1的,一个是e2的。不管你调用e1.equals(e2)还是e2.equals(e1),结果都是相同的。
equals()方法比较两个对象,测试二者是否相等。比较运算符“”用于检测是否两个引用指向同一对象,这种比较方法是与equals()完全不同的。如果e1和e2指向不同的对象,那么不管e1是否与e2相等,表达式e1e2的计算结果都是false。