1. 超类与子类

继承的基本思想是,基于已有类创建新类,新类继承已有类的成员,并新增成员以适应不同情况。

Employee,假设员工分为普通员工与管理人员,后者拥有奖金,那么可以创建一个新类 Manager,继承 Employee 的成员,新增 salary 字段及相关成员。每个管理人员都是员工,它们的关系为“是”(“is-a”)。

1) 继承层次结构

继承层次结构(inheritance hierarchy):由一个公共父类派生出来的所有类的集合。

继承链(inheritance chain):在继承层次结构中,从某个特定的类到其祖先的路径。

一个祖先类可以有多个子孙链,不同链上的子类之间没有关系。

2) 子类

关键字 extends:表示继承关系。

public class Manager extends Employee {
    // 新增成员
    ...
}

extends 指示正在构造的新类继承一个已存在的类,后者称为 超类(superclass) / 基类(base class) / 父类(parent class),新类称为 子类(subclass / child class) / 派生类(derived class)。

public class Manager extends Employee {
    private double bonus;
    ...
    public void setBonus(double bonus) {
        this.bonus = bonus;
    }
}

子类不能访问父类的私有字段。

记录不能继承或被继承。

3) 覆盖方法

Manager 中,方法 getSalary() 应该返回薪水与奖金之和,为此需要在 Manager 中提供同名方法 Manager::getSalary覆盖(override) Employee::getSalary

public double getSalary() {
    return salary + bonus; // ERROR: 子类不能访问父类的私有字段
}
 
public double getSalary() {
    double baseSalary = getSalary(); // 无限循环调用自身
    return baseSalary + bonus;
}

关键字 super:指示调用父类中的方法(而非子类中的同名方法)。例如,调用 super.getSalary() 将调用 Employee::getSalary

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

在覆盖方法时,子类方法的可见度不能低于父类方法,如果后者标记 public,前者也必须标记 public

4) 子类构造器

子类构造器必须在首条语句调用父类构造器(除非父类有无参构造器)。

public Manager(String name, double salary, int year, int month, int day) {
    super(name, salary, year, month, day); // 调用父类中参数为 name, salary, year, month, day 的构造器
    bonus = 0;
}
Manager boss = new Manager("Jane", 8000, 2012, 11, 9);
boss.setBonus(3000);
 
Employee[] staff = new Employee[3];
staff[0] = boss;
staff[1] = new Employee(...);
staff[2] = new Employee(...);
 
for (Employee e : staff)
    System.out.println(e.getName() + " " + e.getSalary());

对调用 e.getSalary(),编译器将自动选出正确的 getSalary,尽管将 e 声明为 Employee 类型,但是它实际上既可以引用 Employee 对象,也可以引用其子类 Manager 对象。

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

5) 多态

上例中,bossstaff[0] 引用同一个 Manager 对象,但编译器只认为 staff[0] 引用一个 Employee 对象,所以,可以调用 boss.setBonus(3000),但不能调用 staff[0].setBonus(3000)

不能将父类对象引用赋给子类变量,例如 Manager m = staff[0] 非法。

6) 方法调用

假设调用 x.f(args),隐式参数 xC 类的一个对象,以下为调用步骤:

  1. 编译器查看对象的声明类型与方法名。可能存在多个名为 f 但参数类型不同的方法,编译器将列举 C 及其父类中所有名为 f 且可访问的方法(父类的私有方法不可访问)。
  2. 编译器确定方法调用中提供的参数类型。此过程称为 重载解析(overloading resolution),由于类型转换,情况可能很复杂。如果没有找到参数类型匹配的方法或者类型转换后有多个方法匹配,将报错。
  3. 如果该方法或构造器为 private / static / final,编译器将准确地知道需要调用的方法,这称为 静态绑定(static binding)。与之相对,如果要调用的方法依赖于隐式参数的实际类型,那么运行时采用动态绑定。
  4. 采用动态绑定调用方法时,JVM 调用与 x 所引用对象实际类型对应的方法。假设 x 的实际类型为 C 的子类 D,如果 D 定义了方法 f(String),将调用它,否则将在 C 及其父类中寻找同名方法。

如果每次调用方法都要完成这个搜索过程,时间开销很大,为此 JVM 预先为每个类计算了一张 方法表(method table),其中列出了所有方法的签名和要调用的实际方法。

7) final 类与方法

final:不允许继承的类。

public final class Executive extends Manager {
    ...
}

final 方法:不允许覆盖的方法。

public class Employee {
    ...
    public final String getName() {
        return name;
    }
    ...
}

final 类中的方法自动为 final 方法。

如果一个方法没有被覆盖并且很短,编译器将对它进行优化处理,该过程称为 内联(inlining),例如,内联调用 e.getName() 将被替换为访问字段 e.name

JVM 中的即时编译器处理能力更强,它将知道有哪些类继承自其他类,并且能检查其中的方法是否被覆盖,如果方法很简短、被频繁调用并且未被覆盖,即时编译器将对它内联。

8) 强制类型转换

Manager boss = new Manager("Jane", 8000, 2012, 11, 9);
boss.setBonus(3000);
 
Employee[] staff = new Employee[3];
staff[0] = boss;
staff[1] = new Employee(...);
staff[2] = new Employee(...);

这是上文的代码,事先声明 Manager 类型的 boss,再将其存入 Employee 对象数组 staff。现在改为在 staff 中构造该 Manager 对象:

Employee[] staff = new Employee[3];
staff[0] = new Manager("Jane", 8000, 2012, 11, 9);
staff[1] = new Employee(...);
staff[2] = new Employee(...);

staff[0] 引用一个 Manager 对象,但其类型为 Employee 对象,为了访问 Manager 的字段 bonus,需将 staff[0] 强制转换为 Manager 对象:

Manager boss = (Manager) staff[0];

将值存入变量时,编译器将检查情况:

  • 将子类引用赋给父类变量,允许通过。
  • 将父类引用赋给子类变量,必须进行强制类型转换。

试图将一个事实父类对象强制转换为子类对象时,将抛出 ClassCastException

Manager boss = (Manager) staff[1]; // ClassCastException

操作符 instanceOf:检查对象是否属于指定类。

if (staff[i] instanceOf Manager) {
    boss = (Manager) staff[i];
    boss.setBonus(3000);
}

在将父类对象强制转换为子类对象前,必须进行 instanceOf 检查。

9) instanceOf 模式匹配

以上代码较为冗长,在 Java 16 以上可以简写为:

if (staff[i] instanceOf Manager boss) boss.setBonus(3000);

如果 staff[i]Manager 对象,则将 boss 设置为 staff[i],避免了强制转换。

无意义的 instanceOf 模式将出错:

Manager boss = new Manager(...);
if (boss instanceOf Employee e) ...; // boss 当然是 Employee 对象

instanceOf 模式引入的变量可以立即在同一个表达式中使用:

Employee e;
if (e instanceOf Manager m && m.getBonus() > 2000) ...;

10) 受保护访问

一般将类的字段标记 private、方法标记 public,不过有时需要子类能访问父类的字段、父类的方法只能由子类调用。

关键字 protected:限定字段/方法只能由同一个包中的子类访问/调用。

访问修饰符:

  • private:仅本类可以访问。
  • public:可由外部访问。
  • protected:仅本包中的所有子类可以访问。
  • 默认(无需修饰符):仅本包可以访问。

2. Object

Object 类是 Java 中所有类的祖先类,每个类都扩展自 Object

1) Object 类变量

Object 类型的变量可以引用任何类型的对象:

Object obj = new Employee(...);

Object 类型的变量只能作为一个泛型容器,对其中的内容进行具体操作前,需要强制转换为对象的实际类型:

Employee e = (Employee) obj;

只有基本类型(数值、字符和布尔值)不是对象,所有数组类型(无论对象数组还是基本类型数组)都扩展自 Object

Employee[] staff = new Employee[10];
obj = staff; // OK
obj = new int[10]; // OK

2) equals

方法 equals:检查两个对象是否相等(检查两个对象引用是否相同)。

P175-176

3) 相等测试与继承

P176-178

4) hashCode

散列码(hash code):由对象导出的一个整型值,没有规律,不同对象的散列码不同。

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

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

String s = "OK";
StringBuilder sb = new StringBuilder(s);
String t = new String("OK");
StringBuilder tb = new StringBuilder(t);
System.out.println(s.hashCode()); // 2556
System.out.println(sb.hashCode()); // 20526976
System.out.println(t.hashCode()); // 2556
System.out.println(tb.hashCode()); // 20527144

st 的散列码相同,因为字符串的散列码由内容导出;而 sbtb 的散列码不同,因为 StringBuilder 类没有重写 hashCode(),将调用 Object::hashCode,返回由对象的存储地址导出的散列码。

重写 hashCode 时,应合理组合实例字段的散列码,使不同对象的散列码尽量分散开。例如,对于日期,算法 7 * year + 11 * month + 13 * day 将产生很多冲突,31 * 12 * year + 31 * month + day 则较优。

public int hashCode() {
    return 7 * name.hashCode + 11 * Double.valueOf(salary).hashCode + 13 * hireDay.hashCode();
}

改进:

  • 使用 null 安全的方法 Objects::hashCode(参数为 null 时返回 0)。
  • 使用静态方法 Double::hashCode 避免构造 Double 对象。
public int hashCode() {
    return 7 * Objects.hashCode(name) + 11 * Double.hashCode(salary) + 13 * Objects.hashCode(hireDay);
}

方法 Objects::hash:提供所有参与生成散列码的值作为参数,它将对各个参数调用 Objects::hashCode,再自动组合。

public int hashCode() {
    return Objects.hash(name, salary, hireDay);
}

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

方法 Arrays::hashCode:返回数组类型字段的散列码。

记录类型自动提供 hashCode

5) toString

方法 toString():返回一个字符串,表示对象的值。

"*ClassName[*fields]"
public String toString() {
    return "Employee[name=" + name + ... + "]";
}

改进,调用 getClass().getName() 获取类名。

public String toString() {
    return getClass().getName() + "[name=" + name + ... + "]";
}

如果父类 toString 有调用 getClass().getName(),那么子类只需调用 super.toString()

public String toString() {
    return super.toString() + "[name=" + name + ... + "]";
}

自动对对象 x 调用 x.toString()

  • x 与字符串通过字符串连接符(+)连接时(因此 x.toString() 可写作 ""+x,不同在于,当 x 为基本类型时,只有后者可以编译)。
  • 调用 System.out.println(x) 时。

如果调用 x.toString() 时,其所属类没有重写 toString,将调用 Object::toString,返回所属类类名与散列码的字符串。

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

s"[I@1a46e30"[I 表示这是一个 int 类型数组。这不是对数组内元素的描述,要获得预期的描述,应调用 方法 Arrays::toString

String s = Arrays.toString(n); // "[2, 3, 5, 7, 11, 13]"

3. ArrayList

一些语言要求在编译时确定数组大小,Java 则允许在运行时确定。

ArrayList:一个有 类型参数(type parameter) 的 泛型类(generic class),类似于数组,但可以容纳任意类型的对象,并且支持创建后动态修改大小。

1) 声明 ArrayList

ArrayList<*element_type> *variable = new ArrayList<*element_type>();
 
// 也可以使用 var
 
var variable = new ArrayList<*element_type>();
 
// 不使用 var 时, 尖括号中的元素类型参数可以省略
 
ArrayList<*element_type> *variable = new ArrayList<>();

这称为菱形语法(因为一对尖括号(<>)形似菱形),它结合 new 使用。如果赋值给变量 / 传递给方法 / 从方法返回,编译器将该变量 / 参数 / 方法的泛型类型放在 <> 中:

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

此处,new ArrayList<>() 将赋值给类型为 ArrayList<Employee>staff,泛型类型为 Employee

方法 add():添加元素。

staff.add(new Employee(...));

ArrayList 的空间耗尽后,再次调用 add() 时,将自动创建一个更大的 ArrayList,并将所有元素拷贝到新数组。

自动创建新 ArrayList 并拷贝元素的开销较大,如果预先知道数组可能存储的元素数量,可以在填充数组前调用 ensureCapacity() 指定初始容量,或在构造时指定。

staff.ensureCapacity(100);
 
// 或者
 
ArrayList<Employee> staff = new ArrayList<>(100);

现在,前 100 次 add() 调用不会引起自动创建新 ArrayList 并拷贝元素的开销

size():返回实际包含的元素数量,调用 list.size() 等同于数组的 a.length()

trimToSize():如果能确定数组列表的大小不会再改变,调用 list.trimToSize() 将内存块的大小调整为存储当前实际元素所需的空间。

2) 访问数组列表元素

set():修改已有元素,调用 list.set(i, harry) 修改第 i 个元素为 harry,等同于数组的 a[i] = harry

只有当 al 的大小大于 i 时才可以调用 list.set(i, *object),反例:

List<Employee> staff = new ArrayList<>(100);
staff.set(0, "harry"); // 此时第 0 个元素还不存在

set() 只是修改已有元素,要使用 add() 添加新元素(与数组不同,数组在声明后可直接赋值,而 ArrayList 需要先添加元素)。

get():获取元素,调用 list.get(i) 获取第 i 个元素,等同于数组的 a[i]

P190-191

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

P192

4. 对象包装器与自动装箱

所有基本类型都有与之对应的 final 类(例如 int 对应 Integer 类),这些类称为 包装器(wrapper),名称分别为 IntegerLongFloatDoubleShortByteCharacterBoolean

包装器类对象不可变,一经构造,包装在其中的值无法修改。

比较两个包装器对象需用 equals()

var intAl = new ArrayList<int>(); // 非法
 
// 需使用 Integer 包装器类
 
var intAl = new ArrayList<Integer>();

自动装箱:自动转换特性,调用 list.add(3) 将自动转换为 list.add(Integer.valueOf(3))

自动拆箱:与自动装箱过程相反的特性,调用 list.get(i) 将自动转换为 list.get(i).intValue();

如果在同一个表达式中混用 IntegerDouble 类型,Integer 值将拆箱为 int,提升为 double 后再装箱为 Double

包装器对象引用可为 null,所以自动装箱可能抛出 NullPointerException

parseInt():将数字字符串转换为数值,调用 Integer.parseInt(s) 将字符串 s 转换为 int。实际上,parseInt()static,与 Integer 对象没有关系。

5. 参数个数可变的方法

public class PrintStream {
    public PrintStream printf(String fmt, Object... args) {
        return format(fmt, args);
    }
}

printf() 接收两个参数,一个 String 和一个 Object[],省略号(...)是代码的一部分,表示该 Object[] 可以容纳任意数量的参数。

可以自定义参数个数可变的方法:

public static double max(double... values) {
    double max = Double.NEGATIVE_INFINITY;
    for (double v : values) max = Math.max(v, max);
    return max;
}

调用 double m = max(3.1, 40.4, -5) 时,max 将接收数组 double[] { 3.1, 40.4, -5 }

对一个方法,只要最后一个参数为数组,就可以改写为参数个数可变的方法。甚至可以将 main() 声明为 public static void main(String... args)

6. 抽象类

现在,扩展 Employee 层次结构,新增 Student 类,员工与学生都是人,他们有一些共同的属性(如姓名),通过引入公共父类 Person,可以将 getName() 等通用方法放在继承层次结构中的更高层。

增加 getDescription(),返回对一个人的简短描述。对于两个子类来说这很容易,但 Person 类除了姓名外对此人一无所知,可以返回一个空串,不过更好的做法是使用 关键字 abstractPerson::getDescription 声明抽象方法。

public abstract String getDescription();

包含抽象方法的类必须声明为抽象类。

public abstract class Person {
    ...
    public abstract String getDescription();
}

抽象方法相当于要在子类中实现的具体方法的占位符,除抽象方法外,抽象类也可以有字段与具体方法。

扩展一个抽象类时可以…

  • 在子类中保持抽象父类中的至少一个抽象方法仍未定义,子类仍需声明为 abstract
  • 在子类中实现抽象父类的全部抽象方法,子类无需声明为 abstract(不过也可以这样做)。

抽象类不能实例化,只能创建其对象变量,且该变量只能引用其非抽象子类的对象:

Person p = new Student(...)
Person[] 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() 将分别调用 Employee::getDescriptionStudent::getDescriptionp 永远不会引用一个 Person 对象,所以永远不会调用尚未实现的 Person::getDescription

如果省略 Person::getDescription 的声明而只在其子类中定义 getDescription(),那么将不能调用 p.getDescription(),这就是抽象方法存在的意义。

7. 枚举类

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

此处定义了一个枚举类,SMALLEXTRA_LARGE 均为其实例。

枚举类不可能构造新的对象,因此在比较对象时可以用相等运算符(==),无需 equals()

枚举类可以有字段、构造器与方法。

public enum Size {
    SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL");
    private String abbreviation;
    
    Size(String abbreviation) {
        this.abbreviation = abbreviation;
    }
    
    public String getAbbreviation() {
        return abbreviation;
    }
}

枚举类的构造器只能且自动声明为私有。

所有枚举类都是抽象类 Enum 的子类,它们继承了很多方法:

  • toString():返回枚举常量名,调用 Size.SMALL.toString() 将返回 "SMALL"
  • valueOf()toString() 的逆方法,调用 Size s = Enum.valueOf(Size.class, "SMALL")s 赋值为 Size.SMALL
  • values():返回一个包含全部枚举值的数组,调用 Size[] values = Size.values()values 赋值为 { Size.SMALL, Size.MEDIUM, Size.LARGE, Size.EXTRA_LARGE }
  • ordinal():返回一个枚举常量在枚举类声明中的位置,调用 Size.MEDIUM.ordinal() 将返回 1

8. 密封类

除非将类声明为 final,否则它可以被继承,如果要对自定义类(例如自己的 JSON 库)拥有更多控制权,可以使用 密封类 控制其他类对它的继承权限,关键字 sealedpermits

public abstract sealed class JSONValue permits JSONArray, JSONNumber, JSONString, JSONBoolean, JSONObject, JSONNull {
    ...
}

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

密封类的声明可以不含 permits 子句,此时,其所有直接子类都必须与其在同一个文件中声明,而一个文件最多有一个 public 类,所以其所有子类只能为 private

使用密封类的优点为编译时检查,假定方法 JSONValue::type(使用了 Java 17 中的预览特性,带模式匹配的 switch 表达式):

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";
        // 无需 default
    };
}

编译器可以检查到此处无需 default,因为 JSONValue 的所有子类都出现在 case 中。

密封类的子类必须声明为 sealedfinal,或者 non-sealed(允许继续派生子类)。

关键字 non-sealed 是第一个带连字符的关键字,这可能是未来趋势。

9. 反射

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

能够分析类能力的程序称为可反射。可以用以:

  • 在运行时分析类的能力。
  • 在运行时检查对象,例如,编写一个适用于所有类的 toString()
  • 实现泛型数组操作代码。
  • 利用 Method 对象。

P209-230

10. 继承的设计技巧

以下是使用继承时的一些技巧。

  • 将公共操作和字段放在父类中。
  • 不要使用受保护的字段。
  • 使用继承实现“is-a”关系。
  • 除非所有继承的方法都有意义,否则不要使用继承。
  • 覆盖方法时,不要改变预期的行为。
  • 使用多态,而不要使用类型信息。
  • 不要滥用反射。