1 类、超类和子类
假设你在某个公司工作,这个公司中经理的待遇与普通雇员的待遇存在着一些差异。不过, 他们之间也存在着很多相同的地方, 例如, 他们都领取薪水。只是普通雇员在完成本职任务之后仅领取薪水, 而经理在完成了预期的业绩之后还能得到奖金。这种情形就需要使用继承。
这是因为需要为经理定义一个新类 Manager, 以便增加一些新功能。但可以重用 Employee 类中已经编写的部分代码,并将其中的所有域保留下来。从理论上讲, 每个经理都是一名雇员:“ is-a” 关系是继承的一个明显特征。
1.1 定义子类
关键字extends 表示继承。
public class Manager extends Employee
{
// 添加方法和域
}
关键字extends 表明正在构造的新类派生于一个已存在的类。
- 已存在的类称为超类( superclass )、基类( base class ) 或父类( parent class) ;
- 新类称为子类( subclass)、 派生类
( derived class ) 或孩子类(child class )。- 超类和子类是Java 程序员最常用的两个术语,而了解其他语言的程序员可能更加偏爱使用父类和孩子类,这些都是继承时使用的术语。
1.2 覆盖方法
超类中的有些方法对子类Manager 并不一定适用。具体来说, Manager 类中的
getSalary 方法应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖( override) 超类中的这个方法:
public class Manager extends Employee
{
public double getSalary()
{
...
}
// 应该如何实现这个方法呢? 乍看起来似乎很简单, 只要返回 salary 和bonus 域的总和就可以了:
public double getSalary()
{
return salary + bonus; // won't work
}
/**
* 这个方法并不能运行。因为Manager 类的getSalary 方法不能够直接地访问超类的私有域。也就是说,
* 尽管每个Manager 对象都拥有一个名为salary 的域, 但在Manager 类的getSalary 方法中并不能够
* 直接地访问 salary 域。只有Employee 类的方法才能够访问私有部分。如果Manager 类的方法一定要
* 访问私有域, 就必须借助于公有的接口
*/
public double getSalary()
{
double baseSalary = getSalaryO;// still won't work
return baseSalary + bonus;
}
/** 上面这段代码仍然不能运行。问题出现在调getSalary 的语句上,这是因为Manager 类也有一个getSalary
* 方法(就是正在实现的这个方法),所以这条语句将会导致无限次地调用自己,直到整个程序崩溃为止。
* 这里需要指出: 我们希望调用超类Employee 中的getSalary 方法, 而不是当前类的这个方法。
* 为此, 可以使用特定的关键字super 解决这个问题:
*/
super.getSalary()
//下面是Manager 类中getSalary 方法的正确书写格式:
public double getSalaryO
{
double baseSalary = super.getSalary();
return baseSalary + bonus;
}
}
1.3 子类构造器
public Manager(String name, double salary, int year, int month, int day)
{
super(name, salary, year , month, day) ;
bonus = 0;
}
super(n, s, year, month, day) ;
是“ 调用超类Employee 中含有n、s、year、month 和day 参数的构造器” 的简写形式。
- 由于Manager 类的构造器不能访问Employee 类的私有域, 所以必须利用Employee 类的构造器对这部分私有域进行初始化,
- 我们可以通过super 实现对超类构造器的调用。使用super 调用构造器的语句必须是子类构造器的第一条语句。
- 如果子类的构造器没有显式地调用超类的构造器, 则将自动地调用超类默认(没有参数)的构造器。如果超类没有不带参数的构造器,并且在子类的构造器中又没有显式地调用超类的其他构造器,则Java 编译器将报告错误。
1.4 继承层次
继承并不仅限于一个层次。例如, 可以由Manager 类派生Executive 类。由一个公共超类派生出来的所有类的集合被称为继承层次( inheritance hierarchy ),在继承层次中, 从某个特定的类到其祖先的路径被称为该类的继承链( inheritance chain ) .
1.5 多态
多态是同一个行为具有多个不同表现形式或形态的能力。
多态就是同一个接口,使用不同的实例而执行不同操作,如图所示:
多态的优点
- 消除类型之间的耦合关系
- 可替换性
- 可扩充性
- 接口性
- 灵活性
- 简化性
多态存在的三个必要条件
- 继承
- 重写
- 父类引用指向子类对象:Parent p = new Child();
以下是一个多态示例
public class Test {
public static void main(String[] args) {
show(new Cat()); // 以 Cat 对象调用 show 方法
show(new Dog()); // 以 Dog 对象调用 show 方法
Animal a = new Cat(); // 向上转型,父类引用指向子类对象
a.eat(); // 调用的是 Cat 的 eat
Cat c = (Cat)a; // 向下转型
c.work(); // 调用的是 Cat 的 work
}
public static void show(Animal a) {
a.eat();
// 类型判断
if (a instanceof Cat) { // 猫做的事情
Cat c = (Cat)a;
c.work();
} else if (a instanceof Dog) { // 狗做的事情
Dog c = (Dog)a;
c.work();
}
}
}
abstract class Animal {
abstract void eat();
}
class Cat extends Animal {
public void eat() {
System.out.println("吃鱼");
}
public void work() {
System.out.println("抓老鼠");
}
}
class Dog extends Animal {
public void eat() {
System.out.println("吃骨头");
}
public void work() {
System.out.println("看家");
}
}
输出结果是
吃鱼
抓老鼠
吃骨头
看家
吃鱼
抓老鼠
1.6 理解方法调用:编译与执行
假设要调用x.f(args),隐式参数 x 声明为类C 的一个对象。下面是调用过程的详细描述:
- 编译器査看对象的声明类型和方法名。假设调用x.f(param),且隐式参数x 声明为C类的对象。需要注意的是: 有可能存在多个名字为f, 但参数类型不一样的方法。例如,可能存在方法f(int) 和方法 f(String)。 编译器将会列举所有C 类中名为f 的方法和其超类中访问属性为public 且名为f 的方法(超类的私有方法不可访问)。
至此, 编译器已获得所有可能被调用的候选方法。 - 接下来,编译器将査看调用方法时提供的参数类型。如果在所有名为f 的方法中存在一个与提供的参数类型完全匹配, 就选择这个方法。这个过程被称为重栽解析( overloading resolution )。例如, 对于调用x.f(“Hello” )来说, 编译器将会挑选f(String),而不是f(int)。由于允许类型转换( int 可以转换成double, Manager 可以转换成Employee, 等等),所以这个过程可能很复杂。如果编译器没有找到与参数类型匹配的方法, 或者发现经过类型转换后有多个方法与之匹配, 就会报告一个错误。
至此, 编译器已获得需要调用的方法名字和参数类型。 - 如果是private 方法、static 方法、final 方法或者构造器, 那么编译器将可以准确地知道应该调用哪个方法, 这种调用方式称为静态绑定( static binding )。与此对应的是,调用的方法依赖于隐式参数的实际类型, 并且在运行时实现动态绑定。在我们列举的示例中, 编译器采用动态绑定的方式生成一条调用f(String) 的指令。
- 当程序运行,并且采用动态绑定调用方法时, 虚拟机一定调用与x 所引用对象的实际类型最合适的那个类的方法。假设 x 的实际类型是D,它是C 类的子类。如果D 类定义了方法f(String),就直接调用它;否则, 将在D 类的超类中寻找f(String),以此类推。
每次调用方法都要进行搜索,时间开销相当大。因此, 虚拟机预先为每个类创建了一个 方法表( method table),其中列出了所有方法的签名和实际调用的方法。这样一来,在真正 调用方法的时候, 虚拟机仅查找这个表就行了。在前面的例子中, 虚拟机搜索D 类的方法表, 以便寻找与调用f(Sting) 相K配的方法。这个方法既有可能是D.f(String) , 也有可能是 X.f(String) , 这里的X 是D 的超类。这里需要提醒一点, 如果调用super.f(param), 编译器将对隐式参数超类的方法表进行搜索。
1.7 阻止继承:final 类和方法
假设希望阻止人们定义 Executive 类的子类,就可以在定义这个类的时候。使用final 修饰符声明。声明格式如下所示:
public final class Executive extends Manager
{
...
}
如果将一个类声明为final, 只有其中的方法自动地成为final ,而不包括域。
类中的特定方法也可以被声明为final。如果这样做,子类就不能覆盖这个方法( final 类中的所有方法自动地成为final 方法)。 例如
public class Empolyee
{
public final String getName()
{
return name;
}
...
}
有些程序员认为: 除非有足够的理由使用多态性, 应该将所有的方法都声明为final。事
实上,在C++ 和C# 中, 如果没有特别地说明, 所有的方法都不具有多态性。这两种做法可能都有些偏激。我们提倡在设计类层次时, 仔细地思考应该将哪些方法和类声明为final。
在早期的Java 中, 有些程序员为了避免动态绑定带来的系统开销而使用final 关键字。如果一个方法没有被覆盖并且很短, 编译器就能够对它进行优化处理, 这个过程为称为内联(inlining )。例如,内联调用e.getName( ) 将被替换为访问e.name 域。这是一项很有意义的改进, 这是由于CPU 在处理调用方法的指令时, 使用的分支转移会扰乱预取指令的策略, 所以,这被视为不受欢迎的。然而, 如果getName 在另外一个类中被覆盖, 那么编译器就无法知道覆盖的代码将会做什么操作, 因此也就不能对它进行内联处理了。
幸运的是, 虚拟机中的即时编译器比传统编译器的处理能力强得多。这种编译器可以准确地知道类之间的继承关系, 并能够检测出类中是否真正地存在覆盖给定的方法。如果方法很简短、被频繁调用且没有真正地被覆盖, 那么即时编译器就会将这个方法进行内联处理。如果虚拟机加载了另外一个子类,而在这个子类中包含了对内联方法的覆盖, 那么将会发生什么情况呢? 优化器将取消对覆盖方法的内联。这个过程很慢, 但却很少发生。
1.8 强制类型转换
可能需要将某个类的对象引用转换成另外一个类的对象引用。对象引用的转换语法与数值表达式的类型转换类似, 仅需要用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前就可以了。例如:
Manager boss = (Manager) staff[0]:
进行类型转换的唯一原因是:在暂时忽视对象的实际类型之后, 使用对象的全部功能。例如, 在managerTest 类中, 由于某些项是普通雇员, 所以staff 数组必须是Employee 对象的数组。我们需要将数组中引用经理的元素复原成Manager 类, 以便能够访问新增加的所有变量。
将一个值存入变量时, 编译器将检查是否允许该操作。将一个子类的引用赋给一个超类变量, 编译器是允许的。但将一个超类的引用赋给一个子类变量, 必须进行类型转换, 这样才能够通过运行时的检査。
如果试图在继承链上进行向下的类型转换,并且“ 谎报” 有关对象包含的内容, 会发生什么情况呢?
Manager boss = (Manager) staff[1] ; // Error
运行这个程序时, Java 运行时系统将报告这个错误, 并产生一个 ClassCastException 异常。如果没有捕获这个异常, 那么程序就会终止。因此,应该养成这样一个良好的程序设计习惯: 在进行类型转换之前, 先查看一下是否能够成功地转换。这个过程简单地使用 instanceof 操作符就可以实现。例如:
if (staff[1] instanceof Manager)
{
Manager boss = (Manager) staff[1]:
...
}
综上所述:
- 只能在继承层次内进行类型转换。
- 在将超类转换成子类之前,应该使用 instanceof 进行检查。
1.9 抽象类
抽象类不能被实例化。也就是说, 如果将一个类声明为abstract , 就不能创建这个类的对象。
public abstract class Person
{
private String name;
public Person(St ring name)
{
this.name = name ;
}
public abstract String getDescribtion();
public String getName()
{
return name;
}
}
class Employee extends Person
{
public Employee(String n, double s, int year, int month, int day)
{
super(n);
salary = s;
GregorianCalendar calendar = new GregorianCalendar(year, month - 1, day);
hireDay = calendar.getTime();
}
public double getSalary()
{
return salary;
}
public Date getHireDay()
{
return hireDay;
}
public String getDescription()
{
return String.format("an employee with a salary of $%.2f", salary);
}
public void raiseSalary(double byPercent)
{
double raise = salary * byPercent / 100;
salary += raise;
}
private double salary;
private Date hireDay;
}
class Student extends Person
{
/**
* @param n the student's name
* @param m the student's major
*/
public Student(String n, String m)
{
// pass n to superclass constructor
super(n);
major = m;
}
public String getDescription()
{
return "a student majoring in " + major;
}
private String major;
}
包含一个或多个抽象方法的类本身必须被声明为抽象的。
1.10 受保护访问
在有些时候,人们希望超类中的某些方法允许被子类访问, 或允许子类的方法访问超类的某个域。为此, 需要将这些方法或域声明为protected。例如, 如果将超类Employee中的hireDay 声明为proteced, 而不是私有的, Manager 中的方法就可以直接地访问它。
小结
Java 用于控制可见性的4 个访问修饰符:
- 仅对本类可见 private。
- 对所有类可见 public。
- 对本包和所有子类可见 protected。
- 对本包可见——默认(很遗憾),不需要修饰符。
2 Object:所有类的超类
Object 类是Java 中所有类的始祖, 在Java 中每个类都是由它扩展而来的。但是并不需
要这样写:
public class Employee extends Object
如果没有明确地指出超类,Object 就被认为是这个类的超类。由于在Java 中,每个类都是由Object 类扩展而来的,除了 数值、字符和布尔类型 等基本类型。
2.1 equlas 方法
Object 类中的equals 方法用于检测一个对象是否等于另外一个对象。
Java 语言规范要求equals 方法具有下面的特性:
- 自反性: 对于任何非空引用x, x.equals(?0 应该返回truec
- 对称性: 对于任何引用x 和y,当且仅当y.equals(x) 返回true, x.equals(y) 也应该返 回true。
- 传递性: 对于任何引用x、y 和z,如果x.equals(y) 返N true, y.equals(z) 返回true, x.equals(z) 也应该返回true。
- 一致性: 如果x 和y 引用的对象没有发生变化, 反复调用x.eqimIS(y) 应该返回同样 的结果。
- 对于任意非空引用x,x.equals(null) 应该返回false。
2.2 hashCode 方法
散列码(hashcode) 是由对象导出的一个整型值。散列码是没有规律的。如果 x 和 y 是两个不同的对象, x.hashCode( ) 与 y.hashCode( ) 基本上不会相同。
Equals 与 hashCode 的定义必须一致: 如果 x.equals(y) 返回 true, 那么 x.hashCode( ) 就必 须与 y.hashCode( ) 具有相同的值。
2.3 toString 方法
在 Object 中还有一个重要的方法, 就是 toString 方法, 它用于返回表示对象值的字符串。
绝大多数(但不是全部)的 toString方法都遵循这样的格式: 类的名字, 随后是一对方括号括起来的域值。 下面是 Employee 类中的 toString 方法的实现:
public String toStringO
{
return getClassO.getNameO
+ "[name=" + name +'salary: " + salary
+ ",hireDay=" + hireDay + T;
}
3 泛型数组列表
许多程序设计语言中, 特别是在 C++ 语言中, 必须在编译时就确定整个数组的大小。
在 Java 中, 情况就好多了。 它允许在运行时确定数组的大小。
int actualSize = . . .;
Employee[] staff = new Employee[actualSize];
当然 这段代码并没有完全解决运行时动态更改数组的问题 一旦确定了数组的大小,改变它就不太容易了。 在 Java 中, 解决这个问题最简单的方法是使用 Java 中另外一个被称为 ArrayList 的类。 它使用起来有点像数组, 但在添加或删除元素时, 具有自动调节数组容量的功能, 而不需要为此编写任何代码。
ArrayList 是一个采用类型参数 (type parameter ) 的泛型类(generic class )。 为了指定数 组列表保存的元素对象类型, 需要用一对尖括号将类名括起来加在后面, 例如,ArrayList < Employee>。
ArrayList<Employee> staff = new ArrayList<Eniployee>0;
一旦能够确认数组列表的大小不再发生变化, 就可以调用 trimToSize 方法。这个方法将存储区域的大小调整为当前元素数量所需要的存储空间数目。 垃圾回收器将回收多余的存储空间。
4 对象包装器与自动装箱
有时, 需要将 int 这样的基本类型转换为对象。 所有的基本类型都冇一个与之对应的类。例如,Integer 类对应基本类型 int。 通常, 这些类称为包装器 ( wrapper )
尖括号中的类型参数不允许是基本类型, 也就是说, 不允许写成 ArrayList< int>。 这里就用到了 Integer 对象包装器类。 可以声明一个 Integer 对象的数组列表。
ArrayList<Integer> list = new ArrayList<>();
由于每个值分别包装在对象中, 所以 ArrayList 的效率远远低于 int[ ] 数 组。 因此, 应该用它构造小型集合, 其原因是此时程序员操作的方便性要比执行效率更加重要。
幸运的是, 有一个很有用的特性, 从而更加便于添加 int 类型的元素到 ArrayList 中。 下面这个调用
list.add(3) ;
将自动地变换成
list .add(Integer.value0f (3) ) ;
这种变换被称为自动装箱 (autoboxing)
自动打包 (autowrapping) 更加合适,而装箱(boxing)源自于 C#。
相反地, 当将一个 Integer 对象赋给一个 int 值时, 将会自动地拆箱。
5 参数数量可变的方法
public class PrintStream
{
public PrintStream printf(String fmt, Object... args){
return format(fmt, args);
}
}
这里的省略号 . . .
是 Java 代码的一部分, 它表明这个方法可以接收任意数量的对象 (除 fmt 参数之外)。
6 枚举类
public enuni Size { SMALL, MEDIUM, LARGE, EXTRAJARGE };
这个声明定义的类型是一个类, 它刚好有 4 个实例, 在此尽量不要构造新对象。
import java.util.*;
/**
* This program demonstrates enumerated types.
* @version 1.0 2004-05-24
* @author Cay Horstmann
*/
public class EnumTest
{
public static void main(String[] args)
{
Scanner in = new Scanner(System.in);
System.out.print("Enter a size: (SMALL, MEDIUM, LARGE, EXTRA_LARGE) ");
String input = in.next().toUpperCase();
Size size = Enum.valueOf(Size.class, input);
System.out.println("size=" + size);
System.out.println("abbreviation=" + size.getAbbreviation());
if (size == Size.EXTRA_LARGE)
System.out.println("Good job--you paid attention to the _.");
}
}
enum Size
{
SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL");
private Size(String abbreviation) { this.abbreviation = abbreviation; }
public String getAbbreviation() { return abbreviation; }
private String abbreviation;
}
7 反射
能够分析类能力的程序称为反射 (reflective)。 反射机制的功能极其强大, 在下面可以看 到, 反射机制可以用来:
- 在运行时分析类的能力。
- 在运行时查看对象, 例如, 编写一个 toString 方法供所有类使用。
- 实现通用的数组操作代码。
- 利用 Method 对象, 这个对象很像中的函数指针。
7.1 class 类
在程序运行期间, Java 运行时系统始终为所有的对象维护一个被称为运行时的类型标识。 这个信息跟踪着每个对象所属的类。 虚拟机利用运行时类型信息选择相应的方法执行。
可以通过专门的 Java 类访问这些信息。保存这些信息的类被称为 Class, 这个名字很容易让人混淆。
获取类对象有3种方式
// 第一种,常用
Employee e;
Class cl = Class.forName(e);
// 第二种
Employee e;
Class cl = e.class;
// 第三种
Employee e;
Class cl = e.getClass();
可以利用 == 运算符实现两个类对象比较
还有一个很有用的方法 newlnstance(), 可以用来动态地创建一个类的实例例如,
e.getClass().newlnstance();
创建了一个与 e 具有相同类类型的实例。 newlnstance 方法调用默认的构造器 (没有参数的构 造器)初始化新创建的对象。 如果这个类没有默认的构造器, 就会抛出一个异常
将 forName 与 newlnstance 配合起来使用, 可以根据存储在字符串中的类名创建一个对象
Employee e;
Class cl = Class.forName(e).newlnstance();
7.2 捕获异常
当程序运行过程中发生错误时 就会 抛出异常 抛出异常比终止程序要灵活得多,可以提供一个“捕获”异常的处理器(handler)对异常情况进行处理。
将可能抛出已检査异常的一个或多个方法调用代码放在 try 块中, 然后在 catch 子句中提供处理器代码。一个示例:
try
{
String name = ...
Class cl = Class.forName(name); // might. throw exception
//do something with cl
}
catch (Exception e)
{
e.printStackTrace();
}
如果类名不存在, 则将跳过 try 块中的剩余代码, 程序直接进人 catch 子句(这里, 利用 Throwable 类的 printStackTrace 方法打印出栈的轨迹。 Throwable 是 Exception 类的超类)。 如 果 try 块中没有抛出任何异常, 那么会跳过 catch 子句的处理器代码。
7.3 利用反射分析类的能力
在java.lang.reflect 包中有三个类 Field、Method 和 Constructor 分别用于描述类的域、方法和构造器。
// todo
7.4 在运行时使用反射分析对象
查看对象域的关键方法是 Field 类中的 get 方法。 如果 f 是一个 Field 类型的对象(例如,通过 getDeclaredFields 得到的对象),obj 是某个包含 f 域的类的对象, f.get(obj) 将返回一个 对象, 其值为 obj 域的当前值。示例如下
Employee harry = new Employee("Harry Hacker", 35000, 10, 1, 1989);
Class cl = harry.getClass0;
// 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"
反射机制的默认行为受限于 Java 的访问控制。 然而, 如果一个 Java 程序没有受到安 全管理器的控制, 就可以覆盖访问控制。 为了达到这个目的, 需要调用 Field、 Method 或 Constructor 对象的 setAccessible 方法。 例如,
f.setAccessible(true)
setAccessible 方法是 AccessibleObject 类中的一个方法, 它是 Field、 Method 和 Constructor 类的公共超类。 这个特性是为调试、 持久存储和相似机制提供的。
可以获得就可以设置 调用 f.set(obj value) 可以将 obj 对象的 f 域设置成新值。
7.5 使用反射编写泛型数组代码
java.lang.reflect 包中的 Array 类允许动态地创建数组。 例如, 将这个特性应用到 Array 类中的 copyOf方法实现中, 这个方法可以用于扩展已经填满的数组。
7.6 继承的设计技巧
- 将公共操作和域放在超类
- 不要使用受保护的域
- 使用继承实现 is-a 关系
- 除非所有继承的方法都有意义, 否则不要使用继承
- 在覆盖方法时, 不要改变预期的行为
- 使用多态, 而非类型信息
- 不要过多地使用反射
反射机制使得人们可以通过在运行时查看域和方法, 让人们编写出更具有通用性的程序。
这种功能对于编写系统程序来说极其实用, 但是通常不适于编写应用程序。 反射是很脆弱的, 即编译器很难帮助人们发现程序中的错误, 因此只有在运行时才发现错误并导致异常。