黄子涵

5.1 类、超类和子类

假设你在某个公司工作,这个公司里经理的待遇与普通员工的待遇存在着一些差异。不过,他们之间也存在着很多相同的地方,例如,他们都领取薪水。只是普通员工在完成本职任务之后仅领取薪水,而经理在完成了预期的业绩之后还能得到奖金。这种情形就需要使用继承。为什么呢?因为需要为经理定义一个新类Manager,并增加一些新功能。但可以重用Employee类中已经编写的部分代码,并保留原来Employee类中的所有字段。从理论上讲,在Manager与Employee之间存在着明显的“is-a”(是)关系,每个经理都是一个员工:“is-a”关系是继承的一个明显特征。

注释

这一章中,我们使用了员工和经理的经典示例,不过必须提醒你的是对这个例子要有所保留。在真实世界里,员工也可能会成为经理,所以你建模时可能希望经理 也是员工,而不是员工的一个子类。不过,在我们的例子中,假设公司里只有两类人:一些人永远是员工,另一些人一直是经理。

定义子类

可以如下继承Employee类来定义Manager类,这里使用关键字extends表示继承。

public class Manager extends Employee
{
    added methods and fields
}

关键字extends

关键字extends表明正在构造的新类派生于一个已存在的类。这个已存在的类称为超类(superclass)、基类(base class)或父类(parent class);新类称为子类(subclass)、派生类(derived class) 或孩子类(child class)。超类和子类是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);

当然,由于setBonus方法不是在Employee类中定义的,所以属于Employee类的对象不能使用它。

然而,尽管在Manager类中没有显式地定义getNamegetHireDay等方法,但是可以Manager对象使用这些方法,这是因为Manager类自动地继承了超类Employee中的这些方法。

类似地,从超类中还继承了namesalaryhireDay这3个字段。这样一来,每个Manager对象就包含了4个字段:namesalaryhireDaybonus

通过扩展超类定义子类的时候,只需要指出子类与超类的不同之处。因此在设计类的时候,应该将最一般的方法放在超类中,而将更特殊的方法放在子类中,这种将通用功能抽取到超类的做法在面向对象程序设计中十分普遍。

覆盖方法

超类中的有些方法对子类Manager并不一定适用。具体来说,Manager类中的getSalary方法应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖(override)超类中的这个方法:

public class Manager extends Employee
{
    . . .
    public double getSalary()
    {
        . . .
    }
    . . .
}

应该如何实现这个方法呢?乍看起来似乎很简单,只要返回salarybonus字段的总和就可以了:

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方法(就是我们正在实现的这个方法),所以这条语句将会导致无限次地调用自己,直到整个程序最终崩溃。

关键字super

这里需要指出:我们希望调用超类Employee中的getSalary方法,而不是当前类的这个方法。为此,可以使用特殊的关键字super解决这个问题:

super.getSalary()

这个语句调用的是Employee类中的getSalary方法。下面是Manager类中getSalary方法的正确版本:

public double getSalary()
{
    double baseSalary = super.getSalary(); 
    return baseSalary + bonus;
}

注释

有些人认为superthis引用是类似的概念,实际上,这样比较并不太恰当。 这是因为super不是一个对象的引用,例如,不能将值super赋给另一个对象变量,它只是一个指示编译器调用超类方法的特殊关键字。

正像前面所看到的那样,在子类中可以增加字段、增加方法或覆盖超类的方法,不过,继承绝对不会删除任何字段或方法。

子类构造器

在例子的最后,我们来提供一个构造器。

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关键字也有两个含义:一是调用超类的方法,二是调用超类的构造器。在调用构造器的时候,thissuper这两个关键字紧密相关。调用构造器的语句只能作为另一个构造器的第一条语句出现。构造器参数可以传递给当前类(this)的另一个构造器,也可以传递给超类(super)的构造器。

重新定义Manager对象的getSalary方法之后,奖金就会自动地添加到经理的薪水中。

下面给出一个例子来说明这个类的使用。我们要创建一个新经理,并设置他的奖金:

Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
boss.setBonus(5000);

下面定义一个包含3个员工的数组:

var staff = new Employee[3];

在数组中混合填入经理和员工:

staff[0] = boss;
staff[l] = 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对象时,e.getSalary()调用的是Manager类中的getSalary方法。虚拟机知道e实际引用的对象类型,因此能够正确地调用相应的方法。

多态(polymorphism)和动态绑定(dynamic binding)

一个对象变量(例如,变量e)可以指示多种实际类型的现象称为多态(polymorphism)。在运行时能够自动地选择适当的方法,称为动态绑定(dynamic binding)。

下面的程序展示了Employee对象与Manager对象在薪水计算上的区别。

程序示例

inheritance/Huangzihan_Employee.java

package inheritance;

import java.time.*;

public class Huangzihan_Employee
{
	private String huangzihan_name;
	private double huangzihan_salary;
	private LocalDate huangzihan_hireDay;
	
	public Huangzihan_Employee(String huangzihan_name, double huangzihan_salary, int huangzihan_year, int huangzihan_month, int huangzihan_day) 
	{
		this.huangzihan_name = huangzihan_name;
		this.huangzihan_salary = huangzihan_salary;
		huangzihan_hireDay = LocalDate.of(huangzihan_year, huangzihan_month, huangzihan_day);
	}
	
	public String huangzihan_getName() 
	{
		return huangzihan_name;
	}
	
	public double huangzihan_getSalary() 
	{
		return huangzihan_salary;
	}
	
	public LocalDate huangzihan_getHireDay() 
	{
		return huangzihan_hireDay;
	}
	
	public void huangzihan_raiseSalary(double huangzihan_byPercent) 
	{
		double huangzihan_raise = huangzihan_salary * huangzihan_byPercent / 100;
		huangzihan_salary += huangzihan_raise;
	}
}

inheritance/Huangzihan_Manager.java

package inheritance;

public class Huangzihan_Manager extends Huangzihan_Employee
{
    private double huangzihan_bonus;
	
	/*
	 * @param huangzihan_name 雇员的姓名
	 * @param huangzihan_salary 工资
	 * @param huangzihan_year 雇佣年份
	 * @param huangzihan_month 雇佣月份
	 * @param huangzihan_day 雇佣天数
	 * 
	 */
	
	 public Huangzihan_Manager(String huangzihan_name, double huangzihan_salary, int huangzihan_year, int huangzihan_month, int huangzihan_day) 
	 {
		 super(huangzihan_name, huangzihan_salary, huangzihan_year, huangzihan_month, huangzihan_day);
		 huangzihan_bonus = 0;
	 }
	 
	 public double huangzihan_getSalary() 
	 {
		 double huangzihan_baseSalary = super.huangzihan_getSalary();
		 return huangzihan_baseSalary + huangzihan_bonus;
	 }
	 
	 public void huangzihan_setBonus(double huangzihan_b) 
	 {
		 huangzihan_bonus = huangzihan_b;
	 }
}

inheritance/Huangzihan_ManagerTest.java

package inheritance;

/* @功能:这个程序演示了继承。
 * @版本:1.21
 * @时间:2021-07-21
 * @作者:黄子涵
 * 
 */

public class Huangzihan_ManagerTest
{
	public static void main(String[] huangzihan_args) 
	{
		// 构造一个Manager对象
		Huangzihan_Manager huangzihan_boss = new Huangzihan_Manager("huangzihan", 80000, 1987, 12, 15);
		
		huangzihan_boss.huangzihan_setBonus(5000);
		
		var huangzihan_staff = new Huangzihan_Employee[3];
		
		// 用Manager和Employee对象填充人员数组
		
		huangzihan_staff[0] = huangzihan_boss;
		huangzihan_staff[1] = new Huangzihan_Employee("Huangzihan", 50000, 1989, 10, 1);
		huangzihan_staff[2] = new Huangzihan_Employee("huang_zihan", 40000, 1990, 3, 15);
		
		// 打印出所有Employee对象的信息
		for(Huangzihan_Employee huangzihan_e : huangzihan_staff) 
		{
			System.out.println("名字=" + huangzihan_e.huangzihan_getName() + ",工资=" + huangzihan_e.huangzihan_getSalary());
		}
	}
}

运行结果

名字=huangzihan,工资=85000.0
名字=Huangzihan,工资=50000.0
名字=huang_zihan,工资=40000.0

继承层次

继承链(inheritance chain)

继承并不仅限于一个层次。例如,可以由Manager类派生Executive类。由一个公共超类派生出来的所有类的集合称为继承层次(inheritance
hierarchy),如图(Employee继承层次)所示。在继承层次中,从某个特定的类到其祖先的路径称为该类的继承链(inheritance chain)。

image

通常,一个祖先类可以有多个子孙链。例如,可以由Employee类派生出子类 ProgrammerSecretary,它们与Manager类没有任何关系(它们彼此之间也没有任何关系)。必要的话,可以将这个过程一直延续下去。

多态

有一个简单规则可以用来判断是否应该将数据设计为继承关系,这就是“is-a”规则,它指出子类的每个对象也是超类的对象。例如,每个经理都是员工,因此,将Manager类设计为Employee类的子类是有道理的;反之则不然,并不是每一名员工都是经理。

替换原则(substitution principle)

"is-a”规则的另一种表述是替换原则(substitution
principle)。它指出程序中岀现超类对象的任何地方都可以使用子类对象替换。

例如,可以将子类的对象赋给超类变量。

Employee e;
e = new Employee(. . .); // Employee object expected
e = new Managerf(. . .);// OK, Manager can be used as well

在Java程序设计语言中,对象变量是多态的(polymorphic.)。一个Employee类型的变量既可以引用一个Employee类型的对象,也可以引用Employee类的任何一个子类的对象(例如,ManagerExecutiveSecretary等)。

在程序(inheritance/Huangzihan_ManagerTest.java)中,我们就利用了这个替换原则:

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.setBonusf(...),这就会发生运行时错误。

警告

在Java中,子类引用的数组可以转换成超类引用的数组,而不需要使用强制类 型转换。例如,下面是一个经理数组

Manager[] managers = new Manager[10];

将它转换成Employee[]数组完全是合法的:

Employee[] staff = managers;   // OK

这样做肯定不会有问题,请思考一下其中的缘由。毕竟,如果manager[i]是一个Manager,它也一定是一个Employee。不过,实际上将会发生一些令人惊讶的事情。要切记managersstaff引用的是同一个数组。现在看一下这条语句:

staff[0] = new Employee("Harry Hacker", . . .);

编译器竟然接纳了这个赋值操作。但在这里,staff[0]manager[0]是相同的引用,似乎我们把一个普通员工擅自归入经理行列中了。这是一种很不好的情形,当调用managers[0].setBonus(1000)的时候,将会试图调用一个不存在的实例字段,进而搅乱相邻存储空间的内容。

为了确保不发生这类破坏,所有数组都要牢记创建时的元素类型,并负责监督仅 将类型兼容的引用存储到数组中。例如,使用new managers[10]创建的数组是一个经理数组。如果试图存储一个Employee类型的引用就会引发ArrayStoreException异常。

理解方法调用

准确地理解如何在对象上应用方法调用非常重要。下面假设要调用x.f(args),隐式参数x声明为类C的一个对象。下面是调用过程的详细描述:

  1. 编译器查看对象的声明类型和方法名。需要注意的是:有可能存在多个名字为f但参数类型不一样的方法。例如,可能存在方法f(int)和方法f(String)。编译器将会一一列举C类中所有名为f的方法和其超类中所有名为f而且可访问的方法(超类的私有方法不可访问)。

至此,编译器已知道所有可能被调用的候选方法。

重载解析(overloading resolution)

  1. 接下来,编译器要确定方法调用中提供的参数类型。如果在所有名为f的方法中存在一个与所提供参数类型完全匹配的方法,就选择这个方法。这个过程称为重载解析(overloading resolution)。例如,对于调用x.f("Hello"),编译器将会挑选f(String),而不是f(int)。由于允许类型转换(int可以转换成doubleManager以转换成Employee,等等),所以情况可能会变得很复杂。如果编译器没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个方法与之匹配,编译器就会报告一个错误。

至此,编译器已经知道需要调用的方法的名字和参数类型。

注释

前面曾经说过,方法的名字和参数列表称为方法的签名。例如,f(int)f(String)是两个有相同名字、不同签名的方法。如果在子类中定义了一个与超类签名相同的方法,那么子类中的这个方法就会覆盖超类中这个相同签名的方法。

返回类型不是签名的一部分。不过在覆盖一个方法时,需要保证返回类型的兼容 性。允许子类将覆盖方法的返回类型改为原返回类型的子类型。例如,假设Employee类有以下方法:

public Employee getBuddyf() { . . . }

经理不会想找这种底层员工作搭档。为了反映这一点,在子类Manager中,可以如下覆盖这个方法:

public Manager getBuddyf() { . . . }  // OK to change return type

我们说,这两个getBuddy方法有可协变的返回类型。

静态绑定(static binding)

  1. 如果是private方法、static方法、final方法或者构造器,那么编译器将可以准确地知道应该调用哪个方法。这称为静态绑定(static binding)。与此对应的是,如果要调用的方法依赖于隐式参数的实际类型,那么必须在运行时使用动态绑定。编译器会利用动态绑定生成一个调用f(String)的指令。

  2. 程序运行并且采用动态绑定调用方法时,虚拟机必须调用与x所引用对象的实际类型对应的那个方法。假设x的实际类型是D,它是C类的子类。如果D类定义了方法f(String),就会调用这个方法;否则,将在D类的超类中寻找f(String),以此类推。

方法表(method table)

每次调用方法都要完成这个搜索,时间开销相当大。因此,虚拟机预先为每个类计算了一个方法表(method table),其中列岀了所有方法的签名和要调用的实际方法。这样一来,在真正调用方法的时候,虚拟机仅查找这个表就行了。在前面的例子中,虚拟机搜索D类的方法表,寻找与调用f(Sting)相匹配的方法。这个方法既有可能是D.f(String),也有可能是X.f(String),这里的X是D的某个超类。这里需要提醒一点,如果调用是super.f(param),那么编译器将对隐式参数超类的方法表进行搜索。

现在来详细分析程序(inheritance/Huangzihan_ManagerTest.java)中调用e.getSalary()的过程。e声明为Employee类型。Employee类只有一个名叫getSalary的方法,这个方法没有参数。因此,在这里不必担心重载解析的问题。

由于getSalary不是private方法、static方法或final方法,所以将釆用动态绑定。虚拟机为EmployeeManager类生成方法表。在Employee的方法表中列出了这个Employee类本身定义的所有方法:

Employee:
    getName() -> Employee.getName()
    getSalary() -> Employee.getSalary() 
    getHireDay() -> Employee.getHireDay() 
    raiseSalary(double) -> Employee.raiseSalary(double)

实际上,上面列出的方法并不完整,稍后会看到Employee类有一个超类ObjectEmployee类从这个超类中还继承了大量方法,在此,我们略去了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的实际类型的方法表。这可能是EmployeeManager的方法表,也可能是Employee类的其他子类的方法表。

  2. 接下来,虚拟机查找定义了getSalary()签名的类。此时,虚拟机已经知道应该调用哪个方法。

  3. 最后,虚拟机调用这个方法。

动态绑定有一个非常重要的特性:无须对现有的代码进行修改就可以对程序进行扩展。假设增加一个新类Executive,并且变量e有可能引用这个类的对象,我们不需要对包含调用e.getSalary()的代码重新进行编译。如果e恰好引用一个Executive类的对象,就会自动地调用Executive.getSalary()方法。

警告

在覆盖一个方法的时候,子类方法不能低于超类方法的可见性。特别是,如果超类方法是public,子类方法必须也要声明为public。经常会发生这类错误:即子类方法不小心遗漏了public修饰符。此时,编译器就会报错,指出你试图提供更严格的访问权限。

阻止继承: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类中的getTimesetTime方法都声明为final。这表明Calendar类的设计者负责实现Date类与日历状态之间的转换,而不允许子类来添乱。同样地,String类也是final类,这意味着不允许任何人定义String的子类。换言之,如果有一个String引用,它引用的一定是一个String对象,而不可能是其他类的对象。

有些程序员认为:除非有足够的理由使用多态性,否则应该将所有的方法都声明为final。事实上,在C++和C#中,如果没有特别地说明,所有的方法都不使用多态性。这两种做法可能都有些偏激。我们提倡在设计类层次时,要仔细地思考应该将哪些方法和类声明为final

内联(inlining)

在早期的Java中,有些程序员为了避免动态绑定带来的系统开销而使用final关键字。如果一个方法没有被覆盖并且很短,编译器就能够对它进行优化处理,这个过程称为内联(inlining)。例如,内联调用e.getName()将被替换为访问字段e.name。这是一项很有意义的改进,CPU在处理当前指令时,分支会扰乱预取指令的策略,所以,CPU不喜欢分支。然而,如果getName在另外一个类中被覆盖,那么编译器就无法知道覆盖的代码将会做什么操作,因此也就不能对它进行内联处理了。

幸运的是,虚拟机中的即时编译器比传统编译器的处理能力强得多。这种编译器可以准确地知道类之间的继承关系,并能够检测出是否有类确实覆盖了给定的方法。如果方法很简短、被频繁调用而且确实没有被覆盖,那么即时编译器就会将这个方法进行内联处理。如果虚拟机加载了另外一个子类,而这个子类覆盖了一个内联方法,那么将会发生什么情况呢?优化器将取消对这个方法的内联。这个过程很慢,不过很少会发生这种情况。

强制类型转换

将一个类型强制转换成另外一个类型的过程称为强制类型转换。Java程序设计语言为强制类型转换提供了一种特殊的表示法。例如:

double x = 3.405;
int nx = (int) x;

将表达式x的值转换成整数类型,舍弃了小数部分。

程序示例

public class HuangzihanTest
{
	public static void main(String[] huangzihan_args) 
	{
		double huangzihan_x = 3.405;
		int huangzihan_nx = (int) huangzihan_x;
		System.out.println(huangzihan_nx);
	}
}

运行结果

3

正像有时候需要将浮点数转换成整数一样,有时候也可能需要将某个类的对象引用转换成另外一个类的对象引用。要完成对象引用的强制类型转换,转换语法与数值表达式的强制类型转换类似,仅需要用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前就可以了。例如:

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进行检查。

注释

如果xnull,进行以下测试

x instanceof C

不会产生异常,只是返回false。之所以这样处理是因为null没有引用任何对象,当然也不会引用C类型的对象。

实际上,通过强制类型转换来转换对象的类型通常并不是一种好的做法。在我们的示例中,大多数情况并不需要将Employee对象强制转换成Manager对象,两个类的对象都能够正确地调用getSalary方法,这是因为实现多态性的动态绑定机制能够自动地找到正确的方法。

只有在使用Manager中特有的方法时才需要进行强制类型转换,例如,setBonus方法。如果岀于某种原因发现需要在Employee对象上调用setBonus方法,那么就应该自问超类的设计是否合理。可能需要重新设计超类,并添加setBonus方法,这才是更合适的选择。请记住,只要没有捕获ClassCastException异常,程序就会终止执行。一般情况下,最好尽量少用强制类型转换和instanceof运算符。

抽象类

如果自下而上在类的继承层次结构中上移,位于上层的类更具有一般性,可能更加抽象。从某种角度看,祖先类更有一般性,人们只将它作为派生其他类的基类,而不是用来构造你想使用的特定的实例。例如,考虑扩展Employee类层次结构。员工是一个人,学生也是一个人。下面扩展我们的类层次结构来加入类Person和类Student。图(Person及其子类的继承图)显示了这三个类之间的继承关系。

image

为什么要那么麻烦提供这样一个高层次的抽象呢?每个人都有一些属性,如姓名。学生与员工都有姓名属性,因此通过引入一个公共的超类,我们就可以把getName方法放在继承层次结构中更高的一层。

现在,再增加一个getDescription方法,它可以返回对一个人的简短描述。例如:

an employee with a salary of $50,000.00
a student majoring in computer science

abstract关键字

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 Personf("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类中的全部方法都是具体的,这个类不再是抽象类。

(Huangzihan_abstractClasses/Huangzihan_PersonTest.java)的程序中定义了抽象超类Person(见程序Huangzihan_abstractClasses/Huangzihan_Person.java)和两个具体子类
Employee(见程序Huangzihan_abstractClasses/Huangzihan_Employee.java)和Student(见程序Huangzihan_abstractClasses/Huangzihan_Student.java)。下面将员工和学生对象填充到一个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对象,而是引用诸如EmployeeStudent这样的具体子类的对象,而这些对象中都定义了getDescription方法。

是否可以干脆省略Person超类中的抽象方法,而仅在EmployeeStudent子类中定义getDescription方法呢?如果这样做,就不能在变量p上调用getDescription方法了。编译器只允许调用在类中声明的方法。

程序示例

Huangzihan_abstractClasses/Huangzihan_Person.java

package Huangzihan_abstractClasses;

public abstract class Huangzihan_Person
{
	public abstract String huangzihan_getDescription();
	private String huangzihan_name;
	
	public Huangzihan_Person(String huangzihan_name) 
	{
		this.huangzihan_name = huangzihan_name;
	}
	
	public String huangzihan_getName() 
	{
		return huangzihan_name;
	}
}

Huangzihan_abstractClasses/Huangzihan_Employee.java

package Huangzihan_abstractClasses;

import java.time.LocalDate;

public class Huangzihan_Employee extends Huangzihan_Person
{
	private double huangzihan_salary;
	private LocalDate huangzihan_hireDay;
	
	public Huangzihan_Employee(String huangzihan_name, double huangzihan_salary, int huangzihan_year, int huangzihan_month, int huangzihan_day)
	{
		super(huangzihan_name);
		this.huangzihan_salary = huangzihan_salary;
		huangzihan_hireDay = LocalDate.of(huangzihan_year, huangzihan_month, huangzihan_day);
	}
	
	public double huangzihan_getSalary() 
	{
		return huangzihan_salary;
	}
	
	public LocalDate huangzihan_getHireDay() 
	{
		return huangzihan_hireDay;
	}
	
	public String huangzihan_getDescription() 
	{
		return String.format("员工的工资为$%.2f", huangzihan_salary);
	}
	
	public void huangzihan_raiseSalary(double huangzihan_byPercent) 
	{
		double huangzihan_raise = huangzihan_salary * huangzihan_byPercent / 100;
		huangzihan_salary += huangzihan_raise;
	}
}

Huangzihan_abstractClasses/Huangzihan_Student.java

package Huangzihan_abstractClasses;

public class Huangzihan_Student extends Huangzihan_Person
{
	private String huangzihan_major;
	/*
	 * @param huangzihan_name 学生的名字
	 * @param huangzihan_major 学生的专业
	 * 
	 */
	
	public Huangzihan_Student(String huangzihan_name, String huangzihan_major) 
	{
		// 将名称传递给超类构造函数
		super(huangzihan_name);
		this.huangzihan_major = huangzihan_major;
	}
	
	public String huangzihan_getDescription() 
	{
		return "学生正在主修" + huangzihan_major + "专业";
	}
}

Huangzihan_abstractClasses/Huangzihan_PersonTest.java

package Huangzihan_abstractClasses;

/*
 * @功能:该程序演示了抽象类。
 * @版本:1.01
 * @时间:2021-07-21
 * @作者:黄子涵
 * 
 */

public class Huangzihan_PersonTest
{
	public static void main(String[] huangzihan_args) 
	{
		var huangzihan_people = new Huangzihan_Person[2];
		
		// 用Huangzihan_Student和Huangzihan_Employee对象填充people数组
		huangzihan_people[0] = new Huangzihan_Employee("huangzihan", 50000, 1989, 10, 1);
		huangzihan_people[1] = new Huangzihan_Student("黄子涵", "电子信息工程专业");
		
		// 打印出所有Huangzihan_Person对象的名称和描述
		for(Huangzihan_Person huangzihan_p: huangzihan_people) 
		{
			System.out.println(huangzihan_p.huangzihan_getName() + "," + huangzihan_p.huangzihan_getDescription());
		}
	}
}

运行结果

huangzihan,员工的工资为$50000.00
黄子涵,学生正在主修电子信息工程专业专业

受保护访问

大家都知道,最好将类中的字段标记为private,而方法标记为public。任何声明为private的内容对其他类都是不可见的。前面已经看到,这对于子类来说也完全适用,即子类也不能访问超类的私有字段。

不过,在有些时候,你可能希望限制超类中的某个方法只允许子类访问,或者更少见地,可能希望允许子类的方法访问超类的某个字段。为此,需要将这些类方法或字段声明为受保护(protected)。例如,如果将超类Employee中的hireDay字段声明为proteced,而不是privateManager方法就可以直接访问这个字段。

在Java中,保护字段只能由同一个包中的类访问。现在考虑一个Administrator子类,这个子类在另一个不同的包中。Administrator类中的方法只能查看Administrator对象自己的hireDay字段,而不能查看其他Employee对象的这个字段。有了这个限制,就能避免滥用保护机制,不能通过派生子类来访问受保护的字段。

在实际应用中,要谨慎使用受保护字段。假设你的类要提供给其他程序员使用,而你在设计这个类时设置了一些受保护字段。你不知道的是,其他程序员可能会由这个类再派生出新类,并开始访问你的受保护字段。在这种情况下,如果你想修改你的类的实现,就势必会影响那些程序员。这违背了OOP提倡数据封装的精神。

受保护的方法更具有实际意义。如果需要限制某个方法的使用,就可以将它声明为protected。这表明子类(可能很熟悉祖先类)得到了信任,可以正确地使用这个方法,而其他类则不行。

这种方法的一个很好的示例就是Object类中的clone方法。

Java中的4个访问控制修饰符

下面对Java中的4个访问控制修饰符做个小结:

  1. 仅对本类可见————private。
  2. 对外部完全可见————public。
  3. 对本包和所有子类可见————protected。
  4. 对本包可见——默认(很遗憾),不需要修饰符。
posted @ 2021-08-24 11:19  黄子涵  阅读(410)  评论(0编辑  收藏  举报