继承
先明确概念:
-
继承已存在的类就是在复用这些类的方法和域
-
超类和子类的关系,就是一般和特殊的关系(sub ⊆ super)
一、超类和子类
在设计超类和子类时的基本思想是:将通用的方法放在超类中、具有特殊用途的方法放在子类中。经常会遇到超类的方法不适用于子类的情况,这就需要提供一个新的方法来覆盖(override),或者叫重写。
覆盖
所谓覆盖就是对超类同名、同参数列表的方法的修改,以体现子类的“特殊性”。
在覆盖超类方法的时候,经常需要访问超类的私有域,但它们是不对子类开放的。解决办法是调用超类的访问器方法,需要一个新的关键字 super :
public class Manager extends Employee { private double bonus; // ... @Override public double getSalary() { return bonus + super.getSalary(); } // ... }
super 不是所谓的隐式参数,也不是一个对象的引用,只是一个指示编译器调用超类方法的特殊关键字。
-
返回类型:允许定义为原返回类型的子类型,也即支持可协变的返回类型。
-
访问修饰符:子类方法不能低于超类方法的可见性(不能更严格)。
标注 @Override 会告诉编译器下面将要定义的方法是对超类某个方法的覆盖,可以作为检查方法签名是否写对的辅助手段。
子类构造器
关键字 super 还有一个用途:调用超类构造器。由于子类的域只是对超类已有的域的补充,在对子类进行初始化时不能遗漏继承得来的域,即便它们是不可见的,所以,大多数时候都要在第一行调用超类的构造器。倘若超类没有不带参数的构造器,并且子类又没有显式地调用超类的其他构造器,将无法通过编译。
Manager(String name, double salary, int year, int month, int day, double bonus) { super(name, salary, year, month, day); this.bonus = bonus; }
多态
这里有一个 Employee 类型的变量,假定是一个叫小明的雇员,其他的信息暂时不知道:
Employee unknownEmployee;
查阅公司员工名单,发现小明其实是一个经理,于是让这个变量引用一个 Manager 对象:
unknownEmployee = new Manager("Xiao Ming", 6000, 1990, 3, 8, 2500);
到这里,可以明确一点:可以将子类的引用赋给超类的变量。接着,对小明调用 getSalary,会发现 Manager 类覆盖过的方法成功运行。
System.out.println(unknownEmployee.getName() + ": " + unknownEmployee.getSalary()); // Xiao Ming: 8500
多态指的就是这样一种现象:一个对象引用可以指示多种实际类型。虚拟机能够判断实际引用的对象类型,从而调用相应的方法。这种在运行时能够自动选择调用哪个方法的现象称为动态绑定。与之相对的就是静态绑定,具体体现为 private 方法、static 方法、final 方法和构造器,编译器可以准确知道应该调用哪一个。
在动态绑定的情形下,每次调用方法都要进行搜索,时间开销大,所以虚拟机会预先为每个类创建一个方法表,列出所有方法的签名和实际调用的方法,方便查找。
里氏置换法则(LSP)
之前提到过“is-a”规则,它表明子类的每个对象也是超类的对象,换一种说法就是:程序中所有引用基类的地方必须能够透明地使用其子类对象。
但是反过来就不行了,不能将一个超类的引用赋给子类变量,不然,对子类变量调用子类方法时就有可能发生运行时错误。
final
-
被 final 修饰的类不允许被继承
-
被 final 修饰的方法不允许被重写
强制类型转换
-
只能在继承层次内进行类型转换
-
在将超类转换成子类之前,应该使用 instanceof 操作符进行检查
Manager boss; if (unknownEmployee instanceof Manager) { boss = (Manager) unknownEmployee; // ... }
抽象类
在类的继承层次中,位于上层的类更具有通用性,甚至可能更加抽象。有时我们可能只想把一个类作为派生其他类的基类,而不想也不需要实例化,这样的类就可以用 abstract 关键字声明为抽象类,只能定义对象变量而不能创建对象实例。
用 abstract 修饰的方法是抽象方法,不需要为它编写实现的代码,只是充当一个占位的角色,在非抽象子类中得到具体实现。
拥有至少一个抽象方法的类必须声明为抽象类,不过并不要求所有的方法都必须是抽象的,它依然可以包含具体的数据和方法。
访问修饰符
-
-
public :对所有类可见
-
protected : 对本包和所有子类可见
-
无修饰符(默认):对本包可见
二、Object
Java 中每个类都是 Object 的子类,可以使用 Object 类型的变量引用任何对象,作为通用持有者。Object 类中定义的方法是所有类的通用方法。
equals
在 Object 类中,这个方法将判断两个对象是否具有相同的引用,即所指是否一个地址。如果两个对象具有相同的引用,那么一定是相等的,不过反过来就不成立。对于多数类来说,这种默认的判断方式没有意义,我们需要的是检测两个对象状态的相等性,所以经常需要覆写 equals 方法。
覆写的原则有以下几点:
-
自反性:对于任何非空引用 x ,x.equals(x) 应该为 true
-
对称性:对于任何引用 x 和 y ,x.equals(y) 为 true 的充要条件是 y.equals(x) 也为 true
-
传递性:对于任何引用 x、y 和 z,如果 x.equals(y) 为 true ,y.equals(z) 为 true,那么 x.equals(z) 也应该为 true
-
一致性:如果 x 和 y 引用的对象没有发生变化,反复调用 x.equals(y) 应该返回同样的结果。
- 对于任何非空引用 x ,x.equals(null) 应该为 false
一个完美的 equals 方法的建议:
public boolean equals(Object otherObject) { if (this == otherObject) return true; if (otherObject == null) return false; if (getClass() != otherObjects.getClass()) return false; if (!(otherObject instanceof ClassName)) return false; ClassName other = (ClassName) otherObject; return field1 == other.field1 && Objects.equals(field2, other.field2) && ...; }
在子类中定义 equals 方法时,可以先调用超类的 equals 。
如果调用 equals 的变量可能为 null ,需要使用 Object.equals(a, b) ,作用是:当其中至少有一个参数为 null 时就返回 false ,否则会调用 a.equals(b) 。
hashCode
散列码(哈希码)是由对象导出的一个整型值,代表了对象的特征。对应的算法应该做到合理组合实例域的散列码,让各个不同的对象产生的散列码的分布更加均匀。
Object 类的 hashCode 方法保证了同一对象的引用能得到相同的散列码,这种机制与默认的 equal 方法是一样的,如果重新定义 equal 方法,就必须重新定义 hashCode 方法,目的是方便在使用时将对象插入到散列表中。String 类的 hashCode 源码如下:
public int hashCode() { int h = hash; // 空串的 hash 为 0 if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
需要组合多个散列值时,可以调用 Objects.hash 方法(Objects 用于提供静态方法操作对象)并提供多个参数,它会对各个参数调用 Objects.hashCode,并组合这些散列值。例如:
public int hashCode() { return Objects.hash(field1, field2, field3); }
如果存在数组类型的域,则可以使用 Arrays.hashCode,返回一个由各数组元素的散列码组成的结果。
toString
只要对象与一个字符串通过操作符 “+” 连接起来,编译时就会自动调用 toString 方法,另外,System.out.println 也会直接调用参数的 toString 。
toString 方法还可以作为一种调试工具。
三、ArrayList
这是一个采用类型参数的泛型类,可以把它的对象理解为一个能自动调节容量的数组。
ArrayList<Employee> staff = new ArrayList<>(); // 菱形语法,原先右边是: new ArrayList<Employee>();
ArrayList 对象管理着一个内部数组,使用 add 方法可以往数组中添加新的元素,如果调用 add 且内部数组已满,就将自动创建一个更大的数组,并将所有的对象拷贝到新的数组中去。扩容容量的计算是:
int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩大到原来的 1.5 倍
如果能够估计出数组可能存储的元素数量,可以把初始容量传递给 ArrayList 构造器,或者调用 ensureCapacity 方法。
ArrayList<Employee> staff = new ArrayList<>(100); // staff.ensureCapacity(100);
size 方法将返回实际元素数目,等价于数组的 length 。一旦能够确认 ArrayList 的大小不再发生变化,就可以调用 trimToSize 方法,将存储区域的大小调整为当前元素所刚好需要的,便于 GC 回收多余的存储空间。
get 和 set 方法用于访问或改变数组元素,例如要设置第 i 个元素(确认存在):
staff.set(i, vivian);
可以使用 for each 语法循环遍历 ArrayList 对象:
for (Employee e : staff) // e.跳槽
将一个原始 ArrayList 引用赋给一个类型化 ArrayList 变量会得到一个警告。
API - java.util.ArrayList<E>
-
-
set
-
get
- remove
-
size
-
toArray
-
ensureCapacity
-
trimToSize
四、包装器
-
-
long / Long
-
float / Float
- double / Double
-
short / Short
-
byte / Byte
-
char / Character
-
boolean / Boolean
- Void
前 6 个类派生于 Number 类。包装器类的实例是不可变的。
包装类的好处之一在于:为基本类型应用于泛型提供了转换渠道。
ArrayList<Integer> list = new ArrayList<>();
自动装箱 / 拆箱
自动装箱的特性便于在集合中添加基本类型:
list.add(8); // 自动变换成:list.add(Integer.valueOf(8));
相反地,将一个 Integer 对象赋给一个 int 值时,将会自动拆箱:
int n = list.get(i); // 自动变换成:int n = list.get(i).intValue();
可以将自增操作符用于一个 Integer 引用,编译器将自动地插入一条对象拆箱的指令,进行自增计算后,再将结果装箱:
Integer n = 3; n++; /* Integer n = 3; int m = n.intValue(); m++; n = Integer.valueOf(m); */
自动装箱规范要求 boolean、byte、char <= 127,介于 -128 ~ 127 之间的 short 和 int 会被包装到固定的对象中。举个例子:
Integer i1 = 100; Integer i2 = 100; Integer i3 = 200; Integer i4 = 200; System.out.println((i1 == i2) + ", " + (i3 == i4)); // true, false
另外,如果在一个条件表达式中混合使用 Integer 和 Double 类型,Integer 值就会拆箱,提升为 double,再装箱为 Double :
Integer i = 1; Double d = 2.0; System.out.println(true ? i : d); // 1.0
装箱和拆箱是由编译器负责的,在生成字节码时插入必要的方法调用,再由虚拟机执行这些字节码。
将字符串转换为整型
String s = "100"; int x = Integer.parseInt(s);
这与 Integer 对象没有任何关系,但 Java 设计者认为 Integer 类是放置这个 parseInt 静态方法的好地方。
API - java.lang.Integer
-
-
toString
-
parseInt
- valueOf
-
compare
五、变参方法
先看一下 printf 方法的定义:
public class PrintStream { public PrintStream printf(String fmt, Object... args) { return format(fmt, args); } }
Object 后面的 ... 表明这个方法除了参数 fmt 外,可以接收任意数量的对象,这些对象组成了一个 Object[] 数组,参数 args 是这个数组的引用。编译器会对 printf 的每次调用进行转换,将参数绑定到数组上,在必要的时候会自动装箱:
System.out.printf("%d %s", new Object[] { new Integer(n), "apples" });
定义一个自己的可变参数方法:
public static int max(int... values) { int largest = Integer.MIN_VALUE; for (int v : values) { if (v > largest) { largest = v; } } return largest; }
六、枚举类
所有枚举类型都是 Enum 类的子类。
以下声明定义了一个枚举类,有 4 个实例。
public enum Size { SMALL, MEDIUM, LARGE, EXTRA_LARGE };
在比较两个枚举类型的值时,直接使用 == 就可以。
可以往枚举类型中添加构造器(构造枚举常量时被调用)、方法和域:
public enum Size { SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL"); private String abbreviation; private Size(String abbreviation) { this.abbreviation = abbreviation; } public String getAbbreviation() { return abbreviation; } }
枚举类型的 toString 方法能够返回枚举常量名,静态方法 valueOf 是 toString 的逆方法。
String str = Size.SMALL.toString(); // "SMALL" Size s = Enum.valueOf(Size.class, str); // Size.SMALL
还有静态方法 values ,可以返回一个包含全部枚举值的数组:
Size[] values = Size.values(); // [Size.SMALL, Size.MEDIUM, Size.LARGE, Size.EXTRA_LARGE]
ordinal 方法返回枚举常量的次序,从 0 开始计数。
int ord = Size.LARGE.ordinal(); // ord = 2
API - java.lang.Enum<E>
-
-
toString
-
values
- ordinal
-
compareTo
七、设计技巧
-
-
不要使用 protected 域
-
使用继承之前分析一下是否为“is-a”关系
- 除非所有继承的方法都有意义,否则不要继承
-
覆盖方法时不要改变预期的行为
-
需要类型检测时考虑多态
-
不要过多使用反射(下一篇会讲)