第四章 类和接口
类和接口是 java 程序设计语言的核心,这一章主要阐述一些指导原则,以助于设计出更好的类和接口。
第15条:使类和成员的可访问性最小化
在面向对象的六大原则中的有一条迪米特原则,也叫最小知道原则,只和直接的朋友通信,只暴露最小的接口。与这一条有些相似之处。
使类和成员的可访问性最小化,这就要求我们在设计模块的时候隐藏所有的实现细节,只对外提供 API,这样更有助于解耦,更安全更灵活。
比如在编写一个包模块时,只提供几个供外部使用访问的类(它们的访问级别是公有的),而其他类就全部都是包级私有,这样当我们需要替换增删实现时,外部使用对此并不需要跟着改动。阅读代码时也清楚地知道,哪些是对外的,哪些是对内的。
对于类中的实例域,如果不是私有的,就意味着允许外界修改,这会带来安全隐患。即便是 final 修饰的域,如果是可变对象的话,也是不安全的(外界可以对可变对象进行修改)。
第16条:在公有类中使用访问方法而非公有域
这么做的理由同样是基于安全性考虑,上一条说到如果域不是私有的,会有安全隐患,那么当外部需要和域通信的时候,我们应该在类中提供基于此域的访问方法,通过访问方法来限制对域的通信访问和交互权限范围。
如果类似包级私有的,或者是私有的内部类,由于本身访问范围就被限制了,不能被外部访问,所以直接暴露它的数据域也并没有本质的错误。
第17条:使可变性最小化
一个类的可变性小,那么外界修改它而导致出 bug 的途径也会比较少,一旦出了问题,也比较容易分析该对象的行为,从而解决问题。最极端的就是不可变类,这种类构造完成之后就无法再改变,所以就不会存在因为修改而产生问题。
如果类无法做成不可变的,那么也应该尽量地限制它的可变性,可变性最小化。类的使用者不用担心由于可变性多一不小心就使用出错的情况。
要想一个类不可变,那么就要保证不能提供可以修改对象状态的方法,不能被扩展,所有域都是 final 和私有,并且确保对于可变域的互斥访问。互斥访问便是除以该类,外部无法获得可变域的引用,无法修改,由于这点,不可变类永远不可以用客户端提供的对象来初始化可变域。
不可变类每个实例中包含的所有信息在创建完成之后固定不变,这让它比可变类更加易于设计、实现和使用。许多的值类都是不可变类,即便它们提供了值类计算的方法也是返回一个全新的对象,而不是修改现有的对象。比如复数 Complex
public final class Complex{
...
public Complex add(Complex c){
return new Complex(re - c.re, im - c.im);
}
}
不可变类本质上线程安全的,它们不要求同步。因为不可变,不存在不同线程中实例被改变而不同的情况。也可以被自由的共享,不需要保护性拷贝。
保护性拷贝,有的时候对外提供某个域,担心外界对它进行修改而影响到自身,故只对外提供该域的一个拷贝。这样,外界怎么折腾都不会影响到自身。
不可变类唯一的缺点,对于每一个不同的值都需要一个单独的对象。如果在使用过程需要用到大量的值,那么就会创建出大量的对象,可能需要考虑下创建对象的成本。有的时候可以考虑下使用缓存来避免大量创建对象,或者可以提供一个公有的可变配套类。在 Java 平台类库中,String 类是不可变类,当我们做字符串拼接时会优先考虑 StringBuilder。StringBuilder 就是 String 的可变配套类,它可以大大减少拼接时创建的对象数。
第18条:组合优先于继承
继承是子类拥有父类的域和方法,一般当子类是父类的一种时我们会使用继承,可以有效地复用代码,也是实现多态的一种方法。
但当子类并不是父类的子类型时,只是为了复用代码不同包级之间而使用继承,容易出现问题。
子类如果有覆盖 override 父类的方法,那么子类的功能依赖于父类中特定功能的实现细节,一旦父类的内部实现在版本升级中做了修改,则子类的功能则会受到影响,必须得跟着父类的升级而升级,需要维护。
即便子类在继承扩展一个类时,仅仅只是增加新的方法而不是覆盖现有的方法,也要担心会不会有一天父类再发行一个签名相同但返回类型不同的方法(这样子类就无法通过编译)。
书中举例了一个这样的例子:想查询 HashSet 被创建已来曾经添加了多少个元素:
// 继承的方式
public class InstrumentedHashSet<E> extends HashSet<E>{
private int addCount = 0;
@Override
public boolean add(E e){
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c){
addCount += c.size();
return super.addAll();
}
}
// 组合的方式
public class ForwardingSet<E> implement Set<E>{
private final Set<E> set;
public ForwardingSet(Set<E> set){
this.set = set;
}
...
}
public class InstrumentedHashSet<E> extends ForwardingSet<E>{
private int count = 0;
public InstrumentedHashSet(Set<E> set){
super(set);
}
@Override
public boolean add(E e){
count++;
set.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c){
count += c.size();
set.addAll(c);
}
}
因为 HashSet 的 addAll方法的实现中是调用了 add 方法,直接继承的话在调用子类的 addAll 方法时也会调用子类自身的 add 方法,上述的实现就不行,当然我们可以根据 HashSet 的内部实现而选择不去覆盖子类的 addAll 方法,但是如果 HashSet 又添加了其他增加元素的方法,又会出错。
而如果是采用组合的方式来实现,将现有的类变成新类中的一个组件,不仅替换方便,而且不依赖于现有类的实现细节,即使现有类后来又添加了新的方法,也不会影响新的类。复合的方式实现之后,新类中的每个方法都是调用被包含的现有类中对应的方法,并返回它的结果,这被称为转发。
转发和委托不同的地方是,转发是调用组件的方法得到结果,而委托是将自身传递给组件去处理。
复合的方式,用一个新类将现有类包装起来,所以新类也被叫包装类。包装类几乎没有什么缺点,但是却不适合用在回调框架中,因为回调框架中是将现有类引用传递进去,方便后续的回调,但是包装类却对此不知情,回调框架并不会回调它。
第19条:要么为继承而设计,并提供文档说明,要么就禁止继承
上面说到组合优先继承,而且继承会打破封装性,所以对于继承要慎用。
不是为了继承而设计,又没有提供文档说明,客户端如果选择继承类库来实现功能,则容易出现问题。所以有了上面的要求,如果不是为此而设计的,就从一开始就禁止继承。
如果提供文档,那么必须精确地描述覆盖每个方法所带来的影响,即可覆盖的方法的只用性。第18条中的例子 HashSet 的 addAll 方法就是自用了 add 方法。可覆盖方法在自用时的调用顺序,这些都影响到子类的功能,都必须在文档加以说明。
另外为了程序员能够编写出更加有效的子类,而无需承受不必要的痛苦,类必须通过某种形式提供适当的钩子,以便能够进入到它的内部工作流程中,这种形式可以是精心选择的受保护方法,也可以是受保护的域。这个的主要原因是实现机制在父类中,如果父类中的域都是私有的,也没有提供可以访问触及这些域的受保护方法,那么当子类实现功能时需要用到这些域时就不那么方便了。
为了允许继承,类必须遵守一些约束:
构造器决不能调用可被覆盖的方法。原因:父类的构造器在子类的构造器之前执行,如果调用到被覆盖方法,此时子类的被覆盖方法中可能直接出错(依赖于构造器初始化的变量)。
最好不要实现 Cloneable, Serializable 接口。因为它们实际上是将实现负担转移到了扩展这个类的程序员身上。
禁止子类化,比较容易的办法是把类声明成 final,另外一种办法是将所有的构造器都变成私用或包级私有,并提供静态工厂方法来替代构造器。因为如果要继承的话,子类的构造器也要调用父类的构造器,调不到时就自然无法继承了。
第20条:接口优于抽象类
Java 语言提供了两种机制,可以用来定义允许多个实现的类型:接口和抽象类。这两个最大的区别在于:抽象类中可以有一些方法的实现,而接口只能定义方法(不带实现)。
Java 值允许单继承,而却可以实现多个接口,这使得抽象类作为类型定义收到了极大的限制。比如说原本一个类已经有一个继承了,这时候要让它再扩展一个类型,就无法再继承了;而如果原本是使用接口,那么再扩展几个接口类型都是可以的。
如果你希望让两个类扩展同一个抽象类,就必须把抽象类的层级放高,这样在间接地伤害到类层次,其所有的后代类都扩展了这个抽象类,无论是否适合。
对于一些非层次结构,混合类型的框架,接口是最理想的选择。因为类可实现多个接口,不排他。
尽管抽象类和接口相比,有那么多的不足。但使用抽象类来为每个重要接口都提供一个抽象的骨架实现类,却可以将接口和抽象类的优点结合起来。接口的作用仍然是定义类型,而骨架抽象类接管了所有与接口实现相关的工作,让客户端在使用时不必重复地编写实现代码。
骨架实现,为抽象类提供了实现上的帮助,又不强加"抽象类被用做类型定义"所特有的严格限制。如果预置的类无法扩展骨架实现类,可以选择手动实现,或者实现该接口后,使用包装类模式将其作为内部私有类的扩展类,转发方法,这种方法被称为模拟多重继承。
使用抽象类来定义允许多个实现的类型,与使用接口相比有一个明显的优势:抽象类的演变比接口的演变要容易得多。如果在后续的版本中,希望在抽象类中增加新的办法,就可以增加具体方法而其子类不需要改动。但如果是接口,添加一个方法,则其所有的实现类都需要改动到。
第21条:为后代设计接口
在 Java 8 之前,是无法为现有的接口添加新的方法的,会导致现有实现该接口的类无法编译通过。
在 Java 8,允许为现有的接口添加新的方法,但为现有接口添加新方法是一件非常危险的事情。
Java 8 接口添加一个 default method 构造器,可以声明 default method 并带上一个 default 实现。有了这个实现,那么实现了接口的类就可以选择不实现 default method。这种机制使得可以为现有接口添加新方法,但不保证 default method 在那些先前的实现类中也能工作。
尽管现在Java 8有途径(添加新方法)可以修正已经发布的接口中的设计缺陷,但并不能依赖于此。设计接口时仍然需要谨慎。
第22条:接口只用于定义类型
当类实现接口时,接口就可以当引用这个类的实例的类型,除此之外的其他目的而定义接口都是不恰当的。
常量接口的使用是不恰当的。当常量与类或接口的实现紧密相关时,就定义在相关的类和接口里;当常量最好被看做枚举类型的成员时,就应该用枚举类型;其他的情况,则应该使用不可实例化的工具类来导出这些常量。
常量接口:接口中没有方法,只有常量。
当使用常量接口时,会把类中的实现细节(常量)暴露到接口中,对于类而已实现该接口除了能拿到变量外没有其他价值,反而是在以后的版本中都需要继续实现这个接口,以确保二进制兼容性,即便不再需要这些常量。
第23条:类层次优于标签类
标签类:类中带有两种或以上中风格的实例,并包含表示实例风格的标签的类。这个解释不太容易理解,书中的举例代码更容易说明问题:
// 这个类能够表示圆形或者矩形
class Figure{
enum Shape{RECTANGLE, CIRCLE};
final Shape shape;
// 这些域仅仅是用于当 shape 为 RECTANGLE
double length;
doubel width;
// 这些域仅仅是用于当 shape 为 CIRCLE
double radius;
Figure(double radius){
shape = Shape.CIRCLE;
this.radius = radius;
}
Figure(double length, double width){
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
double area(){
switch(shape){
case RECTANGE:
...
case CIRCLE:
...
}
}
}
标签类,其实例承担着其他不属于它风格的域,域不能做成 final,过于冗长,容易出错,效率低下。
而类层次,自然是当需要表示多种风格对象时使用子类型化。比如上面例子中将 Figure 定义成抽象父类,然后用 Circle,Rectangle 去做它的子类,如果以后要扩展其他的形状也方便扩展。
第24条:优先考虑静态成员类
嵌套类是指被定义在另一个类的内部的类,它唯一的存在目的就是为其外围类提供服务,如果它将来有可能被用在其他地方,那么就应该定义成顶级类。
嵌套类有四种:静态成员类,非静态成员类,匿名类,局部类。后三种都被称为内部类。
静态成员类与非静态成员类的主要区别在于:非静态成员类的实例都隐含拥有一个外围类的实例。这就使得非静态类实例无法在没有外围类实例的时候独立存在,要创建内部实例必须先创建一个外围实例后才行,被创建时就和外围实例之间建立关联,却不能修改,浪费空间和时间。保留外围类实例的引用,会导致外围实例在符合垃圾回收时仍然无法被回收。
静态成员类的一种常见用法是作为公有的辅助类,仅当与它的外部类一起使用时才有意义。
非静态成员类的一种常见用法是定义一个 Adapter,它需要访问外围实例。
匿名类的一种常见用法是动态创建函数对象、过程对象。比如创建 Runnable, Thread实例。
第25条:限制文件只有一个最顶层的类
Java 编译器允许在单个文件中定义多个最顶层的类,当最好不要这么做。可以替换成一个顶层,然后其他的类做成静态内部类。先看书中举例的代码
// Utensil文件,一个文件中多最顶层类
class Utensil{
...
}
class Dessert{
...
}
//Test文件,一个文件只有一个最顶层的类,可以有多个静态内部类
class Test{
...
private static class Utensil{
...
}
private static class Dessert{
...
}
}
永远不要在一个源文件中放多个最顶层的类或接口。如果在一个文件中放着多个最顶层的类或接口,在编译时容易发生编译顺序的问题,比如我们编译 javac Utensil.java ,当程序中 Dessert 类的导入在 Utensil 之前,这就编译不通过了。
第五章 泛型
Java 1.5 之后增加了泛型,有了泛型之后,就可以规定集合中只能插入某种对象类型,取出时也会自动转换成该类型,泛型的使用可以使得程序更安全,更清楚。
第26条:不要在新代码中使用原生态类型
声明中具有一个或者多个类型参数的类或者接口,就是泛型。而原生态类型就是不带任何实际类型参数的泛型名称,比如 List<E> 相对应的原生态类型就是 List,原生态类型就像是从类型声明中删除了所有泛型信息一样,无法享用到泛型带来的优点。
而在不确定或者不在乎集合中的元素类型的情况下,你也许会使用原生态类型,但其实还可以使用无限制的通配符类型来替代,比如泛型Set<E> 的无限制通配符类型为Set<?>,这是最普通的参数化 Set 类型,可以持有任何集合。
原生态类型允许将任何元素放入,而无限制通配符类型则无法将任何元素放入(除了null)。无限制通配符类型虽然无法放入,但是可以取出。
第27条:消除非受检警告
当开始使用泛型编程时,可能会看到许多编译器警告:unchecked cast warnings, unchecked method invocation warnings, unchecked parameterized varary type warnings , and unchecked conversion warnings.
大部分的警告是容易消除的,只是对泛型的使用上不太规范而已,而且现在的 IDE 都比较智能,按提示修改。
比如:
Set<Lark> exaltation = new HashSet();
// 上面这一句可以能出现警告: warning:[unchecked] unchecked conversion
// 只需要给 HashSet 也加上尖括号即可。
Set<Lark> exaltation = new HashSet<>();
消除所以的非受检警告意味着可以肯定你的代码是类型安全的,不会再运行时抛出 ClassCastException 。
但是万一有的警告你消除不了怎么办?如果可以证明引起警告的代码是类型安全的,(只有在这种情况下)可以用一个@SuppressWarnings("unchecked") 注解来禁止这条警告。
SuppressWarnings 注解可以用在任何粒度的级别中,所以应该始终在尽可能小的范围中使用,而不是在整个类上注解。
第28条:列表优先于数组
列表优先于数组的主要原因在于数组无法和泛型很好的混合使用,而列表可以。
数组与泛型相比,有两个重要的不同点,这两点造成了数组和泛型不好混合使用,比如不能创建泛型、参数化类型或者类型参数的数组。
- 数组是协变的。比如 Sub[] 是 Super[] 的子类型,而泛型则是不可变,List<A> 和 List<B> 不会为父子类型关系,协变会造成,父类型引用指向子类型实例,然后添加父类型元素,这是可以通过编译的,但在运行期会抛异常。
- 数组是具体化的。因此数组会在运行时才知道并检查它们的元素类型约束,而泛型则是通过擦除来实现的,只会在编译时强化类型信息,然后在运行时擦除元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用。
所以优先使用集合类型 List<E>,而不是数组类型 E[],这样可能会损失一些性能或者简洁性,但是换回的却是更高的类型安全和互用性。
第29条:优先考虑泛型
一般来说,将集合声明参数化,以及使用 JDK 所提供的泛型和泛型方法,这些都不太困难,编写自己的泛型会比较困难些,不让客户端每次都需要将 Object 转换成目标类型,还有可能会转换出错。
第二版书中展示了如何将一个例子用 Object[] 作为域的 Stack 类改成 使用 E[] 做域规定只能 push 和 pop E 泛型的 Stack 类,在这个例子中,使用注解@SuppressWarning("unchecked")来禁止 Object[] 强转化成 E[] 时的警告,以及使用数组而不是列表,这些都是上面条目中不推荐的做法,只是实际上不可能总是或者总想着在泛型中使用列表。Java 并不是生来就支持列表,因此有些泛型入 ArrayList,则必须在数组上实现。为了提升性能,其他泛型如 HashMap 也在数组上实现。
总而言之,使用泛型比使用需要在客户端代码中进行转换的类型来得更加安全,更加容易。在设计新类型时,要确保客户端不需要这种转换就可以使用。
第30条:优先考虑泛型方法
如同类可以从泛型中受益一般,方法也一样。静态工具方法尤其适合于泛型化。
一般地,当方法的参数,返回都是容器类,使用泛型,可以使方法变成是类型安全的,无需明确指定类型参数的值,编译器通过检查方法参数和类型来计算类型参数的类,因此知道返回什么类型,这个过程称作类型推导,比如:
public static <E> Set<E> union(Set<E> s1, Set<E> s2){
Set<E> result = new HashSet<E>(s1);
result.addAll(s2);
return result;
}
有一个模式叫泛型单例工厂,适合在当会创建不可变但又适合于许多不同类型的对象的时候使用。由于泛型是通过擦除实现的,可以给所有必要的类型参数使用单个对象,但是需要编写一个静态工厂方法,重复地给每个必要的类型参数分发对象。这种模式最常用与函数对象。
书中举了一个例子,假设要提供一个恒等函数,如果在每次需要的时候都重新创建一个,会很浪费,因为它是无状态的。如果泛型被具体化了,每个类型都需要一个恒等函数,但是它们被擦除以后,就只需要一个泛型单例。IDENTITY_FN 转换成 UnaryFunction<T> ,产生了一条未受检警告,因为UnaryFunction<Object> 对于每个 T 来说并非都是 UnaryFunction<T>, 但是恒等函数返回的是未被修改的参数,因此我们知道无论 T 的值是什么,用它作为UnaryFunction<T> 都是类型安全的。
public interface UnaryFunction<T> {
T apply(T arg);
}
private static UnaryOperator<Object> IDENTITY_FN =
new UnaryFunction<Object>(){
public object apply(Object arg) {return arg;}
}
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction(){
return (UnaryOperator<T>) IDENTITY_FN;
}
有一种常见的用法也出现在方法中,类型限制,通过某个包含该类型参数本身的表达式来限制类型参数。比如类型限制
<T extends Comparable<T>>,可以读作"针对可以与自身进行比较的每个类型 T",
public static <T extends Comparable<T>> T max(List<T> list){
...
}
第31条:利用有限制通配符来提升 API 的灵活性
为了获得最大限度的灵活性,要在表示生产者或者消费者的输入参数上使用通配符类型。如果某个输入参数既是生产者,又是消费者,那么通配符类型对你就没有什么好处了:因为你需要的是严格的类型匹配,这无法使用任何统配符得到。
通配符的使用:生产者使用 extends, 消费者使用 super。
示例:
public void pushAll(Iterable<E> src){
for(E e: src)
push(e);
}
public void pushAll2(Iterable<? extends E> src){
for(E e: src)
push(e);
}
pushAll, pushAll2 方法中的参数都是T生产者,在使用时 pushAll 的参数就只能是指定的类型,比如我们指定的类型是 Number,那么我们就只能放 Number 而无法放 Number的子类;pushAll2 就可以放 Number 及其子类。
Iterable<? extends E> 表示 E 的某个子类型的 Iterable 接口。
public void popAll(Collection<E> dst){
while(!isEmpty())
dst.add(pop());
}
public void popAll2(Collection<? super E> dst){
while(!isEmpty())
dst.add(pop());
}
上面的例子中,我们就可以将 Collection<Object> 参数传递给类型的Number 的类的 popAll2 方法,而 popAll 方法只能接受 Collection<Number> 。
Collection<? super E> 表示 E 的某种超类的集合。
有一个复杂的声明,其修改前后是这样的:
public static <T extends Comparable<T>> T max(List<T> list);
public static <T extends Comparable<? super T>> T max(List<? extends T> list);
其中,由于参数 list 是生产者,所以将类型声明为List<? extends T>,而最初 T 被指定用来扩展Comparable<T>,comparable 是消费 T 实例,因此声明为 Comparable<? super T>,
第32条:考虑类型安全的异构容器
类型安全的异构容器说的是:当你向它请求 String,它就返回 String 而不是其他类型;同时它是异构的,它的所有键都是不同类型的,value 也是不同类型的。
泛型最常用与容器,比如 Set,Map,ThreadLocal,AtomicReference 等。在这些用法中,容器只能有固定的类型参数,比如 Set 只有一个类型参数Set<String>,Map 只有两个类型参数Map<k, V>。
这条的优先并不是很准确,而是有的时候我们可以用别的容器来满足这样的需求:容器里的元素不同的,同时还能以类型安全的方式访问。
这里的实现是将 key 进行参数化而不是将容器参数化,然后将参数化的 key 提交给容器来插入或获取 value,用泛型系统来确保 value 的类型和它的 key 相符。
其中 Class 对象是参数化 key 的部分。类 Class 是泛型化的,是Class<T>。比如 String.class 属于 Class<String>类型,而 Integer.class属于Class<Integer>类型。当一个类的字面文字被用在方法中,来传达编译时和运行时的类型信息时,就被称为 type token。
请看下面的实现:
public class Favorites{
private Map<Class<?>, Object> favorites = new HashMap();
public <T> void putFavorite(Class<T> type, T instance){
if(type == null)
throw new NullPointerException("type is null");
// 确保instance 一定是 type 类型,否则会抛异常
favorites.put(type, type.cast(instance);
}
public <T> T getFavorite(Class<T> type){
return type.cast(favorites.get(type));
}
}
Map<Class<?>, Object> 每一个键都可以有一个不同的参数化类型,异构就是从这里而来的。
Favorites 类的局限:不能用在不可具体化的类型中,如想保存List<String>,是无法拿到一个对应的 Class 对象的,List<String>.Class 是个语法错误。
网友评论