xyliang / effective-java3_chinese-version Goto Github PK
View Code? Open in Web Editor NEW[Chinese-version]effective-java 3
[Chinese-version]effective-java 3
Java 8之前,在不破坏现有实现类的情况下,不允许为接口添加方法。如果你擅自给一个接口添加新的方法,已经实现这个接口的类就会因为缺少这个方法的实现而报错。在Java 8 中,默认方法被设计进来,其目的是允许为已有的接口添加方法。但是为现有接口添加默认方法是充满风险的。声明的默认方法可以被所有实现这个接口的类直接调用,但是并不保证这些方法可以正常的工作。这个默认方法在实现类不知情的情况下被**注入(injected)**到实现类中。
在Java8中,collection接口中被添加了很多默认方法,最主要的便利之处是使用lambdas表达式(第6章)。像这类Java库中的默认实现是高质量并通用的,在大多数情况下,它们都工作的很顺畅。但是并不能保证默认方法能适应所有可变的环境。
例如,在Java8的Collection接口中添加的removeIf方法,这个方法会删除所有符合被提供的boolean表达式(或正则表达式)的元素,然后返回删除结果的布尔值。
/**
* Removes all of the elements of this collection that satisfy the given
* predicate. Errors or runtime exceptions thrown during iteration or by
* the predicate are relayed to the caller.
*
* @implSpec
* The default implementation traverses all elements of the collection using
* its {@link #iterator}. Each matching element is removed using
* {@link Iterator#remove()}. If the collection's iterator does not
* support removal then an {@code UnsupportedOperationException} will be
* thrown on the first matching element.
*
* @param filter a predicate which returns {@code true} for elements to be
* removed
* @return {@code true} if any elements were removed
* @throws NullPointerException if the specified filter is null
* @throws UnsupportedOperationException if elements cannot be removed
* from this collection. Implementations may throw this exception if a
* matching element cannot be removed or if, in general, removal is not
* supported.
* @since 1.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的公共类库中的org.apache.commons.collections4.-collection.SynchronizedCollection方法,类似于在java.util中Collections.-synchronizedCollection的静态工厂方法:
/**
* @serial include
*/
static class SynchronizedCollection<E> implements Collection<E>, Serializable {
private static final long serialVersionUID = 3053995032091335093L;
final Collection<E> c; // Backing Collection
final Object mutex; // Object on which to synchronize
SynchronizedCollection(Collection<E> c) {
this.c = Objects.requireNonNull(c);
mutex = this;
}
SynchronizedCollection(Collection<E> c, Object mutex) {
this.c = Objects.requireNonNull(c);
this.mutex = Objects.requireNonNull(mutex);
}
public int size() {
synchronized (mutex) {return c.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return c.isEmpty();}
}
public boolean contains(Object o) {
synchronized (mutex) {return c.contains(o);}
}
public Object[] toArray() {
synchronized (mutex) {return c.toArray();}
}
public <T> T[] toArray(T[] a) {
synchronized (mutex) {return c.toArray(a);}
}
public Iterator<E> iterator() {
return c.iterator(); // Must be manually synched by user!
}
public boolean add(E e) {
synchronized (mutex) {return c.add(e);}
}
public boolean remove(Object o) {
synchronized (mutex) {return c.remove(o);}
}
public boolean containsAll(Collection<?> coll) {
synchronized (mutex) {return c.containsAll(coll);}
}
public boolean addAll(Collection<? extends E> coll) {
synchronized (mutex) {return c.addAll(coll);}
}
public boolean removeAll(Collection<?> coll) {
synchronized (mutex) {return c.removeAll(coll);}
}
public boolean retainAll(Collection<?> coll) {
synchronized (mutex) {return c.retainAll(coll);}
}
public void clear() {
synchronized (mutex) {c.clear();}
}
public String toString() {
synchronized (mutex) {return c.toString();}
}
// Override default methods in Collection
@Override
public void forEach(Consumer<? super E> consumer) {
synchronized (mutex) {c.forEach(consumer);}
}
@Override
public boolean removeIf(Predicate<? super E> filter) {
synchronized (mutex) {return c.removeIf(filter);}
}
@Override
public Spliterator<E> spliterator() {
return c.spliterator(); // Must be manually synched by user!
}
@Override
public Stream<E> stream() {
return c.stream(); // Must be manually synched by user!
}
@Override
public Stream<E> parallelStream() {
return c.parallelStream(); // Must be manually synched by user!
}
private void writeObject(ObjectOutputStream s) throws IOException {
synchronized (mutex) {s.defaultWriteObject();}
}
}
和上面使用同步锁对象(Object mutex;)不同的是,Apache版本使用的可以是用户提供的对象作为锁对象,不过不管怎样,都是为了替代collection实现同步的collection实现的封装。换句话说,这其实就是一个包装类(Item 18),在将所有方法代理给collection之前都加上了同步锁,锁住了这里的mutex对象。
Apache的SynchronizedCollection类仍然被积极的维护着,但它并不是重写了removeIf方法,假如它结合Java8使用的话,机会默认继承removeIf的实现,这将会破坏这个类的基本功能承诺:自动的为每一个方法调用同步控制。默认的方法并没有任何同步操作。如果一个客户端在其他线程调用了SynchronizedCollection类的修改collection方法,就可能会有ConcurrentModificationException或者其他异常出现。
为了避免类似的问题在Java平台库发生,JDK的维护者必须重写默认的removeIf和其他类似的方法实现,保证在调用默认实现之前执行必要的同步操作。
有些包含默认方法的接口,可能编译时可以通过,但是运行时就会失败。已知在Java8中有一小部分添加到colletions接口的方法是易受影响的,还有易影响的现有的实现。
应该尽量避免为现有的接口添加默认方法,除非这是必要的,此时你应该认真思考添加的方法会不会对现有的接口实现造成不必要的影响。然而,在创建接口时添加默认方法的实现时很有用的,可以减轻接口实现的负担(Item20)。
细心的设计接口是非常重要的,因为一个很细小的错误都可能会破坏API甚至永远惹怒用户。
因此,在你公开接口之前要细心的测试它,至少你应该为它设计三种不同的实现,而且要使用实现接口的对象执行不同的任务来测试,这将大大提高它的安全性。
一个不可变的类指的是一个类的对象不能被修改。在这个对象的生命周期中它所包含的信息是不变的。Java中有很多不可变的类,比如String、原始类型、BigInteger、BigDecimal。不可变类比可变类更容易设计、实现和使用,而且更少出错也更安全。那么怎样才能使类变得不可变呢?请遵循以下5条原则:
前面的很多例子中的类都是不可变的,例如Item 11中的PhoneNumber,它的每个属性都可以被访问但不能被修改。这里有一个更加完整的例子:
public final class Complex {
private final double re;
private final double im;
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
public double realPart() { return re; }
public double imaginaryPart() { return im; }
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
public Complex minus(Complex c) {
return new Complex(re - c.re, im - c.im);
}
public Complex times(Complex c) {
return new Complex(re * c.re - im * c.im,
re * c.im + im * c.re);
}
public Complex dividedBy(Complex c) {
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp,
(im * c.re - re * c.im) / tmp);
}
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Complex))
return false;
Complex c = (Complex) o;
// See page 47 to find out why we use compare instead of ==
return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0;
}
@Override public int hashCode() {
return 31 * Double.hashCode(re) + Double.hashCode(im);
}
@Override public String toString() {
return "(" + re + " + " + im + "i)";
}
}
这个类表示一个复数(有实部和虚部的数字)。除了一些标准的对象方法之外,它提供了访问实部和虚部的方法以及基础的加、减、乘、除方法。请注意:这些算数运算的返回结果中并没有修改这个对象的属性,而是生成一个新的Complex实例对象。这种模式被称为函数式方法,因为这种方法把调用函数的结果在不修改函数调用者的同时返回给调用者。注意方法名是介词(如plus)而不是动词(如add),是为了强调方法不会修改对象的值。在BigInteger和BigDecimal中没有遵循这种命名规范,所以造成了很多错误的调用。
如果你不熟悉函数式方法,可能会觉得它看着不太自然,但是它具有不可变性而且有很多优点。使对象不可变。如果你能确认类的所有构造方法都不改变其状态,并且无论何时都不会被改变,那么一个不可变对象的状态就可以一直被保持在它被创建时的状态。因为可变对象具有可变性,如果在setter方法中没有给出详细说明其变化的描述,那么这个可变的类将很难甚至不能被可靠的使用。
不可变对象与生俱来就是线程安全的,所以不需要synchronization。它们不会被多个线程同时访问所污染,所以这是实现线程安全最简单的方法。不可变对象可以自由分享。不可变的类应该鼓励调用者尽可能重用同一个类对象。简单有效的方法是提供public static final 的常量以供公共使用。例如,Complex类可以提供这些常量:
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
这种方法还可以更进一步。一个不可变的类可以提供static 的工厂(Item 1)来缓存经常要访问的对象,以避免当一个对象存在了还会被经常的创建。现在的基础类型的包装类和BigInteger都是这么做的。这样带来的好处是:避免创建多余的相同对象,减少内存消耗和垃圾回收消耗。使用static工厂而不是public的构造方法是为了在将来想添加一个内容时提供灵活性,不用修改调用者的代码。
一个不可变对象可以被自由分享,这就意味着不需要对他们进行防御性拷贝(Item50)。因为它们的内容永远和初始时是一样的。所以不可变的类中你不需要提供clone方法或者copy构造方法(Item13)。这在Java早期的时候是不太好理解的,所以在String类中仍然有copy的构造方法,但是尽量应该不要这么使用(Item6)。
不仅可以公开不可变对象,而且可以公开它们的实现。例如:BigInteger类使用一个有符号的数,符号用一个int类型表示,值用int数组表示。negate方法返回一个符号相反绝对值相等的新的BigInteger对象。即使它是可变的也不需要使用copy数组;这个新的对象指向相同的内部数组。
不可变对象作为其他对象的组成元素很有优势,无论这个对象可不可变,因为不用担心不可变量被胡乱修改。
不可变对象免费提供失败原子性(Item76)。它们的状态不会被改变,所以不可能不一致。
不可变对象的主要缺点是:对于不同的值它都需要一个全新的对象。尤其是对于较大的对象,创建出来的代价是高昂的。例如:你有一个百万为的BigInteger对象,当你想改变它的最低位时:
BigInteger moby = ...;
moby = moby.flipBit(0);
方法flipBit会创建一个新的百万位的BigInteger对象出来,不同的仅仅是最后一位。这个操作消耗的时间和空间和BigInteger的大小成正比。对比java.util.BitSet和BigInteger,BitSet是可变的,有任意长度的bit位,提供仅改变数值中一位的方法:
BitSet moby = ...;
moby.flip(0);
如果你执行很多步骤,每一步都产生一个新对象,并仅仅在最后才释放所有对象的话,这将会产生很严重的性能问题。有两种方法应对这个问题。第一是将公用的多步操作封装起来,就不用每一步都生成一个新的对象了。例如BigInteger类有一个包私有的可变“伙伴类”,用来加速类似于模幂运算的多步操作。
如果你能预测调用者可能用你的不可变类做的哪些操作,就可以将这些操作做成包私有级别的方法。如果没办法预测可能需要什么操作时,你最好提供一个public的可变伙伴类,例如String类,它的可变伙伴类是StringBuilder(它的已废弃的前身是StringBuffer)。
现在你已经知道了如何创建一个不可变的类,而且知道了不可变性的优点和缺点,让我们导论一下设计上的替代品。会想一下为了保证不可变性,一个类不能被子类化,这可以通过final来实现。但是有另一个更加灵活的的方案:不是使不可变的类被final修饰,而是使构造方法变成private或者package-private的,然后添加public static工厂方法代替public 构造方法(Item 1)。请看下面实例:
//用不可变类的静态工厂代替构造方法
public class Complex {
private final double re;
private final double im;
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
... // Remainder unchanged
}
通常这种方法是最好的替代品,具有最灵活的特性,因为这样可以实现多包级私有特性。包外的类没办法继承这个类,因为对于其他包而言这个类就是final的,不能继承不同包的类而且也没有public或者protected的构造方法。这个方法还能在将来的版本中通过优化静态方法中的对象缓存来优化性能。
在BigInteger和BigDecimal类被创造的时候,不可变类必须保证final这个观点并没有被普遍的认知,所以它们的方法都是有可能被重写的。不幸的是,这种错误现在并不能被修改,因为它们现在都要做向后兼容。如何你依赖于BigInteger和BigDecimal的不可变性实现一个安全的类时,一定要确认它们是真实的BigInteger和BigDecimal,而不是它们的(不被我们信任的)子类。如果确认它们是子类,那么应该做防御性拷贝,因为它们可能已经重写了实现(Item50):
public static BigInteger safeInstance(BigInteger val) {
return val.getClass() == BigInteger.class ?
val : new BigInteger(val.toByteArray());
}
文章开始时我们说不可变类是不能修改它的对象并且他的属性都是final的。事实上,这些规则有点过于严格了,可以适当放松来提高性能。实际上,没有方法能提供外部可见的内部变化。然而,一些不可变的类有一个或多个非final的属性,用来缓存一些结果,这些结果在第一次被需要的时候会消耗一些计算成本,但是在之后被需要的时候就不需要再次计算,缓存可以直接返回不可变的对象,这保证了重复计算时返回相同的结果。
例如PhoneNumber的hashCode方法(Item11),第一次计算的时候会缓存起来,下次就可以直接被使用。同样的延迟初始的方法(Item83)String类也使用了。
特别注意当你的不可变类实现Serializable接口时,并且如果它包含一个或多个可变的对象,即使默认serialized可用,你也必须提供一个清晰的readObject或readResolve方法,或者使用ObjectOutputStream.writeUnshared 和ObjectInputStream.readUnshar
ed方法。否则攻击者可能会用你的类创建一个可变对象,这种情况在Item88中会详细说明。
在不需要的情况下,不要为每一个getter方法写一个setter方法,如果类可以做成不可变的,就尽量不要使它们可变。不可变的类有很多的优点,只有一个缺点是在某些情况下可能有性能问题。Java平台有几个类本应该是不可变但实际上却不是,如java.util.Date和java.awt.Point。只有在你必须实现令人满意的性能的时候才应该把不可变类变成可变的公共类(Item 67)。
不能变成不可变的类,那就应该尽量限制它的可变形。减少状态的数量就能使分析问题更加容易,也能减少可能的错误。应该把变量设置成final 的,除非有足够的原因需要设置成非final的。结合item15说的,你应该自然的把变量设置成private final的,除非有原因需要设置成其他的。
构造方法应该创建完全初始化的对象,以使它们的不确定性确立。除非有充足的理由,都不要创建除了构造器和静态方法之外的公共初始化方法。同样,也不要提供重新初始化方法重新把一个对象初始化成新的状态。因为这样增加了复杂度换来了仅仅一点性能的优势。
类CountDownLatch 遵循了这个规则。它是可变的但是它的状态空间被限制的很小。你创建一个对象,使用它的时候:一旦countdown 的锁计数降到0,你是不能重新使用它的。
最后需要提一下这里的Complex类不是一个工业标准的类,仅仅是为了说明不可变性存在的。
###ITEM 23 优先使用类层级结构,减少使用tag标记类
有时候一个类对象可以表示多种形式,用一个tag值指示这个类对象表示哪一种形式。例如下面表示圆形和矩形的类:
class Figure {
enum Shape { RECTANGLE, CIRCLE };
// Tag field - the shape of this 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.length = length;
this.width = width;
}
double area() {
switch(shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError(shape);
}
}
}
像这样一个tag类有很多的缺点:
幸运的是,像Java一样的面向对象语言提供了一种更好的可以代表多种形态的替代方案:子类类型。而tag类其实就是模仿子类的一种简单的实现而已。
转换tag类成为继承结构;
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; }
}
这个类层次修复了刚才提到的所有tag类的缺点。
类层级带来的其他的优点是,很自然的映射出了类层次的关系,提供了更灵活也更好的编译期检查。这个类层级很容易可以被扩展,例如添加正方形:
class Square extends Rectangle {
Square(double side) {
super(side, side);
}
}
注意,上面例子为了简洁,把数据属性设置成可以直接访问的,而不是通过存取器。真实场景下使用时应该注意public属性的缺陷(Item16)。
总结:tag类总是不太恰当的,如果你打算写一个类用tag来区分不同行为,考虑将其替换成类层级关系。
###ITEM 27 消除 unchecked(未检查) 警告
当你使用范型的时候,可能会经常看到各种各样的编译器警告:未检查方法调用警告、未检查变量类型警告、未检查转换警告等等。很多未检查警告很容易被消除。例如,假如你写了这样的代码:
Set<Lark> exaltation = new HashSet();
此时编译器会警告你:
Venery.java:4: warning: [unchecked] unchecked conversion
Set<Lark> exaltation = new HashSet();
^
required: Set<Lark>
found: HashSet
要消除这里的警告,可以使用钻石符号(<>),编译器可以自己推断出真实的类型(这里是Lark):
Set<Lark> exaltation = new HashSet<>();
当你遇到一些很难消除的警告时,持之以恒,尽可能的将其解决!当你消除了所有的警告时,好处是就可以确定你的代码是类型安全的。意味着在运行时你不会遇到ClassCastException的异常。
当你遇到你不能消除的警告时,如果你能保证被警告的代码类型是什么,就可以使用(也只能使用这种方式)@SuppressWarnings(“unchecked”)注解来消除警告。但是当你不能确保其类型的时候,就不要使用这个注解消除警告,因为即使你在编译期间消除了警告,在运行时也可能会报出ClassCastException的异常。
SuppressWarnings注解可以用在声明任何对象时,例如声明一个变量、方法、结构体或类等。但是永远要保证尽可能小范围的使用SuppressWarnings注解。不要对整个类使用SuppressWarnings,因为这样可能会掩盖掉严重的警告。
如果你发现你要使用SuppressWarnings注解在一个对象(方法或结构体等)上长度超过了一行,那么你可以通过移动这个对象成为一个本地的变量解决。虽然这样需要声明一个本地变量,但是这是值得的。例如,ArrayList中的toArray方法:
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
这个方法会报出警告:
ArrayList.java:305: warning: [unchecked] unchecked cast
return (T[]) Arrays.copyOf(elements, size, a.getClass());
^
required: T[]
found: Object[]
在android平台上是将整个方法添加了@SuppressWarnings("unchecked"),但是尽量不要这样做。你可以缩小注解的范围像这样:
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// This cast is correct because the array we're creating
// is of the same type as the one passed in, which is T[].
@SuppressWarnings("unchecked") T[] result =
(T[])Arrays.copyOf(elements, size, a.getClass());
return result;
}
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
**每次当你使用@SuppressWarnings("unchecked")注解时,都应该添加注释说明为什么这么做是安全的。**这样做不仅可以使其他人更容易读懂,而且防止别人随意更改代码。
Item18中讲了没有文档和设计的继承是多么危险,那么一个类为继承被设计和添加文档是什么意思呢?
首先,必须为可重写的方法添加明确的文档。换句话说就是,类必须说明它自己使用的那些可被重写的方法。对每个public和protected方法,文档必须指明那些方法被重写后,或者在将来被改变会对类本身产生的影响。(能被重写我们指的是:nonfinal和public或protected方法)。一般情况下,一个类必须对任何环境下调用的可重写的方法进行文档说明。例如:调用可能来自后台线程或静态初始化。
一般调用了可被重写方法的描述在文档描述后面,被关键字Implementation Requirements指定,这个关键字由Javadoc的@implSpec生成,这里会描述方法内部的实现。这里有个例子,来自java.util.AbstractCollection:
Removes a single instance of the specified element from this
collection, if it is present (optional operation). More formally,
removes an element e such that Objects.equals(o, e), if this
collection contains one or more such elements. Returns true if this
collection contained the specified element (or equivalently, if this
collection changed as a result of the call).
Implementation Requirements:
This implementation iterates over the collection looking for the specified element. If it finds the
element, it removes the element from the collection using the
iterator’s remove method. Note that this implementation throws
an UnsupportedOperationException if the iterator returned by this
collection’s iterator method does not implement the removemethod
and this collection contains the specified object.
这个文档清晰的描述了重写iterator的方法会对remove方法产生的影响以及影响的结果是什么。在Item18中,程序员没有说明重写add方法是否会对addAll方法产生什么影响。一个好的API文档应该仅仅描述方法干了什么,而不应该描述它是如何实现的,那么像上面说的这种文档是不是违反了这种规则呢?答案是肯定的。这是使用继承破坏封装性所带来的不良后果。
这个@implSpec标志在Java8中添加,在Java9中被广泛使用。这个tag应该默认被使用的,但是直到Java9中还没有默认使用,除非在命令行中打开
-tag "apiNote:a:API Note:"。
为了让程序员写出高效的子类而不太痛苦,除了包括自调用的文档。一个类还不得不提供一些恰当的protected方法或者属性。例如java.util.AbstractList类的removeRange方法:
/**
* 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) {
ListIterator<E> it = listIterator(fromIndex);
for (int i=0, n=toIndex-fromIndex; i<n; i++) {
it.next();
it.remove();
}
}
当你设计一个继承类的时候,你决定暴漏什么protected方法给子类呢?不幸的是,并没有万能的方法,你能做的就是认真思考,然后多写几个子类来继承你写的类进行测试。你应该尽可能的少暴漏protected成员,但也不能太少,因为太少就限制了子类化。如果你遗漏了一个protected成员,尝试写子类的时候就可能会带来麻烦。相反的,如果几个子类都没有使用到一个成员,那应该将这个成员私有化。
经验表明,3个子类就足够测试一个可继承的类了。当你实现一个用于继承的类时,记得将自用方法添加文档,然后决定公开哪些受保护的方法,但是注意这些公开的方法在将来的版本中可能成为限制你提高性能的承诺。因此,在你公开你的类之前,你必须测试好它。
同时需要注意,专门为继承而写的文档有时候会给正常使用的程序员造成一定的困扰,直到写这篇文章的时候,也没有一个好的工具来把正常的文档和专门为继承而写的文档分开。还有其他的一些需要子类遵守的规则。构造方法不能直接或间接的调用可被重写的方法。如果你违反了这条规则,程序将会失败,因为一个父类的构造方法先于子类的构造方法被调用,子类重写的方法会在子类构造方法调用之前被调用,如果这个被重写的方法的一些初始化在子类的构造方法中就会产生错误。这里给出具体的例子:
public class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
这里子类重写了方法overrideMe,就会造成父类唯一的构造方法的调用错误。
public final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;
Sub() {
instant = Instant.now();
}
// Overriding method invoked by superclass
constructor
@Override public void overrideMe() {
System.out.println(instant);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
你可能期望程序打印instant两次,但是第一次打印确实null,因为super的构造方法先于Sub的构造方法被调用,而instant的初始化在子类的构造方法中。还要注意的是,这个程序中观察到final 属性的两种不同状态,这是不是很奇怪?另外,调用instant的地方需要抛出NullPointerException。这里的程序没有抛出这个异常是因为println方法可以接受null的参数。
对于构造方法来说,调用privete方法、final方法、static方法都是安全的,因为它们不能被重写。
在设计继承的时候Cloneable 和 Serializable接口会带来特殊的困难。如果一个类实现了这样类似的接口,那就会给继承于它的类带来很大的麻烦。所以可以使用特殊的动作来允许子类实现这些接口,而不是强制它们实现这些接口,这写细节讲述在Item 13 和 Item 86.
如果你决定在设计继承中实现Cloneable 或 Serializable接口时,应该意识到的是clone 和 readObject方法类似于构造方法,有可能直接或间接的调用被重写的方法。对于readObject而言,重写的方法会在子类反序列化之前被调用;对于clone ,重写的方法会在子类的clone方法调用之前调用。无论哪种情况都可能造成失败。例如clone时,可能破坏原始的对象以及克隆的对象。例如当复制没有完成时,被重写的方法修改了原始对象的结构。如果你觉得为设计的继承类实现Serializable接口,而且这个类重写了readResolve 或 writeReplace方法。你必须时readResolve 或 writeReplace是被保护的而不是私有的,如果这些方法是私有的,它们对子类来说是不可见的。这是另外一种为了实现继承而暴漏实现细节的情况。
现在为止可以看到,设计用于继承的类是要小心的,有很多的限制。这不是一个随随便便的决定。有很多情况下需要这么做,例如抽象类,包括接口的骨架(Item20);也有很多情况下不能这么做,例如不可变类(Item17)。
那平时我们写的类是怎样的呢?一般它们是非final、不是为继承设计的、也没有很好的文档,这样的状态都是很危险的。当改变这样的一个类时,就会破坏继承于这些类的类。这不仅是一个理论上的问题,在实际中,也经常会有相关子类异常的日志出现。
最好的解决办法是禁止在哪些没有文档和不是为继承设计的类上子类化。有两种方法禁止子类化。想对简单的是把类设计成final的。另一种是将所有构造方法都设置成私有的或者包内私有的,然后提供一个公共的静态工厂方法,这种方法能够提供了在使用子类内部的灵活性(Item17)。这两种方法都是可以接受的。
这些建议或许会有一些争议,因为很多程序员已经习惯于继承具体的类来实现一些功能,例如通知、同步、限制功能等等。如果一些类实现了包含本质的一些接口,例如Set、List或 Map,就可以通过使用包装类(Item18)的方式提供比继承更好的添加功能的方法。
如果一些类没有实现标准的接口,那么你禁止继承就可能会给一些程序员带来麻烦。如果你觉得一些类必须允许继承的话,那有一个可行的方法是保证这个类不调用可能被重写的方法并且添加文档加以说明。换句话说就是去除类自己调用自己可被重写的方法。如果这么做了,你就可以写出安全的子类,而且重写方法也不会影响任何其他方法的行为。
你可以将本来在类中调用的可重写的方法的实现新写在另一个帮助的私有方法中,然后让类调用这个私有的帮助方法。
在语法上,设计一个用于继承的类是困难的。你必须为自调用的方法写相应的文档,而且在这个类的整个生命周期都维护这个文档。因为你如果不这么做,当你修改了父类时子类就可能被破坏。要想让别人写出有效的子类,你必须暴漏几个protected的方法。如果你确认这个类不用继承,那就可以设置成final或者将构造方法设置成私有的。
###ITEM24 内部类优先设置成静态的
嵌套类指的是定义在其他类中的类。
当类仅仅只对另一个类服务时(而不对其他类服务)才应该被设计成嵌套类。如果一个类对多个类服务,那么它就应该被设计成顶层类。
嵌套类分为四种:
除了第一种之外都可以叫做内部类。这个章节告诉你何时以及如何选择嵌套类的使用。
你可以将嵌套类理解成一个声明在其他类中的普通类,可以访问包含它的类的任何成员(即使是private成员也不例外)。
一个静态成员类拥有像其他静态成员一样的可访问性。如果它被声明称private的,那么就只有它的宿主类能够访问它等等。
一种常见使用静态成员类是将其声明为public的helper类,仅仅当它和外部类一起使用才有用。例如,Item34中一个表示计算器运算规则的枚举。Operation枚举是Calculator类中的一个public static的成员类。客户端可以通过Calculator.Operation.PLUS 和 Calculator.Operation.MINUS来调用。
在语法上,静态和非静态成员类的区别仅仅是在声明静态成员类时在前面添加了static关键字。即使语法上看起来很相似,但是这两种类其实有很大的不同。
非静态成员类的实例对象隐含的与包含它的类实例对象相关联。在非静态成员类的对象方法中,你可以调用它宿主类的方法,或者可以在构造方法中引用一个包含它的类的实例对象。
如果一个嵌套类的实例可以与宿主类的实例独立开来,那么这个嵌套类就应该被成定义成静态的成员类:因为非静态的成员类对像是不可能在脱离它的宿主类的情况下被创建的。
当非静态类的对象被创建的时候,它和宿主类对象的关系就被确立了,并且之后不能再被改变了。一般来说,当类对象调用它的内部静态类的对象方法时,这种关系就被自动的建立了。尽管很少见,但可以使用表达式enclosingInstance.new MemberClass(args)手动建立关联。但同时,这种关联会占用非静态成员类的空间和构造时间。
通常使用非静态成员类的例子是Adapter(允许外部类对象被看作与它不相关的类对象)。例如,Map接口的实现类HashMap,通常会使用非静态成员类例如Values、KeySet、EntrySet等来实现collection的内容。
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
final class Values extends AbstractCollection<V> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<V> iterator() { return new ValueIterator(); }
public final boolean contains(Object o) { return containsValue(o); }
public final Spliterator<V> spliterator() {
return new ValueSpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
// Android-changed: Detect changes to modCount early.
for (int i = 0; (i < tab.length && modCount == mc); ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
类似的还有例如Set和List,使用非静态成员类实现自己的迭代器:
public class MySet<E> extends AbstractSet<E> {
... // Bulk of the class omitted
@Override public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator implements Iterator<E> {
...
}
}
如果你声明了一个成员类不需要访问宿主类的对象,那么应该将其声明称static类型的。如果你忘记添加static标志,那么这个类就会隐式的被宿主类对象引用。正如前面说的,这个引用会消耗时间和空间。更严重的是,可能造成宿主对象在达到垃圾回收条件时不能被正常的回收(Item7),这会造成内存泄漏的风险,而且这很难被检查出来,因为这个引用是不可见的。
私有的内部静态类通常用来表示宿主类的一个组件。例如Map对象中的表示Keys和Values的关联关系的组件。Map中的每一对key-value的关联关系都会有一个Entry的对象来表示。即使每一个entry都是和map相关的,但是entry中的方法(getKey, getValue, 和 setValue)都不需要调用map类。因此使用私有静态类来表示entry是最好的。如果你忘记将其设置成静态类,它也能工作,但是会造成每个entry都会隐式的包含map的引用,这将造成很大的空间和时间的浪费。
在一个public或者protected类中,选择使用静态或非静态的类显得更加重要。因为成员类会成为API的一部分,为了向后兼容,将来不能随意更换现有的类从非静态转化为静态的。
匿名类指的是没有名字的类,它不是宿主类的成员,它不和其他成员一起被声明。而是在使用时同时被声明和初始化的。匿名类在书写正确的前提下可以出现在代码的任何一个地方。匿名类仅当他们出现在非静态区域时才会拥有宿主类的对象。匿名类出现在静态区域时,除了常量之外他们也不会包含任何静态成员。
匿名类有很多适应性的限制:
在lambdas被添加到Java之前(Chapter6),匿名类是创建小函数对象和执行间断逻辑的首选方案,但是现在lambdas是首选方案(Item42)。匿名类的另一种常见用途是静态工厂方法(Item20中的intArrayAsList)。
局部类是四种嵌套类中最少被使用的。一个局部类可以被声明在可以声明任何局部变量的地方,并且遵守同样的可访问权限。局部类有一些类似于其他嵌套类的特性,比如像成员类一样,他们有名字可以被重复的使用;就像匿名类一样,他们被定义在非静态区域时会有宿主类的对象,并且不会包含静态成员,他们要保持简短以保证良好的可读性。
总结一下,如果一个嵌套类需要在一个方法外部可见,或者太长以至于放在一个方法中不合适,那么应该使用一个成员类。如果没有成员类的对象都需要引用一个宿主类的对象,就将其设置成非静态的,否则设置成静态的。假设一个类在一个方法内部,如果你只需要一个这样的实例并且已经有可以表示这个类类型,那么将其设置成匿名类,否则将其设置成本地类。
###ITEM25 保持一个源文件只包含一个顶层类
虽然Java编译器允许你在一个源文件中定义多个顶层类,但是你这么做也没有任何好处,反而会有一些明显的坏处。**主要的坏处是:可能会造成一个类有多种实现。**最终使用哪一种实现依赖于源文件被添加到编译器的先后顺序。
举个实例,下面的源文件包含一个Main类,它引用了两个顶层类(Utensil和Dessert):
public class Main {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
}
现在假设你是在一个文件Utensil.java中定义了这两个类:
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
很显然,程序会打印出pancake。
现在假设你无意间定义了另外一个源文件Dessert.java,里面也同时定义了这两个类:
class Utensil {
static final String NAME = "pot";
}
class Dessert {
static final String NAME = "pie";
}
此时,
修复这个问题很简单,只需要将其中一个类分开到另一个文件即可。如果你真的需要将多个顶层类放到一起,可以参考(Item24)中的嵌套类。如果一个类从属于其他类,那就使他们成为静态的成员类,因为这样提高可读性并且可通过声明private降低可访问性。这里给个例子:
public class Test {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
private static class Utensil {
static final String NAME = "pan";
}
private static class Dessert {
static final String NAME = "cake";
}
}
这一小节很简短,主旨是:永远不要把多个顶层类或者接口放到同一个源文件中。这反过来保证了编译生成的类文件以及生成的程序的行为与源文件传递给编译器的顺序无关。
当一个类实现了一个接口时,这个接口就被当作一个可以引用该类对象的类型在使用。因此类实现了哪个接口,就能调用哪个接口的方法。
用接口类定义其他事情都是不恰当。
有一种失败的接口是常量接口。这样的接口没有包含任何方法,仅仅包含一些static final的常量。这里有个例子:
//常量接口反例-不要这样用
public interface PhysicalConstants {
// Avogadro's number (1/mol)
static final double AVOGADROS_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中。对用户来说,类实现常量接口不但用处不大而且容易造成混乱。更糟糕的是,常量接口代表了一个承诺:如果将来类被修改成不再需要使用这些常量,但它为了二进制兼容还必须继续实现这个接口,这很痛苦。如果一个非final的类实现了常量接口,那么它子类的命名空间就可能被这些常量污染。
在JAVA平台库中有几个常量接口存在,例如java.io.ObjectStreamConstants.这些接口应该被视为不规则的不应该被效仿的接口。
如果你想导出常量,有几种合理的选择。
package com.effectivejava.science;
public class PhysicalConstants {
private PhysicalConstants() { } // Prevents instantiation
public static final double AVOGADROS_NUMBER =
6.022_140_857e23;
public static final double BOLTZMANN_CONST =
1.380_648_52e-23;
public static final double ELECTRON_MASS =
9.109_383_56e-31;
}
顺便说一下,注意在上面的数字中使用的下划线(_),从Java7开始是合法的使用,添加下划线不会影响数字的值,而且能使数字的可读性更强。无论是整数还是小数,当数字个数超过5个的时候,你都可以每3个分成一组来划分。
通常使用工具类调用常量时要求用类名.常量名,例如PhysicalConstants.AVOGADROS_NUMBER.但是如果你大量的使用常量时,可以通过导入工具类来直接使用常量:
import static com.effectivejava.science.PhysicalConstants.*;
public class Test {
double atoms(double mols) {
return AVOGADROS_NUMBER * mols;
}
...
// Many more uses of PhysicalConstants justify static import
}
总结:接口应该仅被用于定义类型,而不应该用于仅仅导出常量。
###ITEM26 不要使用原始类型
从Java5开始引入范型。
在没有范型的时候,如果有人不小心将错误的类型加入到collection中,就会造成运行时的错误。
有了范型之后,你能告诉编译器,哪种类型被允许加入到collection 中,而且能在编译期间就发现错误。这个系列的文章会告诉你如何更优雅的使用范型。
首先,有几个术语解释一下。一个类或者接口如果声明了一个或多个类型参数,就可以被叫做范型类或范型接口。例如,List接口有一个类型参数:E,代表元素的类型,接口的全名是:List.读作E列表。但是大多数人简单读作列表。范型类和范型接口被统称为范型类型。
每个范型类型的组成是在类名或者接口名后面跟着一对尖括号,里面是真实参数类型对应范型类型的列表。例如List(读作string列表)代表的是每一个元素都是String类型的list(String是对应形参E的真实参数类型)。
每一个范型都定义了一个原始类型,如List中的List(它们的存在主要是为了兼容范型之前的代码)。
// 邮票集合. 只能存储邮票实例.
private final Collection stamps = ... ;
如果你这样声明而且错误的放了一个不是邮票的实例进入到这个集合中,编译和运行期间并不会报错:
//错误的放一个coin类型实例进入邮票集合中
stamps.add(new Coin( ... )); // Emits "unchecked call" warning
直到你尝试从集合中取出这个coin时才会报错:
for (Iterator i = stamps.iterator(); i.hasNext();)
Stamp stamp = (Stamp) i.next(); // 抛出异常ClassCastException
stamp.cancel();
但是我们的原则是越早发现问题越好,最好是在编译期就能发现问题。在上面这种情况下,在运行时也只能到执行到上面代码时才发生错误。而且编译器不会告诉你是因为添加了coin进入stamps导致的,它仅能告诉你“Contains only Stamp instances.”。
如果使用范型的话,可以指定类型:
private final Collection<Stamp> stamps = ... ;
编译期就知道stamps只能包含stamp类型的实例并且保证这个规则是被满足的。如果插入其他类型的实例(比如coin),编译器会告诉你发生了错误:
Test.java:9: error: incompatible types: Coin cannot be converted
to Stamp
c.add(new Coin());
^
编译器会隐含的添加强转的逻辑,并保证它们不会失败。把coin加入stamp集合的例子虽然看起来不太恰当,但这也确实会发生:例如很容易会把BitInteger对象放到BitDecimal集合中。
即使使用原始类型是合法的,但是你也尽量不要这样做。因为你是使用原始类型会失去使用范型的安全性和表达的便利性。 既然你不应该使用它们,那么为什么语言还要设计允许你使用呢?答案是为了兼容性。java出现范型的时候是它被发明出来的十年后,当时已经存在大量的代码没有使用范型,所以老的代码应该是合法的,并且老代码也应该是可以和范型正常交互的,方法可以正常的传递真实类型参数,反之亦然。这被叫做迁移兼容性。
即使你不使用像List一样的具体真实类型,你也可以很方便的使用参数化类型来允许插入任何实例类型,像List。那么原始类型List和List有什么不同呢?简单来说,后者明确的告诉编译器它能添加对象类型的参数。你能将List传递到List,但你不能将其传递到List。范型是有子类型规则的,List是List的子类型,但是不是List的子类(Item28)。总结,**使用像List的原始类型,就会失去类型安全性。**具体说明请看例子:
// Fails at runtime - unsafeAdd method uses a raw type
(List)!
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42));
String s = strings.get(0); // Has compiler-generated cast
}
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
程序可以编译,但是会收到警告:
Test.java:10: warning: [unchecked] unchecked call to add(E) as a
member of the raw type List
list.add(o);
^
实际上,运行时当程序尝试执行strings.get(0)时,你会得到ClassCastException异常。因为要将Integer转换成String报错。如果你把List替换成List然后重新编译程序,你将会发现不能编译通过而是发生错误。
Test.java:5: error: incompatible types: List<String> cannot be
converted to List<Object>
unsafeAdd(strings, Integer.valueOf(42));
^
你可能想使用原始类型作为集合,包含一些未知类型的对象。例如,假如你想写一个方法返回两个sets中所共有的元素个数,代码如下:
// Use of raw type for unknown element type - don't do
this!
static int numElementsInCommon(Set s1, Set s2) {
int result = 0;
for (Object o1 : s1)
if (s2.contains(o1))
result++;
return result;
}
这样的方法可以正常工作,但是是危险的。更安全的方法是使用无限制通配符类型。
如果当你想使用范型,但是你又不知道或者不确定真实类型是什么,你可以使用一个问号标志代替。例如,Set的无限制通配符号类型是Set<?>,读作某种类型的set。这是一种可用范围更广的Set类型,可以包含任何set。将numElementsInCommon方法声明称无限制通配符类型是:
// Uses unbounded wildcard type - typesafe and flexible
static int numElementsInCommon(Set<?> s1, Set<?> s2) { ... }
Set和Set类型的区别是什么呢? 通配符是更加安全的。因为你可以将任何原始类型元素放入到一个集合中,这样会很容易的破坏集合类型的不变性(就像上面的unsafeAdd一样)。但是你不能添加任何元素(除了null)外到Collection。尝试这么做会在编译期间报错:
WildCard.java:13: error: incompatible types: String cannot be
converted to CAP#1
c.add("verboten");
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ?
很明显这个错误已经提示了一些期望的信息,而且编译器也完成了它的使命,防止了你去破坏集合的不可变性。不但防止了你放入一个除了null之外的元素进入Collection<?>,而且不能确定你从集合中取出的是何种类型。如果这些限制对你来说不可接受,那么你就可以使用范型方法(Item30)或者无限制的通配符号类型(Item31)。
但是对于这条限制也有一些特殊的例外情况你必须使用原始类型。
if (o instanceof Set) { // Raw type
Set<?> s = (Set<?>) o; // Wildcard type
...
}
快速回顾一下:
最后介绍下术语:
术语 | 例子 |
---|---|
参数化类型 | List<String) |
真实类型 | String |
范型 | List |
正式类型参数 | E |
无限制通配符类型 | List<?> |
原始类型 | List |
有限制类型参数 | |
递归类型边界 | <T extends Comparable> |
有限制通配符类型 | List<? extends Number> |
范型方法 | static List asList(E[] a) |
继承是实现代码重用的有效方法,但是使用的不恰当的话就会导致软件变得脆弱。在一个包里使用继承是安全的,这里子类和父类都由同一个程序员控制。在特定的设计和文档说明下使用也是安全的(Item19)。但是跨越包继承就是危险的了。这里说的继承指的是实现继承(一个类extends另外一个类),不包括接口的继承(一个类实现一个接口或一个接口继承另一个接口)。
继承破坏了封装性。换句话说,子类过于依赖父类的实现细节。然而父类的实现可能随着版本更替而发生改变,这时候子类就算没有任何改动也可能会崩溃。结果就是:子类必须随着父类的变化而变化,除非父类的作者有文档说明或者这个类本身就是用来继承的。
举个例子,假如我们有个程序使用HashSet,我们需要计算一共添加到这个HashSet多少个元素(当删除元素时,会减少标志)。为了实现这个需求,我们给HashSet添加一个计数变量并提供一个getter方法返回这个变量的值。已知的是HashSet类有两个方法可以添加元素,add和addAll。所以我们重写这些方法:
//不恰当的使用继承例子
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@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;
}
}
这个例子看着没啥问题,但是却是不能正常工作的。假设我们创建一个实例然后使用addAll给它添加3个元素。(顺便提一句,Java9中我们创建一个list可使用静态工厂方法List.of,之前我们使用的是Arrays.asList):
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
然后我们调用getAddCount方法获取结果,发现结果不是3而是6。难道我们哪里写错了么?
原来在HashSet内部,addAll方法会调用自己的add方法。所以每个元素被加了两次。
我们可以修复这个问题通过移除addAll方法,虽然这么做可以修复这个问题,但是这样的做法的前提是建立在HashSet的addAll方法调用了内部add方法的基础上。而这个前提并不能保证随着Java版本的更替不会被改变。因此,InstrumentedHashSet这个类就变的很脆弱了。
有一种稍微更好点的做法是重写addAll方法的实现,使用迭代器把每一个元素使用add方法添加到指定的collection中,这样的话不管原来addAll方法有没有调用add都没关系了,因为现在不会调用父类的addAll的实现了。然而,这样还是没有解决我们所有的问题。因为这时候当父类中如果存在调用自己的addAll将会出问题,而且可能会导致方法更耗时,降低性能等等。另外,这些方法有时候会被设置成private的,也不总是会允许子类重写。如果父类在之后的版本中增加新的方法来添加元素也将导致子类出问题,因为此时子类没有在新方法中实现自己的计数功能。当Hashtable和Vector被升级进入Collections Framework时类似的安全漏洞必须被修复。
这些问题都源于overriding 方法。你可能会想继承一个类仅仅添加新的方法而不重写就是安全的。确实这样会更加安全一点,但是也不是没有一点风险,如果父类在之后的版本中添加了一个和你同名但返回值不同的方法,那么你的类将编译不通过。如果父类添加了一个和你同名返回值也相同的方法,那就相当于你重写了这个方法,上面说的问题就又出现了。
幸运的是,有一种方法可以避免上面提到的所有问题。不是继承于一个现有的类,而是添加一个对现有类的私有属性,这种设计叫做组合,因为已存在的类成了新类的组件。新类中的这个实例调用现有类相应的方法并返回相应结果,这叫转发。因为新类不依赖于现有类的实现细节,所以会变得相对健壮,不会跟现有类的方法产生冲突。这里提供一种替代InstrumentedHashSet类的实现方法,使用组件和转发方法。注意:这里的实现分成两个类,类本身和一个可重用的转发类,它包含所有要转发的方法:
//包装类
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; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
InstrumentedSet类基于Set接口实现,该接口包含了HashSet的功能特性。InstrumentedSet类实现了Set接口并提供一个接受Set类型的构造方法。本质上讲,这种实现是将一个Set转换成了另一个,并添加了自己的计数功能。不像继承那样只能提供单一功能而且要求实现父类的一系列构造方法,这个包装类可以实现任何set工具,并可以结合现有的任何构造方法一起使用。
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
InstrumentedSet类甚至可以临时被用于替换没有相应功能的set实例:
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // Within this method use iDogs instead of dogs
}
因为每一个InstrumentedSet实例都包含另一个set对象,所以被称为包装类。这也被称为装饰器模式,如InstrumentedSet类用计数方法装饰类set类。有时候来看,组合和转发的结合体非常类似于代理模式。但是只要包装对象没有把自己传递给别的包装对象就不算是代理。
包装类的缺点很少,但有一点是包装类不能被用在回调型的框架中。在包装类中,回调时调用的是对象本身,因为对象发送对自己的引用给其他的对象。这被叫做SELF问题。因为被包装的类不知道谁是它的包装类,所以回调也就避开了包装器。有人担心转发类的性能和内存占用问题,但是实际证明这两者都不会造成太大的问题。写转发类是乏味的,而且你不得不为每一个接口写一次转发类。
只有当一个类的类型真正属于另一个类的时候才适合使用继承。换句话说,就是类B继承于类A时,一定是当“is-A”的时候。如果当你的类B要继承于类A的时候,要问自己:B真的属于A类型么?如果你不能确定,那就不应该使用继承。如果回答是No,那么B应该包含一个类A的私有对象。在Java平台很多地方都违背了这个原则,例如Stack类不属于Vector,但是继承于Vector;同样一个属性list不是一个hash table,但是Perperties继承于HashTable。在这两种情况下,组合都会更好点。
如果在适合使用组合的地方使用了继承,就会暴漏实现细节。会被原始的实现限制住,永远限制了你类的性能。更严重的是,暴漏内部实现是你的客户端(使用者)直接访问到内部。至少,它会导致语意混乱。例如,如果p引用一个Properties对象,然后p.getProperty(key)可能会和p.get(key)产生混乱,因为后者是从Hashtable继承而来的方法。更严重的是,子类可能通过直接修改父类会破环父类的不变性。至于Properties,设计者希望只有String 类型被允许当作keys和values,但是由于继承于Hashtable,父类的put和putAll允许其他类型。一旦在遭到破坏的Properties对象上使用load和store将会不成功。当发现这个问题时也为时以晚,因为已经有很多客户端已经基于这个类使用了非string类型的key和value。如java库中的描述:
* Because <code>Properties</code> inherits from <code>Hashtable</code>, the
* <code>put</code> and <code>putAll</code> methods can be applied to a
* <code>Properties</code> object. Their use is strongly discouraged as they
* allow the caller to insert entries whose keys or values are not
* <code>Strings</code>. The <code>setProperty</code> method should be used
* instead. If the <code>store</code> or <code>save</code> method is called
* on a "compromised" <code>Properties</code> object that contains a
* non-<code>String</code> key or value, the call will fail. Similarly,
* the call to the <code>propertyNames</code> or <code>list</code> method
* will fail if it is called on a "compromised" <code>Properties</code>
* object that contains a non-<code>String</code> key.
当你要使用继承代替组合时,问自己最后一个问题:继承的父类的api有没有瑕疵呢?如果有你是否愿意将这种瑕疵继承到自己的api中?继承会将父类的瑕疵传递下来,而组合可以允许你设计新的api隐藏这些瑕疵。
总结起来,继承是强大的,但是它会造成很多问题,因为它会破坏封装性。它仅适合当子类和父类是is-a的关系时使用。不仅如此,继承还可能导致类的脆弱,假如类继承于不同包的类或父类不是被用于继承设计时。为了避免这种问题,使用组合和转发来代替继承。使用包装类不仅比继承更加健壮而且也更加强大。
类和接口是Java语言的核心。他们是组成抽象的基本单元。Java语言提供了很多关于类和接口的特性,这一章节的内容帮助你使用这些特性来实现更加健壮,灵活的类和接口。
区分组件好坏的一个重要条件是看它是否隐藏了自身内部的数据和实现。一个好的组件会隐藏它的所有实现细节,并将它自己的API和API的实现清晰的分离开来。然后组件间的通讯仅仅通过他们的API,并忽略彼此的内部实现。这被叫做隐藏或封装。这也是软件设计的基本原则。
在一个系统中的各个组件之间,隐藏组件各自的内部实现主要是为了解耦合。这样每个组件就可以单独进行开发、测试以及优化。各个组件之间的平行化开发也加快了系统的开发效率。并且维护起来也更容易,因为组件能够更加快速的调试、修改以及替换原来的实现。虽然隐藏实现信息本身不能造成高性能,但是它能影响到执行的性能:一旦一个系统开发完成,分析是那一部分模块造成的问题就变得可行了,那些有问题的模块组件可以在不影响其他组件的情况下进行优化。隐藏实现增加了软件的重用性,组件之间的低耦合通常被证明即使不在它们被开发出来的场景下也是可以被重用的。最后,隐藏实现减小了编译大型项目的风险,因为即使在系统不能正常使用情况下,被分离的组件也是可以被单独验证其正确性的。
Java有很多工具可以帮助实现隐藏信息。访问控制机制指的是类、接口或者成员的可访问性。这个可访问性指的是被(private、protected和public)中哪一个修饰词修饰。正确的使用这些修饰词是隐藏实现的必要手段。
经验法则很简单:尽量使每一个类和成员不可被访问。换句话说就是在保证你写软件正确的基础上使用最小的可访问等级。
对于顶层(非嵌套)的类和接口,只有两种可访问的等级package-private 和 public。如果你声明一个类或接口是public修饰的,它就是公开的。如果一个顶层的类或接口可以被设置成私有的,那就应该将其设置成私有的。通过将其私有化,你就可以随意的在将来的发行版里面修改、替换甚至删除它,而不用担心会影响到依赖于老版本实现的程序。但是如果你将它设置成public(公开的),那么你就有责任保持它的后续版本一直是可兼容的。
如果一个私有的顶层类或者接口仅仅在一个类中被使用,考虑将这个顶层的类或接口做成private static 私有的静态类内嵌到使用它的类里(Item 24),成为内部类,这样做可以减少了它的在同包中的可访问性。但是减少不必要的公共类的可访问性是更加重要的:因为公共类是API的一部分,而私有顶层类只是API实现的一部分,而API是可以随意被别人调用的。
对于成员(属性、方法、内嵌类和内嵌接口),有四种可以访问的等级,从上往下可访问性依次增加:
当设计完公共API之后,你应该让其他所有的成员都变成私有的。如果当同包中的其他类真的必须访问你的一个私有成员时,你应该将这个私有成员的修饰符从private改成 package-private以供它们调用。但是如果你发现你经常需要这么做,你应该重新审查你的系统设计,看是否可以通过分解类来更好的和其他的类解除耦合。然而,如果一个类实现了Serializable 接口,这些私有的域就会泄漏到API中(Items 86 和 87)。
一个protected类型的成员也是这个类导出的API的一部分,所以并且要永远支持。因为一个受保护的成员是这个类对公众承诺的实现细节(Item19)。但是一般情况下protected类型的成员是比较少见的。
有个约定是,你要尽可能的减少方法的可访问性。但如果一个方法重写父类的方法,它不能比父类有更小的可访问权限,必须保证父类可以被访问的地方子类也可以(传递规则)。如果你不遵守这个规则,在即编译子类的时候编译器就会生成一个错误信息。但是当一个类实现一个接口的时候,这些被实现的方法必须是public的。
你测试代码的时候,为了方便你可能会把你的类、接口或者成员的可访问性变成比他们需要的更大,这是一个可以加快速度的起点。但是把一个公共类的private成员变成package-private尚且可以接受,不可接受的是把它变成其他更高级的访问等级。换句话说就是,不可接受的就是为了方便测试把类、接口和成员由原来的private等级变成包的导出API等级。
类的实例字段最好不要设置成public的(Item16),如果一个public实例字段不是final的或者是一个mutable可变对象,这样你就对这个字段存储的值失去了控制。同时,当这个变量被修改的时候你也没办法做一些特殊动作了,所以类中的可变字段一般都不是线程安全的。即使是一个final和immutable的对象,假如被设置成public,同样也会失去在内部随意更换数据的灵活性。
同样的建议也适用于static字段,但有一个除外。当你通过public static final声明一个常量时,这个字段的命名应该是大写字母加下划线分割(Item68)。而且这些static字段应该包含原始值或对不可变对象的引用(Item17),如果包含了可变对象那就有了像非final字段一样的缺点:虽然不能修改这个final引用,但是却可以修改引用的对象,这具有灾难性的后果。
需要注意的是:一个长度不为零的数组总是可变(mutable)的,所以一个类有一个public static final array 字段或者geter方法(accessor)返回一个这样的字段都是不正确的。如果一个类有这样的字段或者这样的accessor,客户端(调用者)就可能会修改这个array的内容。这也是一个经常发生的安全漏洞:
//潜在的安全漏洞
public static final Thing[] VALUES = {...};
你可以通过将数组私有化private或者添加一个public的不可变list来修复这个潜在的漏洞:
private static final Thing[]PRIVATE_VALUES = {...};
public static final List<Thing>VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
或者你可以使数组私有化然后添加一个public的方法返回对这个私有数组的拷贝:
private static final Thing[]PRIVATE_VALUES = {...};
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
你可以通过思考这些问题来选择到底使用什么方案:调用者更喜欢什么样的结果?哪种返回结果更加便利?哪种会有更好的表现等等。
在Java 9中,模块系统之间添加了两个隐式的访问级别。一个是包组,一堆包是一个模块,就类似于一个包里的类是一组一样。一个模块通常在它的module-info.java文件中通过模块声明导出它的一些包。在这个模块中未被导出的public和protect成员在其他模块中是不可见的,而在本模块中的可访问性不受影响。使用模块系统你就可以在本模块内共享一些类,而对其他模块进行隐藏。而在模块内没有被导出的包中public类的public和protect成员就类似于平常的public和protected访问等级一样。像这种需求一般比较少,而且通常可以通过调整包内类的顺序去除。
不像上面四种主要的访问等级,这两个基于模块的访问等级主要是建议。如果你把模块的JAR包放在了应用程序的类路径而不是模块路径:那么这个包里公共类的public和protected成员不管有没有被模块导出,就都像普通的可访问成员一样具有正常的可访问性了。这种新介绍的访问等级在JDK内部已经被严格的执行了:Java库中没有被导出的包在其他的包里面是不能被访问的。
对于传统的Java程序来说,模块之间提供的这种访问控制不仅仅实用上有限制,而且本质上目前也只是建议这么做。因为为了使用它,你必须分拆你的软件包成模块,然后很清晰的声明模块之间的相互依赖,重新调整你的源码树层级,并且当你的包内非模块化的代码被访问的时候才去合适的行为来适应这种调用。所以现在要求除了JDK之外的模块也实现这种访问控制还为时尚早。所以除非你有特殊需求,目前最好还是避免使用它们。
概括起来就是:在合理的范围内你应该尽可能减少程序的可访问等级。当仔细的设计完最小化公开的API之后,你应该放置任何零散的类、接口或者成员变成API的一部分。公共类除了pubic static final的常量(constant)属性外,不应该有任何其他的公共属性,而且要保证public static final的属性是不可变的。
有时候你可能会写一些很简单的类来把一些实例属性聚集在一起,类似于下面的类:
//这种退化的类不应该被设置成public
class Point {
public double x;
public double y;
}
这种类的属性可以被直接访问,所以会破坏封装性(Item 15)。因为当你想调整这个类就必须同时调整相应的API,也不能强制使这些变量变得不可变,而且当变量被改变时你也很难做一些特殊的额外操作。这样做是有悖于面向对象开发的,在面向对象开发中一般会把它改成私有的变量,然后设置他们的存取器方法(getter和setter):
class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
public void setX(double x) { this.x = x; }
public void setY(double y) { this.y = y; }
}
当一个类是公共类时应该强制遵守这样的规则:如果一个类可以在它的包外部被访问到,就应该提供访问器方法来保持类可被修改的灵活性。如果你的类公开了自己数据域,那么将来当你想修改的时候就要考虑已经基于你的这些数据域开发的客户端可能已经遍布全球,你也应该有责任不影响它们的使用,所以此时你想修改这些数据域将会面临困难。
然而,如果一个类是package-private或者是一个私有的内部嵌套类,那么公开它的数据域本身就没有什么错误—假设是这些数据域已经足以描述类的抽象性。这种方式无论是在定义的时候还是在被其他人调用的时候都比访问器看起来更加简洁。你可能会想这些类的内部数据不也会被调用者绑定并修改么?但此时也只有包内部的类才有权限修改。而且如果是内部类,修改的范围会更小一步被限制在包含内部类的类中。Java库中的很多类也违反了这条规则,直接公开了一些属性。最常见的例子就是在java.awt包中的Point 和 Dimension类。这样的类我们应该摒弃而不是模仿。在Item67条会介绍到,因为Dimension类的内部实现对外暴漏,造成一系列性能问题一直延续到今天都没能解决。
虽然公共的类直接暴漏属性给外部不可取,但是如果暴漏的是不可变对象,危险就会降低很多。因为这时候你不能修改类的内部数据,也不能当对象被读取的时候做一些违规操作,你能做的是更加加强其不可变性。例如下面例子中的每一个实例变量代表一种时间:
//公共类暴漏不可变对象
public final class Time {
private static final int HOURS_PER_DAY = 24;
private static final int MINUTES_PER_HOUR = 60;
public final int hour;
public final int minute;
public Time(int hour, int minute) {
if (hour < 0 || hour >= HOURS_PER_DAY)
throw new IllegalArgumentException("Hour: " + hour);
if (minute < 0 || minute >= MINUTES_PER_HOUR)
throw new IllegalArgumentException("Min: " + minute);
this.hour = hour;
this.minute = minute;
}
... // Remainder omitted
}
总结起来就是,public的类不能暴漏可变对象给外部。即使是不可变对象也同样具有一定危害。然而,有时在一些设计中package-private或嵌套内部类中还是需要暴漏可变或不可变对象的。
一个不可变的类指的是一个类的对象不能被修改。在这个对象的生命周期中它所包含的信息是不变的。Java中有很多不可变的类,比如String、原始类型、BigInteger、BigDecimal。不可变类比可变类更容易设计、实现和使用,而且更少出错也更安全。那么怎样才能使类变得不可变呢?请遵循以下5条原则:
前面的很多例子中的类都是不可变的,例如Item 11中的PhoneNumber,它的每个属性都可以被访问但不能被修改。这里有一个更加完整的例子:
public final class Complex {
private final double re;
private final double im;
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
public double realPart() { return re; }
public double imaginaryPart() { return im; }
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
public Complex minus(Complex c) {
return new Complex(re - c.re, im - c.im);
}
public Complex times(Complex c) {
return new Complex(re * c.re - im * c.im,
re * c.im + im * c.re);
}
public Complex dividedBy(Complex c) {
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp,
(im * c.re - re * c.im) / tmp);
}
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Complex))
return false;
Complex c = (Complex) o;
// See page 47 to find out why we use compare instead of ==
return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0;
}
@Override public int hashCode() {
return 31 * Double.hashCode(re) + Double.hashCode(im);
}
@Override public String toString() {
return "(" + re + " + " + im + "i)";
}
}
这个类表示一个复数(有实部和虚部的数字)。除了一些标准的对象方法之外,它提供了访问实部和虚部的方法以及基础的加、减、乘、除方法。请注意:这些算数运算的返回结果中并没有修改这个对象的属性,而是生成一个新的Complex实例对象。这种模式被称为函数式方法,因为这种方法把调用函数的结果在不修改函数调用者的同时返回给调用者。注意方法名是介词(如plus)而不是动词(如add),是为了强调方法不会修改对象的值。在BigInteger和BigDecimal中没有遵循这种命名规范,所以造成了很多错误的调用。
如果你不熟悉函数式方法,可能会觉得它看着不太自然,但是它具有不可变性而且有很多优点。使对象不可变。如果你能确认类的所有构造方法都不改变其状态,并且无论何时都不会被改变,那么一个不可变对象的状态就可以一直被保持在它被创建时的状态。因为可变对象具有可变性,如果在setter方法中没有给出详细说明其变化的描述,那么这个可变的类将很难甚至不能被可靠的使用。
不可变对象与生俱来就是线程安全的,所以不需要synchronization。它们不会被多个线程同时访问所污染,所以这是实现线程安全最简单的方法。不可变对象可以自由分享。不可变的类应该鼓励调用者尽可能重用同一个类对象。简单有效的方法是提供public static final 的常量以供公共使用。例如,Complex类可以提供这些常量:
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
这种方法还可以更进一步。一个不可变的类可以提供static 的工厂(Item 1)来缓存经常要访问的对象,以避免当一个对象存在了还会被经常的创建。现在的基础类型的包装类和BigInteger都是这么做的。这样带来的好处是:避免创建多余的相同对象,减少内存消耗和垃圾回收消耗。使用static工厂而不是public的构造方法是为了在将来想添加一个内容时提供灵活性,不用修改调用者的代码。
一个不可变对象可以被自由分享,这就意味着不需要对他们进行防御性拷贝(Item50)。因为它们的内容永远和初始时是一样的。所以不可变的类中你不需要提供clone方法或者copy构造方法(Item13)。这在Java早期的时候是不太好理解的,所以在String类中仍然有copy的构造方法,但是尽量应该不要这么使用(Item6)。
不仅可以公开不可变对象,而且可以公开它们的实现。例如:BigInteger类使用一个有符号的数,符号用一个int类型表示,值用int数组表示。negate方法返回一个符号相反绝对值相等的新的BigInteger对象。即使它是可变的也不需要使用copy数组;这个新的对象指向相同的内部数组。
不可变对象作为其他对象的组成元素很有优势,无论这个对象可不可变,因为不用担心不可变量被胡乱修改。
不可变对象免费提供失败原子性(Item76)。它们的状态不会被改变,所以不可能不一致。
不可变对象的主要缺点是:对于不同的值它都需要一个全新的对象。尤其是对于较大的对象,创建出来的代价是高昂的。例如:你有一个百万为的BigInteger对象,当你想改变它的最低位时:
BigInteger moby = ...;
moby = moby.flipBit(0);
方法flipBit会创建一个新的百万位的BigInteger对象出来,不同的仅仅是最后一位。这个操作消耗的时间和空间和BigInteger的大小成正比。对比java.util.BitSet和BigInteger,BitSet是可变的,有任意长度的bit位,提供仅改变数值中一位的方法:
BitSet moby = ...;
moby.flip(0);
如果你执行很多步骤,每一步都产生一个新对象,并仅仅在最后才释放所有对象的话,这将会产生很严重的性能问题。有两种方法应对这个问题。第一是将公用的多步操作封装起来,就不用每一步都生成一个新的对象了。例如BigInteger类有一个包私有的可变“伙伴类”,用来加速类似于模幂运算的多步操作。
如果你能预测调用者可能用你的不可变类做的哪些操作,就可以将这些操作做成包私有级别的方法。如果没办法预测可能需要什么操作时,你最好提供一个public的可变伙伴类,例如String类,它的可变伙伴类是StringBuilder(它的已废弃的前身是StringBuffer)。
现在你已经知道了如何创建一个不可变的类,而且知道了不可变性的优点和缺点,让我们导论一下设计上的替代品。会想一下为了保证不可变性,一个类不能被子类化,这可以通过final来实现。但是有另一个更加灵活的的方案:不是使不可变的类被final修饰,而是使构造方法变成private或者package-private的,然后添加public static工厂方法代替public 构造方法(Item 1)。请看下面实例:
//用不可变类的静态工厂代替构造方法
public class Complex {
private final double re;
private final double im;
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
... // Remainder unchanged
}
通常这种方法是最好的替代品,具有最灵活的特性,因为这样可以实现多包级私有特性。包外的类没办法继承这个类,因为对于其他包而言这个类就是final的,不能继承不同包的类而且也没有public或者protected的构造方法。这个方法还能在将来的版本中通过优化静态方法中的对象缓存来优化性能。
在BigInteger和BigDecimal类被创造的时候,不可变类必须保证final这个观点并没有被普遍的认知,所以它们的方法都是有可能被重写的。不幸的是,这种错误现在并不能被修改,因为它们现在都要做向后兼容。如何你依赖于BigInteger和BigDecimal的不可变性实现一个安全的类时,一定要确认它们是真实的BigInteger和BigDecimal,而不是它们的(不被我们信任的)子类。如果确认它们是子类,那么应该做防御性拷贝,因为它们可能已经重写了实现(Item50):
public static BigInteger safeInstance(BigInteger val) {
return val.getClass() == BigInteger.class ?
val : new BigInteger(val.toByteArray());
}
文章开始时我们说不可变类是不能修改它的对象并且他的属性都是final的。事实上,这些规则有点过于严格了,可以适当放松来提高性能。实际上,没有方法能提供外部可见的内部变化。然而,一些不可变的类有一个或多个非final的属性,用来缓存一些结果,这些结果在第一次被需要的时候会消耗一些计算成本,但是在之后被需要的时候就不需要再次计算,缓存可以直接返回不可变的对象,这保证了重复计算时返回相同的结果。
例如PhoneNumber的hashCode方法(Item11),第一次计算的时候会缓存起来,下次就可以直接被使用。同样的延迟初始的方法(Item83)String类也使用了。
特别注意当你的不可变类实现Serializable接口时,并且如果它包含一个或多个可变的对象,即使默认serialized可用,你也必须提供一个清晰的readObject或readResolve方法,或者使用ObjectOutputStream.writeUnshared 和ObjectInputStream.readUnshar
ed方法。否则攻击者可能会用你的类创建一个可变对象,这种情况在Item88中会详细说明。
在不需要的情况下,不要为每一个getter方法写一个setter方法,如果类可以做成不可变的,就尽量不要使它们可变。不可变的类有很多的优点,只有一个缺点是在某些情况下可能有性能问题。Java平台有几个类本应该是不可变但实际上却不是,如java.util.Date和java.awt.Point。只有在你必须实现令人满意的性能的时候才应该把不可变类变成可变的公共类(Item 67)。
不能变成不可变的类,那就应该尽量限制它的可变形。减少状态的数量就能使分析问题更加容易,也能减少可能的错误。应该把变量设置成final 的,除非有足够的原因需要设置成非final的。结合item15说的,你应该自然的把变量设置成private final的,除非有原因需要设置成其他的。
构造方法应该创建完全初始化的对象,以使它们的不确定性确立。除非有充足的理由,都不要创建除了构造器和静态方法之外的公共初始化方法。同样,也不要提供重新初始化方法重新把一个对象初始化成新的状态。因为这样增加了复杂度换来了仅仅一点性能的优势。
类CountDownLatch 遵循了这个规则。它是可变的但是它的状态空间被限制的很小。你创建一个对象,使用它的时候:一旦countdown 的锁计数降到0,你是不能重新使用它的。
最后需要提一下这里的Complex类不是一个工业标准的类,仅仅是为了说明不可变性存在的。
继承是实现代码重用的有效方法,但是使用的不恰当的话就会导致软件变得脆弱。在一个包里使用继承是安全的,这里子类和父类都由同一个程序员控制。在特定的设计和文档说明下使用也是安全的(Item19)。但是跨越包继承就是危险的了。这里说的继承指的是实现继承(一个类extends另外一个类),不包括接口的继承(一个类实现一个接口或一个接口继承另一个接口)。
继承破坏了封装性。换句话说,子类过于依赖父类的实现细节。然而父类的实现可能随着版本更替而发生改变,这时候子类就算没有任何改动也可能会崩溃。结果就是:子类必须随着父类的变化而变化,除非父类的作者有文档说明或者这个类本身就是用来继承的。
举个例子,假如我们有个程序使用HashSet,我们需要计算一共添加到这个HashSet多少个元素(当删除元素时,会减少标志)。为了实现这个需求,我们给HashSet添加一个计数变量并提供一个getter方法返回这个变量的值。已知的是HashSet类有两个方法可以添加元素,add和addAll。所以我们重写这些方法:
//不恰当的使用继承例子
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@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;
}
}
这个例子看着没啥问题,但是却是不能正常工作的。假设我们创建一个实例然后使用addAll给它添加3个元素。(顺便提一句,Java9中我们创建一个list可使用静态工厂方法List.of,之前我们使用的是Arrays.asList):
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
然后我们调用getAddCount方法获取结果,发现结果不是3而是6。难道我们哪里写错了么?
原来在HashSet内部,addAll方法会调用自己的add方法。所以每个元素被加了两次。
我们可以修复这个问题通过移除addAll方法,虽然这么做可以修复这个问题,但是这样的做法的前提是建立在HashSet的addAll方法调用了内部add方法的基础上。而这个前提并不能保证随着Java版本的更替不会被改变。因此,InstrumentedHashSet这个类就变的很脆弱了。
有一种稍微更好点的做法是重写addAll方法的实现,使用迭代器把每一个元素使用add方法添加到指定的collection中,这样的话不管原来addAll方法有没有调用add都没关系了,因为现在不会调用父类的addAll的实现了。然而,这样还是没有解决我们所有的问题。因为这时候当父类中如果存在调用自己的addAll将会出问题,而且可能会导致方法更耗时,降低性能等等。另外,这些方法有时候会被设置成private的,也不总是会允许子类重写。如果父类在之后的版本中增加新的方法来添加元素也将导致子类出问题,因为此时子类没有在新方法中实现自己的计数功能。当Hashtable和Vector被升级进入Collections Framework时类似的安全漏洞必须被修复。
这些问题都源于overriding 方法。你可能会想继承一个类仅仅添加新的方法而不重写就是安全的。确实这样会更加安全一点,但是也不是没有一点风险,如果父类在之后的版本中添加了一个和你同名但返回值不同的方法,那么你的类将编译不通过。如果父类添加了一个和你同名返回值也相同的方法,那就相当于你重写了这个方法,上面说的问题就又出现了。
幸运的是,有一种方法可以避免上面提到的所有问题。不是继承于一个现有的类,而是添加一个对现有类的私有属性,这种设计叫做组合,因为已存在的类成了新类的组件。新类中的这个实例调用现有类相应的方法并返回相应结果,这叫转发。因为新类不依赖于现有类的实现细节,所以会变得相对健壮,不会跟现有类的方法产生冲突。这里提供一种替代InstrumentedHashSet类的实现方法,使用组件和转发方法。注意:这里的实现分成两个类,类本身和一个可重用的转发类,它包含所有要转发的方法:
//包装类
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; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
InstrumentedSet类基于Set接口实现,该接口包含了HashSet的功能特性。InstrumentedSet类实现了Set接口并提供一个接受Set类型的构造方法。本质上讲,这种实现是将一个Set转换成了另一个,并添加了自己的计数功能。不像继承那样只能提供单一功能而且要求实现父类的一系列构造方法,这个包装类可以实现任何set工具,并可以结合现有的任何构造方法一起使用。
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
InstrumentedSet类甚至可以临时被用于替换没有相应功能的set实例:
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // Within this method use iDogs instead of dogs
}
因为每一个InstrumentedSet实例都包含另一个set对象,所以被称为包装类。这也被称为装饰器模式,如InstrumentedSet类用计数方法装饰类set类。有时候来看,组合和转发的结合体非常类似于代理模式。但是只要包装对象没有把自己传递给别的包装对象就不算是代理。
包装类的缺点很少,但有一点是包装类不能被用在回调型的框架中。在包装类中,回调时调用的是对象本身,因为对象发送对自己的引用给其他的对象。这被叫做SELF问题。因为被包装的类不知道谁是它的包装类,所以回调也就避开了包装器。有人担心转发类的性能和内存占用问题,但是实际证明这两者都不会造成太大的问题。写转发类是乏味的,而且你不得不为每一个接口写一次转发类。
只有当一个类的类型真正属于另一个类的时候才适合使用继承。换句话说,就是类B继承于类A时,一定是当“is-A”的时候。如果当你的类B要继承于类A的时候,要问自己:B真的属于A类型么?如果你不能确定,那就不应该使用继承。如果回答是No,那么B应该包含一个类A的私有对象。在Java平台很多地方都违背了这个原则,例如Stack类不属于Vector,但是继承于Vector;同样一个属性list不是一个hash table,但是Perperties继承于HashTable。在这两种情况下,组合都会更好点。
如果在适合使用组合的地方使用了继承,就会暴漏实现细节。会被原始的实现限制住,永远限制了你类的性能。更严重的是,暴漏内部实现是你的客户端(使用者)直接访问到内部。至少,它会导致语意混乱。例如,如果p引用一个Properties对象,然后p.getProperty(key)可能会和p.get(key)产生混乱,因为后者是从Hashtable继承而来的方法。更严重的是,子类可能通过直接修改父类会破环父类的不变性。至于Properties,设计者希望只有String 类型被允许当作keys和values,但是由于继承于Hashtable,父类的put和putAll允许其他类型。一旦在遭到破坏的Properties对象上使用load和store将会不成功。当发现这个问题时也为时以晚,因为已经有很多客户端已经基于这个类使用了非string类型的key和value。如java库中的描述:
* Because <code>Properties</code> inherits from <code>Hashtable</code>, the
* <code>put</code> and <code>putAll</code> methods can be applied to a
* <code>Properties</code> object. Their use is strongly discouraged as they
* allow the caller to insert entries whose keys or values are not
* <code>Strings</code>. The <code>setProperty</code> method should be used
* instead. If the <code>store</code> or <code>save</code> method is called
* on a "compromised" <code>Properties</code> object that contains a
* non-<code>String</code> key or value, the call will fail. Similarly,
* the call to the <code>propertyNames</code> or <code>list</code> method
* will fail if it is called on a "compromised" <code>Properties</code>
* object that contains a non-<code>String</code> key.
当你要使用继承代替组合时,问自己最后一个问题:继承的父类的api有没有瑕疵呢?如果有你是否愿意将这种瑕疵继承到自己的api中?继承会将父类的瑕疵传递下来,而组合可以允许你设计新的api隐藏这些瑕疵。
总结起来,继承是强大的,但是它会造成很多问题,因为它会破坏封装性。它仅适合当子类和父类是is-a的关系时使用。不仅如此,继承还可能导致类的脆弱,假如类继承于不同包的类或父类不是被用于继承设计时。为了避免这种问题,使用组合和转发来代替继承。使用包装类不仅比继承更加健壮而且也更加强大。
Item18中讲了没有文档和设计的继承是多么危险,那么一个类为继承被设计和添加文档是什么意思呢?
首先,必须为可重写的方法添加明确的文档。换句话说就是,类必须说明它自己使用的那些可被重写的方法。对每个public和protected方法,文档必须指明那些方法被重写后,或者在将来被改变会对类本身产生的影响。(能被重写我们指的是:nonfinal和public或protected方法)。一般情况下,一个类必须对任何环境下调用的可重写的方法进行文档说明。例如:调用可能来自后台线程或静态初始化。
一般调用了可被重写方法的描述在文档描述后面,被关键字Implementation Requirements指定,这个关键字由Javadoc的@implSpec生成,这里会描述方法内部的实现。这里有个例子,来自java.util.AbstractCollection:
Removes a single instance of the specified element from this
collection, if it is present (optional operation). More formally,
removes an element e such that Objects.equals(o, e), if this
collection contains one or more such elements. Returns true if this
collection contained the specified element (or equivalently, if this
collection changed as a result of the call).
Implementation Requirements:
This implementation iterates over the collection looking for the specified element. If it finds the
element, it removes the element from the collection using the
iterator’s remove method. Note that this implementation throws
an UnsupportedOperationException if the iterator returned by this
collection’s iterator method does not implement the removemethod
and this collection contains the specified object.
这个文档清晰的描述了重写iterator的方法会对remove方法产生的影响以及影响的结果是什么。在Item18中,程序员没有说明重写add方法是否会对addAll方法产生什么影响。一个好的API文档应该仅仅描述方法干了什么,而不应该描述它是如何实现的,那么像上面说的这种文档是不是违反了这种规则呢?答案是肯定的。这是使用继承破坏封装性所带来的不良后果。
这个@implSpec标志在Java8中添加,在Java9中被广泛使用。这个tag应该默认被使用的,但是直到Java9中还没有默认使用,除非在命令行中打开
-tag "apiNote:a:API Note:"。
为了让程序员写出高效的子类而不太痛苦,除了包括自调用的文档。一个类还不得不提供一些恰当的protected方法或者属性。例如java.util.AbstractList类的removeRange方法:
/**
* 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) {
ListIterator<E> it = listIterator(fromIndex);
for (int i=0, n=toIndex-fromIndex; i<n; i++) {
it.next();
it.remove();
}
}
当你设计一个继承类的时候,你决定暴漏什么protected方法给子类呢?不幸的是,并没有万能的方法,你能做的就是认真思考,然后多写几个子类来继承你写的类进行测试。你应该尽可能的少暴漏protected成员,但也不能太少,因为太少就限制了子类化。如果你遗漏了一个protected成员,尝试写子类的时候就可能会带来麻烦。相反的,如果几个子类都没有使用到一个成员,那应该将这个成员私有化。
经验表明,3个子类就足够测试一个可继承的类了。当你实现一个用于继承的类时,记得将自用方法添加文档,然后决定公开哪些受保护的方法,但是注意这些公开的方法在将来的版本中可能成为限制你提高性能的承诺。因此,在你公开你的类之前,你必须测试好它。
同时需要注意,专门为继承而写的文档有时候会给正常使用的程序员造成一定的困扰,直到写这篇文章的时候,也没有一个好的工具来把正常的文档和专门为继承而写的文档分开。还有其他的一些需要子类遵守的规则。构造方法不能直接或间接的调用可被重写的方法。如果你违反了这条规则,程序将会失败,因为一个父类的构造方法先于子类的构造方法被调用,子类重写的方法会在子类构造方法调用之前被调用,如果这个被重写的方法的一些初始化在子类的构造方法中就会产生错误。这里给出具体的例子:
public class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
这里子类重写了方法overrideMe,就会造成父类唯一的构造方法的调用错误。
public final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;
Sub() {
instant = Instant.now();
}
// Overriding method invoked by superclass
constructor
@Override public void overrideMe() {
System.out.println(instant);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
你可能期望程序打印instant两次,但是第一次打印确实null,因为super的构造方法先于Sub的构造方法被调用,而instant的初始化在子类的构造方法中。还要注意的是,这个程序中观察到final 属性的两种不同状态,这是不是很奇怪?另外,调用instant的地方需要抛出NullPointerException。这里的程序没有抛出这个异常是因为println方法可以接受null的参数。
对于构造方法来说,调用privete方法、final方法、static方法都是安全的,因为它们不能被重写。
在设计继承的时候Cloneable 和 Serializable接口会带来特殊的困难。如果一个类实现了这样类似的接口,那就会给继承于它的类带来很大的麻烦。所以可以使用特殊的动作来允许子类实现这些接口,而不是强制它们实现这些接口,这写细节讲述在Item 13 和 Item 86.
如果你决定在设计继承中实现Cloneable 或 Serializable接口时,应该意识到的是clone 和 readObject方法类似于构造方法,有可能直接或间接的调用被重写的方法。对于readObject而言,重写的方法会在子类反序列化之前被调用;对于clone ,重写的方法会在子类的clone方法调用之前调用。无论哪种情况都可能造成失败。例如clone时,可能破坏原始的对象以及克隆的对象。例如当复制没有完成时,被重写的方法修改了原始对象的结构。如果你觉得为设计的继承类实现Serializable接口,而且这个类重写了readResolve 或 writeReplace方法。你必须时readResolve 或 writeReplace是被保护的而不是私有的,如果这些方法是私有的,它们对子类来说是不可见的。这是另外一种为了实现继承而暴漏实现细节的情况。
现在为止可以看到,设计用于继承的类是要小心的,有很多的限制。这不是一个随随便便的决定。有很多情况下需要这么做,例如抽象类,包括接口的骨架(Item20);也有很多情况下不能这么做,例如不可变类(Item17)。
那平时我们写的类是怎样的呢?一般它们是非final、不是为继承设计的、也没有很好的文档,这样的状态都是很危险的。当改变这样的一个类时,就会破坏继承于这些类的类。这不仅是一个理论上的问题,在实际中,也经常会有相关子类异常的日志出现。
最好的解决办法是禁止在哪些没有文档和不是为继承设计的类上子类化。有两种方法禁止子类化。想对简单的是把类设计成final的。另一种是将所有构造方法都设置成私有的或者包内私有的,然后提供一个公共的静态工厂方法,这种方法能够提供了在使用子类内部的灵活性(Item17)。这两种方法都是可以接受的。
这些建议或许会有一些争议,因为很多程序员已经习惯于继承具体的类来实现一些功能,例如通知、同步、限制功能等等。如果一些类实现了包含本质的一些接口,例如Set、List或 Map,就可以通过使用包装类(Item18)的方式提供比继承更好的添加功能的方法。
如果一些类没有实现标准的接口,那么你禁止继承就可能会给一些程序员带来麻烦。如果你觉得一些类必须允许继承的话,那有一个可行的方法是保证这个类不调用可能被重写的方法并且添加文档加以说明。换句话说就是去除类自己调用自己可被重写的方法。如果这么做了,你就可以写出安全的子类,而且重写方法也不会影响任何其他方法的行为。
你可以将本来在类中调用的可重写的方法的实现新写在另一个帮助的私有方法中,然后让类调用这个私有的帮助方法。
在语法上,设计一个用于继承的类是困难的。你必须为自调用的方法写相应的文档,而且在这个类的整个生命周期都维护这个文档。因为你如果不这么做,当你修改了父类时子类就可能被破坏。要想让别人写出有效的子类,你必须暴漏几个protected的方法。如果你确认这个类不用继承,那就可以设置成final或者将构造方法设置成私有的。
##Test
first
second
有时候你可能会写一些很简单的类来把一些实例属性聚集在一起,类似于下面的类:
//这种退化的类不应该被设置成public
class Point {
public double x;
public double y;
}
这种类的属性可以被直接访问,所以会破坏封装性(Item 15)。因为当你想调整这个类就必须同时调整相应的API,也不能强制使这些变量变得不可变,而且当变量被改变时你也很难做一些特殊的额外操作。这样做是有悖于面向对象开发的,在面向对象开发中一般会把它改成私有的变量,然后设置他们的存取器方法(getter和setter):
class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
public void setX(double x) { this.x = x; }
public void setY(double y) { this.y = y; }
}
当一个类是公共类时应该强制遵守这样的规则:如果一个类可以在它的包外部被访问到,就应该提供访问器方法来保持类可被修改的灵活性。如果你的类公开了自己数据域,那么将来当你想修改的时候就要考虑已经基于你的这些数据域开发的客户端可能已经遍布全球,你也应该有责任不影响它们的使用,所以此时你想修改这些数据域将会面临困难。
然而,如果一个类是package-private或者是一个私有的内部嵌套类,那么公开它的数据域本身就没有什么错误—假设是这些数据域已经足以描述类的抽象性。这种方式无论是在定义的时候还是在被其他人调用的时候都比访问器看起来更加简洁。你可能会想这些类的内部数据不也会被调用者绑定并修改么?但此时也只有包内部的类才有权限修改。而且如果是内部类,修改的范围会更小一步被限制在包含内部类的类中。Java库中的很多类也违反了这条规则,直接公开了一些属性。最常见的例子就是在java.awt包中的Point 和 Dimension类。这样的类我们应该摒弃而不是模仿。在Item67条会介绍到,因为Dimension类的内部实现对外暴漏,造成一系列性能问题一直延续到今天都没能解决。
虽然公共的类直接暴漏属性给外部不可取,但是如果暴漏的是不可变对象,危险就会降低很多。因为这时候你不能修改类的内部数据,也不能当对象被读取的时候做一些违规操作,你能做的是更加加强其不可变性。例如下面例子中的每一个实例变量代表一种时间:
//公共类暴漏不可变对象
public final class Time {
private static final int HOURS_PER_DAY = 24;
private static final int MINUTES_PER_HOUR = 60;
public final int hour;
public final int minute;
public Time(int hour, int minute) {
if (hour < 0 || hour >= HOURS_PER_DAY)
throw new IllegalArgumentException("Hour: " + hour);
if (minute < 0 || minute >= MINUTES_PER_HOUR)
throw new IllegalArgumentException("Min: " + minute);
this.hour = hour;
this.minute = minute;
}
... // Remainder omitted
}
总结起来就是,public的类不能暴漏可变对象给外部。即使是不可变对象也同样具有一定危害。然而,有时在一些设计中package-private或嵌套内部类中还是需要暴漏可变或不可变对象的。
Java有两种机制允许多种实现:interface和abstract类。因为在Java8中提到接口也可以写默认方法了,所以这两种机制都允许你提供一些实例方法的实现了。这两种机制最主要的不同点是:通过一个抽象类实现一个类,那么这个类必须是这个抽象类的子类。而在Java平台中只允许单继承,所以抽象类作为类型的使用被这种约束严格的限制住了。然而任何正常的类(定义了所要求的方法和遵循一般的类定义规则的类),不论它处于哪个类层级都可以实现任何一个接口。
**现有的类可以很容易的通过实现一个接口来更新它的能力。**你需要做的仅仅是实现接口的方法,并在类声明的地方添加implements接口的句子。例如,很多现有的类都是通过实现Comparable, Iterable, 和 Autocloseable接口添加相应的功能。而一般现有的类不能通过继承一个新的抽象类来更新功能。如果你想要让两个类都继承自同一个抽象类,你就必须调整抽象类的层级成为比这两个类更高一层的类。但是这样做可能会造成对类层级的危害,因为这样无论是否合适都需要强制把所有的类都设置成这个新抽象类的子类。
**接口是定义mixins类型的理想选择。**不严格地说,一个minxin类型指的是一个类在它主要的功能之外可以添加一些其他可选的行为。例如,Comparable允许一个类使用自己的类对象和其他的遵守comparable接口的对象进行排序,像这样的接口被称为一个mixin类型,因为它允许可选的功能被“mixed in”到一个主要的功能上。抽象类不能被用于定义mixins,因为它们不能被添加到一个现有的类上:一个类不能有多个父类,而且在类层级上也没有恰当的地方安放这样的类。
**接口允许我们创建一个非层级结构的框架。**层级结构对于一些事情是好的,但是严格的层级化结构不利于其他的东西加入进来。例如,假设我们有一个接口代表歌唱家,另一个接口代表作曲家:
public interface Singer {
AudioClip sing(Song s);
}
public interface Songwriter {
Song compose(int chartPosition);
}
在现实中,一些歌唱家也是作曲家。因为我们使用的是interface而不是抽象类,所以很容易可以做到使一个类同时实现Singer和Songwriter两个接口。事实上,我们可以定义第三个接口同时继承自Singer和Songwriter两个接口,并添加适合的新方法到这个这个组合上:
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}
你可能不需要这种灵活性,但是一旦你需要的时候,接口就能够为你提供。
而使用抽象类的话,想要实现上面说的那些组合就会造成类的层级臃肿。如果有n种类型,那么就可能需要2的n次方种组合类出现,这被称为组合爆炸。这种臃肿的类结构会导致这些类存在很多仅仅是参数不同的方法,因为在这些类层次中没有能够捕获公共行为的类。
通过使用(Item18)中的包装类,接口能提供更加安全和有利的功能。如果你使用抽象类定义类型,那么程序猿只能通过继承重写或者添加新的功能,这就比包装类多了一些限制,少了很多可提供的功能。
当有接口为其他接口实现默认的方法时,考虑为使用者提供帮助文档,使用Item19中的@implSpec文档标签。
接口中使用默认方法有以下一些限制:
但是你可以结合使用接口和抽象类的优点,使用接口实现抽象的骨架实现类。这里接口定义类型,也可能实现了一些默认方法,骨架实现类在原有的接口方法之上保留了非原始的接口方法。扩展骨架的实现把大多数的工作从实现接口中剥离了出来。这被称为模版方法模式。
按照惯例,骨架实现类被称为Abstract+接口名字,这里的接口是要实现的接口。例如Collections框架为每一个主要的collection接口都这么做了:AbstractCollection, AbstractSet, AbstractList和 AbstractMap.在它们的命名上有一些争议或许应该叫SkeletalCollection, SkeletalSet, SkeletalList, 和 SkeletalMap,但是Abstract的习惯已经根深蒂固了。
当被恰当的设计时,骨架实现(无论是单独的抽象类还是和接口的默认方法组合)都可以使程序员很容易的提供它们自己的接口实现。例如,这里有一个位于AbstractList之上的静态工厂方法,它包含一个完整的、功能齐全的List实现:
static List<Integer> intArrayAsList(int[] a) {
Objects.requireNonNull(a);
// 菱形操作符只在JAVA9之后可用,如果你在之前的版本请使用 <Integer>
return new AbstractList<>() {
@Override public Integer get(int i) {
return a[i]; // Autoboxing (Item 6)
}
@Override public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}
@Override public int size() {
return a.length;
}
};
}
当你想用List来实现一些事情时,这个例子很好的体现了骨架实现类的能力。顺便提一下,这个例子是一个Adapter,可以将int数组作为一个Integer列表来使用。而不用将int和Integer来回转换(因为转换的性能并不高)。注意这个实现是匿名类的形式(Item24)。骨架实现类可以为抽象类提供实现,而不受抽象类做为类型使用时的约束。大多情况下,实现一个骨架实现类的接口是继承于它,但这也是可选的。一个类如果不能继承于一个骨架实现类,但是可以直接实现接口。这个类还是能从接口的默认方法中继续继承一些实现。此外,骨架实现类还是有办法帮助到调用者完成工作。实现接口的类可以转发一个接口方法的实现到一个类内部私有的对象上,这个对象可能是一个骨架实现类的子类对象。这个技术被称为模拟多重继承,关于此最近的讨论是在Item18中。它有很多多重继承的优点而且避免了很多缺陷。
写一个骨架实现类相对来说是比较简单的,但是过程稍微有点乏味。
如果你已经做了所有原始的方法和默认方法就不用实现骨架实现类了。否则,写一个实现接口的类,实现所有接口中剩下的方法。这个类可能并不包含公共的参数和方法。
参考一个例子,Map.Entry接口。很明显getKey, getValue, 和 (可选的) setValue方法是原始方法,这个接口有equals和hashCode的行为,并且有toString的实现。因为不允许为Object的方法添加默认实现,所有的实现都被骨架实现类代替:
// Skeletal implementation class
public abstract class AbstractMapEntry<K,V>
implements Map.Entry<K,V> {
// Entries in a modifiable map must override this method
@Override public V setValue(V value) {
throw new UnsupportedOperationException();
}
// Implements the general contract of Map.Entry.equals
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry) o;
return Objects.equals(e.getKey(), getKey())
&& Objects.equals(e.getValue(), getValue());
}
// Implements the general contract of Map.Entry.hashCode
@Override public int hashCode() {
return Objects.hashCode(getKey())
^ Objects.hashCode(getValue());
}
@Override public String toString() {
return getKey() + "=" + getValue();
}
}
注意这个骨架实现不能被实现在Map.Entry接口内部,也不能成为Map.Entry的子接口,因为不允许覆盖Object的默认方法如equals, hashCode, 和 toString。
因为骨架实现是为继承而设计的,所以你要遵循Item19中说的文档和设计原则。为了简单起见,前面的例子中文档有些被省略了,但是好的文档在骨架实现中绝对是有必要的。
有一点不同的是简单实现,例如AbstractMap.SimpleEntry。它也实现了接口并也是为继承而设计的,但是它不是抽象的,可以被单独的使用。
总结一下:接口是允许多种实现的定义类型最好的方法。如果你导出一个重要的接口,你应该为它提供一个骨架实现。在可能的范围内,你应该尽可能通过提供默认的接口方法实现,以便所有实现这个接口的类都可以调用。也就是说,对接口的限制通常要求骨架实现采用抽象类的形式。
类和接口是Java语言的核心。他们是组成抽象的基本单元。Java语言提供了很多关于类和接口的特性,这一章节的内容帮助你使用这些特性来实现更加健壮,灵活的类和接口。
##ITEM 15 最小化类和类成员的可访问性
区分组件好坏的一个重要条件是看它是否隐藏了自身内部的数据和实现。一个好的组件会隐藏它的所有实现细节,并将它自己的API和API的实现清晰的分离开来。然后组件间的通讯仅仅通过他们的API,并忽略彼此的内部实现。这被叫做隐藏或封装。这也是软件设计的基本原则。
在一个系统中的各个组件之间,隐藏组件各自的内部实现主要是为了解耦合。这样每个组件就可以单独进行开发、测试以及优化。各个组件之间的平行化开发也加快了系统的开发效率。并且维护起来也更容易,因为组件能够更加快速的调试、修改以及替换原来的实现。虽然隐藏实现信息本身不能造成高性能,但是它能影响到执行的性能:一旦一个系统开发完成,分析是那一部分模块造成的问题就变得可行了,那些有问题的模块组件可以在不影响其他组件的情况下进行优化。隐藏实现增加了软件的重用性,组件之间的低耦合通常被证明即使不在它们被开发出来的场景下也是可以被重用的。最后,隐藏实现减小了编译大型项目的风险,因为即使在系统不能正常使用情况下,被分离的组件也是可以被单独验证其正确性的。
Java有很多工具可以帮助实现隐藏信息。访问控制机制指的是类、接口或者成员的可访问性。这个可访问性指的是被(private、protected和public)中哪一个修饰词修饰。正确的使用这些修饰词是隐藏实现的必要手段。
经验法则很简单:尽量使每一个类和成员不可被访问。换句话说就是在保证你写软件正确的基础上使用最小的可访问等级。
对于顶层(非嵌套)的类和接口,只有两种可访问的等级package-private 和 public。如果你声明一个类或接口是public修饰的,它就是公开的。如果一个顶层的类或接口可以被设置成私有的,那就应该将其设置成私有的。通过将其私有化,你就可以随意的在将来的发行版里面修改、替换甚至删除它,而不用担心会影响到依赖于老版本实现的程序。但是如果你将它设置成public(公开的),那么你就有责任保持它的后续版本一直是可兼容的。
如果一个私有的顶层类或者接口仅仅在一个类中被使用,考虑将这个顶层的类或接口做成private static 私有的静态类内嵌到使用它的类里(Item 24),成为内部类,这样做可以减少了它的在同包中的可访问性。但是减少不必要的公共类的可访问性是更加重要的:因为公共类是API的一部分,而私有顶层类只是API实现的一部分,而API是可以随意被别人调用的。
对于成员(属性、方法、内嵌类和内嵌接口),有四种可以访问的等级,从上往下可访问性依次增加:
当设计完公共API之后,你应该让其他所有的成员都变成私有的。如果当同包中的其他类真的必须访问你的一个私有成员时,你应该将这个私有成员的修饰符从private改成 package-private以供它们调用。但是如果你发现你经常需要这么做,你应该重新审查你的系统设计,看是否可以通过分解类来更好的和其他的类解除耦合。然而,如果一个类实现了Serializable 接口,这些私有的域就会泄漏到API中(Items 86 和 87)。
一个protected类型的成员也是这个类导出的API的一部分,所以并且要永远支持。因为一个受保护的成员是这个类对公众承诺的实现细节(Item19)。但是一般情况下protected类型的成员是比较少见的。
有个约定是,你要尽可能的减少方法的可访问性。但如果一个方法重写父类的方法,它不能比父类有更小的可访问权限,必须保证父类可以被访问的地方子类也可以(传递规则)。如果你不遵守这个规则,在即编译子类的时候编译器就会生成一个错误信息。但是当一个类实现一个接口的时候,这些被实现的方法必须是public的。
你测试代码的时候,为了方便你可能会把你的类、接口或者成员的可访问性变成比他们需要的更大,这是一个可以加快速度的起点。但是把一个公共类的private成员变成package-private尚且可以接受,不可接受的是把它变成其他更高级的访问等级。换句话说就是,不可接受的就是为了方便测试把类、接口和成员由原来的private等级变成包的导出API等级。
类的实例字段最好不要设置成public的(Item16),如果一个public实例字段不是final的或者是一个mutable可变对象,这样你就对这个字段存储的值失去了控制。同时,当这个变量被修改的时候你也没办法做一些特殊动作了,所以类中的可变字段一般都不是线程安全的。即使是一个final和immutable的对象,假如被设置成public,同样也会失去在内部随意更换数据的灵活性。
同样的建议也适用于static字段,但有一个除外。当你通过public static final声明一个常量时,这个字段的命名应该是大写字母加下划线分割(Item68)。而且这些static字段应该包含原始值或对不可变对象的引用(Item17),如果包含了可变对象那就有了像非final字段一样的缺点:虽然不能修改这个final引用,但是却可以修改引用的对象,这具有灾难性的后果。
需要注意的是:一个长度不为零的数组总是可变(mutable)的,所以一个类有一个public static final array 字段或者geter方法(accessor)返回一个这样的字段都是不正确的。如果一个类有这样的字段或者这样的accessor,客户端(调用者)就可能会修改这个array的内容。这也是一个经常发生的安全漏洞:
//潜在的安全漏洞
public static final Thing[] VALUES = {...};
你可以通过将数组私有化private或者添加一个public的不可变list来修复这个潜在的漏洞:
private static final Thing[]PRIVATE_VALUES = {...};
public static final List<Thing>VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
或者你可以使数组私有化然后添加一个public的方法返回对这个私有数组的拷贝:
private static final Thing[]PRIVATE_VALUES = {...};
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
你可以通过思考这些问题来选择到底使用什么方案:调用者更喜欢什么样的结果?哪种返回结果更加便利?哪种会有更好的表现等等。
在Java 9中,模块系统之间添加了两个隐式的访问级别。一个是包组,一堆包是一个模块,就类似于一个包里的类是一组一样。一个模块通常在它的module-info.java文件中通过模块声明导出它的一些包。在这个模块中未被导出的public和protect成员在其他模块中是不可见的,而在本模块中的可访问性不受影响。使用模块系统你就可以在本模块内共享一些类,而对其他模块进行隐藏。而在模块内没有被导出的包中public类的public和protect成员就类似于平常的public和protected访问等级一样。像这种需求一般比较少,而且通常可以通过调整包内类的顺序去除。
不像上面四种主要的访问等级,这两个基于模块的访问等级主要是建议。如果你把模块的JAR包放在了应用程序的类路径而不是模块路径:那么这个包里公共类的public和protected成员不管有没有被模块导出,就都像普通的可访问成员一样具有正常的可访问性了。这种新介绍的访问等级在JDK内部已经被严格的执行了:Java库中没有被导出的包在其他的包里面是不能被访问的。
对于传统的Java程序来说,模块之间提供的这种访问控制不仅仅实用上有限制,而且本质上目前也只是建议这么做。因为为了使用它,你必须分拆你的软件包成模块,然后很清晰的声明模块之间的相互依赖,重新调整你的源码树层级,并且当你的包内非模块化的代码被访问的时候才去合适的行为来适应这种调用。所以现在要求除了JDK之外的模块也实现这种访问控制还为时尚早。所以除非你有特殊需求,目前最好还是避免使用它们。
概括起来就是:在合理的范围内你应该尽可能减少程序的可访问等级。当仔细的设计完最小化公开的API之后,你应该放置任何零散的类、接口或者成员变成API的一部分。公共类除了pubic static final的常量(constant)属性外,不应该有任何其他的公共属性,而且要保证public static final的属性是不可变的。
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.