corejava11(5.1 类,超类,以及子类)

5.1 类,超类,以及子类

让我们回到上一章讨论的Employee类。假设你在一家公司工作,在那里经理的待遇不同于其他员工。当然,经理在很多方面都和员工一样。员工和经理都有薪水。然而,虽然员工被期望完成他们分配的任务以换取他们的工资,但如果经理们真的实现了他们应该做的,他们就会得到奖金。这是一种需要继承的情况。为什么?嗯,您需要定义一个新的类,Manager,并且添加功能。但是您可以保留一些您已经在Employee类中编程的内容,并且可以保留原始类的所有字段。更抽象地说,经理和员工之间存在着明显的“is-a”关系。每个经理都是雇员:这种“is-a”的关系是继承的标志。

注意

在这一章中,我们使用员工和经理的经典例子,但是我们必须要求您用修改一下这个例子。在现实世界中,员工可以成为经理,所以您希望建模让经理成为员工的角色,而不是子类。然而,在我们的例子中,我们假设企业世界中有两类人:永远是员工的人和一直是经理的人。

5.1.1 定义超类

下面是如何定义从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。

当通过扩展子类来定义子类时,您只需要指出子类和超类之间的区别。在设计类时,您将最通用的方法放在超类中,而将更专用的方法放在其子类中。在面向对象编程中,通过将通用功能移动到超类来分解通用功能是一种通常做法。

5.1.2 重载方法

有些超类方法不适用于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。

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);

是“使用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;
   }
}

5.1.4 继承层次结构

继承不会停在派生一层类之后。例如,我们可以有一个扩展Manager的Executive类。扩展公共超类的所有类的集合称为继承层次结构,如图5.1所示。在继承层次结构中,从特定类到其祖先的路径是其继承链。

图5.1 员工继承层次结构

通常有不止一个来自远祖类的血统链。您可以形成继承Employee的子类Programmer或Secretary,它们与Manager类(或彼此)没有任何关系。只要有必要,这个过程就可以继续。

C++注意

在C++中,一个类可以有多个超类。Java不支持多重继承。有关恢复多继承功能的方法,请参见第296页第6.1节“接口”。

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类任何子类的对象(如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。

5.1.6 理解方法调用

准确理解方法调用如何应用于对象是很重要的。假设我们调用x.f(args),并且隐式参数x声明为类C的对象。下面是发生的情况:

  1. 编译器检查对象的声明类型和方法名。请注意,可能有多个方法,它们都具有相同的名称f,但参数类型不同。例如,可能有一个方法f(int)和一个方法f(string)。编译器枚举类C中调用f的所有方法和C的超类中调用f的所有可访问方法(超类的私有方法不可访问)。
    现在编译器知道了要调用的方法的所有可能的候选者。
  2. 接下来,编译器确定方法调用中提供的参数类型。如果在所有调用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方法有协变的返回类型。

  1. 如果方法是private、static、final或构造函数,那么编译器就确切地知道要调用哪个方法。(下一节将解释final修饰符。)这称为静态绑定。否则,要调用的方法取决于隐式参数的实际类型,并且必须在运行时使用动态绑定。在我们的示例中,编译器将生成一条使用动态绑定调用f(String)的指令。
  2. 当程序运行并使用动态绑定调用方法时,虚拟机必须调用适合x引用的对象的实际类型的方法版本。假设实际的类型是D,C的子类。如果类D定义了方法f(String),则调用该方法。如果没有,将搜索D的超类以查找方法f(String),依此类推。
    每次调用方法时都要执行此搜索,这将非常耗时。相反,虚拟机为每个类预计算一个方法表,该表列出所有方法签名和要调用的实际方法。当实际调用方法时,虚拟机只需进行表查找。在我们的示例中,虚拟机查询类D的方法表,并查找要调用f(String)的方法。该方法可以是D.f(string)X.f(string),其中X是D的某个超类。这个场景有一个转折点。如果调用是super.f(param),那么编译器将查询隐式参数的超类的方法表。

让我们仔细看一看清单5.1中调用e.getSalary()的过程。e的声明类型为EmployeeEmployee类有一个名为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()的解析如下:

  1. 首先,虚拟机获取实际类型e的方法表。这可能是EmployeeManagerEmployee的其他子类的表。
  2. 然后,虚拟机查找getSalary()签名的定义类。现在它知道要调用哪个方法了。
  3. 最后,虚拟机调用该方法。

动态绑定有一个非常重要的特性:它使程序可以扩展,而不需要修改现有的代码。假设添加了一个新的类Executive,并且变量e可能引用该类的对象。不需要重新编译包含调用e.getSalary()的代码。如果e恰好引用Executive类型的对象,则会自动调用Executive.getSalary()方法。

小心

当您重写一个方法时,子类方法必须至少和超类方法一样可见。特别是,如果超类方法是public,子类方法也必须声明为public。意外地省略子类方法的public说明符是一个常见错误。然后编译器会抱怨您试图提供更严格的访问权限。

5.1.7 防止继承: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类的getTimesettTime方法是final的。这表明Calendar类的设计人员已经接管了Date类和日历状态之间的转换。不应允许任何子类破坏这种安排。类似地,String类是final类。这意味着没有人能定义String的子类。换句话说,如果您有一个String引用,您就知道它引用的必然是一个字符串。

一些程序员认为,除非您有充分的理由需要多态性,否则应该将所有方法声明为final方法。事实上,在C++和C语言中,除非特别请求,否则方法不使用多态性。这可能有点极端,但我们同意在设计类层次结构时仔细考虑final方法和类是一个好主意。

在早期的Java中,一些程序员使用final关键字来避免动态绑定的开销。如果一个方法没有被重写,而且很短,那么编译器就可以优化方法调用——一个称为inlining的过程。例如,内联调用e.getname()将其替换为访问字段e.name。这是一个有价值的改进——CPU讨厌分支,因为它会干扰它们在处理当前指令时预取指令的策略。但是,如果getName可以在另一个类中被重写,那么编译器就不能内联它,因为它无法知道重写代码可能会做什么。

幸运的是,虚拟机中的实时编译器可以比传统编译器做得更好。它确切地知道哪些类扩展了给定的类,并且可以检查是否有任何类实际重写了给定的方法。如果一个方法很短,经常被调用,并且实际上没有被重写,则实时编译器可以将其内联。如果虚拟机加载重写内联方法的另一个子类,会发生什么情况?然后优化器就会取消内联。这需要时间,但很少发生。

5.1.8 强制转换

回忆一下第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<Manager* >(staff[1]); // C++

有一个重要的区别。如果强制转换失败,则不会生成空对象,而是引发异常。从这个意义上讲,它就像一个C++的引用模型。这是颈部疼痛。在C++中,您可以在一个操作中处理类型测试和类型转换。

Manager* boss = dynamic_cast<Manager*>(staff[1]); // C++
if (boss != NULL) . . .

在Java中,需要使用instanceof运算符和强制转换的组合。

if (staff[1] instanceof Manager)
{
    Manager boss = (Manager) staff[1];
    . . .
}

5.1.9 抽象类

当您向上移动继承层次结构时,类变得更一般,可能更抽象。在某种程度上,祖先类变得如此普遍,以至于你认为它更多的是作为其他类的基础,而不是作为一个具有你想要使用的特定实例的类。例如,考虑一下我们的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

这种方法很容易在EmployeeStudent类中实现。但是你能在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;
   }
}

5.1.10 protected访问权限

如您所知,类中的字段最好标记为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中四种访问控制修饰符的摘要:

  1. 只能在类中访问(private)。
  2. 所有人都可访问(public)。
  3. 可在包和所有子类中访问(protected)。
  4. 在包中可以访问(不幸的)默认值。不需要修改器。

猜你喜欢

转载自blog.csdn.net/nbda1121440/article/details/90597631