Effective Java

Riicarus大约 152 分钟JavaSpecificationJavaSpecificationEffective Java

Effective Java

本文中, 英文部分均来自于原文摘抄, 如果想要更好的理解和阅读体验, 请不要忽略英文引用!

Effective Java 这本书作为 Java 编程思想和范式的"集大成者", 需要很深的 Java 编程功底. 我在阅读时也感受到很多没有了解过的东西, 尤其是读英文原著, 感觉一些部分的理解尚且非常浅薄, 尤以 Stream API 的部分为甚.

现在只是第一遍阅读这本书的原版, 只在博客中做粗略的记录和思考. 在读完第一遍之后, 会再次进行巩固和更新.

预期阅读计划:

  1. 英文原版第一遍(current process).
  2. 中文译版对照阅读.
  3. 英文原版第二遍.

对象的创建与销毁

Creating and Destroying Objects.

Item 1: 使用静态工厂方法代替构造方法

Consider static factory methods instead of constructors.

此处的静态工厂方法与设计模式中的静态工厂方法不同, 指的是使用类的静态方法来创建类实例, 而不是使用类构造器.

例:

// static factory method
String hello = String.valueOf("Hello");

// constructor
String hello = new String("hello");

通常, 在使用静态工厂方法时, 需要将类构造方法设置为 private.

静态工厂方法相较于类构造方法, 主要优点在于它具有更高的灵活性:

可以自定义方法名称

One advantage of static factory methods is that, unlike constructors, they have names.

构造方法只能使用类名作为方法名, 但是静态工厂方法可以根据方法的内部逻辑来表现出不同的命名, 如: String.valueOf(), List.of(), Collections.lit(). 可以通过命名来更好地展示(区分)方法的作用, 细分构造出类实例的类型.

不需要创建新对象

A second advantage of static factory methods is that, unlike constructors, they are not required to create a new object each time they're invoked.

构造方法每次返回一个新的类实例, 但是很多时候我们希望一个类是单例的, 或者说类实例可以复用, 来减少创建消耗. 静态工厂方法可以自定义地返回类实例, 对已创建的实例进行复用.

public class Singleton {
    private static final Singleton SINGLETON = new Singleton();

    private Singleton() {}

    public Singleton getInstance() {
            return SINGLETON;
    }
}

可以返回类及其子类的实例

A third advantage of static factory methods is that, unlike constructors, they can return object of any subtype of their return type.

构造方法只能返回对应类的实例, 但是静态工厂方法可以返回类及其子类的实例. 返回类型更加灵活. 如: Collections.list(), 返回的是一个 List 的子类的实例.

可以根据输入参数返回不同的类型

A forth advantage of static factories is that the class of the returned object can vary from call to call as a function of the input parameters.

静态工厂方法可以根据输入参数的不同, 来返回不同类型的实例. 如: EnumSet 根据输入的枚举类的常量的数量, 来返回不同类型的子类.

和 Redis 的底层实现略有类似, Redis hash 会根据存储的数据的类型和数量, 在两种存储数据结构(ziplist/hashtable)中进行选择.

返回的类可以不存在

A fifth advantage of static factories is that the class of the returned object need not exist when the class containing the method is written.

Java 中有很多的情况, 以 Java SPI 和 RPC 最为经典. 比如 JDBC 中, 获取到的数据库驱动(Driver)是由下游厂家实现的, 在 JDBC 中并不存在对应的类.

只有静态工厂方法的类不能被继承

The main limitation of providing only static factory methods is that classes without public or protected constructors cannot be subclassed.

类构造方法被设置为了 private, 子类无法通过 super() 调用父类的构造方法, 导致无法继承.

但是, 这更好的驱动开发者去使用组合替代继承的设计策略, 这是一个良好的设计方法.

静态工厂方法的 API 不易寻找

A second shortcoming of static factory methods is that they are hard for programmers to find.

静态工厂方法不像类构造方法那样有固定的名称, 正式由于其很灵活, 才导致需要使用者主动去寻找其提供的 API.

所以说, 一个良好的项目流程中, 文档的编写和维护一定是非常重要的.

常见的静态工厂方法

String.valueOf();

Date.from();

List.of();

Collections.list();

Array.newInstance();

Item 2: 使用构造器代替构造方法

Consider a builder when faced with many constructor parameters.

当一个类的构造方法中含有过多的参数时, 构造方法就变得很复杂而且难以读写(或者使用很多的 setter 方法). 这时候, 我们堆外提供一个类构造器来选择性地传入参数构造对应的类实例.

构造器通常和静态工厂方法配合使用, 可以获得更好的效果.

The Builder pattern is a good choice when designing classes whose constructors or static factories would have more than a handful of parameters, especially if many of the parameters are optional or of identical type.

例:

public abstract class Pizza {

    public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }

    final Set<Topping> toppings;

    // 这里使用了循环泛型参数, 参见 Item 30
    abstract static class Builder<T extends Builder<T>> {
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

        public T addTopping(Topping topping) {
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();

        // 注意这里, 让子类的构造器可以返回自己, 而不是只能返回父类型.
        protected abstract T self();
    }

    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone();
    }
}

在这里写一个子类:

class MyPizza extends Pizza {

    public enum Size { SMALL, MEDIUM, LARGE }

    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

        // 这里更改了返回的类型为 MyPizza
        @Override
        public MyPizza build() {
            return new MyPizza(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    // 注意这里不暴露类构造方法
    private MyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }
}

Item 3: 强制单例构造方法私有或使用枚举类

Enforce the singleton property with a private constructor or an enum type.

这在 Item 1 中也有简略说明, 即需要限制构造器的使用来保证该类有且只有一个单例. 单例的保证通常由类加载机制(枚举类/饿汉式单例)或者双重检查锁定来实现.

这里以较为复杂的懒汉式单例举例, 为防止因为被反射机制调用而产生多个实例, 可以设置构造方法调用时抛出异常:

public class Singleton {

    private static volatile Singleton SINGLETON;

    private Singleton() {
        if (SINGLETON != null) throw new IllegalInvokeException();
    }

    public Singleton getInstance() {
        if (SINGLETON != null) return SINGLETON;

        synchronized (this) {
            // 双重检查锁定.
            if (SINGLETON == null) SINGLETON = new Singleton();
        }

        return SINGLETON;
    }
}

建议将 SINGLETON 单例设置为 private, 限制通过 getInstance() 方法获取, 以此来掩盖内部实现. 同时, getInstance() 是一个良好的 Supplier<Singleton> 方法, 可以很好的应用于函数式编程工作. 当然, 如果你不需要这两个考虑因素, 那么设置为 public 也是可以的.

Item 4: 强制非实例化类的构造方法私有

Enforce noninstantiability with a private constructor.

通常地, 工具类提供的 API 都是静态方法, 不需要进行任何类实例. 此时, 需要将类地构造方法私有化. 可以在 JDK 中找到很多例子, 如: java.util.Arrays, java.util.Collections.

Item 5: 使用依赖注入替代硬性资源引用

Prefer dependency injection to hardwiring resources.

其实依赖注入(DI)在开发中很常见, 通过 setter/constructor 传入依赖的参数都属于依赖注入. 依赖注入相较于将依赖写死更具有灵活性, 也易于扩展.

同时, 依赖注入的方式很好的适配了函数式编程, 依赖可以通过 Supplier<?> 的方式通过函数来传递.

Spring 中大量使用的 IOC 就是很好的例子(IOC 使用了 DI 的思想, 是 DI 的一种实现方式).

Item 6: 避免创建不必要的实例

Avoid creating unnecessary objects.

内存是宝贵的, GC 带来的性能消耗让人烦恼. 我们更希望去重用已经创建的对象, 来节省堆/方法区的内存使用.

非常经典的, JVM 对 String 进行了非常多的优化, 使得很多的 String 对象在常量池中有一份实例, 我们应该尽可能去使用已经存在的实例, 而不是去新建.

例:

// 这会在常量池保存一份 "hello" 的基础上, 在堆中额外创建一个 String 对象, 消耗额外的内存.
String str = new String("hello"); // DON"T DO THIS

String str = "hello";

在开发中使用越是频繁的实例, 我们就就越应该想办法对其进行重用, 否则会导致很多不必要的性能开销.

这也可以通过使用静态工厂方法的方式来实现.

You can often avoid creating unnecessary objects by using static factory methods(Item 1) in preference to constructors on immutable classes that provide both.

在 Java 中, 还有一些会隐式产生对象的地方--自动装箱. 自动装箱会导致产生很多不必要的对象, 产生很高的性能损耗.

Autoboxing blurs but does not erase the distinction between primitive and boxed primitive types.

如:

private static long sum() {
    Long sum = 0L;
    // 这会产生大约 2e31 个不必要的 Long 对象!!!
    for (long i = 0; i <= Integer.MAX_VALUE; i++) {
        sum += i;
    }

    return sum;
}

如果我们将上述的 Long sum 改为 long sum, 运行时间会从越 6s 降低至约 600 ms(数据来源: Effective Java).

Prefer primitives to boxed primitives, and watch out for unintentional autoboxing.

Item 7: 消除过时的对象引用

Eliminate obsolete object references.

不要觉得 Java 有自动垃圾回收机制, 就可以不用去关注对象的状态. 垃圾收集器并不能覆盖到所有的对象需要被回收的情况, 这时候需要我们手动将对象引用释放, 使得垃圾回收器能够对其进行回收.

非常经典的情况有两个:

  1. ThreadLocal 中弱引用的使用和释放 Entry 的时机.
  2. Stack 栈收缩时释放元素的时机.

ThreadLocalMap 中, Entry<ThreadLocal, V> 是一个弱引用, 便于 Entry 不再使用时, GC 能够自动收集 V 对象. 但是, ThreadLocal 是一个强引用对象, 无法被收集, 所以需要使用 ThreadLocal.remove() 方法, 将 ThreadLocal 对应的 Entry<ThreadLocal, V> 的键/值都设置为 null.

对于 Stack, 我们用一个简单的例子(实现)来看:

public class MyStack {

    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public MyStack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) throw new EmptyStackException();

        return elements[--size];
    }

    private void ensureCapacity() {
        if (elements.length == size) elements = Arrays.copyOf(elements, size << 1 + 1);
    }
}

注意, 我们在 pop() 之后没有处理弹出元素在 elements[] 中的引用, GC 无法收集对应的对象, 可能导致 OutOfMemoryError.

因此可以做如下优化:

public Object pop() {
    if (size == 0) throw new EmptyStackException();

    Object result = elements[--size];
    // 擦除过时引用
    elements[size] = null;
    return result;
}

Nulling out object references should be the exception rather than the norm.

Whenever a class manages its own memory, the programmer should be alert for memory leaks.

可能出现内存泄漏的情况还有:

  1. 缓存. 开发者很容易忘记将缓存中不需要的/失效的对象引用释放. 可以通过使用 WeakHashMap 或者设置过期时间来解决.
  2. 监听器和回调函数. 已注册的监听器和回调函数没有被及时释放引用, 同样可以使用 WeakHashMap 来解决.

WeakHashMap: 其 Entry 维持了指向 Key 的弱引用, 不会阻止 GC 对 Key 的回收.

Item 8: 避免使用 finalize() 等方法

Avoid finalizers and cleaners.

Object#finalize() 方法的执行结果是不确定的(可能会延迟执行, 也可能根本不执行), 这一点在 JavaGC 中讲解的很明确. 因此, 不应该将其作为一个可预期的功能/执行方式来使用, 尤其是对执行时机有要求的任务.

同时, Object#finalize() 方法的实现随虚拟机的不同可能不同, 更加不能保证在不同平台/环境上有让人安心的效果.

Finalizers are unpredictable, often dangerous, and generally unnecessary.

Cleaner is the replacement of finalizer. Cleaners are less dangerous than finalizers, but still unpredictable, slow and generally unnecessary.

Never do anything time-critical in a finalizer or cleaner.

Never depend on a finalizer or cleaner to update persistent state as it may not be executed at all.

但是, 有时候 Object#finalize() 方法是有用的--防止 finalizer 攻击.

如果我们想要在外界调用构造方法时抛出异常, 来阻止对象的创建. 但是如果一个类(通过反序列化等方式)实现了 Object#finalize() 方法, 那么其可能被执行, 然后造成一些预期之外的结果. 对此, 我们可以主动重写一个空的 Object#finalize() 方法, 使其不做任何事.

如果想要一个类/资源在使用结束后能够自动释放, 我们可以使用 try-with-resources, try-finally 或者实现 AutoCloseable 接口.

Item 9: 使用 try-with-resources 替代 try-finally

Prefer try-with-resources to try-finally.

对于实现了 AutoCloseable 接口的资源, 使用 try-with-resources 的方式更好, 它可以自动去释放资源, 而不需要在 finally 语句块中手动关闭.

类共有方法

Methods Common to All Objects.

所有的类都继承自 Object 类, 它也为其子类提供了一系列通用的可重写的方法, 使得子类可以根据自身情况去重写这些方法, 来达到预期的目的. 比如, HashMap 中需要使用的 Object#hashcode()Object#equals() 方法.

下面, 我们将讨论什么时候重写以及如何去重写这些共有方法, 当然, Object#finalize() 方法除外(详情见 Item 8). 我们还会给出一些也很通用的, 但是不属于 Object 类的方法, 如: Comparable#compareTo() 方法.

Item 10: 遵循重写 equals() 方法的共识

Obey the general contract when overriding equals().

当我们遇到以下情况时, 需要重写 equals() 方法:

  1. 任何实例都是互异的.
  2. 该类不需要提供逻辑相等这个特性.
  3. 父类已经重写了 equals() 方法, 并且其行为与当前类相符.
  4. 该类被 private 或者 default 修饰, 并且确定不会使用到 equals() 方法.

在重写 equals() 方法时, 需要注意保证以下几点离散数学中的知识:

  1. 自反性(Reflexive): 如果 x != null, 那么 x.equals(x) == true.
  2. 对称性(Symmetric): 如果 x != null && y != null, x.equals(y) == true, 那么 y.equals(x) == true.
  3. 传递性(Transitive): 如果 x != null && y != null && z != null, x.equals(y) == true && y.equals(z) == true, 那么 x.equals(z) == true.
  4. 持久性(Consistent): 如果 x != null && y != null, x.equals(y) 无论执行多少次, 结果应该一致.
  5. 任何 x != null, x.equals(null) 一定返回 false.

但是这在重写时是很难完全兼顾的. 尤其是传递性, 因为我们可能比较的是父类的不同子类实例, 不同实例需要比较的内容不同, 导致无法做到上面的特性.

There's no way to extend an instantiable class and add a value component while preserving the equals contract, unless you're willing to forgo the benefits of object-oriented abstraction.

还有很重要的一点, equals() 方法中, 不要依赖不可靠对象(经常变动的)进行相等的判断.

Whether or not a class is immutable, Do not write an equals method that depends on unreliable resources.

以下是写好一个 equals() 方法的关键:

  1. 使用 == 来判断两个对象的引用是否相同.
  2. 使用 instanceof 来判断参数类型是否正确.
  3. 将参数转换为正确的类型.
  4. 对每个"标志"属性, 检查是否相等.

Item 11: 总是同时重写 hashCode() 和 equals() 方法

Always(You must) override hashCode() when you override equals().

如果不能重写 hashCode() 方法, 那么必须要保证 equals() 的对象的 hashCode() 一定相同.

The key provision is that is violated when you fail to override hashCode is the second one: equals objects must have equal hash codes.

不要为了提高性能而忽略 hashCode() 中重要属性的计算.

Do not be tempted to exclude significant fields from the hash code computation to improve performance.

Item 12: 总是重写 toString() 方法

Always override toString().

toString() 方法默认返回的是 ClassName@hashcode 的格式, 这并不便于让我们理解一个对象到底有什么内容. 所以推荐所有的类都重写 toString() 方法.

Providing a good toString() implementation makes your class much more pleasant to use and makes systems using the class easier to debug.

在重写 toString() 时, 选择输出哪些属性呢? 答案是输出我们想要的属性即可.

When practical, the toString() method should return all of the interesting information contained in the object.

同时, 我们最好注意以下 toString() 的输出格式, 尽量做到有过一个固定的标准格式, 并且不会引起歧义. 通过这样的方式我们可以很好的增加输出的可读性, 甚至可以通过反序列化的方式从输出恢复一个对象(通常会同时提供静态方法或者工厂方法来实现这一功能). 这一点在值类型的对象中更加重要.

同时, 注意在方法的文档中进行相关的说明.

One important decision you'll have to make when implementing a toString() method is whether to specify the format of the return value in the documentation. It is recommended that you do this for value classes.

那么我们可以使用哪些输出格式呢? 这里举一些例子:

  1. JSON
  2. XML/YAML...(Marking Languages)
  3. 标准化日期格式.
  4. Manually

手动的话, 可以看一下 IDEA 的推荐格式:

public class ToStringClass {

    private Long id;
    private String name;
    private String email;

    @Override
    public String toString() {
        return "ToStringClass{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", email='" + email + '\'' +
                '}';
    }
}

Item 13: 谨慎地重写 clone() 方法

Override clone() judiciously.

Java 的 Cloneable 接口处在一个很尬尴的位置--Cloneable 本身不提供任何的方法, 只是作为一种接口标志. 真正的 clone() 方法是由 Object#clone() 定义的. 因此, 即使一个类实现了 Cloneable 接口, 也不能保证能调用其 clone() 方法, 或者对其 clone() 方法的调用结果进行保证. 原因如下:

// Object 中的 clone() 方法
protected native Object clone() throws CloneNotSupportedException;

如果没有在子类中实现 clone() 方法, 我们无法调用 Object#clone(), 即使通过反射调用成功, 也只会抛出 CloneNotSupportedException.

因此, 当我们需要提供 clone() 的服务时, 需要实现 Cloneable 接口, 同时重写 Object#clone() 方法.

In practice, a class implementing Cloneable is expected to provide a properly functioning public clone() method.

即使是这样, 我们提供的 clone() 方法依然说不上完美, 因为我们无法保证父类一定实现了 clone() 方法.

下面来说一下 clone() 方法的使用规范:

  1. 保证 x.clone() != x;
  2. 保证 x.clone().getClass() == x.getClass().
  3. 通常情况下要求 x.clone().equals(x).
  4. 不可变类禁止提供 clone() 方法.
  5. clone() 出的类的修改尽可能不要影响被 clone() 的类.
  6. 调用 clone() 时, 通常先调用 super.clone().
  7. 公开的 clone() 方法通常不要抛出异常.

我们真的需要如此复杂的又脆弱的 clone() 方法吗, 只有 clone() 方法以及父类的 clone() 方法都写得很好的情况下, clone() 才会良好地发挥作用.

下面提供一种更好的替代方案--使用复制构造器复制工厂方法.

A better approach to object copying is to provide a copy constructor or copy factory.

// Copy constructor
public Yum(Yum yum) {}

// Copy factory
public static Yum newInstance() {}

在以上方法中我们可以完全地掌握复制的所有逻辑, 甚至是返回类型, 并且能够确保执行的结果.

不过, 如果一个类被 final 修饰了, 那么一般来说使用 Cloneable 是没有多大问题的.

对数组的拷贝也更推荐使用 clone() 方法.

As a rule, copy functionality is best provided by constructor or factories. A notable exception to this rule is arrays, which are best copies with the clone() method.

Item 14: 考虑实现 Comparable 接口

Consider implementing Comparable.

Comparable 提供的 compareTo() 方法具有和 Object#equals() 方法类似的作用和性质, 不过多了更大还是更小的情况表达.

其具有的三大性质:

  1. Reflexive
  2. Symmetric
  3. Transitive

Java 中大多数的值类型类都实现了 Comparable 接口.

大多数的集合类也都对 Comparable 接口进行了适配, 默认使用自然序进行排序. 如:

Arrays.sort();
Stream#sort();

不过, 我们对 Comparable 的实现就直接通过 >, <, = 来对各种属性进行比较吗? 答案是不建议这样做, 因为 Java 为我们提供了更好的方法--Comparator.

Use of the relational operators < and > in compareTo() methods is verbose and error-prone and no longer recommended.

Comparator 类以及很多值类型类为我们提供了很多的快捷比较方法, 使用这些方法可以很大程度上减少错误, 增加代码可读性. 如:

public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode);
    if (result == 0) {
        result = Short.compare(prefix, pn.prefix);
        if (result == 0) {
            result = Short.compare(lineNum, pn.lineNum);
        }
    }
    return result;
}
public static final Comparator<PhoneNumber> COMP =
    Comparator.comparingInt((PhoneNumber pn) -> pn.areaCode)
    .thenComparingInt(pn -> pn.prefix)
    .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
    return COMP.compare(this, pn);
}

上面的 Comparator API 每一个方法调用都会组合之前的 lambda 表达式, 并生成一个新的 lambda 表达式, 最终形成一个链式的比较器.

部分源码如下:

int compare(T o1, T o2);

public static <T, U> Comparator<T> comparing(
        Function<? super T, ? extends U> keyExtractor,
        Comparator<? super U> keyComparator)
{
    Objects.requireNonNull(keyExtractor);
    Objects.requireNonNull(keyComparator);
    return (Comparator<T> & Serializable)
        (c1, c2) -> keyComparator.compare(keyExtractor.apply(c1),
                                          keyExtractor.apply(c2));
}

public static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) {
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> Integer.compare(keyExtractor.applyAsInt(c1), keyExtractor.applyAsInt(c2));
}

default Comparator<T> thenComparingInt(ToIntFunction<? super T> keyExtractor) {
    return thenComparing(comparingInt(keyExtractor));
}

default Comparator<T> thenComparing(Comparator<? super T> other) {
    Objects.requireNonNull(other);
    return (Comparator<T> & Serializable) (c1, c2) -> {
        int res = compare(c1, c2);
        return (res != 0) ? res : other.compare(c1, c2);
    };
}

类与接口

Item 15: 最小化类和成员可见性

Minimize the accessibility of classes and members.

一个设计良好的组件对外隐藏了它所有的内部实现, 将其对外暴露的 API 和其内部实现清晰地隔离开. 组件之间都只通过他们提供的 API 进行交互, 对彼此 API 的内部实现没有感知, 也不需要了解内部是如何实现的.

A well-designed component hides all its implementation details, cleanly separating its API from its implementation. Components then communicate only through their APIs and are oblivious to each other's inner workings.

良好的封装带来的是组件间的解耦合, 使得他们能够被独立地开发, 测试, 优化, 使用, 理解和修改.

It decouples the components that comprise s system, allowing them to be developed, tested, optimized, used, understood, and modified in isolation.

Java 的访问修饰符为组件的可见性设置提供了很好的帮助. 通过合理地使用访问修饰符, 可以准确地限制类和成员的可见性, 实现良好的封装.

相较而言, Golang 的可见性设计就更加简单, 只存在可见和不可见两种状态, 虽然不如 Java 功能强大, 但是更容易理解.

在封装组件的过程中, 我们需要遵循一个原则: 尽可能让类或者类成员不访问.

The rule of thumb is simple: make each class or member as inaccessible as possible.

处于更高级别的类或者接口要尽量做到仅包内可见.

If a top-level class or interface can be made package-private, it should be.

如果一个仅包内可见的处于最高级别的类或接口只被某一个类使用, 可以考虑将其设置为使用它的类的内部私有静态类.

If a package-private top-level class or interface is used by only one class, consider making the top-level class a private static nested class of the sole class that use it.

在这里, 我们来回忆以下 Java 的访问修饰符:

  1. private: 仅当前类中可见.
  2. default/package-private: 仅相同包中的类可见.
  3. protected: 仅相同包, 当前类及其子类(可以在不同的包中)可见.
  4. public: 公开可见.

在类的继承中, 访问修饰符的范围只能变大, 不能变小. 因为必须保证满足里氏替换原则(the Liskov substitution principle), 所有子类能使用的地方, 其父类必须也能够使用(多态).

If a method overrides a superclass method, it cannot have a more restrictive access level in the subclass than in the superclass.

当我们在进行测试工作的时候, 可能需要访问一些被 private 修饰的属性或者方法, 因此需要扩展它的访问可见性. 推荐只将其提升到 package-private, 因为可以在同一个包中进行测试.

It is acceptable to make a private member of a public class package-private in order to test it, but it is not acceptable to raise the accessibility any higher.

公共类中的实例字段应该很少是公共的, 尤其是可变的属性或者指向可变对象的引用. 如果将其对外暴露, 我们就无法确保实例字段的值一定在我们的掌控中(可能会被外界调用者改变). 这样之后, 对应的实例字段也一定不能保证线程安全.

Instance fields of public classes should rarely be public.

Classes with public mutable fields are not generally thread-safe.

对于被 public static final 修饰的字段, 是可以对外暴露的, 除了数组或者可修改的集合. 它们的命名应该遵循: 全大写字母+下划线分隔的方式.

对于数组和可修改的集合, 如果想要对外暴露而不想其被外界更改, 我们可以使用不可变集合对其进行包装:

// 注意将数组/集合设置为 private
private static final String[] PRIVATE_VALUES = {...};
public static final List<String> values = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

或者提供一个访问方法也可以:

public static final List<String> values() {
    // return Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
    return PRIVATE_VALUES.clone();
}

Item 16: 在公共类中, 使用访问方法, 而不是公共字段

In public classes, use accessor methods, not public fields.

也就是说, 我们通常推荐使用 setter/getter 方法来提供可公开字段的访问和修改功能, 而不直接将对应字段设置为 public.

If a class is accessible outside its package, provide accessor methods.

通过这样的设置, 我们可以将内部字段的实现和处理隐藏起来, 外界只能通过我们提供的方法来访问和修改, 更加符合面向对象的封装特性. 同时, 我们可以在方法中做更多的事情, 比如说记录/转换...

如果一个类是仅包内可见(package-private)或者私有(private)的,那么就没有必要像上面那样处理.

If a class is package-private or is a private nested class, there is nothing inherently wrong with exposing its data fields.

在 Java 9 之后, 可以使用 record 进行代替, 不过要注意一些兼容性调整, 如果要兼容之前的版本, 那么不推荐使用这种方式.

Item 17: 最小化可变性

Minimize mutability.

不可变类指的是一个类的实例无法被修改. 类实例包含的内容在整个声明周期不可变.

An immutable class is simply a class whose instances cannot be modified. All of the information contained in each instance is fixed for the lifetime of the object, so no changes can ever be observed.

不可变类在设计, 实现和使用上都比可变类更简单, 也更少出错, 更加安全.

Immutable classes are easier to design, implement and use than mutable class.

Java 中有很多不可变类, 比如: String, 基础数据类型的装箱类, BigInteger, BigDecimal 等.

要让一个类不可变, 需要做到以下几点:

  1. 不要提供可以修改对象状态的方法, 如: setter.
  2. 保证类不可被继承, 即用 final 修饰, 或者使用静态工厂同时将所有的构造器设置为 private/package-private.
  3. 让所有的字段都不可变, 即使用 final 修饰所有字段.
  4. 让所有字段私有, 即使用 private 修饰所有字段.
  5. 保证所有指向可变对象的字段都不能被外界访问.

在开发中, 可能一些字段无法被设置为 final, 那么就尽可能使它的可见性降到最低. 同时, 在构造一个不可变类时, 一定要保证构造出的是一个完整的类实例, 因为在构造器外我们不能对任何字段进行修改.

不可变类在线程安全上有天然的优势: 它们天生就是线程安全的, 不需要任何的同步措施.

ps: 不可变类的值不可被修改, 也就是没有并发修改的可能, 对其进行的只能是读操作, 读操作不需要任何并发保证.

Immutable objects are inherently thread-safe; they require no synchronization.

不可变类可以带来更多的好处:

不可变类本身可以被共享, 甚至还可以在不可变类之间共享它们的内部实现. 如: BigInteger 的内部由两部分构成, sign-magnitude, 也就是 符号-数值. 当我们需要一个具有相同值的相反符号的 BigInteger 时, 只需要调用 BigInteger#negate() 方法, 将符号位取反, 数值数组的引用指向当前数值数组即可, 这样实现了数值数组的复用, 减少了空间和时间消耗.

不可变类使得其他类的设计更加简便. 由于其不可变性, 它们在各种键值结构中提供了充分的稳定性, 无论是作为 key 还是作为 value 使用, 使用者都不需要担心它们的值发生改变, 从而影响键值结构的稳定性. 尤其是在各种排序集合中, 如: PriorityQueue, 如果对象在被添加到队列中后, 它的决定排序先后的值发生改变, 那么队列中就不再会具有稳定的排序结果, 导致不可预期的损失.

不可变类永远是原子性的, 无论操作成功还是失败, 其不变性保证它们永远具有原子性, 在设计时不需要考虑由于不可变类带来的原子性损失.

不可变类最大的缺点在于对于每一个不同的值, 都需要一个不同的实例去表征. 这样可能会带来很大的性能开销, 造成一些内存上的问题.

Item 18: 使用组合替代继承

Favor composition over inheritance.

继承是实现代码复用的一种很好的方式, 但是未必是最好的方式. 对继承的滥用可能导致系统变得很不稳定.

在同一个包中使用继承一般问题不大, 因为所有的父类和子类都被同一个开发者掌控与开发. 在对扩展支持很好的类中使用继承也是没有问题的. 但如果跨包对一般设计的类使用继承, 可能会带来一些意想不到的问题.

与方法调用不同, 继承违反了封装性.

Unlike method invocation, inheritance violates encapsulation.

在继承中, 子类需要依赖父类的方法实现, 这使得父类在每次迭代中需要考虑对子类的影响, 否则子类可能无法正常工作(子类不一定清除父类的实现细节). 正常情况下, 应该是父类依赖子类, 而不是子类依赖父类.

我们来看看如何使用组合替代继承:

需求: 我们提供如下的接口, 需要实现如下接口, 并且对统计输入的数量.

/**
 * 可执行接口, 接收类型为 I 的输入, 输出类型为 R 的输出.
 */
public interface Executable<I, R> {

    // 将具体的执行逻辑单独提出来
    R doExecute(I input);

    // 对单个输入执行一次
    default R execute(I input) {
        return doExecute(input);
    }

    // 对所有的输入都执行一次
    default List<R> executeAll(List<I> input) {
        return input.stream().map(this::execute).toList();
    }
}

假定这个接口十分复杂, 我们对该接口的具体实现可能不清楚, 那么使用直接使用继承可能会写出如下的代码:

public class DirectInheritedExecutable<I, R> implements Executable<I, R> {

    private int executedCnt = 0;

    @Override
    public R doExecute(I input) {
        return null;
    }

    @Override
    public R execute(I input) {
        executedCnt++;
        return Executable.super.execute(input);
    }

    @Override
    public List<R> executeAll(List<I> input) {
        executedCnt += input.size();
        return Executable.super.executeAll(input);
    }

    public int getExecutedCnt() {
        return executedCnt;
    }
}

乍一看感觉代码没有问题, 但是如果我们运行如下的代码测试一下:

public static void main(String[] args) {
    DirectInheritedExecutable<String, String> die = new DirectInheritedExecutable<>();
    die.executeAll(List.of("hello", "world", "!"));
    die.execute("Hello");
    System.out.println(die.getExecutedCnt()); // 7
}

会发现输出竟然为 7. 这时候我们再去看源码, 会发现 executeAll() 还会调用 execute() 方法, 导致计数重复.

我们再来看看组合的方式呢?

如何组合? 我们将要继承的父类/接口作为私有变量进行维护, 然后继承/实现父类/接口, 重写方法时, 将所有的调用都转发(Forwarding)到私有变量的方法来执行. 我们称之为转发类. 转发类是用来进行重用的, 这样我们每次在继承的时候都不需要额外组合.

再基于转发类, 我们编写包装类(Wrapper) 继承转发类即可, 也可以称之为装饰器模式.

public class ForwardingExecutable<I, R> implements Executable<I, R> {

    private final Executable<I, R> executable;

    public ForwardingExecutable(Executable<I, R> e) {
        executable = e;
    }

    @Override
    public R doExecute(I input) {
        return executable.doExecute(input);
    }

    @Override
    public R execute(I input) {
        return executable.execute(input);
    }

    @Override
    public List<R> executeAll(List<I> input) {
        return executable.executeAll(input);
    }
}

public class ExeFunction<I, R> extends ForwardingExecutable<I, R> {

    private int executedCnt = 0;

    public ExeFunction(Executable<I, R> e) {
        super(e);
    }

    @Override
    public R doExecute(I input) {
        return super.doExecute(input);
    }

    @Override
    public R execute(I input) {
        executedCnt++;
        return super.execute(input);
    }

    @Override
    public List<R> executeAll(List<I> input) {
        executedCnt += input.size();
        return super.executeAll(input);
    }

    public int getExecutedCnt() {
        return executedCnt;
    }

此时我们再测试, 发现结果就没有问题, 为 4.

public static void main(String[] args) {
    ExeFunction<String, String> ef = new ExeFunction<>(new ForwardingExecutable<>(i -> null));
    ef.executeAll(List.of("hello", "world", "!"));
    ef.execute("Hello");
    System.out.println(ef.getExecutedCnt()); // 4
}

组合实现继承的方式的缺点很少, 主要的缺点在于使用组合对回调函数的支持不是很好. 我们一般通过传递自身引用的方式为外部提供回调, 但是被包装的类并不了解其包装类的自身引用, 因此无法完成传递. 我们称之为 SELF problem.

那么就不用继承了吗? 在子类 A 的确是父类 B 的子类型的情况下, 使用继承是合适的. 如果不是, 那么最好就不要使用继承了, 而是使用组合来替代. 这种情况下更多是 B 希望使用 A 的 API 来完成某些任务, 并对外暴露自己的 API, 而不是暴露 A 的 API.

在 JDK 中就有这样的例子, Stack 没有继承 Vector, Properties 没有继承 HashTable, 而都是通过组合的方式实现的.

Item 19: 要么为继承做好兼容和文档化, 要么禁止继承

Design and document for inheritances or else prohibit it.

上文中我们就讨论了继承的缺点, 下面我们来讨论如何为继承做好兼容和文档化工作.

类方法的文档一定要写清楚继承可能导致的影响. 类必须为它们会被自身调用的可重写方法编写完善的文档. 尤其是被 public 或者 protected 修饰的方法, 必须明确指出该方法可能调用哪些可重写方法, 以什么顺序, 以及每个调用的结果如何影响后续处理.

The class must document its self-use of overridable methods.

在文档的注释中, 我们推荐使用 @implSpec 注释来指明该方法对继承的要求和影响.

为继承设计的类的唯一测试方式就是编写它的子类, 因此在发布该类之前, 一定要在测试中编写子类进行完善的测试.

还有一个要求是: 不要在构造函数中调用可重写方法, 你不知道它们可能对类的构造产生什么样的影响. Object#clone()Serializable#readObject() 这类类似构造器的方法中同样需要禁止.

Constructors must not invoke overridable methods, directly or indirectly.

Neither clone() or readObject() may invoke an overridable method, directly or indirectly.

如果你觉得你的类不需要被继承, 那么请将它设计为不可继承, 防止错误的继承导致不可预计的错误.

有两个方法来实现:

  1. 将该类设置为 final.
  2. 将该类的构造器设置为 privatepackage-private, 并提供静态工厂方法来创建实例.

Item 20: 使用接口替代抽象类

Prefer interfaces to abstract classes.

Java 提供了两种制定类的规范的方式: 接口和抽象类. 在 1.8 之后, 它们都可以很好的定义抽象方法和默认方法. 那么我们在开发过程中如何在两者之间进行选择呢?

Java 是只支持单继承的语言, 这导致一个类最多只能有一个父类. 因此使用抽象类的方式会有很大的局限性. 相反, Java 中一个类可以实现多个接口, 使用接口会更加灵活和可扩展.

现有类可以通过简单的改变来继承新的接口.

Existing classes can easily be retrofitted to implement a new interface.

接口是实现混合器的理想选择. 简单来说, 一个类在自己主类的基础上, 还能够实现的其他类型可以称作 混合器, 用其来提供一些额外的行为(功能). 如: Comparable, Serializable 等, 都可以称为混合器.

Interfaces are ideal for defining mixins.

接口允许构建非分层类型框架.

Interfaces allow for the construction of nonhierarchical type frameworks.

很多事物的抽象并不具有层级关系, 因此很难用类的继承来实现(类的继承表现了一种很强烈的层级关系--从属). 比如: 一名会写歌(SongWriter)的歌唱家(Singer), 这两者的抽象就没有层级关系, 因此不能使用继承实现. 相反的, 使用接口将其作为混合器的额外功能就很符合情况.

public interface Singer {
    AudioClip sing(Song s);
}

public interface SongWriter {
    Song compose(Idea idea);
}

如果我们需要定义一个既会唱歌又会写歌的抽象, 那么可以按以下方式使用接口简单地实现:

public interface SongWriterSinger extends Singer, SongWriter {
    AudioClip sing(Song s);
    Song compose(Idea idea);
}

我们可以通过混合器接口的任意组合, 来添加我们需要的新功能, 就像搭建积木一样. 即:

接口通过类似包装器的方式提供安全而强大的功能增强.

Interfaces enable safe, powerful functionality enhancements via the wrapper class idiom.

不过, 接口也有一些缺点:

  1. 有一些方法我们无法提供默认实现, 如: equals(), hashCode().
  2. 不能定义静态变量之外的变量.
  3. 无法为其他接口额外添加默认方法.

但是呢, 我们可以同时使用接口和抽象类来定义一个类的"骨架"来弥补这些缺点. 我们使用接口去定义类型主要方法, 也包括一些默认方法; 然后使用抽象类去定义一些属性非主要方法, 以此构成"骨架".

这种方式我们也称之为模板方法模式.

以这种方式实现的"骨架"的命名一般为: Abstract+InterfaceName. 如: AbstractCollection, AbstractMap 等. 表示实现了目标接口的抽象类.

需要注意, 此时的接口不再是我们之前所说的混合器, 而是占主导地位的接口.

由于骨架涉及到很多的继承, 在编写"骨架"时, 务必做好文档化工作.

总结一下就是:

  1. 能用接口尽量用接口.
  2. 不能用接口尽量使用接口+抽象类的混合"骨架".
  3. 把接口看作一种动作, 可以作为抽象的主导, 也可以作为混合器进行功能增强.

Item 21: 为子类设计接口

Design interfaces for posterity.

在设计接口时, 我们需要考虑到对子类的兼容情况, 因为我们可能会提供一些具有默认实现的模板方法. 比如: 我们在更新一个抽象为集合的接口时, 提供了一个非线程安全的删除元素的方法 removeIf(Predicate<?> pre), 当 pre 的返回值为 true 时删除对应的元素. 但如果某一个子类需要保证线程安全, 并且在这个接口更新之前就提供了完整的 API. 当我们更新接口后, 该子类如果调用 removeIf() 方法, 就可能导致线程安全问题.

所以, 当我们在设计/更新一个接口时, 一定要先考虑一下未来的子类可能会出现的情况, 尽量设计地更完善.

While it may be possible to correct some interface flaws after an interface is released, you cannot count on it.

Item 22: 只用接口来定义类型

Use interfaces only to define types.

我们使用接口是用它来定义一种类型, 对应实现类的使用者可以通过接口获知该类能够做哪些事情. 接口只应该用于这一个目的.

有些开发者在接口中只定义常量, 而没有任何方法. 我们称这种接口为常量接口(Constant Interface). 这种方法是很不推荐的, 因为大多数人看到接口都会认为它可能具有某种动作, 而常量接口只维护了一些常量.

如果需要定义常量, 我们推荐使用一个单独的类进行定义, 而不是使用接口或者抽象类.

Item 23: 使用类的层级结构代替类标签

Prefer class hierarchies to tagged class.

有时候, 一个类的实例的同一属性可能有多种不同的属性值, 这些属性值通过一个标签属性维护, 以此来区分不同类型的实例. 如:

class Figure {
    enum Shape { RECTANGLE, CIRCLE};

    final Shape shape;

    // ...
}

这种定义的方式具有很多的弊端, 它们的可读性和可扩展性都很差, 并且, 由于需要维护额外的 tag 标签, 也会带来性能上的损失.

Tagged classes are verbose, error-prone, and inefficient.

这种情况下, 我们更应该使用类的继承, 将不同 tag 的类抽象为父类的不同子类.

A tagged class is just a pallid imitation of a class hierarchy.

我们可以做如下更改:

class Figure {
    // ...
}

class Rectangle extends Figure {
    // ...
}

class Circle extends Figure {
    // ...
}

并且, 我们可以在复用父类提供的方法的基础上, 对不同的子类进行不同的特殊处理.

Item 24: 尽可能使用静态成员类

Favor static member classes to nonstatic.

很多时候我们会在类中定义一些嵌套类, 这些内部类只会用来服务于他们的封装类.

常见的嵌套类有如下几种:

  1. 静态成员类(static member classes).
  2. 非静态成员类(nonstatic member classes).
  3. 匿名类(anonymous classes).
  4. 局部类(local classes).

除了第一种以外, 其他的都称之为内部类.

下面我们来看看这些嵌套类都用在什么情境下.

静态成员类

静态成员类是最简单的嵌套类, 它可以被看作是一个恰巧被声明在了内部的普通类, 具有对封装类中所有成员的访问权限, 即使是私有变量.

静态成员类的一大用处是作为公共辅助类, 用于和其封闭类进行合作. 如: 在 Calculator 类中, 定义了一个枚举静态成员类来标志各种运算符: Calculator.Operation.PLUS, Calculator.Operation.MINUS, .etc.

私有静态成员类主要用于定义封闭类中的一些组件. 如: Map 中的键值对结构, 使用 Entry 这个私有的静态成员类来表示.

非静态成员类

在语言层面上, 非静态成员类和静态成员类的主要区别就在于静态成员类可以访问它们定义域中的静态变量, 而非静态成员类不行.

在语言层面之外, 就有很多的不同了. 每个非静态成员类的实例都是其封闭类的封闭实例. 在非静态成员类的方法中, 可以通过使用 this 的方式来持有对其封闭类的引用.

如果一个嵌套类的类实例能够不依赖于其封闭类的实例而存在, 那么这个嵌套类应该被定义为静态成员类.

If an instance of a nested class can exist in isolation class: it is impossible to create an instance of a nonstatic member class without an enclosing instance.

非静态成员类和其封闭类的关系建立发生在成员类实例被创建时, 并且在这之后无法被更改. 一般来说, 这种关系的建立会发生在在封闭类实例调用一个非静态成员类的构造方法时, 当然, 我们也可以通过封闭类手动调用非静态成员类的构造方法: enclosingInstance.new MemberClass(args).

非静态成员类会在封闭类实例中占用更多的空间, 带来性能消耗.

非静态成员类的一大用处就是提供封闭类的适配器(Adaptor), 使得封闭类实例可以被看做某些不相关的类.

如: Map 接口中使用了非静态成员类 Iterator 来实现他们为 Collections 接口提供的方法.

如果你定义的成员类不需要获取封闭类的实例, 那么将其设置为静态的.

If you declare a member class that does not require access to an enclosing instance, always put the static modifier in its declaration, making it a static rather than a nonstatic class.

匿名内部类

匿名内部类没有名称, 不是其封闭类的成员.

匿名内部类并不和其他成员一同声明, 而是在使用时被声明和实例化.

Rather than being declared with other members, it is simultaneously declared and instantiated at the point of use.

只有在非静态的上下文中, 匿名内部类才能持有其封闭类的实例引用. 但是即使他们处于静态上下文中, 也没有对其他静态成员(除常量外)的访问权限.

在 Lambda 表达式被加入前, 匿名内部类通常用于创建一些运行时的小型的函数对象(function objects)和处理对象(process objects). 在 Lambda 之后, 匿名内部类被广泛用于 Lambda 表达式的定义和函数式编程中. 另外, 匿名内部类也被用于静态工厂方法中(应该也属于函数式编程的一部分).

局部内部类

局部内部类使用很少.

局部内部类可以被定义在任何局部变量能够能被定义的地方, 并且遵循同样的作用域范围. 局部内部类具有名称, 可以被重复使用, 也持有其封闭类的实例的引用(在非静态上下文中), 但是无法持有静态变量. 局部内部类最好和匿名内部类一样简洁精炼, 以提高可读性.

当一个类只需要存在于一个方法中, 并且已经有类型定义了这个类的行为, 那么就将其作为匿名内部类使用; 否则, 将其作为局部内部类使用.

Item 25: 一个源文件中只应该有一个主类

Limit source files to a single top-level class.

虽然 Java 编译器允许我们在一个源文件中定义多个主类(top-level class, 被 public 修饰的 class), 但是我们非常不建议这样去做, 因为这会带来很多问题: 我们可能会在不同的源文件中定义同名的类, 这时候加载哪一个类就要看编译器了, 这样就会带来很大的不确定性.

泛型

Generics

Item 26: 不要使用原始类型

Do not use raw types.

先介绍一下泛型类和接口的的定义: 声明具有一个或者多个参数类型的类或者接口.

A class or interface whose declaration has one or more type parameters is a generic class or interface.

每个泛型类型都定义了一组"参数化类型", 其中包含类或接口名称, 后跟与泛型类型的形式类型参数相对应的尖括号中的"实际类型参数"列表.

Each generic type defines a set of parameterized types, which consist of the class or interface name followed by an angle-bracketed list of actual type parameters corresponding to the generic type's formal type parameters.

每个泛型类型都定义了一个原始类型, 即不带有任何类型参数的泛型类型的名称. 如: List<String> 中, List 就是原始类型. 它们的存在主要是为了兼容没有泛型时的代码.

Each generic type defines a raw type, which is the name of the generic type used without any accompanying type parameters.

泛型相较于原始类型是类型安全的, 它可以保证参数一定是能够被使用的类型, 同时在类型转换时不会出现异常.

这里展示一个使用原始类型导致的错误:

private final Collection stamps = ...;

//Note that Coin is not a subtype of Stamp
stamps.add(new Coin(...));

// Raw type leads raw iterator type
for (Iterator i = stamp.iterator(); i.hasNext(); ) {
    Stamp stamp = (Stamp) i.next(); // Throws ClassCastException
    // ...
}

如果我们使用泛型接口 Collection<Stamp>, 那么在执行到添加语句 stamp.add(new Coin(...)) 时, 编译器就会报错, 将运行时错误转移到编译器, 保证程序运行时的安全.

当我们想要在 List<E> 中存放任意类型的变量时, 也不要使用原始类型 List, 而是使用 List<Object> 的方式. 因为 List<Object> 是泛型支持的, 可以有安全性保证. 如: List<String>List 的子类型, 但不是 List<Object> 的子类型, 因此, 泛型为 List<String> 元素无法放入泛型为 List<Object> 的集合中.

我们使用一个更形象的例子, 如:

List<List<Number>> objListsList = new ArrayList<>();
objListsList.add(new ArrayList<Integer>()); // wrong, can not cast

List<List<? extends Number>> numListsList = new ArrayList<>();
numListsList.add(new ArrayList<Integer>()); // right

当我们不知道集合中的泛型是什么的时候, 可以使用通配符 <?> 表示任意的. 更高级的, 可以使用 ? extends A, 表示 A 及其子类的泛型.

如:

static int numElementsInCommon(Set<?> s1, Set<?> s2) {
    int res = 0;
    for (Object o1 : s1) {
        if (s2.contains(o1)) res++;
    }
    
    return res;
}

但是, 除了 null 外, 不能将任何元素放入 Collection<?>.

You can't put any element(other then null) into a Collection<?>.

在类字面量中, 只能使用原始类型.

如: List.class, String[].class, int.class, 而 List<String>.class, List<?>.class 是错误的.

You must use raw types in class literals.

由于泛型信息会在运行时擦除, 因此对除 <?> 之外的泛型类型使用 instanceOf 是非法的. 使用 <?> 代替原始类型不会影响任何 instanceOf 的行为.

我们通常利用这个特性来限制原始类型的使用, 也是最推荐的的在 instanceOf 中使用泛型的方式:

if (o instanceOf Set) {
    Set<?> s = (Set<?>) o; //cast raw type to wildcard type
    ...
}

Item 27: 排除未检查警告

Eliminate unchecked warnings.

Java 开发中可能会遇到很多的未检查警告, 如: unchecked cast warnings, unchecked method warnings, unchecked method invocation warnings ... 越合理地使用泛型, 就能越好地避免此类警告.

很多未检查警告是很好避免的, 比如: Set<String> s = new HashSet(); 会抛出 unchecked conversion 警告, 此时我们只需要将其改为: Set<String> s = new HashSet<>(); 即可.

如果有些警告无法排除, 但是我们可以肯定代码不会出现任何问题, 此时(也只有此时)应该使用 @SuppressWarnings("unchecked") 注解来消除警告.

If you can't eliminate a warning, but you can prove that the code that provoked the warning is typesafe, then (and only then) suppress the warning with an @SuppressWarnings("unchecked") annotation.

@SuppressWarnings 注解可以用在任何声明处, 从单个的变量的声明, 到整个类都可以使用. 不过, 我们要求将 @SuppressWarnings 的作用范围限制到最小. 同时, 永远不要在整个类上使用, 这会隐藏很多严重问题.

Always use the @SuppressWarnings annotation on the smallest scope possible.

每次使用 @SuppressWarnings("unchecked") 时, 都要通过注释写明为什么这样做是安全的.

Every time you use a @SuppressWarnings("unchecked") annotation, add a comment saying why it is safe to do so.

Item 28: 使用 List 替代 Array

Prefer lists to arrays.

数组和泛型类型(这里指泛型类集合)有两大差异.

数组是协变的, 泛型类型是不变的

Arrays are covariant, while generics are invariant.

协变: 如果 SubSuper 的子类, 那么数组 Sub[] 是数组 Super[] 的子类.

不变: 任何两个不同的 Type1, Type2, List<Type1>List<Type2> 没有任何继承关系.

协变导致数组在开发上效率更低.

如:

Object[] objArray = new Long[1];
objArray[0] = "I don't fit in"; // Throws ArrayStoreException, runtime

List<Object> ol = new ArrayList<Long>(); // Incompatible types, compile time
ol.add("I don't fit in");

数组是具体化的

Arrays are reified.

具体化导致数组在运行时必须直到它们的元素类型. 而泛型在运行时会有类型擦除(erase), 只在编译期限制元素类型, 在运行时擦除类型. 类型擦除使得泛型可以随意与不使用泛型的合法代码合作.

上面两点差异导致数组和泛型的兼容性很差. 如: 我们不能使用泛型去创建任何数组.

因此, 我们建议, 在数组和泛型集合中, 优先使用泛型集合.

Item 29: 使用泛型类

Favor generic types.

相较于使用 Object 之类的很宽泛的类去限制元素类型, 我们更推荐使用泛型.

在之前(Item 7), 我们自己设计了一个 MyStack, 我们用泛型来进行重构:

public class GenericStack<E> {

    private E[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    // This elements array will contain only E instances from push(E).
    // This is sufficient to ensure type safety,
    // but the runtime type of the array won't be E[];
    // it will always be Object[], as a result of type erase.
    @SuppressWarnings("unchecked")
    public GenericStack() {
        elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(E e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public E pop() {
        if (size == 0) throw new EmptyStackException();

        E result = elements[--size];

        elements[size] = null;

        return result;
    }

    private void ensureCapacity() {
        if (elements.length == size) elements = Arrays.copyOf(elements, size << 1 + 1);
    }

}

在使用时, 如果想让我们的 GenericStack 存储 Number 及其子类型, 可以使用如下声明:

GenericStack<? extends Number> numStack = new Stack<>();

Item 30: 优先使用泛型方法

Favor generic methods.

Java 中泛型也能够使用泛型. 静态工具方法通常都是基于泛型的, 各种的"算法"方法都是基于泛型的, 如: Collections.sort().

方法中使用泛型和使用泛型类类似.

比如我们想要将两个集合合并, 使用泛型如下:

public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
    Set<E> res = new HashSet<>(s1);
    res.addAll(s2);
    return res;
}

在静态方法中, 我们需要声明方法是泛型的, 即 public static <E> .... 因为静态方法无法从类定义中获取到泛型信息(泛型信息对于每个类实例可能是不同的, 因此在静态上下文中无法获取).

泛型的定义在方法的修饰符和返回值之间.

The type parameter list, which declares the type parameters, goes between a method's modifiers and its return type.

由于泛型在运行时会进行擦除, 也就是在运行时是无状态的, 我们只需要编写一次泛型方法来兼容各种类型的元素, 极大地减少了工作量.

有时候, 一个类型参数可能会被绑定到另一个包含自己的表达式中, 我们称之为循环类型绑定. 如:

public interface Comparable<T> {
    int compareTo(T o);
}

public static <E extends Comparable<E>> E max(Collection<E> c);

上面代码中的 <E extends Comparable<E>> 读作 any type E that can be compared to itself.

Item 31: 使用有界通配符来提高 API 的灵活性

Use bounded wildcards to increase API flexibility.

Item 28 中, 我们说过泛型是不变的(invariant), 也就意味着泛型参数不同的泛型类是不同的, 即使它们的泛型参数具有继承关系, 泛型类本身也不会具有任何的继承关系.

但是我们有时候在 API 中需要更加良好的灵活性, 而不变式(invariant)不能够提供. 比如在 Item 29 中的 GenericStack 类, 我们抽离以下它的 API:

public class GenericStack<E> {
    public Stack();
    public void push(E e);
    public E pop();
    public boolean isEmpty();
}

假定我们想要添加一个输入为一系列元素, 并且将他们都添加到 stack 中的 API, 我们可能会这样写:

public void pushAll(Iterable<E> src) {
    for (E e : src) push(e);
}

这个代码看上去可以良好的运行, 但是如果我们定义了 GenericStack<Number>, 而我们想要将一系列 Integer 类型的值加入其中, 就会发现这个 API 会报错了. 因为虽然 IntegerNumber 的子类, 但是 Iterable<Integer> 不是 Integer<Number> 的子类.

此时, 我们可以使用有界通配符(bounded wildcard, <? extends/super Type>) 来接收更大范围的参数.

public void pushAll(Iterable<? extends E> src) {
    for (E e : src) push(e);
}

我们在加入以下 popAll() 方法:

public void popAll(Collection<E> dst) {
    while (!isEmpty()) dst.add(pop());
}

同样的, popAll(Collection<E>) 方法只接受泛型参数与当前泛型类泛型参数相同的集合, 而不支持任何泛型参数 E 的父类的方法, 我们使用有界通配符进行优化:

public void popAll(Collection<? super E> dst) {
    while (!isEmpty()) dst.add(pop());
}

为了最大程度的灵活性, 在作用为生产者或者消费者的参数中使用带有的通配符泛型参数类型.

For maximum flexibility, use wildcard types on input parameters that represent producers or consumers.

这里提供一个简单的口诀:

PECS: producer-extends, consumer-super.

比如, 在 Item 30 中, 有如下的方法声明:

public static <E> Set<E> union(Set<E> s1, Set<E> s2);

我们发现 s1, s2 都作为生产者参数传入, 需要使用通配符来扩展.

public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2);

注意, 这里的返回值类型还是 Set<E>, 而不是 Set<? super E>.

永远不要在返回值类型中使用通配符.

同样的在 max() 方法中, 使用通配符进行优化:

public static <T extends Comparable<T>> T max(List<T> list);

// 使用两次 PECS:
public static <T extends Comparable<? super T>> T max(List<? extends T> list);

Comparables are always consumers, you should generally use Comparable<? super T> in preference to Comparable<T>. The same is to Comparator<? super T>.

为什么在 Comparable 中使用 <? super T>?

<T extends Comparable<? super T>> 表示可以使用任何超类的比较器, 这样可以使得所有的子类去使用父类的比较器, 或者所有的子类都使用相同的比较器. 如果只是用 T extends Comparable<T> 的话, 就只能使用当前类的比较器, 很不灵活.

当静态方法中只有一个泛型参数时, 有两种写法:

public static <E> void swap(List<E> list, int i, int j);

public static void swap(List<?> list, int i, int j);

这两种写法都是可行的. 我们推荐使用第二种.

如果一个泛型参数只在方法声明中只出现以此, 那么将它替换为通配符.

If a type parameter appears only once in a method declaration, replace it with a wildcard.

但是第二种方式也可能出现一些问题, 如:

public static void swap(List<?> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));
}

此时编译器会报错, 因为我们无法将任何非空的值放入 List<?> 中. 那么怎么解决呢?

使用一个私有的方法去捕获泛型参数.

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}

private static <E> void swapHelper(List<E> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));
}

Item 32: 明智地组合泛型和可变参数

Combine generics and varargs judiciously.

我们首先要明确什么是可变参数, 这里举一个例子:

public static <E> List<E> of(E... elements) {
    ...
}

可变参数, 可以成为变长参数, 是 Java 提供的一种语法糖, 只能在参数列表末尾声明, 用于接收任意长度的参数, 使用 Type... argName 表示.

编译器会将可变参数使用一个数组接收, 使用时, argName 就是一个包含所有输入参数的数组, 即: Type[] argName.

到这里, 我们就已经可以明白为什么不推荐泛型和可变参数进行组合了. 泛型在运行期会进行类型擦除, 包含的信息很少, 而数组在运行期会掌握所有元素的类型, 它们之间很可能导致各种转换错误, 以 ClassCastException 为典型.

当参数化类型的变量引用不属于该类型的对象时,就会发生堆污染(heap pollution).

Heap pollution occurs when a variable of a parameterized type refers to an object that is not of that type.

如:

static void dangerous(List<String>... stringLists) {
    // List<String>[] stringLists;
    List<Integer> intList = List.of(42);
    Object[] objects = stringLists;
    objects[0] = intList;               // Heap pollution
    String s = stringLists[0].get(0);   // ClassCastException
}

堆污染可能会导致由编译器自动生成的类型转换失败, 导致泛型编程的基础被破坏.

向泛型可变参数数组中写入数据是不安全的. 上面的例子中, 我们向泛型可变参数数组中写入了一个与其泛型类型不同的值, 导致它的泛型系统被破坏, 再从数组中取出元素可能就会发生 ClassCastException.

但是我们又会有疑惑, 既然说泛型和可变参数组合是不安全的, 那么为什么 JDK 中的很多 API 还在这样使用呢? 就像我们看到的 public static <E> List<E> List.of(E... elements) 一样. 因为和上面的 dangerous() 方法不同, JDK 中的方法是类型安全的.

再 JDK 7 之后, 提供了一个用于表示使用了泛型可变参数的方法是泛型安全的注解--@SafeVarargs. @SaveVarargs 表示由 API 作者保证的该方法是类型安全的.

当我们在 IDEA 中编写泛型可变参数方法时, IDEA 会提示我们需要保证泛型安全, 防止堆污染, 在确认后, 使用 @SafeVarargs 来消除这一个警告.

只有当我们确认方法是类型安全的时候, 才能够使用 @SafeVarargs 注解!

那么我们如何保证泛型可变参数方法是安全的呢?

  1. 在方法中不向泛型可变参数数组中写入任何数据.
  2. 不允许泛型可变参数数组的引用从方法中逃逸.

当方法满足以上条件时, 它就是类型安全的.

如果不想使用 @SafeVarargs 这种依赖作者进行安全性检查的注解, 还有一种方法--Item 28: 使用 List 替代 Array.

当使用 List 替代数组之后, 就可以使用 JDK 提供的一些安全的方法来传递参数了, 如: List.of(E... elements), Set.of(E... elements), 相当于我们把可变参数的接收和封装交给了 JDK 的官方包, 而不是第三方开发者或者自己提供的不能确保一定安全的接收方法.

总结-32

  1. 要使用泛型可变参数方法, 就一定要保证方法的类型安全性--对泛型可变参数数组做到: 不写入, 不传出.

  2. 要么就不使用泛型可变参数方法, 使用 List 替代数组, 使用 JDK 中的 API 来处理可变参数.

Item 33: 使用各种类型安全的容器

Consider typesafe heterogeneous containers.

毫无疑问的, JDK 中支持泛型的容器都是类型安全的, 我们平时也会用到很多. 如果它们能够支持我们所需的功能, 就尽量使用它们而不是自己去写一个新的.

那么如果有些官方包无法实现的功能, 一定需要自己来进行编写或者封装呢? 那就需要我们来保证容器的类型安全了.

我们用一个需求来举例讲解:

我们对每一个类都一个最喜欢的实例, 使用 Favorites 来表示, 需要能够向其中存放或者取出对应的最喜欢的实例.

如何来设计 API 呢? 对于这种需要兼容很多类的容器, 我们自然地想要使用泛型来进行设计. 那么用泛型来限制什么呢? 是限制容器吗?

对于容器中不同的类型实例, 我们选择使用泛型来限制 key 而不是 container.

如果限制 container, 那么容器中的所有实例都是同一个泛型类型, 限制 key 的话, 就可以使得容器中有多种类型的实例, 但是 keyvalue 的泛型类型都是相同的.

public class Favorites {

    private final Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void put(Class<T> type, T instance) {
        favorites.put(Objects.requireNonNull(type), instance);
    }

    public <T> Optional<T> get(Class<T> type) {
        // dynamically cast using type.cast(...)
        return Optional.ofNullable(type.cast(favorites.get(type))); 
    }
}
// JDK source code
public class Class<T> {
    T cast(Object obj);
}

这里我们使用了 Class<T> 来使用泛型对 key 进行限制, Class<T>T.class. 这样, 我们的容器就是类型安全的了.

为什么是安全的呢? 在 put() 方法中, 一定是安全的, 它由 Class<T> type, T instance 约束; 在 favorites 字段中, 它丢失了 keyvalue 的类型约束, 所以 value 的类型只能使用 Object 进行约束, 因为我们无从得知 value 的类型; 但是在 get() 方法中, 我们又通过 Class<T> type 进行了类型约束, 使得取出的值一定是对应的类型, 不会出现类型安全问题.

如果一个类字面量同时在编译期和运行期都在方法间用于通信被传递时, 就称之为 类字面量.

但是上面的代码还有一些问题, 因为我们不能保证 API 的使用者一定会传递具有泛型的参数, 使用者可以传递一个原始类型的 Class 对象, 这时候 put() 方法在泛型上就无法确定 value 的类型了. 那么如何优化呢?--使用动态类型转换(dynamically cast)--type.cast(...).

public <T> void put(Class<T> type, T instance) {
    favorites.put(Objects.requireNonNull(type), type.cast(instance));
}

还有一个缺陷就是我们无法将非具体化类型(或者说泛化类型, reified)存入我们的容器中(Item 28). 我们能够存储各种具体化类型, 比如 String, String[]; 但是无法存储泛化类型, 比如 List<String>, Set<Integer> 之类的数据.

像我们在 put(Class<T> type, T instance) 中的用法一样, 使用类型标记(type token)作为参数类型的限制参数, 这中类型标记称为有界类型标记(bounded type token).

总结-33

尽量使用类型安全的容器.

可以通过有界类型标记(bounded type token)的方式, 来限制容器中的 key, 而不是容器本身, 使得容器能够容纳多种不同类型的键值, 同时保证其类型安全性.

枚举和注解

Enums and Annotations

Item 34: 使用枚举而不是 int 常量

Use enums instead of int constants.

枚举类(enumerated type)是一种能够包含固定的一系列常量的类型, 如: 一年的四个季节, 太阳系的各大行星等.

在枚举类出现前, 开发者一般使用有名 int 常量(named int constants)来表达枚举的意义.

public class Seasons {
    public static final int SPRING = 0;
    public static final int SUMMER = 1;
    public static final int AUTUMN = 2;
    public static final int WINTER = 3;
}

使用有名 int 常量的方式使得程序很脆弱. 这主要是由于 Java 平台的原因: 由于 int 常量会在编译期进行优化, 在程序中使用对应的值去替换其名称引用, 这样就相当于我们对代码进行了硬编码, 每一次修改常量的值都需要重新进行编译, 否则程序会运行失败.

同时, 我们很难将 int 常量转换为可打印字符串, 这导致我们在 debug 或者进行日志记录时, 无法良好的展示枚举的意义.

再者, int 常量来代替的枚举类自身没有提供任何的可遍历方法或者获取枚举值集合大小的方法.

那使用 String 常量来代替呢? 那就更不推荐了, 虽然我能够通过直接打印的方式获取枚举值对应的意义, 但是这样可能导致有些开发者不重视属性名称的规范. 并且, String 类的比较消耗高得多, 会带来很多性能上的问题.

Java 提供了功能完备的枚举类, 我们来看一看.

public enum Seasons {
    SPRING, SUMMER, AUTUMN, WINTER
}

Java 中的枚举类实例是实实在在的类实例, 而不是某些语言中的 int 常量的封装(如: C, C++, C#).

Java 的枚举类在底层通过 public static final 的字段的方式暴露枚举实例, 每个枚举实例都是单例的, 外部无法创建任何的枚举类实例. 枚举类的单例创建发生在类加载阶段, 通过类加载器的方式保证加载是线程安全的, 因此有时也使用枚举类作为饿汉式的单例实现.

枚举类保证了编译期类型安全. 如果传递枚举类型的参数, 那么编译器能够保证参数一定是枚举类型的实例, 如果不是, 那么编译器会报错. 同时, 我们也可以使用 == 的方式来对枚举类进行比较.

枚举类型不会被编译器优化为硬编码代码, 因此对枚举类实例定义顺序的更改或者增删实例不会导致不可预计的问题.

枚举类还可以通过重写 toString() 方法来便捷地定义打印格式.

为了提高枚举类的效率, Java 允许枚举类添加自己的方法和字段, 或者实现必要的接口. 枚举类自身已经实现了 ComparableSerializable 接口.

公共方法:

public enum Operation {
    
    PLUS, MINUS, TIMES, DIVIDE;
    
    public double apply(double x, double y) {
        return switch (this) {
            case PLUS -> x + y;
            case MINUS -> x - y;
            case TIMES -> x * y;
            case DIVIDE -> x / y;
        };
    }
}

除了在枚举类中为所有的实例都提供相同逻辑的公共方法, 我们还可以通过设置抽象方法的方式, 使不同的实例可以具有不同的方法实现, 我们可以将以上的方法改写为:

抽象方法:

public enum Operation {

    PLUS {
        public double apply(double x, double y) {
            return x + y;
        }
    },
    MINUS {
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES {
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE {
        public double apply(double x, double y) {
            return x / y;
        }
    };

    public abstract double apply(double x, double y);

}

我们发现没有将枚举实例和对应的算符符号联系起来, 并且在 API 的提供和抽象上还不够完善, 因此我们做出如下的优化:

public interface Operation {
    double apply(double x, double y);
}
public enum BaseOperation implements Operation {

    PLUS("+") {
        @Override
        public double apply(double x, double y) {return x + y;}
    },
    MINUS("-") {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double x, double y) {
            return x / y;
        }
    };

    private final String symbol;

    BaseOperation(String symbol) {
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }
}

当我们需要添加新的算符的时候, 就很方便扩展了:

public enum ExtendedOperation implements Operation {
    
    EXP("^") {
        @Override
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
    };
    
    private final String symbol;
    
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
    
    @Override
    public String toString() {
        return symbol;
    }
}

我们再来考虑一个问题: 泛型参数如何和枚举类相结合呢?

上面的示例中, 我们希望在能够使用 BaseOperation 枚举类型的地方, 我们也能够使用 ExtendedOperation 枚举类型(注意, 是只能使用枚举类型); 同时, 还能够不局限于传递单一枚举实例, 而是可以传递一个枚举类的所有实例. 这时候就需要使用泛型了:

public class GenericTest {

    public static void main(String[] args) {
        double x = 1.2;
        double y = 2.5;
        test(BaseOperation.class, x, y);
        test(ExtendedOperation.class, x, y);
    }

    private static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y) {
        for (Operation op : opEnumType.getEnumConstants()) {
            System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
        }
    }

    private static <T extends Enum<T> & Operation> void test(Collection<? extends T> opSet, double x, double y) {
        for (Operation op : opSet) {
            System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
        }
    }
}

我们在这同样能在这里看到有界类型标记(bounded type token)(Item 33)的使用. <T extends Enum<T> & Operation> 保证 T 是枚举类型, 并且实现了 Operation 接口.

Enum<T>Class<T> 类似, Enum 是对 Class 的封装. Enum<T>#getDeclaringClass()Class<T> 的实例.

我们再来看一下在枚举类中使用枚举类的示例:

public enum PayrollDay {

    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
    
    private final PayType payType;

    PayrollDay() {
        // default
        this(PayType.WEEKDAY);
    }
    
    PayrollDay(PayType payType) {
        this.payType = payType;
    }
    
    int pay(int minsWorked, int payRate) {
        return payType.pay(minsWorked, payRate);
    }

    private enum PayType {
        WEEKDAY {
            @Override
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            @Override
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };
        
        abstract int overtimePay(int minsWorked, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;
        
        int pay(int minsWorked, int payRate) {
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }
}

枚举类的常量特性在 switch 语句中也有很好的发挥空间, 这里不再赘述.

总结-34

枚举类型就是 Java 专门提供给开发者对各种常量枚举的实现, 它作为一个类又提供了很好的灵活性, 因此尽量使用枚举类型.

Item 35: 使用实例字段而不是序数

Use instance fields instead of ordinals.

枚举实例中包含自身在枚举集合中的位置, 是一个 int 类型的值, 使用 ordinal() 方法获取.

public enum Ensemble {
    SOLO, DUET, TRIO, QUARTET, QUINTET, 
    SEXTET, SEPTET, OCTET, NONET, DECTET;

    public int numberOfMusicians() {
        return ordinal() + 1;
    }
}

但是我们非常不推荐使用这个方法来将枚举类实例和 int 值进行绑定, 因为当我们每次打乱枚举实例的声明顺序, 其对应的 int 值都会改变, 导致不可预估的错误.

我们更推荐在枚举类中定义一个 int 类型的实例字段的方式来进行绑定, 如:

public enum Ensemble {
    SOLO(1), DUET(2), TRIO(3), ...;

    private final int numberOfMusicians;

    Ensemble(int size) {
        this.numberOfMusicians = size;
    }

    public int numberOfMusicians() {
        return numberOfMusicians;
    }
}

总结-35

永远不要将枚举类实例中的一个值和实例的序数绑定; 应该将其存储到一个实例字段中.

Never derive a value associated with an enum from its ordinal; store it in an instance field instead.

枚举实例的序数只用于在 EnumMap, EnumSet 等集合中起辅助作用.

Item 36: 使用 EnumSet 代替位字段

Use EnumSet instead of bit field.

如果一个枚举类型的元素主要用在集合中, 传统的方式是将其定义成 int 常量枚举, 使每个元素都是 2 的不同次幂.

public class Text {
    
    public static final int STYLE_BOLD = 1;
    public static final int STYLE_ITALIC = 1 << 1;
    public static final int STYLE_UNDERLINED = 1 << 2;
    public static final int STYLE_STRIKETHROUGH = 1 << 3;
    
    public void applyStyles(int styles) {
        // ...
    }
}

这种方式使得我们可以使用 | 或者 & 的方式去将多个枚举常量组合为一个集合, 也称作位字段(bit field).

通过如下方式使用:

text.applyStyles(STYLE_BOLE | STYLE_ITALIC);

但是这种方式不够直观, 而且扩展性不好, 我们在之前就已经分析过了.

Java 为我们提供了对枚举类型进行集合操作的类--EnumSet.

public class Text {

    public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
    
    public void applyStyles(Set<Style> styles) {
        // ...
    }   
}

API 的调用也很简单:

text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

总结-36

Java 为枚举类的集合操作提供了便捷的 EnumSet API, 因此不再需要使用位字段的方式来处理.

Item 37: 使用 EnumMap 而不是序数索引

Use EnumMap instead of ordinal indexing.

有时候我们会看到一些在数组中使用枚举实例的序数作为索引的代码, 如:

public class Plant {
    enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }

    final String name;
    final LifeCycle lifeCycle;

    Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }

    public String getName() {
        return name;
    }

    public LifeCycle getLifeCycle() {
        return lifeCycle;
    }

    @Override
    public String toString() {
        return name;
    }
}

假设我们想要使用一个 Plant 数组来表示一个花园中的植物, 并且希望这些植物按照它们的 LifeCycle 进行划分. 可能会有如下的代码:

List<Plant> garden = new ArrayList<>();
// add some plants to garden
        
@SuppressWarnings("unchecked")
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];

for (int i = 0; i < plantsByLifeCycle.length; i++) {
    plantsByLifeCycle[i] = new HashSet<>();
}
for (Plant p : garden) {
    plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
}

代码能正常运行, 但是有一些缺点:

  1. 泛型和数组不能很好兼容.
  2. 使用枚举实例的序数并不能很好地代表枚举实例, 不提供类型安全保证, 并且可能会因为 int 值的不确定性导致数组越界.

对于这种情况(以枚举实例作为划分的 key), 我们有更好的实现方式--使用 EnumMap:

List<Plant> garden = new ArrayList<>();
// add some plants to garden

Map<LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);

for (LifeCycle lc : LifeCycle.values()) plantsByLifeCycle.put(lc, new HashSet<>());

for (Plant p : garden) {
    plantsByLifeCycle.get(p.lifeCycle).add(p);
}

使用 EnumMap 的代码更加简洁, 并且能够保证类型安全, 而且不会在计算索引时出现各种错误.

需要注意的是, EnumMap 在构造时, 传入了 key 的泛型类信息 Class<K> keyType, 用于运行时获取泛型信息. 这种方式称为有界类型标记(bounded type token).

public EnumMap(Class<K> keyType);

如果我们使用 Stream API, 上面的代码会更加简洁:

Map<LifeCycle, Set<Plant>> plantsByLifeCycle = garden.stream().collect(groupingBy(Plant::getLifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet()));

有些情况下我们可能想要使用二维数组的方式来表达从一个枚举状态到另一个枚举状态的转换, 如:

public enum Phase {

    SOLID, LIQUID, GAS;

    public enum Transition {
        MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;

        // Rows indexed by from-ordinal, cols by to-ordinal
        private static final Transition[][] TRANSITIONS = {
                {null, MELT, SUBLIME},
                {FREEZE, null, BOIL},
                {DEPOSIT, CONDENSE, null}
        };
        
        // Returns the phase transition from one phase to another
        public static Transition from(Phase from, Phase to) {
            return TRANSITIONS[from.ordinal()][to.ordinal()];
        }
    }
}

还是相同的问题, 不要在代码中使用枚举实例的序数来代表枚举实例, 它们很不方便扩展, 并且会有很多问题.

我们还是能够使用 EnumMap 进行优化:

public enum Phase {

    SOLID, LIQUID, GAS;

    public enum Transition {
        MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
        BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
        SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);

        private final Phase from;
        private final Phase to;

        Transition(Phase from, Phase to) {
            this.from = from;
            this.to = to;
        }

        private static final Map<Phase, Map<Phase, Transition>> m =
                Stream.of(values()).collect(Collectors.groupingBy(t -> t.from, () -> new EnumMap<>(Phase.class),
                        Collectors.toMap(t -> t.to, t -> t, (x, y) -> y, () -> new EnumMap<>(Phase.class))));

        public static Transition from(Phase from, Phase to) {
            return m.get(from).get(to);
        }
    }
}

Map<Phase, Map<Phase, Transition>> 很好的表达了: 从 Phase fromPhase toTransition 的意义.

使用 EnumMap 有一个巨大的优点, 就是它的可扩展性非常好. 比如我们想要增加一些数据:

public enum Phase {
    SOLID, LIQUID, GAS, PLASMA;

    public enum Transition {
        MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
        BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
        SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
        IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);

        // Remainder unchanged
    }
}

只需要新声明 PhaseTransition 的枚举实例即可.

总结-37

当需要在映射集合中使用枚举类实例作为 key 的时候, 不要使用它的序数(ordinal()), 而是使用更好的 EnumMap 配合 Stream API 进行处理.

其实总的来说就是, 我们尽量去使用集合类, 而不是去使用各种基础的数组等类型. 集合类一方面可以更好地进行扩展, 另一方面一些特定的还能为特定的类提供更安全, 更有效率的 API.

Item 38: 使用接口模拟可继承枚举类

Emulate extensible enums with interfaces.

Java 的枚举类是不可继承的, 这对可扩展性带来了一定的影响. 不过我们可以是枚举类都实现相同的接口来模拟继承.

以我们在 Item 34 的例子来说, 我们想要 Operation 这个枚举类的抽象 是可以扩展的, 能够添加新的具有其他功能的 Operation. 那么就将 Operation 的功能抽象为一个接口 Operation, 并要求与 Operation 相关的所有的枚举类都需要实现 Operation 接口. 如:

public interface Operation {
    double apply(double x, double y);
}
public enum BaseOperation implements Operation {

    PLUS("+") {
        @Override
        public double apply(double x, double y) {return x + y;}
    },
    MINUS("-") {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double x, double y) {
            return x / y;
        }
    };

    private final String symbol;

    BaseOperation(String symbol) {
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }
}
public enum ExtendedOperation implements Operation {
    
    EXP("^") {
        @Override
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
    };
    
    private final String symbol;
    
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
    
    @Override
    public String toString() {
        return symbol;
    }
}

使用时, 只需要限制传入的参数是枚举类型的实例, 并且实现了对应的接口.

public static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y) {
    for (Operation op : opEnumType.getEnumConstants()) {
        // ...
    }
}

或者:

public static <T extends Enum<T> & Operation> void test(Collection<? extends Operation> opSet, double x, double y) {
    for (Operation op : opSet) {
        // ...
    }
}

总结-38

使用接口来模拟可继承的枚举类以增加扩展性.

While you cannot write an extensible enum type, you can emulate it by writing an interface to accompany a basic enum type that implements the interface.

  1. 将枚举类的方法抽象到接口中.
  2. 所有的具有类似功能的枚举类都实现抽象出的接口.
  3. 使用 <T extends Enum<T> & InterfaceName> 来限定实现了对应接口的枚举类.
  4. 使用 Class<T> 或者 Collection<? extends InterfaceName> 的方式限制参数的类型(type token).

Item 39: 使用注解替代命名模式

Prefer annotations to naming patterns.

在没有注解之前, 通常使用命名模式(naming pattern)来标志一些会被某些工具或者框架特殊处理的类. 以 JUnit 为例, JUnit 是一个经典的测试框架, 在早期, 它是通过检测方法的名称中是否含有 test 前缀来判断是否需要测试的.

如: testSafetyOverride, 但是这样很容易因为拼写错误而出现问题. 只要 JUnit 没有检测到 test 前缀, 它就不会去自动执行测试方法. 同时, 我们也不能很方便地将一整个类的方法都定义为需要测试的方法. 它的测试范围也是很局限的, 我们无法对参数或者返回值进行很细化的测试, 比如说需要测试一个方法抛出异常是否正常.

Java 的注解很好地解决了这个问题. 我们只需要定义如下的注解:

/**
 * Indicates that the annotated method is a test method.
 * Use only on parameterless static methods.
 * 
 * @author Riicarus
 * @create 2023-10-22 14:34
 * @since 1.0.0
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

我们在这里定义了一个空注解(其中不包含任何方法), 也称之为标记注解(marker annotation), 它的作用是标志需要被测试的方法, 并且该方法应该是静态无参(static parameterless methods)的.

It's called a marker annotation because it has no parameters but simply "marks" the annotated element.

我们发现在注解上还能够使用注解, 这种作用于注解上的注解称为元注解(meta-annotation).

Such annotations on annotation type declarations are known as meta-annotations.

@Retention 注解定义了该注解能够被保留到哪一个阶段(源码/编译期/运行期).

@Target 注解定义了该注解能够作用于哪些地方(类/方法/实例...).

在需要测试的类中, 我们去使用这个注解:

public class Sample {
    @Test
    public static void m1() {}
    
    public static void m2() {}
    
    @Test
    public static void m3() {
        throw new RuntimeException("Boom");
    }
    
    public void m4() {}
    
    @Test
    public void m5() {
        // Invalid, it's no-static
    }   
}

虽然注解能够在方法上进行使用了, 但是还不能自动进行测试, 因为我们没有对被注解标记的方法进行任何的操作.

下面我们对注解标记的方法进行处理, 需要使用到 Java 的反射:

public class TestAnnotationHandler {

    public static void test(String name) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName(name);
        // Get methods of the target class
        for (Method method : testClass.getDeclaredMethods()) {
            // judge if annotated by @Test
            if (method.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    // invoke static, the non-static methods will throw exceptions.
                    method.invoke(name);
                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
                    System.out.println(method + " failed: " + exc);
                } catch (Exception e) {
                    System.out.println("Invalid @Test: " + method);
                }
            }
        }

        System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
    }

    public static void main(String[] args) throws ClassNotFoundException {
        test(Sample.class.getName());
    }
}

运行结果如下:

public static void riicarus.github.io.effective.annotations.Sample.m3() failed: java.lang.RuntimeException: Boom
Invalid @Test: public void riicarus.github.io.effective.annotations.Sample.m5()
public static void riicarus.github.io.effective.annotations.Sample.m7() failed: java.lang.RuntimeException: Crash
Passed: 1, Failed: 3

如果我们想要测试一个方法抛出的异常是否正确呢?

那就需要给注解传递参数了, 注解需要直到要检测的异常类型.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
    Class<? extends Throwable> value();
}
public class Sample {
    @Test(ArithmeticException.class)
    public static void m1() {}

    public static void m2() {}

    @Test(RuntimeException.class)
    public static void m3() {
        throw new RuntimeException("Boom");
    }

    public void m4() {}

    @Test(RuntimeException.class)
    public void m5() {
        // Invalid, it's no-static
    }

    public static void m6() {}

    @Test(ArithmeticException.class)
    public static void m7() {
        throw new RuntimeException("Crash");
    }
}
public class TestAnnotationHandler {

    public static void test(String name) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName(name);
        for (Method method : testClass.getDeclaredMethods()) {
            if (method.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    method.invoke(name);
                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
                    Class<? extends Throwable> excType = method.getAnnotation(Test.class).value();
                    if (excType.isInstance(exc)) passed++;
                    else System.out.printf("Test %s failed: expected %s, got %s%n", method, excType.getName(), exc);
                } catch (Exception e) {
                    System.out.println("Invalid @Test: " + method);
                }
            }
        }

        System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
    }

    public static void main(String[] args) throws ClassNotFoundException {
        test(Sample.class.getName());
    }
}

测试结果:

Invalid @Test: public void riicarus.github.io.effective.annotations.Sample.m5()
Test public static void riicarus.github.io.effective.annotations.Sample.m7() failed: expected java.lang.ArithmeticException, got java.lang.RuntimeException: Crash
Passed: 2, Failed: 2

这样就能够指定需要测试的异常类型了. 那如果我们想要测试可能抛出的一系列异常呢?

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
    Class<? extends Throwable>[] value();
}
@Test({ArithmeticException.class, RuntimeException.class})
public static void m7() {
    throw new RuntimeException("Crash");
}
public class TestAnnotationHandler {

    public static void test(String name) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName(name);
        for (Method method : testClass.getDeclaredMethods()) {
            if (method.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    method.invoke(name);
                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
                    int oldPassed = passed;
                    Class<? extends Throwable>[] excTypes = method.getAnnotation(Test.class).value();

                    for (Class<? extends Throwable> excType : excTypes) {
                        if (excType.isInstance(exc)) {
                            passed++;
                            break;
                        }
                    }
                    if (passed == oldPassed) System.out.printf("Test %s failed: %s%n", method, exc);
                } catch (Exception e) {
                    System.out.println("Invalid @Test: " + method);
                }
            }
        }

        System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
    }

    public static void main(String[] args) throws ClassNotFoundException {
        test(Sample.class.getName());
    }
}

测试结果:

Invalid @Test: public void riicarus.github.io.effective.annotations.Sample.m5()
Passed: 3, Failed: 1

以上的功能还有另一种实现方式:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface Test {
    Class<? extends Throwable> value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

当一个方法上有多个 ExceptionTest 注解时, 会被视作被 ExceptionTestContainer 注解注释, 而不是被 ExceptionTest 注解注释. 所以在处理可重复注解时, 需要同时判断是否被单个可重复注解 ExceptionTest 或者多个可重复注解的包装类 ExceptionTestContainer 注释, 否则可能会忽略某些情况.

不过 getAnnotationsByType() 方法不会涉及这个问题.

if (method.isAnnotationPreset(ExceptionTest.class) || method.isAnnotationPresent(ExceptionTestContainer.class)) {
    test++;
    try {
        method.invoke(null);
        // ...
    } catch (Throwable wrappedExc) {
        Throwable exc = wrappedExc.getCause();
        int oldPasses = passed;
        ExceptionTest[] excTests = method.getAnnotationByType(ExceptionTest.class);
        // ...
    }
}

总结-39

如果能够使用注解解决, 就不要把标记的事情放到命名模式中解决.

There is simply no reason to use naming patterns when you can use annotations instead.

应该多使用 Java 提供的注解.

All programmers should use the predefined annotation types that Java provides.

Item 40: 总是使用 Override 注解

Consistently use the Override annotation.

Java 官方为我们提供了一些可用的注解类型, 其中最重要的是 @Override 注解. Override 注解只能被用于方法声明上, 表示被注释的方法重写了一个父类的方法. 总是使用 @Override 注解可以避免很多继承上的问题, 保证子类对父类方法的重写/实现不会出现问题.

Use the Override annotation on every method declaration that you believe to override a superclass declaration.

总结-40

只要重写了父类的方法, 就在对应方法上 @Override 注解.

Item-41: 使用标记接口来定义类型

Use marker interfaces to define types.

标记接口是不包含任何方法声明的接口, 只用来指定(或者说标记)实现类该接口的类具有某些属性. 比如说 Serializable 接口, 它自身不包含任何方法声明, 只是标记实现了这个接口的类实例可以被写入到 ObjectOutputStream 中, 即可以被序列化.

单从标记这个功能看, 可能标记接口和标记注解的功能类似, 但是标记接口在下面两个方面更有优势:

标记接口定义了一种类型, 所有实现这个接口的类实例都会被标记; 标记注解不行. 我们可以通过标记接口在编译期发现一些错误, 而不需要像标记注解那样要等到运行期.

Marker interfaces define a type that is implements by instance of the marked class; marker annotations do not.

标记接口的另一个优点是他们能被精确地定位到. 如果使用被元注解 @Target(ElementType.TYPE) 修饰的标记注解去标记类, 那我们需要在所有的类中寻找被标记的类; 但是使用标记接口实现就不需要, 因为我们可以确定传入的一定是实现了对应标记接口的实例. Java 中的 Set 就是一个很好的例子, 它是一个限制标记接口(restricted marker interface), 只继承了 Collection 接口, 没有增加任何的方法, 只用于限定其子类都是 Set 类型的(但是 Set 接口重写了部分 Collection 接口中方法的实现).

Another advantage of marker interfaces over marker annotations is that they can be targeted more precisely.

标记注解的主要优点在于它们是基于注解注解的设施的一部分. 使用标记注解可以在基于注解的框架中获得更好的功能支持, 比如 Spring 框架.

The chief advantage of marker annotations over marker interfaces is that they are part of the larger annotation facility.

那我们如何在两者间进行选择呢?

如果需要对类或接口以外的元素进行标记, 如: 字段, 方法等, 只能使用标记注解.

如果是对类或者接口进行标记, 那么考虑是否有方法需要限制只接受被标记的类或接口的实例? 如果是, 使用标记接口; 如果不是, 都可以选择.

总结-41

当我们在设计对类或者接口进行标记的功能时, 优先考虑使用标记接口.

If you find yourself writing a marker annotation type whose target is ElementType.TYPE, take time to figure out whether it really should be an annotation type or whether a marker interface would be more appropriate.

Lambdas 与 Streams

Lambdas and Streams

在 Java 8 中, 函数式接口, Lambda 表达式方法引用是避不开的话题.

Item 42: 使用 Lambda 表达式替代匿名类

Prefer lambdas to anonymous classes.

在 Java 8 之前, 我们通常使用匿名类来实现函数式类型(functional type, 只有一个抽象方法的接口), 这种匿名类的实例一般称为函数式对象(functional object).

Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

匿名类在策略模式(Strategy Pattern)中被广泛使用. Comparator 接口作为排序的抽象策略, 基于抽象策略的匿名类作为排序的具体策略.

但是我们可以看到, 使用匿名类很繁琐, 可读性不是很好.

好在在 Java 8 之后, 我们可以使用使用 Lambda 表达式了:

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

在上面的代码中, 我们甚至不需要为 Lambda 表达式的形参声明类型, 因为这能够从上下文中获取. 但是有些情况可能不行, 就需要我们手动指定形参的类型.

Omit the types of all lambda parameter unless their presence makes your program clearer.

在使用 Lambda 表达式时, 更需要注意泛型的使用. Lambda 表达式的形参类型很多时候都是通过上下文中的泛型类型获取的.

如果使用方法引用, 上文中的 Lambda 表达式还能够更加简单:

Collections.sort(words, Comparator.comparingInt(String::length));

// shorter using List.sort() interface after Java 8.
words.sort(Comparator.comparingInt(String::length));

回想在 Item 34 中我们设计的 Operation 类, 是不是也可以使用 Lambda 表达式来定义 apply() 方法呢?

public enum BaseOperation implements Operation {

    PLUS("+", Double::sum),
    MINUS("-", (x, y) -> x - y),
    TIMES("*", (x, y) -> x * y),
    DIVIDE("/", (x, y) -> x / y);

    private final String symbol;
    private final DoubleBinaryOperator op;

    BaseOperation(String symbol, DoubleBinaryOperator op) {
        this.symbol = symbol;
        this.op = op;
    }

    @Override
    public String toString() {
        return symbol;
    }

    @Override
    public double apply(double left, double right) {
        return op.applyAsDouble(left, right);
    }
}

这里我们使用了 JDK 提供的 DoubleBinaryOperator, 是一个函数式接口, 接收两个 double 类型的参数, 返回一个 double 类型的参数.

既然可以使用 Lambda 表达式代替, 那是不是说明使用匿名类的实现方法已经过时了呢? 其实不然.

Lambda 表达式没有名称和文档, 如果一个表达式需要解释, 或者它需要好几行才能够完成, 那么不要使用 Lambda 表达式.

Lambdas lack names and documentation; if a computation isn't self-explanatory, or exceeds a few lines, don't put it in a lambda.

Lambda 表达式的最佳实现就是一行代码描述清楚, 最多不要超过三行代码, 否则可能带来很差的可读性和可维护性.

并且, Lambda 表达式只能用于实现只有一个抽象方法的接口. 如果是抽象类或者有多个抽象方法需要重写, 那么只能使用匿名类的方式实现.

Lambda 表达式是没有状态的, 无法通过 this 获取自身引用, 也不能在其中使用外部的变量.

还有很重要的一点: 不要序列化 Lambda 表达式.

You should rarely, if ever, serialize a lambda(or an anonymous class).

如果需要, 请将它使用私有静态内部类(private static nested class)实现.

总结-42

使用 Lambda 表达式去替代不需要创建多个实例的函数式对象.

Don't use anonymous class for function objects unless you have to create instances of types that aren't functional interfaces.

Item 43: 使用方法引用替代 Lambda 表达式

Prefer method reference to lambdas.

其实我们在上一个 Item 中就已经有过例子了, 使用方法引用的代码更加简洁.

如果一个 Lambda 表达式能够被方法引用替代, 那就使用方法引用. 当然, 如果一个地方没法使用 Lambda 表达式, 那方法引用也没有任何作用.

emmmm, 这里有一个特例...

如果方法引用的名称太长了, 那还是用 Lambda 吧(:

service.execute(WellThisIsAVeryLongName::action); // not so good

service.execute(() -> action()); // shorter and more readable

刚开始接触方法引用可能会觉得很不好理解, 这里有一个方法引用的表单:

Method Ref TypeExampleLambda Equivalent
StaticInteger::parseIntstr -> Integer.parseInt(str)
BoundInstant.now()::isAfterInstant then = Instant.now(); t -> then.isAfter(t)
UnboundString::toLowerCasestr -> str.toLowerCase()
Class ConstructorTreeMap<K, V>::new() -> new TreeMap<K, V>()
Array Constructorint[]::newlen -> new int[len]

总结-43

如果方法引用更加简洁, 那就用; 否则还是乖乖用 Lambda.

Where method references are shorter and clearer, use them; where they aren't, stick with lambdas.

Item 44: 使用标准的函数式接口

Favor the use of standard functional interfaces.

函数式接口的引入导致 Java 中一些编程的最佳实践也在发生改变. 就拿模板方法模式来说, 我们在抽象类中定义模板方法, 提供抽象方法; 子类实现抽象方法. 我们现在更倾向于子类通过提供静态工厂方法或者构造方法的方式接收一个函数式对象来达到相同的效果.

比如如果我们希望将 LinkedHashMap 作为 LRUCache 来使用, 我们需要重写它的一些方法:

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    
    private final int size;

    public LRUCache(int size) {
        this.size = size;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > size;
    }
}

这样做没有任何问题, 但是我们可以用 Lambda 来实现. 如果 LinkedHashMap 将来要被重写, 应该会使用静态工厂方法或者构造方法接收一个函数式对象. 那我们如何在 Lambda 中判断集合的大小呢? 在我们定义 Lambda 的时候, 无法获取到 Map.Entry<K, V> 对象, 因为我们传递的 Lambda 对象不是 LinkedHashMap 的实例, 无法调用它的 size() 方法. 因此 LinkedHashMap 必须在调用时传递自身引用, 以及集合中最近最少使用的 Map.Entry<K, V> 实例.

@FunctionalInterface
public interface EldestEntryRemovalFunction<K, V> {
    boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);   
}

不过, 最好不要在代码中使用这个方法, 因为 java.util.function 包提供了标准函数式接口 BiPredicate<T, U>, 我们只需要传递 BiPredicate<Map<K, V>, Map.Entry<K, V>> 类型的 Lambda 变量即可.

思考: 为什么这里需要传递 Map.Entry<K, V> 对象?

直接看 JavaDoc:

Returns true if this map should remove its eldest entry. This method is invoked by put and putAll after inserting a new entry into the map. It provides the implementor with the opportunity to remove the eldest entry each time a new one is added. This is useful if the map represents a cache: it allows the map to reduce memory consumption by deleting stale entries.

Sample use: this override will allow the map to grow up to 100 entries and then delete the eldest entry each time a new entry is added, maintaining a steady state of 100 entries.

   private static final int MAX_ENTRIES = 100;

    protected boolean removeEldestEntry(Map.Entry eldest) {
       return size() > MAX_ENTRIES;
    }

This method typically does not modify the map in any way, instead allowing the map to modify itself as directed by its return value. It is permitted for this method to modify the map directly, but if it does so, it must return false (indicating that the map should not attempt any further modification). The effects of returning true after modifying the map from within this method are unspecified.

This implementation merely returns false (so that this map acts like a normal map - the eldest element is never removed).


Java 提供了很多标准的函数式接口, 我们可以来看看一些常用的:

InterfaceFunction SignatureExample
UnaryOperator<T>T apply(T t)String::toLowerCase
BinaryOperator<T>T apply(T t1, T t2)BigInteger::add
Predicate<T>boolean test(T t)Collection::isEmpty
Function<T, R>R apply(T t)Arrays::asList
Supplier<T>T get()Instant::now
Consumer<T>void accept(T t)System.out::println

在使用函数式接口的时候, 如果能够使用基础类型, 就尽量不要使用包装类型.

Don't be tempted to use basic functional interfaces with boxed primitives instead of primitive functional interfaces.

如果我们自己定义函数式接口, 需要在接口上使用 @FunctionalInterface 注解, 指定它是一个函数式的接口.

Always annotate your functional interfaces with the @FunctionalInterface annotation.

还有一点需要注意, 当我们在设计使用函数式接口的 API 时, 尽量不要重载接口, 这样可能会导致编译器无法判断使用哪一个接口. 如: ExecutorService#submit() 方法既可以接收 Callable<T> 类型的参数, 也可以接收 Runnable 类型的参数, 这会带来一些困扰.

总结-44

当我们需要函数式接口的时候, 优先考虑有没有标准函数式接口能够实现, 再考虑自己新建一个接口的问题.

Item 45: 明智地使用 Stream API

Use streams judiciously.

Stream API 是 Java 8 提供的一个语法糖. Stream API 提供了两种抽象:

  1. 流(stream): 一个包含有限或者无限个元素的序列.
  2. 流管道(stream pipeline): 对流中元素进行不同的处理.

一个流管道包含了一个数据源以及一系列的中间操作, 还有一个终止操作.

A stream pipeline consists of a source stream followed by zero or more intermediate operations and one terminal operation.

  • 中间操作: 对流中的元素进行某种转换, 如: map(), filter() 等. 中间操作会将一个流转换成另一个流.
  • 终止操作: 对流进行最终计算的过程, 获取一个结果. 结果可以是一个新的集合, 某个元素, 或者某些分析对象.

流管道的计算是延迟的: 结果的计算只会发生在终止操作被调用时; 流中的数据项不需要按照顺序去被处理, 因此流管道可以处理无限流.

Stream pipelines are evaluated lazily: evaluation doesn't start until the terminal operation is invoked, and data elements that aren't required in order to complete the terminal operation that are never computed.

注意, 如果一个流的处理没有终止操作, 那么对流不会有任何的处理动作. 所以一定要在一次流处理中使用终止操作.

Stream API 的调用是很平滑的, 它允许各个 API 之间进行链式调用, 从而组合成一个单一的表达式. 如:

Map<String, Integer> map = words.stream().filter(w -> w.length() > 3).collect(Collectors.toMap(o -> o, o -> 1, (e, n) -> ++e));

Stream API 的链式调用是有序的, 但是也可以使用 parallel() 方法使其并行计算, 不过一般不使用.


Stream API 的使用不是必须的. 合理使用 Stream 可以使得代码更简洁; 反之, 代码可读性会很差. 我们会给出一些 tips.

我们考虑以下的需求:

我们想要一个程序能够从字典文件中读取文件词语, 并且打印大小达到用户指定大小的字母异位词集合.

我们可以写出以下的代码:

public class Anagrams {
    public static void main(String[] args) throws FileNotFoundException {
        String fileName = "words.txt";
        File dictionary = new File(fileName);
        int minGroupSize = 10;

        Map<String, Set<String>> groups = new HashMap<>();
        
        try (Scanner s = new Scanner(dictionary)) {
            while (s.hasNext()) {
                String word = s.next();
                groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
            }
        }

        for (Set<String> group : groups.values()) {
            if (group.size() >= minGroupSize) System.out.println(group.size() + ": " + group);
        }
    }
    
    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return String.valueOf(a);
    }
}

上面的代码中我们没有使用任何的 Stream API, 下面我们可以将其加入:

public class Anagrams {
    public static void main(String[] args) throws IOException {
        String fileName = "words.txt";
        Path dictionary = Paths.get(fileName);
        int minGroupSize = 10;

        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(word -> word.chars().sorted()
                    .collect(StringBuilder::new,
                            (sb, c) -> sb.append((char) c),
                            StringBuilder::append).toString()))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .map(group -> group.size() + ": " + group)
                    .forEach(System.out::println);
        }
    }
}

我们把所有的数据操作都融合到了一个表达式中, 但是我们发现代码变得很难理解了, 非常复杂.

Overusing streams makes programs hard to read and maintain.

那再来优化一下:

public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get("words.txt");
        int minGroupSize = 10;
        
        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(Anagrams::alphabetize))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .forEach(group -> System.out.println(group.size() + ": " + group));
        }
    }

    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return String.valueOf(a);
    }
}

我们只是把判断一个词是否是字母异位词的逻辑抽离到了一个方法中, 就可以使表达式简洁很多. 还要注意, 参数的命名在 Stream API 和 Lambda 表达式中非常重要, 会对代码的可读性产生很大的影响. 尽量使 Lambda 表达式的参数具有良好的可读性.

In the absence of explicit types, careful naming of lambda parameters is essential to the readability of stream pipelines.

在 Stream API 中使用辅助方法(help method)对代码的可读性提升更好. 因为 Lambda 中的参数没有清楚的类型定义信息和变量名称.

Using helper methods is even more important for readability in stream pipelines than in iterative code because pipelines lack explicit type information and named temporary variables.

注意, 对于 char 类型的值, 我们最好不要使用 Stream API.

You should refrain from using streams to process char values.

我们给一个例子:

"Hello world!".chars().forEach(System.out::print); // 721011081081113211911111410810033

"Hello world!".chars().forEach(c -> System.out.print((char) c)); // Hello world!

在我们重构代码时, 可以将旧代码使用 Stream API 来优化, 并在新代码中合理使用 Stream API.

Refactor existing code to use streams and use them in new code only where it makes sense to do so.

总结-45

合理使用 Stream API 能够使代码更加简洁, 但是使用时需要注意代码的可读性.

当我们不能确定是否使用 Stream API 时, 可以用两种方式都实现一下, 看一看哪一个代码更好.

If you're not sure whether a task is better served by streams or iteration, try both and see which works better.

Item-46: 在流中使用无副作用函数

Prefer side-effect-free functions in streams.

Stream 不只为我们提供了 API, 更重要的是为我们提供了一个函数式编程的范式(paradigm).

我们需要思考的是如何在每一步对流中的元素进行变换, 然后得到我们想要的结果. 函数式编程的范式就是尽量使得每个阶段(一次 API 调用)都是一个纯函数(pure function, 结果只依赖输出, 不依赖状态).

所以说, 我们在中间操作和终止操作的逻辑中都应该保证没有副作用.

这里举一个反例:

Map<String, Long> freq = new HashMap<>();

try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

这段代码能够正常运行, 但是它并不是 Stream API 想要我们使用的方式. forEach() 应该只是一个用来呈现最终计算结果的终止操作, 而不应该用来做其他的事情. 上面的代码中我们还更改了 freq 的状态, 导致这并不是无副作用的操作.

可以对上面的代码进行优化:

Map<String, Long> freq;

try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

这样之后, 代码更加简洁, 也更符合 Stream API 的范式.

forEach() 操作只应该被用来呈现 Stream 的计算结果, 而不应该作为计算的一部分.

The forEach() operation should be used only to report the result of a stream computation, not to perform the computation.

如果读者完全按照上文中的代码进行复现时, 会发现很多方法都不能被 IDEA 识别, 因为我们通过静态导包(static import)的方式隐藏了调用他们的类.

在使用 Collectors 的 API 时, 我们通常都会将其静态导包, 因为这样代码可可读性更好.

It is customary and wise to statically import all members of Collectors because it makes stream pipelines more readable.

总结-46

在 Stream API 中, 尽量使用无副作用的函数式方法. 注意理解 Stream API 提供的函数式编程的范式.

Item 47: 使用 Collection 作为 Stream API 的返回类型, 而不是 Stream

Prefer Collection to Stream as a return type.

Stream 并没有为使用者提供非常好的迭代器支持, 而 Collection 提供了. Stream 只能通过 forEach() 方法进行遍历迭代, 而不能通过 Iterable 的方式. 但是 Collection 提供了 Iterable 方式的迭代, 使用更加灵活.

使用者通常会希望 Stream API 的计算结果能够很好地被迭代遍历, 因此更推荐使用 Collection 作为返回值.

Collection 或者它的合适的子类总是公开的, 返回值有序的方法的首选返回类型.

Collection or an appropriate subtype is generally the best return type for a public, sequence-returning method.

但是不要为了能够返回集合而把很大的一个序列保存在内存中.

But do not store a large sequence in memory just to return it as a collection.

总结-47

尽量方法的结果通过 Collection 返回, 因为我们不知道用户想要将结果用作 Stream 的处理还是想要使用迭代器去迭代其中的元素. 如果不方便使用 Collection 返回时, 才使用 Stream 作为返回类型.

Item 48: 谨慎使用并行流

Use caution when making streams parallel.

如果流的源来自于 Stream.iterate(), 或者在中间过程中使用了 limit() 方法, 那么并行流并不能提升性能.

Parallelizing a pipeline is unlikely to increase its performance if the source is from Stream.iterate(), or the intermediate operation limit() is used.

不要不加区别地并行化管道.

Do not parallelize stream pipelines indiscriminately.

有一个经验规律:

能从并行流中得到较大提升的流的源通常是 ArrayList, HashMap, HashSetConcurrentHashMap 实例, 以及数组和 int, long 类型的区间.

Performance gains from parallelism are best on streams over ArrayList, HashMap, HashSet and ConcurrentHashMap instances; arrays; int ranges; long ranges.

因为这些数据结构都能够很简单地被分成任意大小的子集, 很容易并行化. 还有一个原因就是这些数据结构在内存中都是存储在同一块内存区域的, 不需要为了并行而去多次加载内存.

流管道的终止操作也会对并行的效率造成很大的影响. 如果在终止操作中需要进行大量的计算, 那么并行的效率提升时很少的. 通常, 对 Stream 的**规约(reduce)操作, 或者封装后的规约操作(min(), max(), count(), sum(), .etc)能够很好地利用并行性能. 类似 anyMatch(), allMatch() 之类的短路(short-circuiting)**操作也和并行流配合地很好.

相反, 类似 collect() 之类的动态规约(mutable reduction)操作和并行流就不是很适配, 因为合并集合的性能消耗是很大的.

对并行流的不当使用不只会造成低性能, 还可能导致运行失败或者得到错误的结果.

Not only can parallelizing a stream lead to poor performance, including liveness failures; it can lead to incorrect results and unpredictable behavior.

在合理使用下, 通过 parallel() 使用并行流可以带来基于 CPU 核数的近乎线性的性能提升.

User the right circumstances, it is possible to achieve near-line speedup in the number of processor cores simply by adding a parallel() call to a stream pipeline.

总结-48

只有在确定能够提升性能的前提下才使用并行流.

方法

Methods

Item 49: 检查参数有效性

Check parameters for validity.

我们在很多方法中都需要对参数进行某些限制, 也就是需要进行参数校验. 我们在为这些方法编写文档时, 需要清除地写明参数的校验需求; 并在方法逻辑的开始就进行参数的校验. 如果不对参数进行正确的校验, 可能会导致整个服务都出现错误.

当校验到无效参数时, 需要及时(尽可能早)地抛出异常.

在参数校验中我们通常会抛出的方法有: IllegalArgumentException, IndexOutOfBoundsException, NullPointerException, ArithmeticException 等. 我们需要在方法的文档中使用 @throws 来标明这些错误出现的情况. 如:

/**
 * Returns a BigInteger whose value is (this mod m). This method 
 * differs from the remainder method in that it always returns a non-negative BigInteger.
 * 
 * @param m the modulus, which must be positive
 * @return this mod m
 * @throws ArithmeticException if m is less then or equal to 0
 */
public BigInteger mod(BigInteger m) {
    if (m.signum() <= 0) throw new ArithmeticException("Modulus <= 0: " + m);

    // Do the computation
}

在我们校验参数不为 null 时, 可以使用 Objects.requireNonNull() 方法, 它会在参数为 null 时自动抛出 NullPointerException; 如果不为 null, 就返回传入的参数.

public static <T> T requireNonNull(T obj);

public static <T> T requireNonNull(T obj, String message);

在 Java 9 之后, java.util.Objects 中加入了很多参数检查的工具方法, 因此, 如果官方包有实现, 那么我们就没有必要自己去写.

对于不需要对外暴露的方法, 开发者可以使用 assert 断言来检查参数.

private static void sort(long[] a, int offset, int length) {
    assert a != null;
    assert offset >= 0 && offset <= a.length;
    assert length >= 0 && length <= a.length - offset;
    
    // Do the computation
}

如果出错, 断言 assert 抛出的异常是 AssertionError. 我们可以选择开启/关闭断言功能, 如果关闭, 断言不会生效. 使用 -ea/-enableassertions 参数来开启断言功能.

如果一个参数不会立刻在方法中被使用, 而是被存储起来, 那么一定要对参数进行检查. 比如 List, HashMap 等集合类就会对参数进行检查.

如果参数的检查消耗太大, 或者对参数的检查在计算过成功隐式进行了, 那么可以不对参数进行检查. 比如 Collections#sort(List) 方法就不会对传入的 List 实例中的元素进行检查.

不过, 我们在设计方法时应该尽量做到通用性, 所以对参数的检查不是越多越好.

总结-49

当设计方法时, 一定要注意是否需要对参数进行检查. 如果需要, 尽量使用官方已经提供的包进行检查, 并且提供完善的文档.

Item 50: 在需要时制作防御性副本

Make defensive copies when needed.

在编程时, 必须假定程序的使用者会尽最大的努力破坏其不变性.

You must program defensively, with the assumption that clients of your class will do their best to destroy its invariants.

我们在编写程序时, 有时候希望类中的字段是不变(invariant)的, 通常使用 final 关键字修饰字段, 来保证它的引用不能发生改变. 如:

public final class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(start + " after " + end);
        
        this.start = start;
        this.end = end;
    }

    public Date start() {
        return start;
    }

    public Date end() {
        return end;
    }
}

虽然 startend 字段的引用确实无法被更改, 但是 Date 的值本身是可变的.

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // 更改了 p 中的 end 的值.

Java 8 之后, 使用 Instant 代替 Date, 因为 Instant 是不可变的, 不会出现上面的情况.

处理用这个方法, 我们还可以使用**防御性拷贝(defensive copy)**的方式来解决:

It is essential to make a defensive copy of each mutable parameter to the constructor.

public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
        
    // 先复制, 再比较
    if (start.compareTo(end) > 0)
        throw new IllegalArgumentException(start + " after " + end);
}

对参数的检查一定要在防御性拷贝之后, 避免在检查后-拷贝前这段时间内参数的值被改变; 并且应该检查拷贝后的值, 而不是拷贝对象的值.

Defensive copies are mode before checking the validity of the parameters, and the validity check is performed on the copies rather than on the originals.

clone() 方法在我们之前就说过, 可能存在复制出的实例与源实例中存在相同的引用的情况. 因此, 只有对信任的类才能使用 clone() 方法作为防御性复制的手段.

Do not use the clone() method to make a defensive copy of a parameter whose type is subclassable by untrusted parties.

通过上面的一个分析我们大致了解了可能出现不可变引用对象的值被更改的情况, 就会发现, 上文的程序还有一些问题:

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end.setYear(78); // 更改了 p 中的 end 的值.

所以我们需要对所有可能对外暴露不可变对象引用的方法都进行分析处理.

返回可变内部字段的防御性副本.

Return defensive copies of mutable internal fields.

public Date start() {
    return new Date(start.getTime());
}

public Date end() {
    return new Date(end.getTime());
}

总结-50

为了使得类中的所有不可变字段都维持其不可变性, 在所有需要的地方都使用防御性副本.

当然, 如果能够直接使用不可变类是最好的.

Item 51: 仔细设计方法签名

Design method signatures carefully.

良好的方法签名能够使我们的 API 更加易于理解与使用.

仔细选择方法名.

Choose method names carefully.

方法的命名总是应该遵循基础命名规则(Item 68). 方法名称一定要易于理解, 并且和包中的其他方法在含义上连贯. 同时, 不要让方法名称太长. 可以多学习 JDK 中方法的命名技巧.


不要过多提供简化方法.

Don't go overboard in providing convenience methods.

方法应当有一定的复杂度, 不要为了可能的情况一味地提供很多的简化方法, 这会使得方法太多, 难以理解和使用. 只提供确实会被经常使用的简化方法. 如果你在犹豫是否要提供, 那么就不提供.


When in doubt, leave it out.

方法要避免过长的参数列表.

Avoid long parameter lists.

四个参数已经是能够容忍的上限了. 很多使用者无法记住很长的参数列表, 就对方法的文档有很高的依赖性, 但是文档里对参数的解释也会很多, 非常不方便. 很长的方法参数列表是有害的.

Long sequences of identically types parameters are especially harmful.

我们会在下文给出三个缩短参数列表的方案:

  1. 将一个方法切分成很多的小方法.
  2. 使用辅助类(helper class)维护过多的参数.
  3. 结合(1)(2), 使用构造器(builder)(Item 2).

优先使用接口限制参数类型, 而不是类.

For parameter types, favor interfaces over classes.

使用只有两个枚举实例的枚举类代替 boolean 类型.

Prefer two-element enum types to boolean parameters.

还是那个原因, 枚举类能够更好地表意, 还能够有一定的功能扩展性.

总结-51

方法的设计上要注意简洁性和易读性.

  1. 方法名称要简洁.
  2. 简化方法数量不要过多.
  3. 方法参数不要过多.
  4. 方法参数应该尽量抽象, 提高兼容性和扩展性.

Item 52: 谨慎重载方法

Use overloading judiciously.

我们先来看一段代码:

public class CollectionClassifier {
    
    public static String classify(Set<?> s) {
        return "Set";
    }
    
    public static String classify(List<?> list) {
        return "List";
    }
    
    public static String classify(Collection<?> c) {
        return "Unknown Collection";
    }

    public static void main(String[] args) {
        List<Collection<?>> collections = List.of(
                new HashSet<String>(),
                new ArrayList<BigInteger>(),
                new HashMap<String, String>().values());

        for (Collection<?> c : collections) {
            System.out.println(classify(c));
        }
    }
}

你可能觉得最后会输出:

Set
List
Unknown Collection

但实际上是:

Unknown Collection
Unknown Collection
Unknown Collection

因为使用哪一个重载方法是在编译期确定的, 而不是在运行期. 在编译期中, 所有的元素都是 Collection<?>, 因此都使用了同一个重载方法.

重载方法的确定是静态的, 而重写方法的确定是动态的.

Selection among overloaded methods is static, while selection among overridden methods is dynamic.

重写方法需要在运行期根据当前对象的类型判断使用哪一个重写方法, 但是重载方法只需要在编译期根据传递参数的类型就能唯一确定.

上面的代码应该改为如下的格式:

public static String classify(Collection<?> c) {
    return c instanceof Set ? "Set" :
            c instanceof List ? "List" : "Unknown Collection";
}

我们需要避免令人迷惑的方法重载.

Avoid confusing uses of overloading.

重载方法的设计有一个安全且保守的原则: 永远不要暴露两个具有相同参数数量的重载方法. 因为你可以通过不同的方法命名将其改为不同的方法, 这样更便于理解.

A safe, conservative policy is never to export two overloadings with the same number of parameters. You can always give methods different names instead of overloading them.

不要为了在相同的位置接收不同的函数式接口而重载方法.

Do not overload methods to take different functional interfaces in the same argument position.

总结-52

重载方法并不是一个很好的推荐, 尤其是重载具有相同参数数量的方法. 但是在构造方法中我们无法避免, 这时候需要避免可能一个参数列表会有多个可匹配的重载方法的情况.

Item 53: 谨慎使用可变参数

Use varargs judiciously.

我们之前了解过, Java 的可变参数是通过一个数组进行接收的. 如:

static int sum(int... args) {
    int sum = 0;
    for (int arg : args) sum += arg;

    return sum;
}

但是有时候我们需要接收的是 一个或者多个 参数, 如果只接受一个可变参数, 就需要额外的参数检查了.

那么可以通过先接收一个参数, 再接收一个可变参数的方式进行:

static int max(int first, int... others) {
    int min = first;

    for (int arg : others) min = Math.min(arg, min);

    return min;
}

Varargs 设计之初是用来支持 printf() 方法和反射的. 相较于接收定长参数, 接收可变参数由于需要使用数组接收, 会带来一定的性能消耗问题, 如果有一些对性能敏感的方法, 但是又想要使用可变参数, 可以考虑如下的方式:

public void foo() {}
public void foo(int a1) {}
public void foo(int a1, int a2) {}
public void foo(int a1, int a2, int a3) {}
public void foo(int a1, int a2, int a3, int... rest) {}

这样主要就是会创建较多的方法, 只在需要较好的性能的时候使用. EnumSet 就使用了类似的方法.

总结-53

在使用可变参数时, 注意是否需要为性能考虑.

Item 54: 返回空集合或数组, 而不是 null 对象

Return empty collections or arrays, not nulls.

尽量少使用 null 作为返回值来表示空集合或者数组, 这会带来多余的对 null 值的检查, 并且不利于理解.

我们可能会担心每次返回空集合或者数组会带来额外的内存开销, 其实大可不必. 只有在分析后确定性能消耗确实是由申请内存导致的时候, 才去考虑这种问题. 而且, 我们也有相对应的解决方案--使用不可变对象.

不可变对象可以通过自己定义, 也可以使用包中的实现:

// JDK 包
Collections.emptyList();

// 手动
private static final String[] EMPTY_STRING_ARRAY = new String[0];

总结-54

不要返回 null 来代表空集合或者空数组.

Never return null in place of an empty array or collection.

Item 55: 谨慎返回 Optional 对象

Return optionals judiciously.

Java 8 引入了 Optional 对象来作为一个不可变对象实例的容器. 我们通常可以使用 Optional<T> 实例作为方法的返回值, 代替返回值可能为 null 的方法.

public static <E extends Comparable<? super E>> Optional<E> max(Collection<? extends E> c) {
    if (c.isEmpty()) return Optional.empty();

    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) result = Objects.requireNonNull(e);
    }

    return Optional.of(result);
}

注意, 返回值类型为 Optional<T> 方法永远不要返回 null 值.

Never return a null value from an Optional-returning method.

Optional 可以和 Stream API 进行良好的配合:

public static <E extends Comparable<? super E>> Optional<E> max(Collection<? extends E> c) {
    return c.stream().max(Comparator.naturalOrder());
}

如果需要在值为 null 时抛出异常, Optional 也能很好的支持:

String lastWordInLexicon = max(words).orElseThrow(TemperTantrumException::new);

如果我们尝试从值为空的 Optional 对象中获取值时, 会抛出异常--NoSuchElementException.

虽然 Optional 和 Stream API 配合良好, 不过我们一般不会使用 Stream<Optional<T>> 这样的实例, 因为 Stream 希望其中的元素都是非空值的. 但是有一种情况例外--Stream#flatMap().

streamOfOptionals.flatMap(Optional::stream);

在什么时候使用 Optional 作为返回类型呢?

当方法可能返回 null 值, 并且调用者需要对 null 值的情况进行特殊处理的时候, 就应该使用 Optional<T> 作为返回值.

You should declare a method to return Optional<T> if it might not be able to return a result and clients will have to perform special processing if no result is returned.

不要在 Optional<T> 中使用其他的容器类型, 数组或者基础类型的装箱类. 对于基础类型, 应该使用对应的 OptionalInt, OptionalLong 类型.

不要将 Optional<T> 作为集合或者数组的 key, value 或者元素.

最好要将 Optional<T> 实例作为字段存储.

总结-55

Optional 主要用于各种返回值的封装, 可以对返回值的各种处理进行增强. 不过在使用时也需要注意一些需要避免的地方.

Item 56: 为所有暴露的 API 中的元素编写文档注释

Write doc comments for all exposed API elements.

我们需要为 API 暴露的所有类, 接口, 构造器, 方法和字段声明进行完善的注释. 不过为了更好地开发, 合作和维护, 我们同样对于未暴露的元素也应该编写完善的接口.

To document your API properly, you must precede every exported class, interface, constructor, method, and field declaration with a doc comment.

方法的文档应该简明扼要地指出调用者需要遵守的规范. 比如: 在什么时候使用, 使用后会有什么影响等.

The doc comment for a method should describe succinctly the contract between the method and its client.

在使用时, 注意使用不同的 tag 来标记不同的元素.

  • @param: 参数
  • @return: 返回值
  • @throws: 可能抛出的异常

其他的 tag 可以参考官方文档.

文档应该保证在源码和生成的文档中都有很好的可读性, 如果不能兼顾, 优先保证生成的文档的可读性.

Doc comments should be readable both in the source code and in the generated documentation. If you can't achieve both, the readability of the generated documentation trumps that of the source code.

对于枚举类, 文档应该对每个枚举实例进行注释. 对注解类型也类似, 需要为每一个成员(方法)进行注释.

When documenting an enum type, be sure to document the constants as well as the type and any public methods.

When documenting an annotation type, be sure to document any members as well as the type itself.

无论方法是否是类型安全的, 都应该在文档中标明方法的类型安全等级.

Whether or not a class or static method is thread-safe, you should document its thread-safety level.

对于可序列化的类, 应该在文档中标明序列化的格式.

总结-56

文档编写是编码环节很重要的一环, 我们需要对文档进行完善的编写, 保证使用者能够很好地理解和调用.

通用编程事项

General programming.

Item 57: 最小化局部变量的作用域

Minimize the scope of local variables.

Java 允许在任何合法的地方定义局部变量, 因此不用像 C 那样要求在一个块的开始处定义所有的变量. 在 Java 编程中, 尽量使局部变量的作用域最小化.

最小化局部变量的作用域的最佳实现方式就是在需要变量时才声明它.

The most powerful technique for minimizing the scope of a local variable is to declare it where it is first used.

几乎所有的局部变量在声明时都应该被赋一个初始值. 有一个例外就是在使用 try-catch 块时, 需要在块外声明, 在块内赋值与使用.

Nearly every local variable declaration should contain an initializer.

相较于 while, 优先使用 for. 因为 for 可以在它的括号中进行局部变量的声明, 确保局部变量作用域最小.

Prefer for loops to while loops.

最后, 让方法更精简也可以让局部变量的作用域更小.

A final technique to minimize the scope of local variables is to keep methods small and focused.

Item 58: 使用 for-each 代替 for 循环

Prefer for-each loops to traditional for loops.

for-each 循环是 Java 提供的一个语法糖, 在对集合/数组/流之类的元素进行循环遍历时更加简洁, 可读性更高.

for-each 的使用需要集合实现 Iterable<E> 接口.

public interface Iterable<E> {
    // Returns an iterator over the elements in this iterable.
    Iterator<E> iterator();
}

Item 59: 了解并使用库

Know and use the libraries.

使用库可以让我们从专家的知识和之前的使用经验中受益.

By using a standard library, you take advantage of the knowledge of the experts who wrote it and the experience of those who used it before you.

在官方库中, 我们需要注意使用更新的 API. 比如: 在 Java 7 之前, 我们使用 Random 类生成随机数, 但在这之后, 使用 ThreadLocalRandom 更好.

Numerous features are added to the libraries in every major release, and it pays to keep abreast of these additions.

官方库的性能通常是很高的, 我们不仅可以从中获得更好的性能, 还能够通过源码学习的方式, 了解设计的思路和原因.

JDK 中, 我们至少需要熟悉 java.lang, java.util, java.io 这几个包及其子包的使用.

Every programmer should be familiar with the basics of java.lang, java.util, java.io, and their subpackages.

总之, 不要重复造轮子, 多去使用和学习官方库的 API 和实现.

Item 60: 不要在需要精确结果的地方使用 float 或 double

Avoid float and double if exact answers are required.

在开发中, floatdouble 非常不适合进行货币计算. 它们可能会有额外的溢出, 带来一些不可预计的问题.

The float and double types are particularly ill-suited fro monetary calculations.

应该用 BigDecimal, int 或者 long 来进行货币计算.

Use BigDecimal, int, or long for monetary calculations.

Item 61: 使用原始类型而不是装箱类型

Prefer primitive types to boxed primitives.

我们在 Item 6 中说过, 使用装箱类型会创建很多不必要的实例, 也会带来很多的拆箱和装箱的消耗.

同时, 装箱类型的比较是对象实例的比较, 不是值的比较. 考虑如下代码:

Comparator<Integer> naturalOrder = (i, j) -> i < j ? -1 : (i == j ? 0 : 1);

naturalOrder.compare(new Integer(42), new Integer(42)); // false

结果是 false! 因为 == 会比较两个 Integer 对象的内存地址是否相同.

当原始类型和装箱类型混合使用时, 装箱类型会被自动拆箱.

When you mix primitives and boxed primitives in an operation, the boxed primitives is auto-unboxed.

Item 62: 避免在其他类型更合适时使用 String

Avoid strings where other types are more appropriate.

记住 String 只是用来存储字符串(文本)的, 不要给它附加太多的功能.

String 是其他值类型的糟糕替代. 我们从控制台获取的各种输入都是 String 类型的, 但是不意味着我们就要在后续使用 String 类型, 而是需要将他们转化为 int, boolean 等其他值类型.

Strings are poor substitutes for other value types.

String 是枚举类型的糟糕替代. 这在 Item 34 中我们就已经谈过了.

Strings are poor substitutes for enum types.

String 是聚合结构的糟糕替代. 对于很多拥有多个组件的聚合结构来说, String 很难表意.

Strings are poor substitutes for aggregate types.

String 不适合作为键. String 类型在 JVM 中共享命名空间, 因此在一些情况下不适合作为键使用.

Strings are poor substitutes for capabilities.

如:

public class ThreadLocal {
    private ThreadLocal() {}

    public static void set(String key, Object value);

    public static Object get(String key);
}

如果使用 String 作为键, 那么可能出现其他的线程使用同样的值对当前线程的本地缓存进行更改, 导致并发问题. 因此, 在很多对独立性和安全性要求高的地方, 需要使用具有独立命名空间的类型作为键.

public class ThreadLocal {
    private ThreadLocal() {}

    public static class Key {
        Key() {}
    }

    public static Key getKey() {
        return new Key();
    }

    public static void set(Key key, Object value);

    public static Object get(Key key);
}

每个线程本地变量的 key 都有自己独立的命名空间, 使得其对应的值不会被其他线程污染.

这样 ThreadLocal 的键就对外隐藏了, 因此可以对外简化 API:

public final class ThreadLocal<T> {
    public ThreadLocal();
    public void set(T value);
    public T get();
}

Item 63: 注意 String 拼接的性能消耗

Beware the performance of string concatenation.

重复使用 + 拼接 nn 个字符串需要 n2n^2 的时间. 因为 String 中的值是不可变的, 所以拼接两个字符串会将它们都复制一遍.

Use the string concatenation operator(+) repeatedly to concatenate nn strings requires time quadratic in nn.

使用 StringBuilder 来进行字符串的拼接.

To achieve acceptable performance, use a StringBuilder in place of a String.

在需要保证线程安全的环境中, 可以使用 StringBuffer.

总的来说, 当需要拼接好几个字符串的时候, 就不要使用 + 直接拼接了, 而是用 StringBuilder 代替.

Item 64: 通过接口引用对象

Refer to objects by their interfaces.

只要有合适的接口类型, 就使用接口类型声明参数, 返回值, 变量和字段.

If appropriate interface types exist, then parameters, return values, variables, and fields should all be declared using interface types.

使用接口引用对象使得程序更加灵活可扩展.

If you get into the habit of using interfaces as types, your program will be much more flexible.

当然, 如果没有适合的接口类型, 还是需要用对象本身的类型去引用. 我们是尽量去使用更抽象的类型去引用他们的子类型实例.

If there is no appropriate interface, just use the least specific class int the class hierarchy that provides the required functionality.

Item 65: 使用接口代替反射

Prefer interfaces to reflection.

Java 的反射带给程序员可以访问任何类的功能. 只要有一个类的实例, 我们就可以获取到它的从构造器, 方法到字段的任何属性.

基于反射的功能, 我们可以执行从反射中获取到的构造器/方法, 如: Method#invoke().

但是反射也会带来一些问题:

  1. 无法从编译器的类型检查中获益. 直接通过 Method#invoke() 调用无法进行参数类型检查.
  2. 使用反射是一个很笨拙和麻烦的过程. 代码编写很死板, 并且可读性很差.
  3. 反射的性能不好. 相较于普通的方法调用, 基于反射的调用性能很低, 可能会有高达 10 倍左右的性能差距.

但是一些框架还是需要使用反射, 比如 Spring Framework 通过反射实现 IOC.

对反射进行合理且有限的使用可以在付出较少性能代价的情况下, 获得很多益处.

You can obtain many of the benefits of reflection while incurring few of its costs by using it only in a very limited form.

对于一般的基于反射的方法调用, 我们可以通过反射创建实例, 然后通过他们的接口或者父类进行方法调用.

You can create instances reflectively and access them normally via their interface or superclass.

这种思想其实就是简化的 Service Provider Framework, 或者说 Service Provider Interface(SPI).

Item 66: 谨慎使用 native 方法

Use native methods judiciously.

Java Native Interface(JNI) 允许 Java 开发者调用基于本地编程语言(native programming language)如 C, C++ 编写的 native 方法.

Java 的 native 方法主要有三个用途:

  1. 提供对特定平台的设施的访问, 如: 寄存器.
  2. 提供对已有 native 代码库的访问.
  3. 用于编写程序中对性能要求很高的部分.

但是, 对于普通开发者, 我们不推荐借助 native 方法来提升性能. 很多 Java 的官方库会在底层使用 JNI, 不需要我们主动去使用. 并且, native 方法是不安全的, 很容易出现内存冲突之类的问题, 也不便于 debug.

It is rarely advisable to use native methods for improved performance.

总之, 在使用 JNI 之前, 请三思.

Item 67: 谨慎地进行优化

Optimize judiciously.

始终记住, 优化是一件很难的事情, 不是想做就能做到的. 很多时候所谓的优化只会带来更多的麻烦, 尤其是在开发阶段过早地进行优化, 最后你得到了一坨人不人鬼不鬼的东西, 然后巨难受.

能不动就先别动, 除非你确定有一个完美的能够优化的方案, 并且你能够实现.

可以看一段有意思的话:

We follow two rules in the matter of optimization:

  1. Don't do it.
  2. (For experts only). Don;t do it yet--that is, not until you have a perfectly clear and unoptimized solution.

-- M. A. Jackson

其实在写代码时, 我们不应该总是关注优化之类的问题.

相比于写出快的代码, 不如努力写出好的代码.

Strive to write good programs rather than fast ones.

好的代码是高效率代码的基础, 好代码的好结构使得它能够更轻易地进行优化.

再之后就是, 什么是快的代码? 不就是不慢的嘛~

尽量避免限制性能的代码设计.

Strive to avoid design decisions that limit performance.

考虑 API 设计对性能的影响. 比如让一个公共类型可变需要很多不必要的防御性复制动作.

Consider the performance consequences of your API design decisions.

为获得良好的性能而更改 API 是很糟糕的决定. 导致更改 API 的性能缺陷可能在之后的版本中由于平台或者其他底层软件的更改而消失, 但是更改后的 API 需要很烦人的兼容性支持.

It's a very bad idea to warp an API to achieve good performance.

在每次尝试优化前后评估程序性能. 可能大多数时候优化并没有带来很大的性能提升, 甚至性能更差了, 因为我们很难猜测程序到底在什么地方消耗了大量时间. 不过我们可以通过一些分析工具来辅助判断.

Measure performance before and after each attempted optimization.

总之, 不要在性能优化上过于执着.

Item 68: 遵守普遍接受的命名约定

Adhere to generally accepted naming conventions.

Java 有一套完善的命名标准, 主要在 The Java Language Specification 中定义.

Java 中主要的命名问题还是拼写语法错误.

下面给出一些具体的命名建议:

  1. 对于包的命名:
    1. 不要和官方包重名.
    2. 尽量简短, 不超过 8 个字符.
    3. 如果名称过长, 将其划分为多个层次的包.
  2. 类和接口的命名:
    1. 每个单词首字母都要大写.
    2. 尽量只大写每个单词的首字母, 对比: HttpUrlHTTPURL(不如不写)
    3. 包含一个以上的单词.
    4. 尽量不要使用缩写, 除非缩写非常常见, 如: max, min 等.
  3. 方法和字段的命名:
    1. 除了名称首字母小写外, 和类与接口的相同.
    2. 常量字段所有字母大写, 字母之间用下划线(_)隔开.(只有常量名称中推荐包含下划线).
    3. 局部变量类似, 不过允许和鼓励合理的缩写, 如: cnt, num 等.
  4. 类型参数主要使用以下几个单个的大写字母:
    1. T: 一个特定类型.
    2. E: 集合中的元素类型.
    3. K, V: map 中的键/值的类型.
    4. X: 异常类型.
    5. R: 返回类型.
    6. T, U, V or T1, T2, T3: 连续的特定类型.

更多的内容详见 The Java Language Specification.

异常

Exceptions

Item 69: 只在异常情况下使用异常

Use exceptions only for exceptional conditions.

假设你很不幸, 遇到了这样一段代码:

try {
    int i = 0;
    while(true) {
        range[i++].climb();
    }
} catch(ArrayIndexOutOfBoundsException e) {}

不知道读者们如何看到这样一段代码, 它通过抛出并捕获异常的方式结束 while 循环, 显得很"超然世外". 正常来说, 我们的代码应该是这样的:

for (Mountain m : range) m.climb();

通过异常来结束循环会比正常使用 for 循环慢很多, 并且我们无法保证它能够正常的工作, 因为可能在执行过程中出现其他的异常.

异常只应该被应用于异常情况发生时, 而不应该被用在一般性的控制流中.

Exceptions are, as their name implies, to be used only for exceptional conditions; they should never be used for ordinary control flow.

一个设计良好的 API 也不应该强迫使用者将异常用于一般性控制流中.

A well-designed API must not force its clients to use exceptions for ordinary control flow.

Item 70: 可恢复环境使用受检异常, 程序错误使用运行时异常

Use checked exceptions for recoverable conditions and runtime exceptions for programming errors.

Java 提供了三种类型的异常: 受检异常(checked exception), 运行时异常(runtime exception), 错误(error).

初学者往往分不清楚在什么情况下使用它们, 下面给出一些 tips:

对于调用者可以合理预期恢复的情况, 使用受检异常. 我们可以通过抛出受检异常的方式强制调用者捕获或者继续抛出对应的异常, 这样能够使调用者清晰地知道调用该方法可能出现的问题, 并做出相应的处理/恢复措施.

Use checked exceptions for conditions from which the caller can reasonable be expected to recover.

运行时异常和错误, 都是不受检异常. 它们都是不必要, 而且大部分时候不应该被捕获的. 如果一个程序抛出了运行时异常或者错误, 那大概表明在这种情况下是很难进行恢复或者继续执行的.

使用运行时异常来表明程序错误. 大部分运行时异常都是在表示前提条件违规(precondition violation), 即 API 的使用不符合它要求的规则. 如: ArrayIndexOutOfBoundsException 表示数组的索引违反了其规定.

Use runtime exceptions to indicate programming errors.

但是我们有时候并不能确定运行时异常发生的情况能够被恢复. 比如说, 我们在申请一块较大内存的时候, 由于空间不足, 抛出了运行时异常; 但是我们并不能知道内存不足是由于暂时的高内存占用还是长时间的占用, 因此不便于判断能够恢复并继续申请内存.

如果我们能够确定异常抛出后是能够恢复的, 那么抛出受检异常. 如果不行, 或者不确定能恢复, 那么就抛出运行时异常.

虽然 Java 语言规范(Java Language Specification)中没有明确说明, 但是错误(error)是专门为 JVM 保留的, 使用者不应该在程序中主动抛出任何异常.

所有的运行时异常都应该直接或者间接地将 RuntimeException 作为父类.

All of the unchecked throwables you implement should subclass RuntimeException.

我们自己设计的异常要么是 Exception 的子类, 要么是 RuntimeException 的子类, 如果是错误的话就是 Error 的子类. 但是还可以通过实现 Throwable 的方式定义异常, 但是非常不推荐这样使用, 因为 JLS 不能对它很好地进行处理.

很多开发者会忽略一个点: 异常类也是一个功能完备的类, 能够在其中定义方法. 尤其是可以被恢复的受检异常, 我们可以在异常中定义用于进行恢复或者获取更多信息的方法. 具体会在 Item 75 中介绍.

Item 71: 避免不必要地使用受检异常

Avoid unnecessary use of checked exceptions.

受检异常强制 API 调用者捕获可能出现的所有受检异常, 过多地抛出受检异常会使得调用者非常烦躁. 并且, 会抛出受检异常地方法不能被直接用于 Stream API 中.

只有当调用者不能通过合理使用 API 并且可以从 API 抛出的异常中采取一些措施时, 受检异常才应该被使用. 我们在抛出受检异常时, 可以先设想一下调用者如何处理捕获到的异常? 如果没有很合适的处理方式, 那么就不要将其作为受检异常抛出.

This burden may be justified if the exceptional condition cannot be prevented by proper use of the API and the programmer using the API can take some useful action once confronted with the exception.

很多时候, 一个会抛出受检异常的方法可以被分为两个方法: 一个是返回值为 boolean 的方法, 它会在会抛出异常的情况下返回 false; 另一个是按照正常逻辑执行的方法.

我们可以通过这种方式进行优化:

try {
    obj.action(args);
} catch(TheCheckedException e) {
    ... // handle the checked exception
}

划分方法:

if (obj.actionPermitted(args)) obj.action(args);
else ... // handle the exceptional condition.

Item 72: 尽量使用标准异常

Favor the use of standard exceptions.

相较于初级开发者, 资深的开发者总是能够很好地进行代码复用. JDK 中提供了能够满足大多数 API 需求的异常, 我们推荐尽量使用标准异常.

使用标准异常使得 API 更易于被学习和使用, 因为使用者会更加熟悉标准异常. 这也使得 API 可读性更好. 并且, 我们可以定义和使用更少的异常类, 减少了一定的内存使用和类加载的时间.

最常被复用的异常是 IllegalArgumentExceptionIllegalStateException, 以及 NullPointerExceptionArrayIndexOutOfBoundsException. 在并发上, 我们还会使用到 ConcurrentModificationException. 在使用一些带有可选选项的方法时, 还可以抛出 UnsupportedOperationException.

还有一个很重要的要求: 不要直接使用 Exception, RuntimeException, Throwable 或者 Error.

Do not use Exception, RuntimeException, Throwable, or Error directly.

Item 73: 抛出符合抽象层级的异常

Throw exceptions appropriate to the abstraction.

一个方法应该抛出和他所执行的任务有关的方法, 否则抛出的异常没有意义. 因为一些继承/实现/调用上的原因, 抽象层级高的方法可能会捕获层级更低的方法抛出的异常, 但是这些异常和对应的层级没有很好的关联.

抽象层级高的方法应该捕获层级更低方法抛出的异常, 并且抛出与他们抽象层级对应的异常.

Higher layers should catch lower-level exceptions and, in their place, throw exceptions that can be explained in terms of the higher-level abstraction.

try {
    ... // Use lower-level abstraction to do our bidding
} catch (LowerLevelException e) {
    throw new HigherLevelException(...);
}

如果我们在抛出符合抽象层级的异常的同时, 还想保留低级的异常, 可以将其作为参数传入构造器中. 我们将这种方法称为异常链(exception chaining).

    ... // Use lower-level abstraction to do our bidding
} catch (LowerLevelException cause) {
    throw new HigherLevelException(cause);
}

这里给出一个 JDK 中的经典例子--AbstractSequentialList#get(int):

public E get(int index) {
    ListIterator<E> i = listIterator(index);
    try {
        return i.next();
    } catch (NoSuchElementException e) {
        throw new IndexOutOfBoundsException("index: " + index);
    }
}

虽然异常转换优于无脑传播低层级异常, 但是也不应该过度使用. 很多时候可以解决这些低层级的异常, 而不是继续抛出.

While exception translation is superior to mindless propagation of exceptions from lower layers, it should not be overused.

Item 74: 文档化每个方法抛出的所有异常

Document all exception thrown by each method.

在文档中注释每个可能抛出的异常是非常重要的, 这有助于使用中了解抛出异常的原因和可能的解决方法.

总是独立地声明受检异常, 并且在文档中使用 @throw 明确说明抛出异常的原因. 我们希望方法抛出的异常是精确的, 而不是很模糊的概括. 开发者更希望看到抛出的异常是 NoSuchElementException, IllegalArgumentException, 而不是 Exception, RuntimeException, 更不是 Throwable. 当然, 对于只会被 VM 执行的 main() 方法, 可以直接抛出 Exception.

Always declare checked exceptions individually, and document precisely the conditions under which each one is thrown using @throws tag.

虽然 Java 不强制要求开发者声明不受检异常, 不过我们还是推荐对这些异常进行详细的注释, 因为为受检异常更多代表系统的错误, 不易被恢复.

在文档中使用 @throws 为每个异常进行标注, 但是不要在方法上使用 throws 声明不受检异常.

Use the Javadoc @throws tag to document each exception that a method can throw, but do not use the throws keyword on unchecked exceptions.

如果一个异常会在类中的很多方法中都因为相同的原因抛出,那么可以在类的文档中对其进行注释.

If an exception is thrown by many methods in a class for the same reason, you can document the exception in the class's documentation comment rather than documenting it individually for each method.

Item 75: 在详细信息中包含故障捕获信息

Include failure-capture information in detail messages.

开发者在遇到故障时, 不止想要知道发生的是什么故障, 还想要知道是什么导致了故障, 或者说发生故障的上下文信息. 因此, 我们在打印故障信息时, 需要将相关的上下文信息包含进去.

捕获故障时, 详细信息中应该包含导致故障发生的所有参数和字段信息. 比如, 当发生 ArrayIndexOutOfBoundsException 时, 我们希望看到导致故障的 index 的值是多少, lowerBoundupperBound 分别是多少.

To capture a failure, the detail message of an exception should contain the values of all parameters and fields that contributed to the exception.

但是, 不要在故障捕获信息中包含密码, 盐等敏感信息.

Do not include passwords, encryption keys, and the like in detail messages.

Item 76: 尽力保证故障原子性

Strive for failure atomicity.

总的来说, 一个执行错误的方法应该保证其上下文中的对象状态保持在发生错误前. 这样有利于对故障进行恢复, 防止发生更多的异常. 这种操作称为故障原子性(failure atomicity).

Generally speaking, a failed methods invocation should leave the object in the state that it was in prior to the invocation.

一般来说, 有如下几种实现故障原子性的方式:

  1. 使用不可变对象.
  2. 如果是可变对象, 在方法正式执行前完成所有参数检查.
  3. 合理划分方法执行的阶段, 保证每个阶段都是故障原子性的.
  4. 在备份中进行操作.
  5. 发生异常后主动进行故障恢复.

Item 77: 不要忽略异常

Don't ignore exceptions.

异常就是用来传递程序运行过程中的错误信息的, 如果我们忽略异常信息, 那么和没有异常没有任何区别.

一个空的 catch 块违背了使用异常的初衷.

An empty catch exception defeats the purpose of exceptions.

如果一些异常确实可以被忽略, 那么 catch 块中必须包括一个解释为什么可以忽略的注释, 并且异常变量应该被命名为 ignored.

If you choose to ignore an exception, the catch block should contain a comment explaining why it is appropriate to do so, and the variable should be named ignored.

try {
    ... // Do some additional operation.
} catch (TimeOutException ignored) {
    // Use default: this operation is not must required.
}

并发

Concurrency.

Item 78: 并行化对共享可变对象的访问操作

Synchronize access to shared mutable data.

对于需要互斥访问或者线程间交流的情况, 需要并行化操作.

Synchronization is required for reliable communication between threads as well as for mutable exclusion.

在线程控制上, 不要使用 Thread#stop() 方法. 因为这个方法是不安全的, 可能会导致数据冲突. 我们推荐使用更优雅的停止线程的方式:

public class StopThread {
    private static volatile stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) i++;
        });

        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }

    public void requestStop() {
        stopRequested = true;
    }
}

并发访问任何的共享可变数据时, 需要对所有的读写操作都进行并行化处理.

When multiple threads share mutable data, each thread that reads or writes the data must perform synchronization.

Item 79: 避免过度同步

Avoid excessive synchronization.

过度的同步可能导致性能降低, 死锁, 甚至不可预期的行为.

为了避免性能或安全故障,不要将同步方法或块内的控制权交给调用者. 也就是说, 不要在同步块中执行会被重写的方法, 或者外部传入的函数式对象.

To avoid liveness and safety failures, never cede control to the client within a synchronized method or block.

在同步块中做的事情越少越好.

As a rule, you should do as little work as possible inside synchronized regions.

Item 80: 使用线程池, 任务池和流替代线程

Prefer executors, tasks, and streams to threads.

用户自己使用 Thread 进行并发操作并不能很好的控制线程的数量和声明周期, 导致一些性能问题.

java.util.concurrent 包中提供了很多用于实现并发的方法, 都比单独使用 Thread 好. 如: ExecutorService, 或者直接使用 ThreadPoolExecutor 来获得对线程池的最大控制权.

对于一些特殊的任务, 我们还可以使用 ForkJoinPool 来完成.

流处理中使用并行操作在之前就已经讲解过了, 这里不再赘述.

Item 81: 使用并发工具代替 wait 和 notify

Prefer concurrent utilities to wait and notify.

在 Java 中, wait()notify() 是最基础的进行并发控制的方法. 它们使用起来并不方便, 我们推荐使用更高层次的封装--java.util.concurrent 提供的并发工具.

Given the difficulty of using wait and notify correctly, you should use the higher-level concurrent utilities instead.

并发集合中的并发操作是不能被制止的, 如果为其加锁只会降低程序性能. 并且, 并发集合中的并发操作一定是线程安全的, 我们没必要对其进行任何的限制.

It is impossible to exclude concurrent activity from a concurrent collection; locking it will only slow the program.

JUC(java.util.concurrent)中为很多集合提供了它们对应的进行了并发优化的集合类, 如: ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentSkipListMap, ConcurrentSkipListSet 等.

在一些 API 的使用上, 也推荐使用线程安全的, 如: 使用 System.nanoTime() 替代 System.currentTimeMillis() 等.

Item 82: 文档化线程安全情况

Document thread safety.

在进行并发编程时, API 的调用者需要明确知道一个方法是否是线程安全的, 或者说在什么情况下是线程安全的. 因此, 在文档中加入线程安全相关的信息非常重要.

To enable safe concurrent use, a class must clearly document what level of thread safety it supports.

不要以为在代码中使用了 synchronized 就能在文档中表明方法是线程安全的. Javadoc 不会对使用了 synchronized 关键字的代码在文档上进行任何特殊的处理.

The presence of the synchronized modifier in a method declaration is an implementation detail, not a part of its API.

线程不是要么安全, 要么不安全的, 而是具有一些不同的分级. 下面给出一些用于标注线程安全的 tag:

  1. 不可变(Immutable): 类的实例是不可变的, 如: String, Long 或者 AtomicInteger.
  2. 无条件线程安全(Unconditionally thread-safe): 在任何情况下都是线程安全的.
  3. 条件性线程安全(Conditionally thread-safe): 在一定条件下是线程安全的.
  4. 线程不安全(Not thread-safe): 本身是线程不安全的, 使用者需要增加额外的线程安全手段来保证线程安全.
  5. 线程对立(Thread-hostile): 不适合用于并发中.

Item 83: 谨慎使用懒加载

Use lazy initialization judiciously.

懒加载确实能一定程度上提高程序的性能, 不过也可能带来线程安全问题.

这里用一种常见的懒加载举例--懒汉式单例.

一些初级开发者可能会写出如下的代码:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public Singleton getInstance() {
        if (instance == null) instance = new Singleton();
        return instance;
    }
}

看起来好像没有任何问题, 但是在并发条件下, 这个代码不能保证一定是单例的, 这是由 Java 的执行特性导致的, 具体可以见 Java 并发编程的艺术.

在编写懒汉式单例的时候, 我们通常会说需要双重检查锁定(double-check).

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) instance = new Singleton();
            }
        }

        return instance;
    }
}

在使用懒加载时, 一定要考虑是否需要保证线程安全.

Item 84: 不要依靠线程调度器

Don't depend on the thread scheduler.

如果当前有很多线程都处于就绪状态, 那么会由线程调度器决定哪些线程能被执行, 执行多长时间. 但是不同调度器的决策是不同的, 因此我们不能依靠调度器对线程进行控制.

Any program that relies on the thread scheduler for correctness or performance is likely to be nonportable.

实现健壮, 可用和便捷的程序的最佳方式就是让平均可运行线程数量稳定在处理器数量的一半左右. 这可以很大程度上掩盖调度器的决策方式, 让调度器可以使可运行线程运行到它们停止为止.

为了节省处理器资源, 没有进行有效工作的线程不应该继续执行.

Thread should not run if they aren't doing useful work.

Thread#yield() 的执行结果也是不可控的, 取决于 JVM 如何实现.

Thread#yield() has no testable semantics.

另外就是线程优先级的问题了. Java 的线程优先级不一定会生效, 因为它只是对操作系统在调度时起到一个"建议"的作用.

Thread priority are among the least portable features of Java.a

序列化

Serialization.

Item 85: 优先使用 Java 序列化的替代方案

Prefer alternatives to Java serialization.

Java 的序列化功能在它被提供的时候就是有风险的. Java 能从 ObjectInputStream#readObject() 方法中反序列化几乎所有的类实例, 这使得序列化很难做到完全的安全. 同时, 还需要考虑第三方包的问题.

最好的解决办法就是不进行任何的反序列化操作. 并且, 也不要在任何(新的)程序中使用 Java 的序列化功能.

The best way to avoid serialization exploits is never to deserialize anything.

There is no reason to use Java serialization in any new system you write.

在开发中, 常见的跨平台序列化方式主要有两种--JSONProtocol Buffers(protobuf). 这两者的最大区别就是 JSON 是可读的文本形式的, 而 protobuf 是更高效率的二进制数据.

如果我们没有办法避免反序列化, 那么建议使用以上两种之一. 并且, 永远不要反序列化不可信数据.

Never deserialize untrusted data.

我们可以对需要反序列化的数据进行限制, 这种限制最好使用白名单, 而不是黑名单.

Prefer whitelisting to blacklisting.

Item 86: 谨慎实现 Serializable 接口

Implement Serializable with great caution.

实现 Serializable 接口的最大问题就是它降低了类实现的灵活性. 当一个类实现了 Serializable 接口, 它的序列化格式就会作为一个 API 对外暴露. 当这个类的 API 被广泛使用后, 就需要在后续的版本中都去长期维护它的序列化功能.

A major cost of implementing Serializable is that it decreases the flexibility to change a classes implementation once it has been released.

我们在 Item 85 中就提到了, 使用 Java 的序列化方式可能会导致一些 bug 或者安全性问题.

A second cost of implementing Serializable is that it increases the likelihood of bugs and security holes.

另一个问题就是实现序列化接口会令类的测试工作更加繁重.

A third cost of implementing Serializable is that it increases the testing burden associated with releasing a new version of a class.

当我们想要实现序列化接口时, 考虑一下这样做的收益和风险.

Implementing Serializable is not a decision to be undertaken lightly.

用于被继承的类大多数时候都不应该实现 Serializable 接口, 接口同理.

Classes designed for inheritance should rarely implement Serializable, and interfaces should rarely extend it.

内部类不应该实现 Serializable 接口.

Inner classes should not implement Serializable.

Item 87: 使用自定义序列化格式

Consider using a custom serialized form.

默认的序列化格式未必是最佳的, 我们可以使用自定义的序列化格式来达到更好的效果.

Do not accept the default serialized form without first considering whether it is appropriate.

只有在类的物理呈现和逻辑内容相同时, 默认的序列化格式才是可以接受的.

The default serialized form is likely to be appropriate if an object's physical representation is identical to its logical content.

及时决定使用默认的序列化方式, 也需要提供 readObject() 方法来保证不变性和安全性.

Even if you decide that the default serialized form is appropriate, you often must provide a readObject() method to ensure invariants and security.

在序列化时, 我们可以使用 transient 决定哪些字段会被序列化, 而哪些不会. transient 关键字修饰的字段不会被序列化.

确保不被 transient 修饰的字段都是类实例逻辑状态的一部分.

Before deciding to make a field nontransient, convince yourself that its value is part of the logical state of the object.

必须对所有的序列化操作采取同步操作. 一般来说都是在序列化和反序列化方法上使用 synchronized 修饰.

You must impose any synchronization on object serialization that you would impose on any other method that read the entire state of the object.

为任何的可序列化的类添加 UID 字段. 一旦设定之后, 就不要轻易改变 UID, 除非我们想要和之前的内容进行区分.

Regardless of what serialized form you choose, declare an explicit serial version UID in every serializable class you write. Do not change the serial version UID unless you want to break compatibility with all existing serialized instances of a class.

private static final long serialVersionUID = randomLongValue;

Item 88: 防御性地编写 readObject 方法

Write readObject() methods defensively.

就像我们在 Item 50 中提到的那样, 可以通过防御性副本保证更小被客户端破坏稳定性的可能.

反序列化对象时, 防御性地复制任何包含禁止客户端处理的对象引用的字段.

When an object is deserialized, it is critical to defensively copy any field containing an object reference that a client must not process.

Item 89: 优先使用枚举类型进行实例控制

For instance control, prefer enum types to readResolve().

实现了序列化的类会打破单例的特性, 因为它们可以被动态地反序列化生成新的类实例. 不过我们可以通过 readResolve() 方法来保证单例.

private Object readResolve() {
    return INSTANCE:
}

如果通过 readResolve() 进行实例控制, 那么所有的引用类型的实例字段都应该被 transient 修饰. 否则可能会在实例没有被创建时, 通过反序列化的方式创建不安全的实例, 导致安全性问题. 但是使用枚举类进行实例控制不会有这样的问题.

If you depend on readResolve() for instance control, all instance fields with object reference types must be declared transient.

readResolve() 方法的可访问性是很重要的. 如果方法被定义在被 final 修饰的类中, 那么它应该是 private 的; 反之, 就需要慎重考虑是否需要被子类重写了.

The accessibility of readResolve() is significant.

Item 90: 考虑序列化代理而不是序列化实例

Consider serialization proxies instead of serialized instances.

这里提供一种序列化代理模式(serialization proxy pattern): 使用一个私有静态内部类 来完整地表征封装类实例的所有逻辑状态. 这个私有静态内部类就是封装类的序列化代理(serialization proxy). 内部类的构造方法参数是它的封装类实例, 对封装类的所有逻辑状态进行简单复制(不需要进行任何的检查或防御性复制). 只需要内部类实现 Serializable 接口, 并且默认的序列化格式就是内部类的所有字段信息. 然后通过 writeReplace() 方法来创建内部类的实例. 用于序列化.

我们以 Item 50 中的类作为例子:

public final class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        
        // 先复制, 再比较
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    private static class SerializationProxy implements Serializable {
        private final Date start;
        private final Date end;

        SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }

        private static final long serialVersionUID = anyRandomLongNumber;
    }

    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    private void readObject(ObjectInputStream stream) throws InvalidObjectException {
        throw new InvalidObjectException("Proxy required");
    }

    private Object readResolve() {
        return new Period(start, end);
    }
}

结语

妈妈我终于看完这本书的英文版了, 太煎熬了呜呜呜呜. -- 2023.10.29