3 类和接口
Item 15:最小化类和成员的访问权限
一个设计优秀的类应该隐藏它的所有实现细节,将它的 API 和内部实现干净地分离开。这种软件设计的基本准则被称为“封装”(encapsulation)。
封装的优点:
- 组成系统的各组件之间解耦,使得它们能够独立地开发、测试、优化、使用、理解和修改
- 基于第一条,提高了组件的复用性
- 基于第二条,即使整个系统开发失败,某个独立的组件仍可以非常成功
经验法则很简单:最小化每个类和成员的访问权限。
对于非内部类和接口,只有两种访问权限:package-private 和 public。
对于 package-private 非内部类和接口,它们不会被暴露在 API 中,因此有了更多可维护性。如果它只被一个类使用时,可以让它成为这个类的内部类或接口。
对于成员(字段,方法,内部类和内部接口),有四种访问权限:private,package-private,protected 和 public。
private 和 package-private 的成员不会暴露在 API 中,除非类实现了 Serializable 接口(Item 86 和 Item 87)。
子类重写父类的方法时,方法的访问权限不能缩小(满足里氏替换原则)。因此,如果一个类实现了接口,那么它实现的方法都是 public。
public 类的字段的访问权限应该尽可能小于 public,对于 mutable 字段更是如此。而 public static final 的数组字段是保证不了 immutability 的,用户仍然可以修改数组的元素,解决方法参考如下:
private static final Thing[] PRIVATE_VALUES = {...};
public static final List<Thing> VALUES =
Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
或者:
private static final Thing[] PRIVATE_VALUES = {...};
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
Item 16:public 类中,使用 getter 而不是 public 字段
// Degenerate classes like this should not be public!
class Point {
public double x;
public double y;
}
上面的这种 public non-final 字段有很多反封装的缺点,不能保证不变性,不能方便地修改类,不能在访问字段时添加辅助操作。
当然,我们可以采用 JavaBean 式的解决方案,使字段为 private,并添加 setter 和 getter。如果类是 public,这样做很有必要,它屏蔽了内部的字段细节,便于将来的版本更新。如果类是 package-private 或者 private 的内部类,因为它的访问范围较小,所以要求可以放宽一点。
Java 平台中的 java.awt 包中的 Point 和 Dimension 类违反了本条规则,因此在使用它们时要非常小心。
Item 17:最小化 mutability
Immutable 类是指实例不能修改的类,每个实例中的字段信息在对象生命周期中是固定的。比如 String,原始类型的包装类,BigInteger,BigDecimal。
在设计类的时候,首先考虑能不能设计成 immutable 类,如果有特殊原因不能的话,也要尽可能限制它的 mutability,尽可能使每个字段是 final 的,构造器中初始化好所有字段。
设计一个 Immutable 类时的五条规则:
- 不要添加会修改对象状态的方法,如 setter 方法
- 确保类不被继承,两种方案
- 所有字段 final。更宽松一点的说法是,没有方法会产生内部可见的状态改变,String 类中就采用了非 final 的 hash 变量来缓存懒加载的哈希值。
- 所有字段 private。虽然 public final 字段也是安全的,但是考虑到未来的版本更新,尽量隐藏内部细节。
- 确保对 mutable 组件的独占访问。如果类中定义了 mutable 对象的引用,确保用户获取不到这些引用字段,也不要用用户提供的引用来初始化这些字段。如果类是 Serializable,需要显式提供 readObject 或 readResolve 方法,或者使用
ObjectOutputStream.writeUnshared
和ObjectInputStream.readUnshared
方法(Item 88)。
Immutable 类的缺点是,每一个不同的值需要创建一个不同的对象。比如,BigInter 的 flipBit 方法在反转每一位后返回一个新的对象,而 BigSet 的 flip 方法反转某一位后返回修改后的原对象,前者是 immutable,后者是 mutable。如果修改操作非常多的时候,这会产生大问题。解决方法是提供 mutable 的伙伴类,如 String 的 StringBuilder。有的 immutable 的类非常聪明,比如 BigInteger,它会使用 package-private 的伙伴类来加速多步计算,比如 modular exponentiation 计算。
Item 18:使用组合代替继承
继承违反了封装原则,存在很多问题:
- 被重写的方法的自身调用的实现细节可能会变化,我们针对它的重写有潜在风险
- 如果父类添加了新的方法,子类没有及时重写,子类中访问此方法可能达不到预期增强效果
- 子类添加了新的方法,未来的父类版本中可能也会添加同名方法,造成无意的重写
- 子类会继承父类的 API 中的缺点,而组合可以隐藏这些缺点
解决上述问题的一个方案是组合,让旧类的实例成为新类的组件,新类的方法调用转发给旧类。为了增加复用性,我们分成了两部分:
// Wrapper class —— 使用组合代替继承的包装类
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) { super(s); }
@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(c);
}
public int getAddCount() { return addCount; }
}
// 可复用的转发类
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) {this.s = s;}
@Override public int size() { return s.size(); }
@Override public boolean isEmpty() { return s.isEmpty(); }
@Override public boolean contains(Object o) { return false; }
@Override public Iterator<E> iterator() { return s.iterator(); }
@Override public Object[] toArray() { return s.toArray(); }
@Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean add(E e) { return s.add(e); }
@Override public boolean remove(Object o) { return s.remove(o); }
@Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
@Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
@Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
@Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
@Override public void clear() { s.clear(); }
}
InstrumentedSet 包装类的设计又称为“装饰器模式”(Decorator pattern),因为它通过添加装备“装饰”了一个 Set 对象。
包装类的一个注意事项是:包装类不适合在回调框架中使用(一个对象传递自己的引用给其它对象,以便将来的调用(回调))。一个被包装的对象不知道自己的包装者是谁,因此它会把自己的引用传递给其它对象,回调函数就忽略了包装类。
java.util.Properties 就是一个不合适的继承案例,Properties 类继承自 HashTable,它的设计初衷是只能添加 String 类型的键和值,getProperty(key) 可以返回默认值,但是因为继承自 HashTable,所以还可以调用 put 和 putAll 方法来添加非 String 类型的键和值,get(key) 可能返回 null。一旦违反了 Properties 的设计,就不能有效使用 Properties 的其它 API 了(load 和 store)。
Item 19:可继承类要说明自身调用
一个为继承而设计的类需要在 API 文档中说明自己的所有自身调用(self-use),比如按什么顺序调用了类中的其它可重写的方法。
// AbstractList
/**
* Removes from this list all of the elements whose index is between
* {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
* Shifts any succeeding elements to the left (reduces their index).
* This call shortens the list by {@code (toIndex - fromIndex)} elements.
* (If {@code toIndex==fromIndex}, this operation has no effect.)
*
* <p>This method is called by the {@code clear} operation on this list
* and its subLists. Overriding this method to take advantage of
* the internals of the list implementation can <i>substantially</i>
* improve the performance of the {@code clear} operation on this list
* and its subLists.
*
* <p>This implementation gets a list iterator positioned before
* {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
* followed by {@code ListIterator.remove} until the entire range has
* been removed. <b>Note: if {@code ListIterator.remove} requires linear
* time, this implementation requires quadratic time.</b>
*
* @param fromIndex index of first element to be removed
* @param toIndex index after last element to be removed
*/
protected void removeRange(int fromIndex, int toIndex) {...}
这样显然违反了“API 文档应该描述方法做了什么,而隐藏如何做到”的教条,但是没有办法,继承本身已经违反了封装的原则。
如果一个类没有自身调用,你需要在 API 文档中说明这一点,这种父类可以被安全地继承。
对允许继承的类的要求:
-
构造器中禁止直接或间接调用可重写的方法(private、final、static 方法是不可重写的)。原因:父类的构造器会先执行,如果子类重写的方法中依赖了需要子类构造器初始化的变量,父类构造器调用此方法时就会出现异常。
public class Base { private String baseName = "base"; public Base(){ callName(); } public void callName() { System.out.println(baseName); } // null static class Sub extends Base { private String baseName = "sub"; public void callName() { System.out.println (baseName); } } public static void main(String[] args) { Base b = new Sub(); } }
-
不要使父类实现 Cloneable 和 Serializable 接口,替代方案见 Item 13 和 Item 86。如果非要实现的话,要注意,clone 和 readObject 方法的功能有点像构造器,因此也要满足第一条要求。
-
父类实现了 Serializable 接口且有 readResolve 或者 writeReplace 方法时,这两个方法应该是 protected 而非 private。
让一个类不可被继承是一种安全的做法:
- final 修饰
- 使得所有构造器 private 或 package-private,并添加静态工厂方法代替
有的类实现了包含了类的实现要素的接口,比如 Set,List 或 Map,完全可以用非继承的方式实现功能的增强,方案是 wrapper class 模式(Item 18)。
Item 20:接口优于抽象类
接口有一些显然的优势:
-
已经存在的类很容易通过改造,实现一个新的接口。
-
接口非常适合定义 mixin。定义 mixin 是指允许一个类在它的原始功能中混合进(mix in)可选择的功能,比如 Comparable 接口允许一个类声明它的实例可以排序。抽象类不能被用来定义 mixin,因为类只能有一个父类,继承抽象类会破坏类的继承结构。
结合接口和抽象类的优点,使用接口的抽象 skeletal implementation 类,又称为 AbstractInterface,接口中可以提供默认方法,而且 AbstractInterface 类中实现剩余的 non-primitives 方法(primitives 是指留给子类实现的方法),使得子类的实现更容易。比如 AbstractCollection,AbstractList 等。在设计模式中又称为“Template Method”模式。
static List<Integer> initArrayAsList(int[] a) {
Objects.requireNonNull(a);
return new ArrayList<Integer>() {
@Override
public Integer get(int index) {
return a[index]; // Autoboxing
}
@Override
public Integer set(int index, Integer element) {
int oldVal = a[index];
a[index] = element; // Auto-unboxing
return oldVal; // Autoboxing
}
@Override
public int size() {
return a.length;
}
};
}
上面代码中就通过使用 AbstractList 很轻松地实现了 List 接口,同时又是一个“适配器模式”,将 int 数组视为了一个 Integer List 来操作。
模拟多重继承(simulated multiple inheritance):一个直接实现了接口的类,可以将接口的方法调用转发给自己的继承自 AbstactInterface 的内部类,这种设计技巧既提供了多重继承的优势,又避开了陷阱(Item 18)。
skeletal implementation 的设计步骤:
- 研究接口,决定哪些方法作为 primitives 不实现,其余的方法可以由我们实现
- 在 non-primitives 中,优先考虑哪些可以作为接口的默认方法来实现(接口的默认方法不能实现 Object 类的方法比如,equals,hashCode,toString),如果默认方法覆盖了所有 non-primitives,就不必再设计 AbstactInterface 了
- 定义 AbstactInterface 类,实现剩余的接口默认方法不能实现的 non-primitives 方法。
- 作为一个抽象类,完成必要的说明文档。
总之,skeletal implementation 中,尽可能通过接口的默认方法来实现 non-primitives,使得所有实现类都能使用这些方法,在默认方法不能实现时,再提供抽象类来实现这些 non-primitives。
Item 21:用接口的默认方法来添加新方法是不得已之举
Java 8 之前,接口中是不能添加新方法的,否则所有已经存在的实现类,因为没有实现这个方法而不能通过编译。但是 Java 8 引入了默认方法,可以给接口添加新的方法,已经存在的实现类可以通过编译,但运行时还是可能出现问题,因此一定要谨慎。
比如,Collection 接口在 Java 8 中添加了一个默认方法:
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
Apache Commons 库中的 SynchronizedCollection 类并没有实现这个方法,如果在 Java 8 中使用此类的 removeIf 方法,并不能保证此类的基本承诺:自动同步每一个方法调用。在另一个线程并发修改的情景中,此类的 removeIf 方法会抛出 ConcurrentModificationException 或者产生其它异常行为。
但是另一方面,在设计接口的一开始,可以考虑使用默认方法来提供标准的方法实现,来减轻实现接口的负担。
Item 22:接口作为一种定义行为的类型来使用,而不要定义纯粹的常量接口
一个类实现了一个接口,这个接口就可以作为一个类型来引用此类的实例,根据接口的 API,就可以知道此类的实例具有哪些行为。
从这个意义上理解的话,常量接口是对接口的不恰当使用:
// 常量接口反模式 —— 不要这样定义!
public interface PhysicalConstants {
// Avogadro's number (1/mol)
static final double AVOGADRO_NUMBER = 6.022_140_857e23;
// Boltzmann constant (J/K)
static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
// Mass of the electron (kg)
static final double ELECTRON_MASS = 9.109_383_56e-31;
}
- 类内部使用的常量属于实现细节,实现常量接口使得实现细节暴露在 API 中
- 如果将来发布的版本中,此类不再需要这些常量,为了使得二进制兼容,它还是要实现此接口
- 一个可以继承的类实现了常量接口的话,它的子类的命名空间也都会被接口中的常量污染
Java 平台库中的 java.io.ObjectStreamConstants 接口就是这样一个反例。
正确定义常量的推荐做法有:
- 若这些常量和类或接口紧密绑定,就将它们添加到类和接口的常量字段中。如 Integer 和 Double,暴露了常量 MIN_VALUE 和 MAX-VALUE
- 如果这些常量最好看作是枚举类型的成员,就使用枚举类型(Item 34)
- 否则,就使用不可实例化的工具类
// 常量工具类
public class PhysicalConstants {
private PhysicalConstants() {} // Prevents instantiation
public static final double AVOGADRO_NUMBER = 6.022_140_857e23;
public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
public static final double ELECTRON_MASS = 9.109_383_56e-31;
}
Item 23:将 tagged 类重构为类继承体系
Tagged 类:类的实例有多种不同的类型,通过类内部的 tag 字段来表明一个实例是哪种类型。如下:
// Tagged class —— 非常不推荐,应该改造为类继承体系
class Figure {
enum Shape {RECTANGLE, CIRCLE};
// Tag field - the shape of the figure
final Shape shape;
// These fields are used only if shape is RECTANGLE
double length;
double width;
// This field is used only if shape is CIRCLE
double radius;
// Constructor for circle
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// Constructor for rectangle
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.width = width;
this.length = length;
}
double area() {
switch (shape) {
case CIRCLE:
return length * width;
case RECTANGLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError(shape);
}
}
}
tagged 类的缺点很多,概括起来就是臃肿(可读性差)、易出错(扩展麻烦)、低效(占内存)。
将 tagged 类重构为类继承体系:
- 定义一个抽象类,为 tagged 类中取决于 tag 值而选择性表达的每一个方法定义一个抽象方法,tagged 类中不取决于 tag 值的方法以及所有类型共用的字段直接放在抽象类中。
- 为 tagged 类中的每一种类型定义一个抽象类的实现类,包含此类型特有的数据字段,针对此类型实现每一个抽象方法
// 代替 tagged 类的类继承体系
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
final double radius;
Circle(double radius) { this.radius = radius; }
@Override double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
final double length;
final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override double area() { return length * width; }
}
类继承体系改正了 tagged 的缺点,同时反映了类之间的自然等级关系,增加灵活性,编译时检查。如,正方形作为一种特殊的矩形,实现起来也非常容易:
class Square extends Rectangle {
Square(double side) {
super(side, side);
}
}
Item 24:根据使用场景选择嵌套类的种类
嵌套类(nested class)有四种:静态成员类,非静态成员类,匿名类,局部类;后三种又称为内部类(inner class)。
静态成员类,最简单的嵌套类,可以看做一个普通类,恰好被声明在另一个类中,可以访问封闭类(enclosing class)的所有成员(包括 private)。如果 private,那么访问范围局限在封闭类中。一个常见的使用场景是作为 public 帮助类,比如提供常量等。
非静态成员类,隐式地和一个封闭类的实例关联,创建一个非静态成员类的实例时必须先有一个封闭类的实例。在非静态成员类实例的方法中,可以调用封闭类实例的方法,并用 EncosingClassName.this 引用封闭实例。一个常见的使用场景是适配器模式,比如,HashMap 中使用非静态成员类 KeySet、EntrySet、Values 来实现 collection 的视图;再比如 collection 接口(如 Set 和 List)的实现中,使用非静态成员类实现迭代器。
// 非静态成员类的典型应用
public class MySet<E> extends AbstractSet<E> {
//...
@Override public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator implements Iterator<E> {
...;
}
}
非静态成员类相较于静态成员类的缺点有:
- 非静态成员类实例中,会存储封闭实例的引用,这个存储要花费空间和时间
- 更大的缺点是,对封闭实例的引用会影响 GC 过程,导致内存泄漏
所以,除非成员类真的需要使用一个封闭对象的实例,否则总是使用 static 修饰符。比如 Map 中的 private static 的 Entry。
匿名类在使用时定义和初始化,仅仅在非静态上下文中才会有封闭实例的引用,它们的静态成员只能是常量(final 原始类型,或常量表达式初始化的 String 字段)。在能用 lamda 表达式时倾向使用 lamda 代替匿名类。
Item 25:一个 Java 源文件中只定义一个顶层类或顶层接口
假设一个包中有 3 个 Java 源文件:
Main.java:
public class Main {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
}
Utensil.java:
// 一个源文件定义两个 top-level 类,不要这样做!
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
Dessert.java:
// 一个源文件定义两个 top-level 类,不要这样做!
class Utensil {
static final String NAME = "pot";
}
class Dessert {
static final String NAME = "pie";
}
命令行编译时:
- 如果输入
javac Main.java Dessert.java
会报错“you’ve multiply defined the classes Utensil and Dessert”,因为编译器首先读入 Main.java 文件,在遇到 Utensil.NAME,会在目录中寻找 Utensil.java 源文件来加载类,结果就会在源文件中找到了 Utensil 和 Dessert 两个类,当编译器接着读入 Dessert.java 文件时,就会遇到 Utensil 和 Dessert 的重定义。 - 如果输入
javac Main.java Utensil.java
或者javac Main.java
都会正常编译,运行时输出“pancake”。 - 如果输入
javac Dessert.java Main.java
也会通过编译,但是运行时输出“potpie”。
推荐修改方案有两种,一是,将每个顶层类放到单独的 java 源文件中;二是,将多个类作为静态成员类放到一个顶层类中,如下示例。
public class Main {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
private static class Utensil {
static final String NAME = "pot";
}
private static class Dessert {
static final String NAME = "pie";
}
}