本文内容参考《Java核心技术 卷Ⅰ》

类、超类和子类

定义子类

在Java中,通常使用extends关键字表示继承。

1
2
3
public class Manager extends Employee {
...
}

extends表明正在构造的新类派生于一个已存在的类。已存在的类被称为超类、基类或父类;新类被称为子类、派生类或孩子类。子类会自动继承超类的字段和方法,不过子类不能直接访问或使用超类的私有字段和方法。定义子类时,只需指出子类与超类的不同之处。

覆盖方法

超类中的一些方法在子类中不适用,我们可以将该方法进行覆盖,假设超类Employee有一个方法getSalary(),我们在Manager中也写一个和超类相同方法签名的方法

1
2
3
4
5
public class Manager extends Employee {
public double getSalary() {
...
}
}

此时我们如果使用Manager的对象使用getSalary(),调用的是子类的方法,而不是超类的方法。如果想要在子类中调用与超类同名的方法,可以向如下这样使用super关键字

1
2
3
4
5
public class Manager extends Employee {
public double getSalary() {
return super.getSalary();
}
}

在覆盖一个方法时,子类的方法不能低于超类方法的可见性。如果超类方法使用的是public,子类方法也必须是public

子类构造器

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

1
2
3
4
public Manager(String name, double salary, int year, int month, int day) {
super(name, salary, month, day);
bonus = 0;
}

如果子类构造器没有显式地调用超类的构造器,将自动地调用超类的无参数构造器。如果超类没有无参数的构造器,并且在子类的构造器中又没有显式地调用超类的其他构造器,Java编译器会报错。

假设现在Manager类的getSalary方法会自动将奖金添加到工资里

1
2
3
4
5
6
7
8
9
10
11
12
// 创建一个经理,并设置奖金
Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
boss.setBonus(5000);
// 创建一个包含三个员工的数组
Employee[] 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());
}

执行程序后会输出

1
2
3
Carl Cracker 85000.0
Harry Hacker 50000.0
Tony Tester 40000.0

可以看到staff[1]staff[2]只输出了基本薪水,因为它们是Employee对象,而staff[0]Manager对象,所以他输出了基本薪水+奖金。

需要注意,在循环中尽管e被声明为Employee类型,但实际上e既可以引用Employee类型的对象,也可以调用Manager类型的对象。当e引用Employee对象时,e.getSalary()调用的是Employee类中的getSalary方法;当e引用Manager对象时,e.getSalary()调用的是Manager类中的getSalary方法。虚拟机知道e的实际引用对象类型,因此能正确调用相应的方法。

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

多态

可以将子类的对象赋给超类变量,但不能把超类的引用赋给子类变量。

1
2
3
Employee e;
e = new Employee();
e = new Manager();

理解方法调用

假设要调用x.f(args),隐式参数x声明为类C的一个对象。下面是调用过程的详细描述:

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

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

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

  3. 如果是private方法、static方法、final方法或者构造器,那么编译器将可以准确地知道应该调用哪个方法,我们将这种调用方式称为静态绑定。与此对应的是,调用的方法依赖于隐式参数的实际类型,并且在运行时实现动态绑定。在我们列举的示例中,编译器采用动态绑定的方式生成一条调用f(String)的指令。

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

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

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

阻止继承:final类和方法

不允许拓展的类称为final类。假设希望阻止人们派生Executive类的子类,可以如下声明

1
2
3
public final class Executive extends Manager {
...
}

类中的某个特定方法也可以被声明为final。如果这样做,子类就不能覆盖这个方法。例如

1
2
3
4
5
public class Employee {
public final String getName() {
return name;
}
}

如果一个类被声明为final,其中的方法会自动声明为final,而字段不会。

强制类型转换

进行强制转换的唯一原因:要在暂时忽视对象的实际类型之后使用使用对象的全部功能。

需要注意:

  • 只能在继承层次内进行强制类型转换。
  • 在将超类强制转换成子类之前,应该使用instanceof进行检查。

抽象类

自上而下在类的继承层次结构中上移,位于上层的类更具有一般性。人们只将它作为派生其他类的基类,而不是用来构造特定的实例。可以利用abstract关键字将类定义为抽象类

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

包含一个或多个抽象方法的类本身必须被声明为抽象的。抽象类还能包含字段和具体方法。抽象方法充当占位方法的角色,它们在子类中具体实现。

受保护访问

  1. 仅对本类可见 —— private。
  2. 对外部完全可见 —— public。
  3. 对本包和所有子类可见 —— protected。
  4. 对本包可见 —— 默认,不需要修饰符。

泛型数组列表

声明数组列表

声明和构造一个保存Employee对象的数组列表

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

可以省去右边的泛型参数

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

访问数组列表元素

我们不能用[]语法访问或修改数组元素,而要使用getset方法。假如要设置第i个元素,可以使用

1
staff.set(i, harry);

它等价于对数组a的元素进行赋值

1
a[i] = harry;

对象包装器与自动装箱

有时需要将int这样的基本类型转为对象,所有基本类型都有与之对应的类。Integer类对应的基本类型是int,通常这些类被称为包装器。包装器类是不可变的,而且不能够派生他们的子类。加入要定义一个整型数组列表,不能够在尖括号中用基本类型,也就是说不能写成ArrayList<int>的形式,我们可以这样声明

1
ArrayList<Integer> a = new ArrayList<>();

向a中添加元素

1
a.add(3);

它将自动变成

1
a.add(Interger.valueOf(3));

这种变换被称为自动装箱。相反地,如果将Integer对象赋给一个int值时,将会自动拆箱。

反射

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

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

Class类

Java运行时系统始终为所有对象维护一个运行时类型标识。这个信息会跟踪每个对象所属的类。虚拟机利用运行时类型信息选择要执行的正确的方法。Class类保存了这些信息。

Object类中的getClass()方法会返回一个Class类型的实例

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

Class对象会描述一个特定类的属性,最常用的方法是getName(),它将返回类的名字

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

如果e是员工,会输出:Employee Harry Hacker。

如果e是经理,会输出:Manager Harry Hacker。

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

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

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

还有一种获取类对象的方法是利用T.class

1
Class cl = Random.class

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

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

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

1
2
Class cl = forName("java.util.Random");
Object obj = cl.getConstructor().newInstance();

利用反射分析类的能力

检查类的结构是反射机制最重要的内容。

java.lang.reflect包中有三个类FieldMethodConstructor分别用于描述类的字段、方法和构造器。这三个类都有getName()的方法,用来返回字段、方法或构造器的名称。Field类有一个getType()方法,用来返回字段类型的一个对象,这个对象类型是ClassMethodConstructor类有报告参数类型的方法,Method类还有报告返回类型的方法。这三个类都有一个getModifiers()方法,用不同的整数描述所使用的修饰符。

Class类中的getFields()getMethods()getConstructor()方法分别返回这个类中的公共字段、方法和构造器的数组,其中包括超类的公共成员。getDeclareFields()getDeclareMethods()getDeclareConstructors()方法返回类中声明的全部字段、方法和构造器的数组,其中包含私有成员、包成员和受保护的成员,但不包括超类的成员。

继承的设计技巧

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