让我们回到上一章讨论的Employee类。假设你在一家公司工作,在那里经理的待遇不同于其他员工。当然,经理在很多方面都和员工一样。员工和经理都有薪水。然而,虽然员工被期望完成他们分配的任务以换取他们的工资,但如果经理们真的实现了他们应该做的,他们就会得到奖金。这是一种需要继承的情况。为什么?嗯,您需要定义一个新的类,Manager,并且添加功能。但是您可以保留一些您已经在Employee类中编程的内容,并且可以保留原始类的所有字段。更抽象地说,经理和员工之间存在着明显的“is-a”关系。每个经理都是雇员:这种“is-a”的关系是继承的标志。
注意
在这一章中,我们使用员工和经理的经典例子,但是我们必须要求您用修改一下这个例子。在现实世界中,员工可以成为经理,所以您希望建模让经理成为员工的角色,而不是子类。然而,在我们的例子中,我们假设企业世界中有两类人:永远是员工的人和一直是经理的人。
下面是如何定义从Employee
类继承的Manager
类。使用Java关键字extends
表示继承。
public class Manager extends Employee
{
added methods and fields
}
C++注意
继承在Java和C++中是相似的。Java使用extends关键字而不是
:
。Java中的所有继承都是公共继承;没有对私有和受保护继承的C++特性的模拟。
关键字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对象,您可以简单地应用setBonus方法。
Manager boss = . . .;
boss.setBonus(5000);
当然,如果您有Employee对象,则不能应用setBonus方法,因为它不在Employee类定义的方法中。
但是,可以将getName和getHireday等方法与Manager对象一起使用。即使这些方法没有在Manager类中明确定义,它们也会自动从Employee超类继承。
类似地,字段name、salary和hireDay都取自超类。每个Manager对象有四个字段:name、salary、hireDay和bonus。
当通过扩展子类来定义子类时,您只需要指出子类和超类之间的区别。在设计类时,您将最通用的方法放在超类中,而将更专用的方法放在其子类中。在面向对象编程中,通过将通用功能移动到超类来分解通用功能是一种通常做法。
有些超类方法不适用于Manager子类。尤其是getSalary方法应该返回基本工资和奖金的总和。您需要提供一个新方法来重写超类方法:
public class Manager extends Employee
{
. . .
public double getSalary()
{
. . .
}
. . .
}
你如何实现这个方法?乍一看,这似乎很简单-只需返回工资和奖金字段的总和:
public double getSalary()
{
return salary + bonus; // won't work
}
不过,这行不通。回想一下,只有Employee方法可以直接访问Employee类的私有字段。这意味着Manager类的getSalary方法不能直接访问Salary字段。如果Manager方法想要访问这些私有字段,那么它们必须执行其他方法使用公共接口所执行的操作,在本例中是Employee类的公有getSalary方法。
所以,让我们再试一次。您需要调用getSalary,而不是简单地访问salary字段:
public double getSalary()
{
double baseSalary = getSalary(); // still won't work
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赋给另一个对象变量。相反,super是一个特殊的关键字,它指示编译器调用超类方法。
如您所见,子类可以添加字段,它可以添加方法或重写超类的方法。然而,继承永远不能移除任何字段或方法。
C++注意
Java使用关键字super调用超类方法。在C++中,您将使用带有
::
运算符的超类的名称代替。例如,Manager类的getSalary方法将调用Employee::getSalary而不是super.getSalary。
为了完成我们的示例,让我们提供一个构造函数。
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);
是“使用n、s、year、month和day作为参数调用Employee超类的构造函数”的简写。
由于Manager构造函数无法访问Employee类的私有字段,因此必须通过构造函数初始化它们。使用特殊的super语法调用构造函数。使用super的调用必须是子类的构造函数中的第一条语句。
如果子类构造函数没有显式调用超类构造函数,则会调用该超类的无参数构造函数。如果超类没有一个无参数构造函数,并且子类构造函数不显式调用另一个超类构造函数,则Java编译器会报告一个错误。
注意
回想一下,this关键字有两个含义:表示对隐式参数的引用,并调用同一类的另一个构造函数。同样,super关键字有两个含义:调用超类方法和调用超类构造函数。当用于调用构造函数时,this和super关键字是密切相关的。构造函数调用只能作为另一个构造函数中的第一条语句发生。构造函数参数要么传递给同一类(this)的另一个构造函数,要么传递给超类(super)的构造函数。
C++注意
在C++构造函数中,不需要调用super,但使用初始化列表语法构造超类。在C++中,Manager构造函数会是这样的:
// C++ Manager::Manager(String name, double salary, int year, int month, day) : Employee(name, salary, year, month, day) { bonus = 0; }
重新定义Manager对象的getSalary方法后,经理将自动将奖金添加到他们的工资中。
这是一个工作中的例子。我们任命一名新经理并设置经理奖金:
Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
boss.setBonus(5000);
我们有三名员工:
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
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()将调用Employee类的getSalary方法。但是,当e引用一个Manager对象时,将调用Manager类的getSalary方法。虚拟机知道e引用的对象的实际类型,因此可以调用正确的方法。
一个对象变量(如变量e)可以引用多个实际类型的事实称为多态性。在运行时自动选择适当的方法称为动态绑定。在本章中,我们将更详细地讨论这两个主题。
C++注意
在C++中,如果需要动态绑定,则需要声明成员函数为
virtual
。在Java中,动态绑定是默认行为;如果不希望方法是virtual的,则将其标记为final的。(我们将在本章后面讨论final关键字。)
清单5.1包含一个程序,该程序显示了Employee(清单5.2)和Manager(清单5.3)对象的薪资计算是如何不同的。
清单5.1 inheritance/ManagerTest.java
package inheritance;
/**
* This program demonstrates inheritance.
* @version 1.21 2004-02-21
* @author Cay Horstmann
*/
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());
}
}
清单5.2 inheritance/Employee.java
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 += raise;
}
}
清单5.3 inheritance/Manager.java
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;
}
public double getSalary()
{
double baseSalary = super.getSalary();
return baseSalary + bonus;
}
public void setBonus(double b)
{
bonus = b;
}
}
继承不会停在派生一层类之后。例如,我们可以有一个扩展Manager的Executive类。扩展公共超类的所有类的集合称为继承层次结构,如图5.1所示。在继承层次结构中,从特定类到其祖先的路径是其继承链。
图5.1 员工继承层次结构
通常有不止一个来自远祖类的血统链。您可以形成继承Employee的子类Programmer或Secretary,它们与Manager类(或彼此)没有任何关系。只要有必要,这个过程就可以继续。
C++注意
在C++中,一个类可以有多个超类。Java不支持多重继承。有关恢复多继承功能的方法,请参见第296页第6.1节“接口”。
一个简单的规则可以帮助您决定继承是否是数据的正确设计。“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类任何子类的对象(如Manager、Executive、Secretary等)。
我们在清单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引用的员工对象不是经理,那么稍后调用m.setBonus(…)会发生运行时错误。
小心
在Java中,子类引用的数组可以不需要强转就能转换为超类引用数组。例如,考虑这个Manager数组:
Manager[] managers = new Manager[10];
将此数组转换为Employee[]数组是合法的:
Employee[] staff = managers; // OK
当然,你可能会想,为什么不呢?毕竟,如果managers[i]是Manager,那么它也是Employee。但事实上,一些令人惊讶的事情正在发生。请记住,managers和staff是对同一数组的引用。现在考虑一下声明
staff[0] = new Employee("Harry Hacker", . . .);
编译器将欣然地允许此分配。但staff[0]和managers[0]是同一个引用,所以看起来我们好像成功地把一个员工偷偷带进了管理层。这将是非常糟糕的,调用managers[0].setBonus(1000)将尝试访问不存在的实例字段并损坏相邻的内存。
为了确保不会发生这样的损坏,所有数组都记住创建它们时使用的元素类型,并且它们监视只有兼容的引用存储在其中。例如,new Mannager[10]创建的数组记住它是一个Manager数组。尝试存储Employee引用会导致ArrayStoreException。
准确理解方法调用如何应用于对象是很重要的。假设我们调用x.f(args),并且隐式参数x声明为类C的对象。下面是发生的情况:
f
,但参数类型不同。例如,可能有一个方法f(int)
和一个方法f(string)
。编译器枚举类C中调用f
的所有方法和C的超类中调用f
的所有可访问方法(超类的私有方法不可访问)。x.f("hello")
中,编译器选择f(String)
而不是f(int)
。由于类型转换(int到double、Manager到Employee等),情况可能变得复杂。如果编译器找不到任何具有匹配参数类型的方法,或者应用转换后多个方法都匹配,则编译器将报告错误。注意
回想一下,方法的名称和参数类型列表称为方法的签名。例如,f(int)和f(String)是两个同名但签名不同的方法。如果在与超类方法具有相同签名的子类中定义了一个方法,则重写该超类方法。
返回类型不是签名的一部分。但是,当重写方法时,需要保持返回类型兼容。子类可以将返回类型更改为原始类型的子类型。例如,假设Employee类有一个方法
public Employee getBuddy() { . . . }
经理永远不想让一个卑微的员工做朋友。为了反映这一事实,Manager子类可以将此方法重写为
public Manager getBuddy() { . . . } // OK to change return type
我们说这两个getBuddy方法有协变的返回类型。
final
修饰符。)这称为静态绑定。否则,要调用的方法取决于隐式参数的实际类型,并且必须在运行时使用动态绑定。在我们的示例中,编译器将生成一条使用动态绑定调用f(String)的指令。f(String)
,则调用该方法。如果没有,将搜索D的超类以查找方法f(String)
,依此类推。f(String)
的方法。该方法可以是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方法。
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说明符是一个常见错误。然后编译器会抱怨您试图提供更严格的访问权限。
有时,您希望防止某人形成您的某个类的子类。不能扩展的类称为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
和settTime
方法是final的。这表明Calendar类的设计人员已经接管了Date类和日历状态之间的转换。不应允许任何子类破坏这种安排。类似地,String类是final类。这意味着没有人能定义String的子类。换句话说,如果您有一个String引用,您就知道它引用的必然是一个字符串。
一些程序员认为,除非您有充分的理由需要多态性,否则应该将所有方法声明为final方法。事实上,在C++和C语言中,除非特别请求,否则方法不使用多态性。这可能有点极端,但我们同意在设计类层次结构时仔细考虑final方法和类是一个好主意。
在早期的Java中,一些程序员使用final关键字来避免动态绑定的开销。如果一个方法没有被重写,而且很短,那么编译器就可以优化方法调用——一个称为inlining的过程。例如,内联调用e.getname()
将其替换为访问字段e.name
。这是一个有价值的改进——CPU讨厌分支,因为它会干扰它们在处理当前指令时预取指令的策略。但是,如果getName
可以在另一个类中被重写,那么编译器就不能内联它,因为它无法知道重写代码可能会做什么。
幸运的是,虚拟机中的实时编译器可以比传统编译器做得更好。它确切地知道哪些类扩展了给定的类,并且可以检查是否有任何类实际重写了给定的方法。如果一个方法很短,经常被调用,并且实际上没有被重写,则实时编译器可以将其内联。如果虚拟机加载重写内联方法的另一个子类,会发生什么情况?然后优化器就会取消内联。这需要时间,但很少发生。
回忆一下第3章,强制从一种类型转换为另一种类型的过程称为强制转换。Java编程语言有一个特殊的符号用于强制转换。例如,
double x = 3.405;
int nx = (int) x;
将表达式x的值转换为整数,丢弃小数部分。
正如有时需要将浮点数转换为整数一样,可能需要将对象引用从一个类转换为另一个类。要实际进行对象引用的转换,请使用与转换数值表达式类似的语法。用圆括号将目标类名括起来,并将其放在要强制转换的对象引用之前。例如:
Manager boss = (Manager) staff[0];
您希望强制转换的原因只有一个—为了完全使用该对象,因为对象的实际类型已经被忘掉了。例如,在ManagerTest类中,Staff数组必须是Employee对象的数组,因为它的一些元素是常规雇员。我们需要将数组的manager元素强制转换回Manager以访问属于它的新变量。(请注意,在第一节的示例代码中,我们特别努力避免使用强制转换。我们用一个Manager对象初始化了boss变量,然后将其存储在数组中。我们需要正确的类型来设置经理的奖金。)
正如你所知道的,在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 instanceof C
如果x为null,则不生成异常。它只是返回false。这是有道理的:null表示没有对象,所以它肯定没有引用C类型的对象。
实际上,通过强制转换对象的类型通常不是一个好主意。在我们的示例中,大多数情况下不需要将Employee对象强制转换为Manager对象。getSalary
方法将在两个类的两个对象上正确工作。使多态性工作的动态绑定自动定位正确的方法。
进行强制转换的唯一原因是使用manager特有的方法,如setBonus。如果出于某种原因,您发现自己想对Employee对象调用setBonus,那么问问自己这是否表明超类中存在设计缺陷。重新设计超类并添加setBonus方法可能更有意义。记住,终止程序只需要一个未捕获的ClassCastException
。一般来说,最好尽量减少使用强制转换和instanceof
运算符。
C++注意
Java使用C的“bad old days”的强制转换语法,但是它的工作方式与C++的安全
dynamic_cast
操作类似。例如,Manager boss = (Manager) staff[1]; // Java
等同于
Manager* boss = dynamic_cast
(staff[1]); // C++ 有一个重要的区别。如果强制转换失败,则不会生成空对象,而是引发异常。从这个意义上讲,它就像一个C++的引用模型。这是颈部疼痛。在C++中,您可以在一个操作中处理类型测试和类型转换。
Manager* boss = dynamic_cast
(staff[1]); // C++ if (boss != NULL) . . . 在Java中,需要使用
instanceof
运算符和强制转换的组合。if (staff[1] instanceof Manager) { Manager boss = (Manager) staff[1]; . . . }
当您向上移动继承层次结构时,类变得更一般,可能更抽象。在某种程度上,祖先类变得如此普遍,以至于你认为它更多的是作为其他类的基础,而不是作为一个具有你想要使用的特定实例的类。例如,考虑一下我们的Employee
类层次结构的扩展。employee是人,student也是。让我们扩展我们的类层次结构,包括类Person和Student。图5.2显示了这些类之间的继承关系。
图5.2 Person及其子类的继承关系图
为什么要为如此高的抽象级别而烦恼呢?有些属性对每个人都有意义,比如名字。学生和员工都有名字,引入一个通用的超类可以让我们将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
为了增加清晰度,具有一个或多个abstract方法的类本身必须声明为abstract的。
public abstract class Person
{
. . .
public abstract String getDescription();
}
除了abstract方法之外,abstract类还可以有字段和具体方法。例如,Person类存储Person的名称,并具有返回该名称的具体方法。
public abstract class Person
{
private String name;
public Person(String name)
{
this.name = name;
}
public abstract String getDescription();
public String getName()
{
return name;
}
}
提示
一些程序员没有意识到抽象类可以有具体的方法。您应该始终将公共字段和方法(无论是否抽象)移动到超类(无论是否抽象)。
抽象方法充当在子类中实现的方法的占位符。扩展抽象类时,有两个选择。可以不定义某些或所有抽象方法;然后必须将子类标记为抽象的。或者您可以定义所有方法,子类不再是抽象的。
例如,我们将定义一个Student
类,该类扩展抽象的Person
类并实现getDescription
方法。Student
类的方法都不是抽象的,因此不需要声明为抽象类。
类甚至可以声明为abstract
,尽管它没有抽象方法。
无法实例化抽象类。也就是说,如果一个类被声明为abstract
类,则不能创建该类的任何对象。例如,表达式
new Person("Vince Vu")
是一个错误。但是,您可以创建具体子类的对象。
注意,您仍然可以创建抽象类的对象变量,但是这样的变量必须引用非抽象子类的对象。例如:
Person p = new Student("Vince Vu", "Economics");
这里p是一个抽象类型的变量Person,它引用了一个非抽象子类Student的实例。
C++注意
在C++中,抽象方法被称为纯虚函数,并用尾随
=0
来标记,例如class Person // C++ { public: virtual string getDescription() = 0; . . . };
如果C++类具有至少一个纯虚函数,则为抽象类。在C++中,没有特殊的关键字来表示抽象类。
让我们定义一个扩展抽象类Student的具体子类Person:
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(清单5.5)和两个具体的子类,Employee(清单5.6)和Student(清单5.7)。我们用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()
这不是对未定义方法的调用吗?请记住,变量p
从不引用Person
对象,因为无法构造抽象Person
类的对象。变量p
总是引用一个具体子类的对象,例如Employee或Student。对于这些对象,将定义getDescription方法。
您是否可以从Person超类中完全省略抽象方法,只在Employee和Student子类中定义getDescription方法?如果这样做,就无法在变量p上调用getDescription方法。编译器确保只调用类中声明的方法。
抽象方法是Java编程语言中的一个重要概念。您将在接口内部最常见地遇到它们。有关接口的更多信息,请参阅第6章。
清单5.4 abstractClasses/PersonTest.java
package abstractClasses;
/**
* This program demonstrates abstract classes.
* @version 1.01 2004-02-21
* @author Cay Horstmann
*/
public class PersonTest
{
public static void main(String[] args)
{
var people = new Person[2];
// fill the people array with Student and Employee objects
people[0] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
people[1] = new Student("Maria Morris", "computer science");
// print out names and descriptions of all Person objects
for (Person p : people)
System.out.println(p.getName() + ", " + p.getDescription());
}
}
清单5.5 abstractClasses/Person.java
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;
}
}
清单5.6 abstractClasses/Employee.java
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;
}
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 += raise;
}
}
清单5.7
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;
}
public String getDescription()
{
return "a student majoring in " + major;
}
}
如您所知,类中的字段最好标记为private
,方法通常标记为public
。声明为private
的任何功能在其他类中都无法访问。正如我们在本章开头所说,对于子类也是如此:子类不能访问其超类的私有字段。
但是,有时您希望将一个方法仅限于子类,或者不太常见地允许子类方法访问超类字段。在这种情况下,您将类功能声明为protected
。例如,如果超类Employee
声明hireDay
字段是protected
的而不是private
,那么Manager方法可以直接访问它。
在Java中,受保护的字段可以由同一包中的任何类访问。现在考虑另一个包中的Administrator
子类。Administrator
类的方法只能查看Administrator
对象的hireDay
字段,而不能查看其他Employee
对象。这样做的目的是为了不滥用protected
的机制,通过形成子类来访问受保护的字段。
在实践中,小心使用protected
字段。假设您的类被其他程序员使用,并且您用受protected
字段来设计它。您不知道,其他程序员可能会从您的类继承类,并开始访问protected字段。在这种情况下,如果不打扰那些程序员,就不能再更改类的实现。这违背了OOP的精神,OOP鼓励数据封装。
protected方法更有意义。如果很难使用,类可以将方法声明为受保护的。这表明可以信任子类(可能很了解它们的祖先)正确地使用该方法,但其他类不能。
这种方法的一个很好的例子是Object类的clone方法,更多细节见第6章。
C++注意
正如前面提到的,Java中的protected特性可以访问所有子类以及同一个包中的所有其他类。这与C++中protected含义略有不同,它使得Java中
protected
概念比C++更不安全。
下面是Java中四种访问控制修饰符的摘要: