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) 多态
上例中,boss 与 staff[0] 引用同一个 Manager 对象,但编译器只认为 staff[0] 引用一个 Employee 对象,所以,可以调用 boss.setBonus(3000),但不能调用 staff[0].setBonus(3000)。
不能将父类对象引用赋给子类变量,例如 Manager m = staff[0] 非法。
6) 方法调用
假设调用 x.f(args),隐式参数 x 为 C 类的一个对象,以下为调用步骤:
- 编译器查看对象的声明类型与方法名。可能存在多个名为
f但参数类型不同的方法,编译器将列举C及其父类中所有名为f且可访问的方法(父类的私有方法不可访问)。 - 编译器确定方法调用中提供的参数类型。此过程称为 重载解析(overloading resolution),由于类型转换,情况可能很复杂。如果没有找到参数类型匹配的方法或者类型转换后有多个方法匹配,将报错。
- 如果该方法或构造器为
private/static/final,编译器将准确地知道需要调用的方法,这称为 静态绑定(static binding)。与之相对,如果要调用的方法依赖于隐式参数的实际类型,那么运行时采用动态绑定。 - 采用动态绑定调用方法时,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]; // OK2) 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()); // 20527144s 与 t 的散列码相同,因为字符串的散列码由内容导出;而 sb 与 tb 的散列码不同,因为 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);
}equals 与 hashCode 的定义必须相容,如果 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),名称分别为 Integer,Long,Float,Double,Short,Byte,Character,Boolean。
包装器类对象不可变,一经构造,包装在其中的值无法修改。
比较两个包装器对象需用 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();。
如果在同一个表达式中混用 Integer 与 Double 类型,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 类除了姓名外对此人一无所知,可以返回一个空串,不过更好的做法是使用 关键字 abstract 将 Person::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::getDescription 和 Student::getDescription,p 永远不会引用一个 Person 对象,所以永远不会调用尚未实现的 Person::getDescription。
如果省略 Person::getDescription 的声明而只在其子类中定义 getDescription(),那么将不能调用 p.getDescription(),这就是抽象方法存在的意义。
7. 枚举类
public enum Size { SMALL, MEDIUM, LARGE, EXTRA_LARGE };此处定义了一个枚举类,SMALL 至 EXTRA_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 库)拥有更多控制权,可以使用 密封类 控制其他类对它的继承权限,关键字 sealed、permits。
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 中。
密封类的子类必须声明为 sealed、final,或者 non-sealed(允许继续派生子类)。
关键字
non-sealed是第一个带连字符的关键字,这可能是未来趋势。
9. 反射
反射库提供了一个丰富且精巧的工具集,可以用以编写动态操纵 Java 代码的程序,例如用户界面生成器、对象关系映射器以及很多其他需要动态查询类能力的开发工具。
能够分析类能力的程序称为可反射。可以用以:
- 在运行时分析类的能力。
- 在运行时检查对象,例如,编写一个适用于所有类的
toString()。 - 实现泛型数组操作代码。
- 利用
Method对象。
P209-230
10. 继承的设计技巧
以下是使用继承时的一些技巧。
- 将公共操作和字段放在父类中。
- 不要使用受保护的字段。
- 使用继承实现“is-a”关系。
- 除非所有继承的方法都有意义,否则不要使用继承。
- 覆盖方法时,不要改变预期的行为。
- 使用多态,而不要使用类型信息。
- 不要滥用反射。