《Java核心技术卷一》第五章

1
2
3
4
public class Manager extends Employee
{
added methods and fields
}

在Java中,所有的继承都是公共继承。

关键字extends指示正在构造的新类派生于一个已存在的类。

这个已存在的类称为超类、基类或父类;新类称为子类或派生类。

子类比超类拥有的功能更多。所有员工组成的集合包含所有经理组成的集合,员工集合是经理集合的超集,经理集合是员工集合的子集。

声明为私有的类成员不会被这个类的子类继承。(私有字段不会继承,因为子类不能直接访问这些私有字段,子类拥有这些字段)

记录,就是状态完全由构造器参数定义的类,不能扩展记录,而且记录也不能扩展其他类。

关键字 super 指示编译器调用超类方法

super(name, salary,...); “调用超类中带有name,salary等参数的构造器的简写形式”

由于子类的构造器不能访问超类的私有字段,所以必须通过一个构造器来初始化这些私有字段,可以利用特殊的super语法调用这个构造器,使用super调用构造器的语句必须是子类构造器的第一条语句。

如果构造子类对象时没有显式的调用超类的构造器,那么超类必须有一个无参数构造器,这个构造器要在子类构造之前调用。

this 的两个含义:一是指示隐式参数的引用;二是调用该类的其他构造器

super 的两个含义:一是调用超类的方法;二是调用超类的构造器

总结:核心逻辑链

创建子类对象 → 需初始化父类 + 子类 → 子类无法直接初始化父类私有字段 → 必须通过“调用父类构造器”完成父类初始化 → super(参数列表) 是调用父类构造器的语法 → 该语句必须是子类构造器第一条语句 → 若子类没显式调用,编译器自动插 super() → 要求父类必须有无参构造器。

一个对象变量可以指示多种实际类型,这一点称为多态,在运行时能够自动地选择适当的方法,这称为动态绑定。

继承并不仅限于一个层次。

由一个公共超类派生出来的所有类的集合称为继承层次结构,在继承层次结构中,从某个特定的类到其祖先的路径称为该类的继承链。

Java不支持多重继承。(多重继承允许一个类同时继承多个父类的属性和方法)

有一个简单的规则可以用来判断是否应该将数据设计为继承关系,这就是“is-a”规则,它指出子类的每个对象也是超类的对象。

“is-a”规则的另一种表述是替换原则。它指出程序中需要超类对象的任何地方都可以使用子类对象替换。

在java中,对象变量是多态的。一个Employee类型的变量既可以引用一个Employee类型的对象,也可以引用Employee类的任何一个子类的对象。

不能将超类的引用赋给子类变量。

在java中,子类引用数组可以转换成超类引用数组,而不需要使用强制类型转换。(存在风险)

理解方法调用

准确地理解如何在对象上应用方法调用非常重要。下面假设要调用x.f(args),隐式参数

x声明为类C的一个对象。下面是调用过程的详细描述:

  1. 编译器查看对象的声明类型和方法名。需要注意的是:有可能存在多个名字为f(参数类类型不一样)的方法。例如,可能存在方法f(int)和方法f(string)。编译器将会一一访问。接下来,编译器已知所有可能调用的候选方法。

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

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

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

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

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

1
public Employee getBuddy() {...}

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

1
public Manager getBuddy() {...}//ok to change return type

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

  1. 如果是private方法、static方法、final方法(final修饰符将在下一节解释)或者构造器,那么编译器可以准确地知道应该调用哪个方法。这称为静态绑定(static binding)。与此对应的是,如果要调用的方法依赖于隐式参数的实际类型,那么必须在运行时使用动态绑定。在我们的示例中,编译器会利用动态绑定生成一个调用f(string)的指令。
  2. 程序运行并且采用动态绑定调用方法时,虚拟机必须调用与x所引用对象的实际类型对应的那个方法。假设x的实际类型是D,它是C类的子类。如果D类定义了方法f(string),就会调用这个方法;否则,将在D类的超类中寻找f(string),依此类推。每次调用方法都要完成这个搜索,时间开销相当大。因此,虚拟机预先为每个类计算了一个方法表(method table),其中列出了所有方法的签名和要调用的实际方法。虚拟机加载一个类之后可以构建这个方法表,为此要结合它在类文件中找到的方法以及超类的方法表。

这样一来,真正调用方法的时候,虚拟机仅查找这个表就行了。在前面的例子中,虚拟机搜索D类的方法表,寻找与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类本身定义的所有方法:

1
2
3
4
5
6
7
8
9
Employee:

getName() -> Employee.getName()

getSalary() -> Employee.getSalary()

getHireDay() -> Employee.getHireDay()

raiseSalary(double) -> Employee.raiseSalary(double)

实际上,上面列出的方法并不完整,稍后会看到Employee类有一个超类Object,Employee类从这个超类中还继承了大量方法,在此,我们略去了Object方法。

Manager方法表稍微有些不同。其中有三个方法是继承而来的,一个方法是重新定义的,还有一个方法是新增的。

1
2
3
4
5
6
7
8
9
Manager:

getName() -> Employee.getName()

getSalary() -> Manager.getSalary()

getHireDay() -> Employee.getHireDay()

setBonus(double) -> Manager.setBonus(double)

在运行时,调用e.getSalary()的解析过程为:

  1. 首先,虚拟机获取e的实际类型的方法表。这可能是Employee、Manager的方法表,也可能是Employee类的其他子类的方法表。
  2. 接下来,虚拟机查找定义了getSalary()签名的类。此时,虚拟机已经知道应该调用哪个方法。
  3. 最后,虚拟机调用这个方法。

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

警告:在覆盖一个方法的时候,子类方法不能低于超类方法的可见性。具体地,如果不小心遗漏了public修饰符。此时,编译器就会报错,指出你试图提供更严格的访问权限。

不允许扩展的类称为final类。

如果在类定义中使用了final修饰符,就表明这个类是fianl类,final类中的方法自动地称为final方法。可以将类中的某个特定方法声明为fianl,如果这样做,那么所有子类都不能覆盖这个方法。字段也可以声明为final,对于final字段来说,构造对象之后就不允许改变了。不过将一个类声明为final,只有其中的方法自动地成为final,不包括字段。

将方法或类声明为final只有一个原因:确保他们不会在子类中改变语义。

如果一个方法没有被覆盖并且很短,编译器就能够对它进行优化处理,则这个过程称为内联(inlining)。

枚举和记录总是final,它们不允许扩展。

要完成对象引用的强制类型转换,转换语法与数值表达式的强制类型转换类似。用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前。例如:

1
2
3
4
5
var staff = new Employee[3];
staff[0] = new Manager("...");
staff[1] = new Employee(".....");
Manager boss = (Manager) staff[0];
Manager boss = (Manager) staff[1]; //error

进行强制类型转换的唯一原因是:要在暂时忘记对象的实际类型之后使用对象的全部功能。 (编译器只认编译时类型,无法访问子类的特有方法)

instanceof操作符,判断是否能够成功进行强制类型转换。(如果x为null 那么 x instanceof C 不会产生异常,只是返回false。因为null没有引用任何对象,当然也不会引用C类型的对象) 例如:

1
2
3
4
5
if (staff[i] instanceof Manager)
{
boss = (Manager) staff[i];
...
}

综上所述:

只能在继承层次结构内进行强制类型转换。

在将超类强制转换成子类之前,应该使用instanceof进行检查。

1
2
3
4
5
if (staff[i] instanceof Manager)
{
(Manager) boss = (Manager) staff[i];
boss.setBouns(5000);
}

在java16中可以简化,可以直接在instanceof测试中声明子类变量:(在instanceof判读的同时直接完成类型转换,并声明一个对应的子类变量)

1
2
3
4
if (staff[i] instanceof Manager boss)
{
boss.setBouns(5000);
}

当instanceof模式引入一个变量时,可以立即在同一个表达式中使用这个变量(前提是instanceof结果为true)

声明变量的instanceof形式称为“模式匹配”,这是因为它类似于switch中的类型模式,这是java17的一个“预览”特性。

类似于其他局部变量,instanceof模式定义的局部变量会遮蔽字段。

可以将一个类特性(方法或字段)声明为受保护(protected)

在Java中,受保护字段只能由同一个包中的类访问。

1.仅本类可以访问——private

2.可由外部访问——public

3.本包和所有子类可以访问——protected

4.本包中可以访问——默认,不需要修饰符

Object类:所有类的超类

Java中的每一个类都扩展了Object,如果没有明确地指出超类,那么理所当然Object就是这个类的超类。

可以使用Object类型的变量引用任何类型的对象,当然,Object类型的变量只能用于作为任意值的一个泛型容器。

在Java中,只有基本类型(primitive type)不是对象,例如,数值、字符和布尔类型的值都不是对象,

所有的数组类型(不管是对象数组还是基本类型的数组)都扩展了Object类的类类型

Object类中的equals方法用于检测一个对象是否等于另外一个对象。Object类中实现的equals方法将确定两个对象引用是否相同。(即如果两个对象相同,则这两个对象肯定就相等)不过,经常需要基于状态检测对象的相等性。

1
2
3
return name.equals(name, other.name)
&& salary == other.salary
&& hireDay.equals(other.hireDay);

为了防止name或hireDay可能为null的情况,需要使用Objects.equals方法。如果两个参数都为null,Objects.equals(a,b)调用将返回true;如果其中一个参数为null,则返回false;如果两个参数都不为null,则调用a.equals(b)。

1
2
3
return Objects.equals(name, other.name)
&& salary == other.salary
&& Objects.equals(hireDay, other.hireDay);

在子类中定义equals方法时,首先调用超类的equals。若检测失败,那么对象就不可能相等。如果超类中的字段都相等,则可以继续比较子类中的实例字段。

注释:记录是一种特殊形式的不可变类,其状态完全由“标准”构造器中设置的字段来定义。记录会自动定义一个比较字段的equals方法。两个记录实例中相应字段值相等时,这两个记录实例就相等。

Java语言规范要求equals方法具有下述性质。

1.自反性:对于任何非null引用x,x.equals(x)应该返回true。

2.对称性:对于任何引用x和y,当且仅当y.equals(x)返回true时,x.equals(y)返回true。

3.传递性:对于任何引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,则x.equals(z)也应该返回true。

4.一致性:如果x和y引用的对象没有发生变化,则反复调用x.equals(y)应该返回同样的结果。

5.对于任意非null引用x,x.equals(null)应该返回false。

下面给出编写完美 equals 方法的技巧:

  1. 将显式参数命名为 otherObject,稍后需要将它强制转换成另一个名为 other 的变量。

  2. 检测 this 与 otherObject 是否相同:

    1
    if (this == otherObject) return true;

    这条语句只是一个优化。实际上,这种情况很常见,因为检查同一性要比逐个比较字段开销小。

  3. 检测 otherObject 是否为 null,如果为 null,则返回 false。这个检测是必要的。

    1
    if (otherObject == null) return false;
  4. 比较 this 与 otherObject 的类。如果 equals 的语义可以在子类中改变,就使用 getClass 检测:

1
2
3
if (getClass() != otherObject.getClass()) return false;

ClassName other = (ClassName) otherObject;

如果所有的子类都有相同的相等性语义,则可以使用 instanceof 检测:

1
if (!(otherObject instanceof ClassName other)) return false;

注意,如果 instanceof 检测成功,它会把 other 设置为 otherObject。不再需要强制类型转换。

5.现在根据相等性概念的要求来比较字段。使用 == 比较基本类型字段,使用 Objects.equals 比较对象字段。如果所有的字段都匹配,就返回 true;否则,返回 false。

1
2
3
4
5
return field1 == other.field1

&& Objects.equals(field2, other.field2)

&& ... ;

如果在子类中重新定义 equals,就要在其中包含一个 super.equals(other) 调用。

提示:对于数组类型的字段,可以使用静态的Arrays.equals方法检查相应的数组元素是否相等。对于多维数组,可以使用Arrays.deepEquals方法。

可以使用 @Override 标记要覆盖超类方法的那些子类方法,如果犯了错误,没有覆盖方法而是在定义一个新方法,编译器就会报告一个错误。

散列码(hash code)是由对象导出的一个整型值。散列码是没有规律的。如果x和y是两个不同的对象,那么 x.hashCode()与y.hashCode()基本上不会相同。

由于hashCode方法定义在 Object 类中,因此每个对象都有一个默认的散列码,其值由对象的存储地址得出。

字符串的散列码是由内容导出的。

String 类使用以下算法计算散列码:

1
2
3
int hash = 0;
for (int i = 0 ; i< length(); i++)
hash = 31 * hash + charAt(i);

hashCode方法应该返回一个整数(可以是负数)

equals 与 hashCode 的定义必须相容:如果 x.equals(y) 返回 true,那么 x.hashCode() 就必须返回与 y.hashCode() 相同的值,

如果有数组类型的字段,那么可以使用静态的 Arrays.hashCode 方法计算一个散列码,这个散列码由数组元素的散列码组成。

record 类型会自动提供一个 hashCode 方法,它会由字段值的散列码得出一个散列码。

Object中的 toString 方法,它会返回一个字符串,表示这个对象的值。

绝大多数(非全部)toString 方法都遵循这样的格式:首先是类名,随后是一对方括号括起来的字段值。

1
2
3
4
public String toString()
{
return getClass().getName() + "[name=" + name + ", salary=" + salary + ",hireDay= " + hireDay + "]";
}

只要对象与一个字符串通过操作符“+”拼接起来,Java编译器就会自动地调用toStirng 方法来获得这个对象的字符串描述。

提示:可以不写为 x.toString(),而写作 ""+x。这条语句将一个空串与 x的字符串表示(也就是 x.toString())拼接起来。与 toString不同是,即使 x是基本类型,这条语句也能正常工作。

如果 x是一个任意对象,并调用

1
System.out.println(x);

println方法就会简单地调用 x.toString(),并打印得到的字符串。

Object类定义了 toString方法,会打印对象的类名散列码。例如,调用

1
System.out.println(System.out)

将生成以下输出:

1
java.io.PrintStream@2f6684

之所以得到这样的结果,是因为 PrintStream类的实现者没有覆盖 toString方法。

警告:令人烦恼的是,数组继承了 Object类的 toString方法,更有甚者,数组类型将采用一种古老的格式打印。例如:

1
2
int[] luckyNumbers = { 2, 3, 5, 7, 11, 13 };
String s = "" + luckyNumbers;

会生成字符串 “[I@1a46e30”(前缀 [I表示这是一个整型数组)。补救方法是调用静态方法Arrays.toString。以下代码:

1
String s = Arrays.toString(luckyNumbers);

将生成字符串"[2,3,5,7,11,13]"

要想正确地打印多维数组(即数组的数组),则需要调用Arrays.deepToString方法。

toString 方法是一种非常有用的调试工具。在标准类库中,许多类都定义了 toString 方法,以便用户能够获得有关对象状态的有用信息。这在记录日志消息时尤其有用,

1
System.out.println("Current position = " + position);

提示:强烈建议为你自定义的每一个类添加toString方法。

5.3 泛型数组列表

在一些程序设计语言(如 C 或 C++)中,必须在编译时就确定所有数组的大小。程序员对此十分反感,因为这样做将迫使他们做出一些不情愿的折中。例如,一个部门会有多少员工?肯定不会超过 100 人。一旦出现一个拥有 150 名员工的大型部门呢?另外,你愿意为那些仅有 10 名员工的部门浪费 90 个存储空间吗?

在 Java 中,情况就好多了。它允许在运行时确定数组的大小。

1
2
int actualSize = . . .;
var staff = new Employee[actualSize];

当然,这个代码并没有完全解决运行时动态修改数组的问题。一旦确定了数组的大小,就无法再轻松地改变了。在 Java 中,要处理这个常见的情况,可以使用 Java 中的另外一个类,名为 ArrayList。ArrayList 类与数组类似,但在添加或删除元素时,它能够自动地调整容量,而不需要为此额外编写代码。

ArrayList 是一个有类型参数(type parameter)的泛型类(generic class)为了指定数组列表保存的元素对象的类型,需要用一对尖括号将类名括起来追加到 ArrayList 后面,例如 ArrayList

5.3.1 声明数组列表

可以如下声明和构造一个保存 Employee 对象的数组列表:

1
ArrayList<Employee> staff = new ArrayList<Employee>();

在 Java 10 中,最好使用 var 关键字以避免重复写类名:

1
var staff = new ArrayList<Employee>();

如果没有使用 var 关键字,则可以省略右边的类型参数

1
ArrayList<Employee> staff = new ArrayList<>();

这称为 “ 菱形 “ 语法,因为它尖括号 <> 就像是一个菱形。可以结合 new 操作符使用菱形语法。编译器会检查新值要做什么。如果赋值给一个变量,或传递给某个方法,或者从某个方法返回,编译器会检查这个变量、参数或方法的泛型类型,然后将这个类型放在 <> 中。在这个例子中,new ArrayList<>( ) 将赋值给一个类型为 ArrayList 的变量,所以泛型类型为 Employee。

警告: **如果使用 var 声明 ArrayList,就不要使用菱形语法。**以下声明

1
var elements = new ArrayList<>();

会生成一个 ArrayList

注释: Java 5 以前的版本没有提供泛型类,而是有一个保存 Object 类型元素的 ArrayList 类,它是一个”自适应大小”(one-size-fits-all)的集合。你仍然可以使用没有后缀 <…> 的 ArrayList。它被认为是一个擦除了类型参数的”原始”类型。

注释: 在更老的 Java 版本中,程序员使用 Vector 类实现动态数组。不过,ArrayList 类更加高效,没有任何理由再使用 Vector 类。

**使用 add 方法可以将元素添加到数组列表中。**例如,下面展示了如何将 Employee 对象添加到一个数组列表中:

1
2
staff.add(new Employee("Harry Hacker", ...));
staff.add(new Employee("Tony Tester", ...));

数组列表管理着一个内部的对象引用数组。最终,这个数组的空间有可能会部分用尽。这时就显现出数组列表的魅力了:如果调用 add 而内部数组已经满了,数组列表就会自动地创建一个更大的数组,并将所有对象从较小的数组拷贝到较大的数组中

如果已经知道或能够估计出数组可能存储的元素数量,就可以在填充数组之前调用 ensureCapacity 方法:

1
staff.ensureCapacity(100);

这个方法调用将分配一个包含 100 个对象的内部数组。这样一来,前 100 次 add 调用不会带来开销很大的重新分配空间。

另外,还可以把初始容量传递给 ArrayList 构造器:

1
ArrayList<Employee> staff = new ArrayList<>(100);

警告:如下分配数组列表:

1
new ArrayList<>(100) // capacity is 100

这与分配一个新数组有所不同:

1
new Employee[100] // size is 100

数组列表的容量与数组的大小有一个非常重要的区别:

  • 如果分配一个有 100 个元素的数组,数组就有 100 个空位置(槽)可以使用。
  • 而容量为 100 个元素的数组列表只是可能保存 100 个元素(实际上也可以超过 100,不过要以重新分配空间为代价),但是在一开始,甚至完成初始化构造之后,数组列表并不包含任何元素。

size 方法将返回数组列表中包含的实际元素个数。例如:

1
staff.size()

将返回 staff 数组列表的当前元素个数,它等价于数组 aa.length

一旦能够确认数组列表的大小将保持恒定,不再发生变化,就可以调用 trimToSize 方法。这个方法将内存块的大小调整为保存当前元素数量所需要的存储空间。垃圾回收器将回收多余的存储空间。

**注意:**一旦削减了数组列表的大小,添加新元素就需要再次移动内存块,这很耗费时间,所以应当只有在确认不会再向数组列表添加任何元素时才调用 trimToSize

5.3.2 访问数组列表元素

为了提供数组列表自动扩展容量的便利,这要求使用一种更复杂的语法来访问元素。其原因是 ArrayList 类并不是 Java 程序设计语言的一部分,它只是由某个人编写并放在标准库中的一个实用工具类。

不能使用我们喜爱的 [] 语法格式访问或改变数组的元素,而要使用 getset 方法。

例如,要设置第 i 个元素,可以使用:

1
staff.set(i, harry);

它等价于对数组 a 的元素赋值(与数组一样,索引值从 0 开始):

1
a[i] = harry;

警告:只有当数组列表的大小大于 i 时,才能够调用 list.set(i, x)。例如,下面这段代码是错误的:

1
2
var list = new ArrayList<Employee>(100); // capacity 100, size 0
list.set(0, x); // no element 0 yet

要使用 add 方法为数组添加新元素,而不是 set 方法。set 方法只是用来替换数组中之前增加的一个元素。

要得到一个数组列表的元素,可以使用:

1
Employee e = staff.get(i);

这等价于:

1
Employee e = a[i];

**注释:**没有泛型类时,原始 ArrayList 类提供的 get 方法别无选择,只能返回 Object。因此,get 方法的调用者必须将返回值强制转换为所需的类型。

1
Employee e = (Employee) staff.get(i);

原始 ArrayList 还存在一定的危险性。它的 addset 方法接受任意类型的对象。对于下面这个调用:

1
staff.set(i, "Harry Hacker");

它能正常编译而不会给出任何警告,只有在获取对象并试图对它进行强制类型转换时,才会发现有问题。如果使用 ArrayList<Employee>,编译器就会检测到这个错误。

下面这个技巧可以一举两得,既可以灵活地扩展数组,又可以方便地访问数组元素。首先,创建一个数组列表,并添加所有的元素:

1
2
3
4
5
6
var list = new ArrayList<X>();
while (...)
{
x = ...;
list.add(x);
}

执行完上述操作后,使用 toArray 方法将数组元素复制到一个数组中:

1
2
var a = new X[list.size()];
list.toArray(a);

有时需要在数组列表的中间增加元素,为此可以使用 add 方法并提供一个索引参数:

1
2
int n = staff.size() / 2;
staff.add(n, e);

位置 n 及之后的所有元素都要向后移动一个位置,为新元素留出空间。插入新元素后,如果数组列表的新大小超过了容量,数组列表就会重新分配它的存储数组。

类似地,可以从数组列表中间删除一个元素:

1
Employee e = staff.remove(n);

位于这个位置之后的所有元素都向前移动一个位置,并且数组的大小减 1。

插入和删除元素的效率很低。对于较小的数组列表来说,不必担心这个问题。但如果存储的元素很多,又经常需要在中间插入和删除元素,就应该考虑使用链表了。

可以使用 “ for each “ 循环遍历数组列表的内容:

1
2
for (Employee e : staff)
do something with e

这个循环和以下代码具有相同的效果:

1
2
3
4
5
for (int i = 0; i < staff.size(); i++)
{
Employee e = staff.get(i);
do something with e
}

5.3.3 类型化与原始数组列表的兼容性

在你自己的代码中,你可能总是想用类型参数来增加安全性。在本节中,你会了解如何与遗留代码(没有使用类型参数)互操作。

假设有以下遗留类:

1
2
3
4
5
public class EmployeeDB
{
public void update(ArrayList list) { . . . }
public ArrayList find(String query) { . . . }
}

可以将一个类型化的数组列表传递给 update 方法,但并不需要进行任何强制类型转换。

1
2
ArrayList<Employee> staff = . . .;
employeeDB.update(staff);

staff 对象直接传递到 update 方法。

**警告:**尽管编译器没有给出任何错误信息或警告,但是这样调用并不太安全。update 方法可能会在数组列表中增加不是 Employee 类型的元素。访问这些元素时就会出现异常。听起来似乎很吓人,但考虑一下就会发现,这种行为与 Java 中引入泛型之前是一样的。虚拟机的完整性并没有受到威胁。在这种情况下,没有降低安全性,但也没能从编译时检查中受益。

相反,将一个原始 ArrayList 赋给一个类型化 ArrayList 时,会得到一个警告。

1
ArrayList<Employee> result = employeeDB.find(query); // yields warning

注释:为了能够看到警告的文本信息,编译时要提供选项 -Xlint:unchecked

使用强制类型转换并不能避免出现警告。

1
2
ArrayList<Employee> result = (ArrayList<Employee>) employeeDB.find(query);
// yields another warning

这样将会得到另外一个警告信息,指出类型转换有误。

这就是 Java 中不尽如人意的泛型类型限制所带来的结果。出于兼容性的考虑,编译器检查到没有发现违反规则的现象之后,就将所有类型化数组列表转换成原始 ArrayList 对象。在程序运行时,所有的数组列表都是一样的,即虚拟机中没有类型参数。因此,强制类型转换 (ArrayList)(ArrayList<Employee>) 将执行相同的运行时检查。

在这种情况下,你并不能做什么。在与遗留的代码交互时,要研究编译器的警告,确保这些警告不太严重。

一旦确保问题不太严重,可以用 @SuppressWarnings("unchecked") 注解来标记接受强制类型转换的变量,如下所示:

1
2
@SuppressWarnings("unchecked") ArrayList<Employee> result
= (ArrayList<Employee>) employeeDB.find(query); // yields another warning

5.4 对象包装器与自动装箱

有时,需要将 int 这样的基本类型转换为对象。所有的基本类型都有一个与之对应的类。例如,Integer 类对应基本类型 int。通常,这些类称为包装器(wrapper)。这些包装器类有显而易见的名字:Integer、Long、Float、Double、Short、Byte、Character 和 Boolean(前 6 个类派生于公共超类 Number)。包装器类是不可变的,即一旦构造了包装器,就不允许更改包装在其中的值。同时,包装器类还是 final,因此不能派生它们的子类。

假设想要定义一个整型数组列表。遗憾的是,尖括号中的类型参数不允许是基本类型,也就是说,不允许写成 ArrayList<int>。这里就可以用到 Integer 包装器类。我们可以声明一个 Integer 对象的数组列表。

1
var list = new ArrayList<Integer>();

**警告:**由于每个值分别包装在一个对象中,所以 ArrayList 的效率远远低于 int[ ] 数组。因此,只有当程序员操作的方便性比执行效率更重要的时候,才会考虑对较小的集合使用这种构造。

幸运的是,有一个很有用的特性,从而可以很容易地向 ArrayList 添加 int 类型的元素。下面这个调用

1
list.add(3);

将自动地转换成

1
list.add(Integer.valueOf(3));

这种转换称为自动装箱(autoboxing)

**注释:**你可能认为自动包装(autowrapping)与包装器更一致,不过 “ 装箱 ”(boxing)这个词源于 C#。

反过来,当将一个 Integer 对象赋给一个 int 值时,将会自动拆箱(unboxed)。也就是说,编译器将以下语句

1
int n = list.get(i);

转换成

1
int n = list.get(i).intValue();

自动装箱和自动拆箱甚至也适用于算术表达式。例如,可以将自增运算符应用于一个包装器引用:

1
2
Integer n = 3;
n++;

编译器将自动地插入指令对对象拆箱,然后将结果值增 1,最后再将其装箱。

大多数情况下容易有一种假象,认为基本类型与它们的对象包装器是一样的。但它们有一点有很大不同:同一性。大家知道,运算符可以应用于包装器对象,不过检测的是对象是否有相同的内存位置,因此,下面的比较可能会失败:

1
2
3
Integer a = 1000;
Integer b = 1000;
if (a == b) . . .//比较的是对象引用(内存地址)

不过,Java 实现可以(如果选择这么做)将经常出现的值包装到相同的对象中,这样一来,以上比较就可能成功。但这种不确定性并不是我们想要的。解决这个问题的办法是在比较两个包装器对象时调用 equals 方法。

**注释:**自动装箱规范要求 boolean、byte、char (≤127),介于 -128 和 127 之间的 short 和 int 包装到固定的对象中。例如,在前面的例子中,如果将 a 和 b 初始化为 100,那么它们的比较结果一定会成功。

  • boolean:全部缓存
  • byte:全部缓存
  • char:≤ 127
  • shortint:-128 到 127

**提示:**绝对不要依赖包装器对象的同一性。不要用 == 比较包装器对象,也不要将包装器对象作为锁。

不要使用包装器类构造器,它们已被弃用,并将被完全删除。例如,可以使用 Integer.valueOf(1000),而绝对不要使用 new Integer(1000)。或者,可以依赖自动装箱:Integer a=1000

关于自动装箱还有几点需要说明。首先,由于包装器类引用可以为 null,所以自动装箱有可能会抛出一个 NullPointerException 异常:

1
2
Integer n = null;
System.out.println(2 * n); // throws NullPointerException

另外,如果在一个条件表达式中混合使用 Integer 和 Double 类型,则 Integer 值就会拆箱,提升为 double,再装箱为 Double:

1
2
3
Integer n = 1;
Double x = 2.0;
System.out.println(true ? n : x); // prints 1.0

最后强调一下,装箱和拆箱是编译器要做的工作,而不是虚拟机。编译器生成类的字节码时会插入必要的方法调用。虚拟机只是执行这些字节码。

**注释:**Java 将来的版本可能允许类似基本类型的用户自定义类型,其值并不存储在对象中。例如,基本类型 Point 的值(包含 double 字段 x 和 y)只是内存中一个 16 字节的块,并且有两个相邻的 double 值。可以复制这个值,但不能有它的引用。

如果需要一个引用,可以使用一个自动生成的伴随类(在当前提案中,这个类名为 Point.ref)。装箱和拆箱是自动的,这与当前的基本类型相同。

将来某个时候,基本包装器类将与那些类统一起来。例如,Double 将是 double.ref 的一个别名。

使用数值包装器通常还有一个原因。Java 设计者发现,可以将某些基本方法放在包装器中,这会很方便,例如将一个数字字符串转换成数值。

要想将字符串转换成整型,可以使用下面这条语句:

1
int x = Integer.parseInt(s);

这里与 Integer 对象没有任何关系,parseInt 是一个静态方法。但 Integer 类是放置这个方法的一个好地方。

警告:有些人认为包装器类可以用来实现能够修改数值参数的方法,不过这是错误的。在第 4 章中曾经讲到,由于 Java 方法的参数总是按值传递的,所以不可能编写一个能够让整型参数自增的 Java 方法

1
2
3
4
public static void triple(int x) // won't work
{
x = 3 * x; // modifies local variable
}

将 int 替换成 Integer 能解决这个问题吗?

1
2
3
4
public static void triple(Integer x) // won't work
{
...
}

问题在于 Integer 对象是不可变的:包含在包装器中的信息不会改变。所以,不能使用这些包装器类来创建修改数值参数的方法。

5.5 参数个数可变的方法

可以提供参数个数可变的方法(有时,这些方法被称为 “ 变参 ” (varargs)方法)。前面已经看到过这样一个方法:printf 。例如,下面的方法调用

1
System.out.printf("%d", n);

1
System.out.printf("%d %s", n, "widgets");

这两条语句都调用同一个方法,不过一个调用有2个参数,另一个调用有3个参数。

printf 方法是这样定义的:

1
2
3
4
5
6
7
public class PrintStream
{
public PrintStream printf(String fmt, Object... args)
{
return format(fmt, args);
}
}

这里的省略号 ... 是Java代码的一部分,它表明这个方法可以接收任意数量的对象(除 fmt 参数以外)。

实际上,printf 方法接收两个参数,一个是格式字符串,另一个是 Object[] 数组,其中保存着所有其他参数(如果调用者提供的是整数或者其他基本类型的值,则会把它们自动装箱为对象)。现在,不可避免地要扫描 fmt 字符串,并将第 i 个格式说明符与 args[i] 的值匹配。

换句话说,对于 printf 的实现者来说,Object... 参数类型与 Object[] 完全一样。

编译器需要转换每个 printf 调用,将参数打包到一个数组中,并根据需要自动装箱:

1
System.out.printf("%d %s", new Object[] { Integer.valueOf(n), "widgets" } );

你自己也可以定义有可变参数的方法,可以为参数指定任意类型,甚至是基本类型。下面是一个简单的示例,这个函数会计算若干个数值中的最大值(数值个数可变)。

1
2
3
4
5
6
public static double max(double... values)
{
double largest = Double.NEGATIVE_INFINITY;
for (double v : values) if (v > largest) largest = v;
return largest;
}

可以像下面这样调用这个函数:

1
double m = max(3.1, 40.4, -5);

编译器将 new double[] {3.1, 40.4, -5} 传递给max函数。

**注释:**允许将数组作为最后一个参数传递给有可变参数的方法。例如:

1
System.out.printf("%d %s", new Object[] { Integer.valueOf(1), "widgets" } );

因此,如果一个已有函数的最后一个参数是数组,则可以把它重新定义为有可变参数的方法,而不会破坏任何已有的代码。例如,Java 5 中就采用这种方式增强了 MessageFormat.format。如果愿意,甚至可以将 main 方法声明为以下形式:

1
public static void main(String... args)

5.6 抽象类

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

image-20251104162723467

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

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

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

在 Employee 类和 Student 类中实现这个方法很容易。但是在 Person 类中你能提供什么信息呢?除了姓名之外,Person 类对这个人一无所知。当然,可以实现 Person.getDescription() 来返回一个空字符串。不过还有一个更好的方法,如果使用 abstract 关键字,这样就根本不需要实现这个方法了。

1
2
public abstract String getDescription();
// no implementation required 抽象方法只有声明,没有实现(没有方法体) 它强制要求所有子类必须实现这个方法

为了提高程序的清晰性,包含一个或多个抽象方法的类本身必须被声明为抽象的。(如果一个类包含至少一个抽象方法,那么这个类本身也必须声明为抽象类

1
2
3
4
public abstract class Person
{
public abstract String getDescription();
}

除了抽象方法之外,抽象类还可以包含字段和具体方法。例如,Person 类还保存着一个人的姓名,另外有一个返回姓名的具体方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 类中不再含有抽象方法,所以不需要将这个类声明为抽象类。

抽象类的两种用途:

1.作为占位符

  • 抽象方法相当于**”占位符”**,要求子类必须实现
  • 确保所有子类都有统一的行为接口

2.作为通用代码容器

  • 存放子类共有的字段和方法
  • 遵循良好设计原则:“将通用内容放在超类中”

即使不含抽象方法,也可以将类声明为 abstract。(防止直接实例化 只能被继承,不能单独使用)

抽象类不能实例化。也就是说,如果将一个类声明为 abstract,就不能创建这个类的对象。例如,以下表达式

1
new Person("Vince Vu")

是错误的,但可以创建具体子类的对象。

需要注意,仍然可以创建一个抽象类的对象变量(object variable),但是这样一个变量只能引用非抽象子类的对象。例如,

1
Person p = new Student("Vince Vu", "Economics");

这里的 p 是抽象类型 Person 的一个变量,它引用了非抽象子类 Student 的一个实例。

下面定义一个扩展抽象类 Person 的具体子类 Student:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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.7 枚举类

下面是一个典型的例子:

1
public enum Size { SMALL, MEDIUM, LARGE, EXTRA_LARGE }

实际上,这个声明定义的类型是一个类,它刚好有4个实例,不可能构造新的对象。

因此,在比较枚举类型的值时,并不需要使用equals,可以直接使用==来比较。

如果需要的话,可以为枚举类型增加构造器、方法和字段。当然,构造器只是在构造枚举常量的时候调用。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
public enum Size
{
SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL");

private String abbreviation;

Size(String abbreviation) { this.abbreviation = abbreviation; }
// automatically private

public String getAbbreviation() { return abbreviation; }
}

**枚举的构造器总是私有的。**可以像前例中一样省略private修饰符。如果声明一个enum构造器为publicprotected,则会出现语法错误。

所有的枚举类型都是抽象类Enum的子类。它们继承了这个类的许多方法。其中,最有用的是**toString,这个方法会返回枚举常量名**。例如,Size.SMALL.toString()将返回字符串"SMALL"

toString的逆方法是静态方法valueOf。例如,以下语句

1
Size s = Enum.valueOf(Size.class, "SMALL");

s设置成Size.SMALL

每个枚举类型都有一个静态的values方法,它将返回一个包含全部枚举值的数组。例如,如下调用

1
Size[] values = Size.values();

将返回包含元素Size.SMALLSize.MEDIUMSize.LARGESize.EXTRA_LARGE的数组。

ordinal方法返回一个枚举常量在enum声明中的位置,位置从0开始计数。例如,Size.MEDIUM.ordinal()返回1

注释Enum类有一个类型参数,不过为简单起见,我们省略了这个类型参数。例如,实际上枚举类型Size扩展了Enum<Size>。类型参数会在compareTo方法中使用。

5.8 密封类

除非一个类声明为 final,否则任何人都可以派生这个类的子类。如果想对它有更多控制权呢?例如,假设需要编写你自己的 JSON 库,因为现有的库都不能完全满足你的需要。

JSON 标准指出,JSON 值是一个数组、数值、字符串、布尔值、对象或 null 。对此,显然可以使用 JSONArray、JSONNumber 等类来表示,它们都扩展一个抽象类 JSONValue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class JSONValue
{
// Methods that apply to all JSON values
}

public final class JSONArray extends JSONValue
{
...
}

public final class JSONNumber extends JSONValue
{
...
}

通过将 JSONArray、JSONNumber 等类声明为 final,可以确保没有人能派生它们的子类。但我们无法阻止人们派生 JSONValue 的另一个子类。

为什么我们想要控制这一点呢?考虑以下代码:

1
2
3
4
5
6
7
JSONValue v = ...;
if (v instanceof JSONArray a) ...
else if (v instanceof JSONNumber n) ...
else if (v instanceof JSONString s) ...
else if (v instanceof JSONBoolean b) ...
else if (v instanceof JSONObject o) ...
else ... // Must be JSONNull

在这里,从控制流程可以看出,我们知道 JSONValue 的所有直接子类。这不是一个开放性的层次结构。JSON 标准不会改变,如果确实有改变,作为库实现者,我们完全可以增加第 7 个子类。我们不希望别人混乱这个类层次结构。

在 Java 中,密封类(sealed class)会控制哪些类可以继承它。Java 15 中作为一个预览特性增加了密封类,并在 Java 17 中最终确定了这个特性

可以如下将 JSONValue 类声明为密封类:

1
2
3
4
5
public abstract sealed class JSONValue
permits JSONArray, JSONNumber, JSONString, JSONBoolean, JSONObject, JSONNull
{
...
}

如果试图定义一个未经允许的子类,那么这将是一个错误:

1
public class JSONComment extends JSONValue {...} // Error

这是有道理的,因为 JSON 不支持注释。所以,密封类可以准确地描述领域约束。

一个密封类允许的子类必须是可访问的它们不能是嵌套在另一个类中的私有类,也不能是位于另一个包中的包可见的类。

对于允许的公共子类,规则要更为严格。它们必须与密封类在同一个包中。不过,如果使用模块,则必须在同一个模块中

注释:**声明密封类可以不加 permits 子句。**这样一来,它的所有直接子类都必须在同一个文件中声明。不能访问这个文件的程序员就不能派生它的子类。

一个文件最多只能有一个 public 类,所以看起来只有当子类不会公共使用时,这种组织(即所有子类都在同一个文件中)才有用。
不过,可以使用内联类作为公共子类。

使用密封类的一个重要原因是编译时检查。考虑 JSONValue 类的以下这个方法,其中使用了一个带模式匹配的 switch 表达式(这是 Java 17 中的一个预览特性):

1
2
3
4
5
6
7
8
9
10
11
12
13
public String type()
{
return switch (this)
{
case JSONArray j -> "array";
case JSONNumber j -> "number";
case JSONString j -> "string";
case JSONBoolean j -> "boolean";
case JSONObject j -> "object";
case JSONNull j -> "null";
// No default needed here
};
}

编译器可以检查出这里不需要 default 子句,因为 JSONValue 的所有直接子类都已经出现在 case 分支中。

注释:前面的 type 方法看起来不太具有面向对象特点。按照 OOP 的精神,这 6 个类应当提供自己的 type 方法,应该依赖多态性而不是一个 switch。对于一个开放性的层次结构,这是一种好方法。不过,对于一组固定的类,通常更方便的做法是在一个方法中处理所有候选类。

乍一看,似乎密封类的子类必须是 final 类。但对于穷尽测试,我们只需要知道所有直接子类。如果那些类有自己的子类,那么这并没有问题。例如,我们的 JSON 层次结构如图 5-3 所示。

image-20251104163123122

图 5-3 表示 JSON 值的类的完整层次结构

在这个层次结构中,JSONValue 允许有 3 个子类:

1
2
3
4
public abstract sealed class JSONValue permits JSONObject, JSONArray, JSONPrimitive
{
. . .
}

JSONPrimitive 类也是密封的:

1
2
3
4
5
public abstract sealed class JSONPrimitive extends JSONValue
permits JSONString, JSONNumber, JSONBoolean, JSONNull
{
. . .
}

密封类的子类必须指定它是 sealedfinal,还是允许继续派生子类对于最后一种情况,必须声明为 non-sealed

注释non-sealed 关键字是第一个带连字符的 Java 关键字。这可能是将来的一个趋势。在语言中增加关键字总是会带来风险。现有的代码可能无法再编译。由于这个原因,sealed 是一个“有上下文的”关键字。仍然可以声明名为 sealed 的变量或方法

1
int sealed = 1; // OK to use contextual keyword as identifier

利用带连字符的关键字,我们可以不用担心这个问题。唯一可能产生二义性的是减法:

1
2
int non = 0;
non = non - sealed; // Subtraction, not keyword

为什么想要使用一个 non-sealed 子类呢?考虑一个 XML 节点类,它有 6 个直接子类:

1
2
3
4
5
public abstract sealed class Node permits Element, Text, Comment,
CDATASection, EntityReference, ProcessingInstruction
{
. . .
}

我们允许任意派生 Element 的子类:

1
2
3
4
5
6
7
8
9
public non-sealed class Element extends Node
{
. . .
}

public class HTMLDivElement extends Element
{
. . .
}

接口是抽象类的泛化。Java 接口还可以有子类型。密封接口的做法与密封类完全相同,会控制直接子类型。

5.9 反射

**反射库(reflection library)**提供了一个丰富且精巧的工具集,可以用来编写动态操纵 Java 代码的程序。使用反射,Java 可以支持用户界面生成器、对象关系映射器以及很多其他需要动态查询类能力的开发工具。

能够分析类能力的程序称为可反射(reflective)。反射机制的功能极其强大,如下几节所示,可以用它来:

  • 在运行时分析类的能力。
  • 在运行时检查对象,例如,编写一个适用于所有类的 toString 方法。
  • 实现泛型数组操作代码。
  • 利用 Method 对象,这个对象很像 C++ 中的函数指针。

反射是一种功能强大且复杂的机制,不过,主要是开发工具的程序员对它感兴趣,一般的应用程序员并不需要考虑反射机制。如果你只对编写应用程序感兴趣,而不是要为其他 Java 程序员构建工具,那么可以跳过本章的剩余部分,等以后再返回来学习。

5.9.1 Class类

在程序运行期间,Java 运行时系统始终为所有对象维护一个运行时类型标识(runtime type identification)。这个信息会跟踪每个对象所属的类。虚拟机利用运行时类型信息选择要执行的正确方法。

不过,还可以使用一个特殊的 Java 类访问这些信息。保存这些信息的类名为 Class,这个名字有些让人困惑。Object 类中的 getClass( ) 方法将会返回同一个 Class 类型的实例。

1
2
Employee e;
Class cl = e.getClass();

就像 Employee 对象描述一个特定员工的属性一样,Class 对象会描述一个特定类的属性。可能最常用的 Class 方法就是 getName。这个方法将返回类的名字。例如,下面这条语句:

1
System.out.println(e.getClass().getName() + " " + e.getName());

如果 e 是一个员工,则会输出:

1
Employee Harry Hacker

如果 e 是经理,则会输出:

1
Manager Harry Hacker

如果类在一个包里,包名也作为类名的一部分:

1
2
3
var generator = new Random();
Class cl = generator.getClass();
String name = cl.getName(); // name is set to "java.util.Random"

还可以使用静态方法 forName 获得类名对应的 Class 对象。

1
2
String className = "java.util.Random";
Class cl = Class.forName(className);

如果类名称存在一个字符串中,这个字符串会在运行时变化,就可以使用这个方法。如果 className 是一个类名或接口名,这个方法可以正常执行。否则,forName 方法将抛出一个检查型异常(checked exception)。无论何时使用这个方法,都应该提供一个异常处理器(exception handler)

获得 Class 类对象的第三种方法是一个很方便的快捷方式。如果 T 是任意的 Java 类型(或 void 关键字),T.class 将是匹配的类对象。例如:

1
2
3
Class cl1 = Random.class; // if you import java.util.*;
Class cl2 = int.class;
Class cl3 = Double[].class;

请注意,Class 对象实际上描述的是一个类型,这可能是类,也可能不是类。例如,int 不是类,但 int.class 确实是一个 Class 类型的对象。

注释:**Class 类实际上是一个泛型类。**例如,Employee.class 的类型是 Class。我们没有深究这个问题,这是因为它会让已经很抽象的概念变得更加复杂。在大多数实际应用中,可以忽略类型参教,而使用原始的 Class 类。

警告:鉴于历史原因,getName 方法对数组类型会返回有些奇怪的名字:

  • Double[].class.getName() 返回 "[Ljava.lang.Double;"
  • int[].class.getName() 返回 "[I"

虚拟机为每个类型管理一个唯一的 Class 对象。因此,可以使用 == 运算符比较两个类对象。例如:

1
if (e.getClass() == Employee.class) . . .

如果 e 是一个 Employee 实例,则这个测试将通过。与条件 e instanceof Employee 不同,如果 e 是某个子类(如 Manager)的实例,则这个测试将失败。

如果有一个 Class 类型的对象,可以用它构造类的实例。调用 getConstructor 方法将得到一个 Constructor 类型的对象,然后使用 newInstance 方法来构造一个实例。例如:

1
2
3
4
var className = "java.util.Random"; // or any other name of a class with
// a no-arg constructor
Class cl = Class.forName(className);
Object obj = cl.getConstructor().newInstance();

如果这个类没有无参数的构造器,则 getConstructor 方法会抛出一个异常。可以参见 5.9.7 节了解如何调用其他构造器。

注释:有一个已经废弃的 Class.toInstance 方法,它也可以利用无参数构造器构造一个实例。不过,如果构造器抛出一个检查型异常,那么这个异常将不做任何检查重新抛出。这违反了编译时异常检查的原则。与之不同,Constructor.newInstance 会把所有构造器异常包装到一个 InvocationTargetException 中。

5.9.2 声明异常入门

当运行时发生错误时,程序就会”抛出一个异常”。抛出异常比终止程序要灵活得多,这是因为你可以提供一个处理器(handler)”捕获”这个异常并进行处理。

如果没有提供处理器,程序就会终止,并在控制台上打印出一个消息,给出异常的类型。你可能在前面已经看到过一些异常报告,例如,不小心使用了null引用或者数组越界时。

异常有两种类型:非检查型(unchecked)异常和检查型(checked)异常。对于检查型异常,编译器将会检查你(程序员)是否知道这个异常并做好准备来处理后果。不过,有很多常见的异常(例如,越界错误或者访问null引用)都属于非检查型异常。编译器并不期望你为这些异常提供处理器。毕竟,你应该集中精力避免这些错误的发生,而不是为它们编写处理器。

不是所有的错误都是可以避免的。如果竭尽全力还是可能发生异常,大多数Java API都会抛出一个检查型异常。Class.forName方法就是一个例子。没有办法确保有指定名字的类一定存在。在第7章中,将会看到几种异常处理策略。现在,我们只介绍最简单的一个策略。

如果一个方法包含一条可能抛出检查型异常的语句,则在方法名上增加一个throws子句。

1
2
3
4
5
6
public static void doSomethingWithClass(String name)
throws ReflectiveOperationException
{
Class cl = Class.forName(name); // might throw exception
// do something with cl
}

调用这个方法的任何方法也都需要一个throws声明,这也包括main方法。如果一个异常确实出现,则main方法将终止并提供一个栈轨迹。(在第7章中,你将了解如何捕获异常而不是因异常终止程序。)

只需要为检查型异常提供一个throws子句。很容易找出哪些方法会抛出检查型异常——只要你调用了一个可能抛出检查型异常的方法而没有提供相应的异常处理器,编译器就会报错。

5.9.3 资源

类通常有一些关联的数据文件,例如:

  • 图像和声音文件。
  • 包含消息字符串和按钮标签的文本文件。

在 Java 中,这些关联的文件被称为资源(resource)。

例如,考虑一个显示消息的对话框,如图 5-4 所示。
当然,对于本书的下一版,这个面板中显示的书名和版权年会改变。为了便于追踪这个变化,我们将把这个文本放在一个文件中,而不是作为一个字符串硬编码写到代码中。

但是,应该将类似 about.txt 的文件放在哪儿呢?当然,将它与其他程序文件一起放在 JAR 文件中会很方便。

Class 类提供了一个很有用的服务可以查找资源文件。下面给出必要的步骤:

  1. 获得拥有资源的类的 Class 对象,例如 ResourceTest.class
  2. 有些方法(如 ImageIcon 类的 getImage 方法)接受描述资源位置的 URL。那么,可以调用
    1
    URL url = cl.getResource("about.gif");
  3. 否则,使用 getResourceAsStream 方法得到一个输入流来读取文件中的数据。

这里的重点在于 Java 虚拟机知道如何查找一个类,所以它能搜索相同位置上的关联资源。例如,假设 ResourceTest 类在一个 resources 包中。ResourceTest.class 文件就位于 resources 目录中,可以把一个图标文件放在同一个目录下。

除了可以将资源文件与类文件放在同一个目录中,还可以提供一个相对或绝对路径,如:

1
2
data/about.txt
/corejava/title.txt

文件的自动装载是利用资源加载特性完成的。没有标准的方法来解释资源文件的内容。每个程序必须有自己的方法来解释它的资源文件。

另一个经常使用资源的地方是程序的国际化。与语言相关的字符串(如消息和用户界面标签)都存放在资源文件中,每种语言对应一个文件。国际化 API(internationalization API)将在卷 II 的第 7 章中讨论。它支 持一种标准方法来组织和访问这些本地化文件。

程序清单 5-14 的程序展示了资源加载。(先不用担心读取文本和显示对话框的代码,这些内容稍后会详细介绍。)编译、构建一个 JAR 文件并执行:

1
2
3
4
java resource/ResourceTest.java
jar cvfe ResourceTest.jar resources.ResourceTest \
resources/*.class resources/*.gif resources/data/*.txt corejava/*.txt
java -jar ResourceTest.jar

将 JAR 文件移到另外一个不同的目录中,再次运行,以确认程序是从 JAR 文件而不是从当前目录读取资源。

5.9.4 利用反射分析类的能力

下面简要介绍反射机制最重要的内容,这允许你检查类的结构。

java.lang.reflect 包中有三个类 FieldMethodConstructor,分别用于描述类的字段、方法和构造器。这三个类都有一个名为 getName 的方法,用来返回字段、方法或构造器的名字。Field 类有一个 getType 方法,用来返回描述字段类型的一个对象,这个对象的类型同样是 ClassMethodConstructor 类有报告参数类型的方法,Method 类还有一个报告返回类型的方法。这三个类都有一个名为 getModifiers 的方法,它将返回一个整数,用不同的 0/1 位描述所使用的修饰符,如 publicstatic。然后,可以利用 java.lang.reflect 包中 Modifier 类的静态方法分析 getModifiers 返回的这个整数。例如,可以使用 Modifier 类中的 isPublicisPrivateisFinal 判断一个方法或构造器是 publicprivate 还是 final。我们需要做的就是在 getModifiers 返回的整数上调用 Modifier 类中适当的方法。另外,还可以利用 Modifier.toString 方法打印修饰符。

Class 类中的 getFieldsgetMethodsgetConstructors 方法将分别返回这个类支持的公共字段、方法和构造器的数组,其中包括超类的公共成员。Class 类的 getDeclaredFieldsgetDeclaredMethodsgetDeclaredConstructors 方法将分别返回这个类中声明的全部字段、方法和构造器组成的数组,其中包括私有成员、包成员和受保护成员,以及有包访问权限的成员,但不包括超类的成员。

程序清单 5-15 显示了如何打印一个类的全部信息。这个程序提示用户输入一个类名,然后输出类中所有的方法和构造器的签名,以及全部实例字段名。例如,如果输入

1
java.lang.Double

这个程序将会输出:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public final class java.lang.Double extends java.lang.Number
{
public java.lang.Double(double);
public java.lang.Double(java.lang.String);

public boolean equals(java.lang.Object);
public static java.lang.String toString(double);
public java.lang.String toString();
public static int hashCode(double);
public int hashCode();

public static double min(double, double);
public static double max(double, double);
public static native long doubleToRawLongBits(double);
public static long doubleToLongBits(double);
public static native double LongBitsToDouble(long);
public int compareTo(java.lang.Double);
public volatile int compareTo(java.lang.Object);
public static int compare(double, double);
public byte byteValue();

public short shortValue();

public int intValue();

public long longValue();

public float floatValue();

public double doubleValue();

public static java.lang.Double valueOf(java.lang.String);
public static java.lang.Double valueOf(double);
public static java.lang.String toHexString(double);
public volatile java.lang.Object resolveConstantDesc(
java.lang.invoke.MethodHandles$Lookup);
public java.lang.Double resolveConstantDesc(java.lang.invoke.MethodHandles$Lookup);
public java.util.Optional describeConstable();
public boolean isNaN();
public static boolean isNaN(double);
public static double sum(double, double);
public boolean isInfinite();
public static boolean isInfinite(double);
public static boolean isFinite(double);
public static double parseDouble(java.lang.String);

public static final double POSITIVE_INFINITY;
public static final double NEGATIVE_INFINITY;
public static final double NaN;
public static final double MAX_VALUE;
public static final double MIN_NORMAL;
public static final double MIN_VALUE;
public static final int MAX_EXPONENT;
public static final int MIN_EXPONENT;
public static final int SIZE;
public static final int BYTES;
public static final java.lang.Class TYPE;
private final double value;
private static final long serialVersionUID;
}

令人赞叹的是,这个程序可以分析 Java 解释器能加载的任何类,而不仅仅是编译程序时可用的类。

5.9.5 使用反射在运行时分析对象

从前面一节中,我们已经知道如何查看任意对象实例字段的名字和类型:

  • 获得对应的 Class 对象。
  • 在这个 Class 对象上调用 getDeclaredFields。

本节将进一步具体查看字段的内容。当然,在编写程序时,如果知道想要查看的字段名和类型,查看对象中指定字段的内容是一件很容易的事情。而利用反射机制可以查看在编译时还不知道的对象字段。

要做到这一点,关键方法是 Field 类中的 get 方法。如果 f 是一个 Field 类型的对象(例如,通过 getDeclaredFields 得到的对象),obj 是某个包含 f 字段的类的对象,则 f.get(obj) 将返回一个对象,其值为 obj 的当前字段值。这样说起来显得有点抽象,下面来看一个例子。

1
2
3
4
5
6
7
8
var harry = new Employee("Harry Hacker", 59800, 10, 1, 1989);
Class cl = harry.getClass();
// the class object representing Employee
Field f = cl.getDeclaredField("name");
// the name field of the Employee class
Object v = f.get(harry);
// the value of the name field of the harry object, i.e.,
// the String object "Harry Hacker"

当然,不仅可以获得值,也可以设置值。调用 f.set(obj, value) 将把对象 obj 中 f 表示的字段设置为新值。

实际上,这段代码存在一个问题。由于 name 是一个私有字段,所以 get 和 set 方法会抛出一个 IllegalAccessException。只能对可以访问的字段使用 get 和 set 方法。Java 安全机制允许查看一个对象有哪些字段,但是除非拥有访问权限,否则不允许读写那些字段的值。

反射机制的默认行为受限于 Java 的访问控制。不过,可以调用 Field、Method 或 Constructor 对象的 setAccessible 方法覆盖 Java 的访问控制。例如,

1
f.setAccessible(true); // now OK to call f.get(harry)

setAccessible 方法是 AccessibleObject 类中的一个方法,它是 Field、Method 和 Constructor 类的公共超类。这个特性是为调试、持久存储和类似机制提供的。本节稍后将用它编写一个通用的 toString 方法。

如果不允许访问,setAccessible 调用会抛出一个异常。访问可能被模块系统(见卷Ⅱ的第9章)或安全管理器(卷Ⅱ的第10章)拒绝。安全管理器并不常用,而且在 Java 17 后已被废弃。不过,在 Java 9 中,由于 Java API 是模块化的,每个程序都包含模块。
例如,本节最后的示例程序会在看 ArrayListInteger 对象的内容。在 Java 9 直到 Java 16 中运行这个程序时,会出现以下警告消息:

1
2
3
4
5
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by objectAnalyzer.ObjectAnalyzer (file:/home/cay /books/cjll/code/vlcb05/bin/) to field java.util.ArrayList.serialVersionUID
WARNING: Please consider reporting this to the maintainers of objectAnalyzer.ObjectAnalyzer
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release

在 Java 17 中运行这个程序时,会出现一个 InaccessibleObjectException 异常。
要让程序继续运行,需要把 java.base 模块中的 java.utiljava.lang 包”打开”到”无名模块”。详细内容参见卷Ⅱ的第9章。语法如下:

1
2
3
java --add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
objectAnalyzer.ObjectAnalyzerTest

注释:将来的库有可能使用可变句柄(variable handle)而不是反射来读写字段。VarHandleField 类似,可以用它读写一个特定类任意实例的特定字段。不过,要得到一个 VarHandle,库代码需要一个 Lookup 对象:

1
2
3
4
5
6
7
public Object getFieldValue(Object obj, String fieldName, Lookup lookup) 
throws NoSuchFieldException, IllegalAccessException {
Class<?> cl = obj.getClass();
Field field = cl.getDeclaredField(fieldName);
VarHandle handle = MethodHandles.privateLookupIn(cl, lookup).unreflectVarHandle(field);
return handle.get(obj);
}

如果生成这个 Lookup 对象的模块有访问这个字段的权限,那么这种做法是可行的。模块中有些方法会直接调用 MethodHandles.lookup(),这会得到封装了调用者访问权限的一个对象。采用这种方式,一个模块可以为另一个模块提供访问私有成员的权限。实际问题是如何能在提供这些权限的同时尽量减少麻烦。

尽管仍然可以这么做,不过我们来看一个可用于任意类的通用 toString 方法(见程序清单5-16)。这个通用 toString 方法使用 getDeclaredFields 获得所有实例字段,然后使用 setAccessible 便利方法将所有的字段设置为可访问的。对于每个字段,将获得名字和值。通过递归调用 toString 方法,将每个值转换成字符串。

这个通用的 toString 方法需要解决几个复杂的问题。引用循环有可能导致无限递归。因此,ObjectAnalyzer(见程序清单 5-17)会跟踪已访问过的对象。另外,为了能够查看数组内部,需要采用一种不同的方法。有关这种方法的具体内容将在下一节中详细介绍。

可以使用这个 toString 方法查看任意对象的内部信息。例如,下面这个调用

1
2
3
var squares = new ArrayList<Integer>();
for (int i = 1; i <= 5; i++) squares.add(i * i);
System.out.println(new ObjectAnalyzer().toString(squares));

将生成以下结果:

1
2
3
4
java.util.ArrayList[elementData=class java.lang.Object][java.lang.Integer[value=1][][]],
java.lang.Integer[value=4][][]],java.lang.Integer[value=9][][],
java.lang.Integer[value=16][][]],
java.lang.Integer[value=25][][]],null,null,null,null,null,size=5][modCount=5][][]]

还可以使用这个通用的 toString 方法实现你的自定义类的 toString 方法,如下所示:

1
2
3
4
public String toString()
{
return new ObjectAnalyzer().toString(this);
}

这样可以轻松地提供一个通用 toString 方法,无疑也很有用。不过,先不要高兴得太早,不要以为再也不用实现 toString 了,记住:能够不受控地访问类内部的日子将屈指可数。

5.9.6 使用反射编写泛型数组代码

java.lang.reflect 包中的 Array 类允许动态地创建数组。例如,Arrays 类的 copyOf 方法实现中就使用了这个类。应该记得,这个方法可以用于扩展一个已经填满的数组。

1
2
3
4
var a = new Employee[100];
...
// array is full
a = Arrays.copyOf(a, 2 * a.length);

如何编写这样一个通用的方法呢?好在 Employee[] 数组能够转换为 Object[] 数组,这听起来很有希望。下面进行第一次尝试:

1
2
3
4
5
6
public static Object[] badCopyOf(Object[] a, int newLength) // not useful
{
var newArray = new Object[newLength];
System.arraycopy(a, 0, newArray, 0, Math.min(a.length, newLength));
return newArray;
}

不过,在实际使用得到的数组时会遇到一个问题。这段代码返回的数组类型是一个对象数组 (Object[]),这是因为我们使用了下面运行代码来创建这个数组:

1
new Object[newLength]

对象数组不能强制转换成员工数组 (Employee[])。如果这样做,Java 虚拟机会在运行时生成一个 ClassCastException 异常。这里的关键是,前面已经提到,Java 数组会记住每个元素的类型,即创建数组时 new 表达式中使用的元素类型。将一个 Employee[] 临时转换成 Object[] 数组,然后再把它转换回来是可以的,但一个从开始就是 Object[] 的数组却永远不能转换成 Employee[] 数组。为了编写这类通用的数组代码,需要能够创建与原数组类型相同的新数组。为此,需要使用 java.lang.reflect 包中 Array 类的一些方法。其中,最关键的是 Array 类的静态方法 newInstance,这个方法能够构造一个新数组。在调用这个方法时必须提供两个参数,一个是数组的元素类型,另一个是期望的数组长度。

1
Object newArray = Array.newInstance(componentType, newLength);

为了具体执行这个调用,需要获得新数组的长度和元素类型。
可以通过调用 Array.getLength(a) 获得数组的长度。Array 类的静态 getLength 方法会返回一个数组的长度。要获得新数组的元素类型,需要完成以下工作:

  1. 首先获得 a 的类对象。
  2. 确认它确实是一个数组。
  3. 使用 Class 类的 getComponentType 方法(只为表示数组的类对象定义了这个方法)得到数组的正确类型。
  4. 反过来,对于表示类 CClass 对象,arrayType 方法会生成表示 C[]Class 对象。

为什么 getLengthArray 的方法,而 getComponentTypeClass 的方法呢?我们也不清楚——反射方法的分布有时候确实显得有点古怪。

下面给出这段代码:

1
2
3
4
5
6
7
8
9
10
public static Object goodCopyOf(Object a, int newLength)
{
Class cl = a.getClass();
if (!cl.isArray()) return null;
Class componentType = cl.getComponentType();
int length = Array.getLength(a);
Object newArray = Array.newInstance(componentType, newLength);
System.arraycopy(a, 0, newArray, 0, Math.min(length, newLength));
return newArray;
}

请注意,这个 copyOf 方法可以用来扩展任意类型的数组,而不仅是对象数组。

1
2
int[] a = { 1, 2, 3, 4, 5 };
a = (int[]) goodCopyOf(a, 10);

为了使用这个方法,要将 goodCopyOf 的参数声明为 Object 类型,而不是一个对象数组(Object[])。整型数组类型 int[] 可以转换为一个 Object,而不是转换成对象数组!

5.9.7 调用任意方法和构造器

在 C 和 C++ 中,可以通过一个函数指针执行任意函数。从表面上看,Java 没有提供方法指针,也就是说,Java 没有提供途径将一个方法的存储地址传给另外一个方法,以便第二个方法以后调用。事实上,Java 的设计者曾说过:方法指针很危险,而且很容易出错。他们认为 Java 的接口(interface)和 lambda 表达式(将在下一章讨论)是一种更好的解决方案。不过,反射机制允许你调用任意的方法。

回想一下,可以用 Field 类的 get 方法查看一个对象的字段。与之类似,Method 类有一个 invoke 方法,允许你调用包含当前 Method 对象中的方法。invoke 方法的签名为:

1
Object invoke(Object obj, Object... args)

第一个参数是隐含参数,其余的对象提供了显式参数。
对于静态方法,第一个参数会忽略,即可以将它设置为 null

例如,假设用 m1 表示 Employee 类的 getName 方法,下面这条语句显示了如何调用这个方法:

1
String n = (String) m1.invoke(harry);

如果返回类型是基本类型,则 invoke 方法会返回包装器类型。例如,假设 m2 表示 Employee 类的 getSalary 方法,那么返回的对象实际上是一个 Double,必须相应地完成强制类型转换。可以使用自动拆箱将它转换为一个 double

1
double s = (Double) m2.invoke(harry);

如何得到 Method 对象呢?当然,可以调用 getDeclaredMethods 方法,然后搜索返回的 Method 对象数组,直到发现想要的方法为止。也可以调用 Class 类的 getMethod 方法。这与 getField 方法类似。getField 方法接受一个表示字段名的字符串,返回一个 Field 对象。不过,有可能存在若干个同名的方法,因此要准确地得到想要的那个方法必须格外小心。有鉴于此,还必须提供想要的方法的参数类型。getMethod 的签名为:

1
Method getMethod(String name, Class... parameterTypes)

例如,下面展示了如何获得 Employee 类的 getName 方法和 raiseSalary 方法的方法指针:

1
2
Method m1 = Employee.class.getMethod("getName");
Method m2 = Employee.class.getMethod("raiseSalary", double.class);

可以使用类似的方法调用任意的构造器。将构造器的参数类型提供给 Class.getConstructor 方法,并为 Constructor.newInstance 方法提供参数值:

1
2
3
4
Class cl = Random.class; // or any other class with a constructor that
// accepts a long parameter
Constructor cons = cl.getConstructor(long.class);
Object obj = cons.newInstance(42L);

注释MethodConstructor 类扩展了 Executable 类。在 Java 17 中,Executable 类是密封类,只允许 MethodConstructor 作为子类。

到此为止,我们已经了解了使用 Method 对象的规则。下面来看如何具体使用。程序清单 5-19 中的程序会打印一个数字函数(如 Math.sqrtMath.sin)的取值表。打印的结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
public static native double java.lang.Math.sqrt(double)
1.0000 1.0000
2.0000 1.4142
3.0000 1.7321
4.0000 2.0000
5.0000 2.2361
6.0000 2.4495
7.0000 2.6458
8.0000 2.8284
9.0000 3.0000
10.0000 3.1623

当然,打印表格的代码与表格中计算的数学函数无关。

1
2
3
4
5
6
double dx = (to - from) / (n - 1);
for (double x = from; x <= to; x += dx)
{
double y = (Double) f.invoke(null, x);
System.out.printf("%10.4f | %10.4f%n", x, y);
}

在这里,f 是一个 Method 类型的对象。由于我们调用的方法是一个静态方法,所以 invoke 的第一个参数是 null

要打印 Math.sqrt 函数的取值表,可以如下设置 f

1
Math.class.getMethod("sqrt", double.class)

这是 Math 类的一个方法,名为 sqrt,有一个 double 类型的参数。

这个例子清楚地表明,利用 Method 对象可以实现 C 语言中函数指针(或 C# 中的委托)所能完成的所有操作。同 C 中一样,这种编程风格不是很方便,而且总是很容易出错。如果在调用方法的时候提供了错误的参数会发生什么?invoke 方法将会抛出一个异常。

另外,invoke 的参数和返回值必须是 Object 类型。这就意味着必须来回进行多次强制类型转换。这样一来,编译器会丧失检查代码的机会,以至于等到测试阶段才会发现错误,而这个时候查找和修正错误会麻烦得多。不仅如此,使用反射获得方法指针的代码要比直接调用方法的代码慢得多。

有鉴于此,建议仅在绝对必要的时候才在你自己的程序中使用 Method 对象。通常,更好的做法是使用接口以及 Java 8 引入的 lambda 表达式(第 6 章中介绍)。特别要强调:我们建议 Java 开发人员不要使用回调函数的 Method 对象。可以使用回调的接口,这样不仅代码的执行速度更快,也更易于维护。

5.10 继承的设计技巧

在本章的最后,我会给出使用继承时很有用的一些技巧。

  1. 将公共操作和字段放在超类中。
    正是因为这个原因,我们将姓名字段放在 Person 类中,而没有将它重复放在 EmployeeStudent 类中。

  2. 不要使用受保护的字段。
    有些程序员认为,将大多数的实例字段定义为 protected 是一个不错的主意,”以防万一”,这样子类就能够在需要的时候访问这些字段。不过,protected 机制并不能提供太多保护,这有两方面的原因。第一,子类集合是无限制的,任何一个人都能够由你的类派生一个子类,然后编写代码直接访问 protected 实例字段,从而破坏封装性。第二,在 Java 中,同一个包中的所有类都可以访问 protected 字段,而不论它们是否为这个类的子类。
    不过,有些方法不打算作为通用方法,要在子类中重新定义,protected 方法对于指示这种方法可能很有用。

  3. 使用继承实现 “is-a” 关系。
    使用继承很容易达到节省代码量的目的,但有时候也会被人们滥用。例如,假设需要定义一个 Contractor 类。钟点工有姓名和雇用日期,但是没有工资。他们按小时计薪,并且不会因为拖延时间而获得加薪。这似乎在诱导人们由 Employee 派生出子类 Contractor,然后再增加一个 hourlyWage 字段。

    1
    2
    3
    4
    public class Contractor extends Employee
    {
    private double hourlyWage;
    }

    不过,这并不是一个好主意。因为这样一来,每个钟点工对象中都同时包含了工资和时薪这两个字段。在实现打印薪水或税单的方法时,这会带来无尽的麻烦。与不使用继承相比,使用继承的做法最后反而会多写很多代码。
    钟点工与员工之间不是一种 “is-a” 关系。钟点工不是员工的一个特例。

  4. 除非所有继承的方法都有意义,否则不要使用继承。
    假设我们想编写一个 Holiday 类。毫无疑问,每个假日也是一天,并且一天可以用 GregorianCalendar 类的实例表示,因此可以使用继承。

    1
    class Holiday extends GregorianCalendar { ... }

    很遗憾,在继承的操作中,假日集合不是闭合的。GregorianCalendar 中有一个公共方法 add,这个方法可以将假日转换成非假日:

    1
    2
    Holiday christmas;
    christmas.add(Calendar.DAY_OF_MONTH, 12);

    因此,继承对于这个例子来说不太适合。

​ 需要指出,如果扩展一个不可变的类,就不会出现这个问题。假设有一个不可变的日期类,类似 LocalDate 但不是 final 类。如果派生一个 Holiday 子类,就没有任何方法能够把假日变成非假日。

  1. 覆盖方法时,不要改变预期的行为。
    替换原则不仅应用于语法,更重要的是,它也适用于行为。覆盖一个方法的时候,不应该毫无缘由地改变它的行为。就这一点而言,编译器不会提供任何帮助,编译器不会检查你重新定义的行为是否有意义。例如,可以重新定义 add 来”修正” Holiday 类中 add 方法的问题,可能让它什么也不做,或者抛出一个异常,或者是前进到下一个假日。
    不过,这种”修正”会违反替换原则。对于以下语句序列

    1
    2
    3
    4
    int d1 = x.get(Calendar.DAY_OF_MONTH);
    x.add(Calendar.DAY_OF_MONTH, 1);
    int d2 = x.get(Calendar.DAY_OF_MONTH);
    System.out.println(d2 - d1);

    不管 x 的类型是 GregorianCalendar 还是 Holiday,执行上述语句都应该有预期的行为。
    当然,这是个难题。理智和不理智的人们可能就预期行为是什么争论不休。例如,有些人争论说,替换原则要求 Manager.equals 忽略 bonus 字段,因为 Employee.equals 就忽略了这个字段。实际上,凭空讨论这些问题毫无意义。归根结底,关键在于在子类中覆盖方法时,不要偏离最初的设计初衷。

  2. 使用多态,而不要使用类型信息。
    只要看到以下形式的代码

    1
    2
    3
    4
    if (x is of type 1)
    action1(x);
    else if (x is of type 2)
    action2(x);

    都应该考虑使用多态。
    action1action2 表示的是一个通用概念吗?如果是,就应该将这个概念定义为这两个类型的公共超类或接口中的一个方法。然后,就可以调用

    1
    x.action();

    并利用多态性固有的动态分派机制执行正确的动作。
    与使用多个类型检测的代码相比,使用多态方法或接口实现的代码更易于维护和扩展。

  3. 不要滥用反射。
    反射机制使人们可以在运行时查看字段和方法,从而能编写出极具通用性的程序。这种功能对于系统编程极其有用,但是通常并不适合编写应用程序。反射很脆弱,如果使用反射,编译器将无法帮助你查找编程错误,直到运行时才会发现错误并导致异常。

    现在已经了解了 Java 如何支持面向对象编程的基础:类、继承和多态。下一章中我们将介绍两个高级主题:接口和 lambda 表达式。它们对于有效地使用 Java 非常重要。


《Java核心技术卷一》第五章
http://example.com/2025/10/20/《Java核心技术卷一》第五章/
作者
Under1ines
发布于
2025年10月20日
许可协议