《Effective Java (3rd Edition)》中的最佳实践

摘自 Joshua Bloch 所著的《Effective Java (3rd Edition) 》

1. 返回空集合或空数组,而不是 null

如果方法返回一个 Set,在结果为空时不要 return null;,而应该 return Set.of();。

2. 考虑用静态工厂方法代替构造器

public static Boolean valueOf(boolean b) {
    return b ? Boolean.TRUE : Boolean.FALSE;
}

并不总是更好,但在某些场景下,它们有许多优势:

缺点:

3. 遇到多个构造器参数时,考虑使用构建器

静态工厂和构造器有一个共同的局限:当可选参数很多时,它们都不能很好地扩展。我参与过的一些项目就深受其害。

4. 用私有构造器或枚举类型强化单例属性

凡是不需要多个实例的东西,都应该只实例化一次,无论是借助 @ApplicationScoped 还是其他单例模式。

5. 用私有构造器强化不可实例化的能力

有时你会想写一个只包含一组静态方法和静态字段的类。这样的类名声不太好,因为有些人滥用它们来逃避以对象的方式思考,但它们确实有正当的用途。

private MyStaticClass() {
    // private constructor to prevent instantiation
}

6. 避免创建不必要的对象

String s = new String("bikini");  // DON'T DO THIS!

或者:

// Hideously slow! Can you spot the object creation?
private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++)
        sum += i;
    return sum;
}

答案:这个程序算出的结果是对的,但由于一个字符的笔误,它比应有的速度慢得多。变量 sum 被声明成了 Long 而不是 long,这意味着程序构造了大约 2^31 个不必要的 Long 实例(大致每次把 long i 加到 Long sum 上时就会创建一个)。

7. 避免使用终结器和清理器

终结器(finalizer)不可预测,往往很危险,而且通常没有必要。使用它们可能导致行为异常、性能低下以及可移植性问题。

8. try-with-resources 优先于 try-finally

// try-with-resources - the best way to close resources!
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
}

9. 考虑实现 Comparable

实现 Comparable 之后,你的类就能与依赖这个接口的众多泛型算法和集合实现互相配合。

10. 使类和成员的可访问性最小化

区分一个组件设计得好坏,最重要的一个因素就是:它在多大程度上对其他组件隐藏了自己的内部数据和其他实现细节。设计良好的组件会隐藏所有实现细节,把 API 与实现清晰地隔离开来。组件之间只通过 API 通信,彼此对对方的内部运作一无所知。这一概念被称为信息隐藏或封装,是软件设计的一项基本原则。

11. 在公共类中使用访问方法,而不是公共字段

有时你可能会忍不住写出一些退化的类,它们除了把实例字段归拢在一起之外,没有任何用途:

// Degenerate classes like this should not be public!
class Point {
    public double x;
    public double y;
}

由于这类类的数据字段是被直接访问的,它们无法提供封装的好处。不修改 API 就无法改变数据的表示方式,无法强制约束不变量,也无法在访问字段时执行额外的操作。坚定的面向对象程序员认为这样的类是大忌,应当始终用拥有私有字段和公共访问方法(getter)的类来代替,对于可变类,还要提供设值方法(setter):

// Encapsulation of data by accessor methods and mutators
class Point {

    private double x;
    private double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double getX() { return x; }
    public double getY() { return y; }

    public void setX(double x) { this.x = x; }
    public void setY(double y) { this.y = y; }
}

当然,对于公共类来说,强硬派是对的:如果一个类可以在其所在的包之外被访问,就应该提供访问方法,以保留日后修改类的内部表示的灵活性。如果一个公共类暴露了自己的数据字段,那么修改其表示方式的希望就彻底破灭了,因为客户端代码可能早已散布到各个角落。

不过,如果一个类是包级私有的,或者是私有嵌套类,那么直接暴露它的数据字段本身并没有什么错,前提是这些字段足以描述该类所提供的抽象。

12. 使可变性最小化

不可变类就是其实例不能被修改的类。每个实例所包含的全部信息在对象的整个生命周期内都是固定的,因此永远不会观察到任何变化。Java 平台类库中包含许多不可变类,比如 String、基本类型的包装类,以及 BigInteger 和 BigDecimal。这样做有很多充分的理由:不可变类比可变类更易于设计、实现和使用。它们更不容易出错,也更安全。要让一个类成为不可变类,需要遵循以下五条规则:

  1. 不要提供任何会修改对象状态的方法(也就是所谓的设值方法,mutator)。
  2. 确保类不能被扩展。这样可以防止粗心或恶意的子类假装对象的状态已经改变,从而破坏该类的不可变行为。防止子类化的常见做法是把类声明为 final。
  3. 把所有字段都声明为 final。这能以系统强制的方式清楚地表达你的意图。此外,正如内存模型中所阐明的,如果一个指向新创建实例的引用在没有同步的情况下从一个线程传递到另一个线程,就必须这样做才能保证行为正确。
  4. 把所有字段都声明为私有的。这样可以防止客户端获得字段所引用的可变对象,并直接修改这些对象。虽然从技术上讲,不可变类可以拥有包含基本类型值或不可变对象引用的 public final 字段,但并不推荐这样做,因为这会让你在以后的版本中无法改变内部表示。
  5. 确保对任何可变组件的互斥访问。如果类中有字段引用了可变对象,就要确保该类的客户端无法获得这些对象的引用。永远不要用客户端提供的对象引用来初始化这样的字段,也不要从访问方法中返回该字段。在构造器、访问方法和readObject 方法中使用保护性拷贝。

13. 组合优于继承

继承是实现代码复用的有力手段,但它并不总是完成这项工作的最佳工具。使用不当会导致软件变得脆弱。在同一个包内使用继承是安全的,因为子类和超类的实现都由同一批程序员掌控。对于专门为了扩展而设计并且有良好文档的类,使用继承也是安全的。然而,跨越包的边界去继承普通的具体类,则是危险的。

与方法调用不同,继承破坏了封装。你也许会认为,如果只是添加新方法、而不覆盖现有方法,扩展一个类就是安全的。虽然这种扩展要安全得多,但也并非没有风险。如果超类在后续版本中新增了一个方法,而你不幸已经给子类写了一个签名相同、返回类型不同的方法,那么你的子类将无法再通过编译。如果你给子类写的方法与新的超类方法签名和返回类型都相同,那你现在就是在覆盖它,于是又会遇到前面提到的那些问题。此外,你的方法能否满足新超类方法的契约也很值得怀疑,因为在你编写子类方法时,这份契约还根本没有写出来。

幸运的是,有一种办法可以避免上述所有问题。不要扩展现有的类,而是在新类中增加一个私有字段,让它引用现有类的一个实例。这种设计被称为组合,因为现有的类变成了新类的一个组件。新类中的每个实例方法都会调用所包含的现有类实例上对应的方法,并返回其结果。这被称为转发,新类中的这些方法被称为转发方法。由此得到的类将坚如磐石,不依赖于现有类的任何实现细节。即使现有的类添加了新方法,也不会对新类产生任何影响。

14. 接口优于抽象类

Java 有两种机制可以定义允许多种实现的类型:接口和抽象类。自从 Java 8 为接口引入默认方法以来,这两种机制都允许你为某些实例方法提供实现。

一个主要区别在于:要实现由抽象类定义的类型,类必须成为该抽象类的子类。由于 Java 只允许单继承,抽象类的这一限制严重制约了它们作为类型定义的用途。而任何定义了所有必需方法并遵守通用约定的类,都可以实现一个接口,无论这个类处在类层次结构中的哪个位置。现有的类可以很容易地进行改造,以实现一个新的接口。

15. 泛型:不要使用原始类型

直到运行时你才会发现这个错误,那时它早已发生很久了,而且出错的地方可能离包含错误的代码很远。一旦看到 ClassCastException,你就得在整个代码库里搜索,找出是哪一次方法调用把硬币放进了邮票收藏里。编译器帮不了你。

16. 泛型:消除非受检警告

用泛型编程时,你会看到许多编译器警告:

你对泛型积累的经验越多,遇到的警告就越少,但不要指望新写的代码能干干净净地通过编译。许多非受检警告都很容易消除。例如,假设你不小心写了这样一个声明:

Set<Lark> exaltation = new HashSet();

把它改成:

Set<Lark> exaltation = new HashSet<>();

有些警告则难以消除得多。遇到需要动脑筋的警告时,要坚持下去!要尽可能地消除每一个非受检警告。

如果消除了所有警告,你就可以确信代码是类型安全的,这是件非常好的事。这意味着你在运行时不会遇到 ClassCastException,也让你更有信心,相信程序会按你的意图运行。如果无法消除某个警告,但你能证明引起警告的代码是类型安全的,那么(并且只有在这种情况下)才可以用 @SuppressWarnings("unchecked") 注解来抑制该警告。如果没有先证明代码是类型安全的就抑制警告,那只是给了自己一种虚假的安全感。代码也许在编译时不会发出任何警告,但在运行时仍然可能抛出 ClassCastException。

每次使用 @SuppressWarnings("unchecked") 注解时,都要添加一条注释,说明为什么这样做是安全的。

17. 泛型:在合适的地方使用

哎哟,一个急需泛型的类:

一个急需泛型的类的示例

这样的代码最迟也应该在合并请求(merge request)阶段被拦下来!

18. 泛型:反面教材

抱歉,无需多言。请千万不要合并这样的代码:

public abstract class JpaLearningCardBaseContentService<LC extends LearningCardBaseContent<L, LC, AC>, J extends JpaLearningCardBaseContent<L, LC, AC>, L extends
        LearningCardBase<LC, A>, A extends AnswerOptionBase<L, AC>, AC extends AnswerOptionBaseContent<LC, AC, A>, AS extends AnswerOptionBaseService<A, AC, L, LC>, ACS extends
        AnswerOptionBaseContentService<AC, A, L, LC>, LS extends LearningCardBaseService<L>>
    extends JpaLightPersistenceService<LC, J, Long> implements LearningCardBaseContentService<LC, L, A, AC> {
    // ...
}

光是一个类声明,绝不应该要花 5 分钟才能看懂。

19. 列表优于数组

数组与泛型类型有两个重要的不同点。首先,数组是协变的。这个听起来很吓人的词其实只是表示:如果 Sub 是 Super 的子类型,那么数组类型 Sub[] 就是数组类型 Super[] 的子类型。相反,泛型是不变的:对于任意两个不同的类型 Type1 和 Type2,List<Type1> 既不是 List<Type2> 的子类型,也不是它的超类型。

20. Lambda 优先于匿名类

// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

应该这样写:

// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

21. 使局部变量的作用域最小化

要使局部变量的作用域最小化,最有力的方法就是在第一次使用它的地方进行声明……

22. for-each 循环优先于传统的 for 循环

// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
    ... // Do something with e
}

23. 了解并使用类库

假设你想生成介于 0 和某个上限之间的随机整数。面对这个常见的任务,很多程序员会写一个类似这样的小方法:

// Common but deeply flawed!
static Random rnd = new Random();

static int random(int n) {
    return Math.abs(rnd.nextInt()) % n;
}

这个方法看起来不错,但它有三个缺陷。第一,如果 n 是一个比较小的 2 的幂,随机数序列会在相当短的周期之后开始重复。第二,如果 n 不是 2 的幂,那么平均而言,某些数字会比其他数字更频繁地被返回。如果 n 很大,这种效应会相当明显。

random 方法的第三个缺陷是:在极少数情况下,它会发生灾难性的失败,返回一个超出指定范围的数字。这是因为该方法试图通过调用 Math.abs,把 rnd.nextInt() 返回的值映射为一个非负的 int。如果 nextInt() 返回Integer.MIN_VALUE,那么 Math.abs 也会返回 Integer.MIN_VALUE,此时假设n 不是 2 的幂,求余运算符(%)就会返回一个负数。这几乎肯定会导致你的程序失败,而且这种失败可能很难重现。

24. 如果需要精确的答案,请避免使用 float 和 double

涉及金额时请使用 BigDecimal。float 和 double 会产生舍入误差。

例如,假设你口袋里有 1.03 美元,花掉了 42 美分。你还剩多少钱?下面这段幼稚的程序片段试图回答这个问题:

System.out.println(1.03 - 0.42);

遗憾的是,它打印出的是 0.6100000000000001。这并不是个例。假设你口袋里有 1 美元,买了九个单价 10 美分的垫圈。你能找回多少零钱?

System.out.println(1.00 - 9 * 0.10);

根据这段程序片段,你会得到 $0.09999999999999998。

你可能会认为,只要在打印之前对结果进行舍入就能解决问题,但遗憾的是,这种做法并不总是奏效。例如,假设你口袋里有 1 美元,看到货架上摆着一排美味的糖果,标价分别为 10 美分、20 美分、30 美分,以此类推,直到 1 美元。你从 10 美分的那颗开始,每种糖果各买一颗,直到买不起货架上的下一颗为止。

25. 基本类型优先于装箱基本类型

// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

26. 通过接口引用对象

正例:

Set<Son> sonSet = new LinkedHashSet<>();

反例:

LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

27. 接口优先于反射

否则:

28. 遵守普遍接受的命名约定

Java 平台有一套完善的命名约定,其中许多都收录在《Java 语言规范》(The Java Language Specification)[JLS, 6.1] 中。

29. 只在异常情况下使用异常

// Horrible abuse of exceptions. Don't ever do this!
try {
    int i = 0;
    while (true) {
        range[i++].climb();
    }
} catch (ArrayIndexOutOfBoundsException e) {
    // catch it
}

这段代码是做什么的?仅凭阅读完全看不出来,单凭这一点就足以不用它了。原来,这是一种构思极其糟糕的遍历数组元素的惯用写法。当它试图访问数组边界之外的第一个元素时,会抛出、捕获并忽略一个 ArrayIndexOutOfBoundsException,从而结束这个无限循环。它本应等同于遍历数组的标准惯用法,而任何 Java 程序员一眼就能认出后者:

for (Mountain m : range) {
    m.climb();
}

顾名思义,异常只应该用于异常情况;它们永远不应该被用于普通的控制流。

30. 对可恢复的情况使用受检异常,对编程错误使用运行时异常

如果期望调用者能够合理地从某种情况中恢复,就使用受检异常。通过向用户抛出受检异常,API 设计者等于下达了一项必须从该情况中恢复的要求。用户可以捕获异常后置之不理,从而无视这项要求,但这通常不是个好主意。

用运行时异常来表明编程错误。绝大多数运行时异常都表示违反了前置条件。违反前置条件,就是 API 的客户端没有遵守 API 规范所确立的契约。例如,数组访问的契约规定,数组下标必须介于 0 和数组长度减 1 之间(含两端)。ArrayIndexOutOfBoundsException 就表明违反了这一前置条件。

31. 避免不必要地使用受检异常

总而言之,谨慎地使用受检异常可以提高程序的可靠性;而过度使用则会让 API 用起来十分痛苦。

Pair<Boolean, LearningCardGroup> answerLearningCard(@NotNull LearningCardGroup learningCardGroup,
                                                      LearningCardAssignment learningCardAssignment,
                                                      @NotNull Collection<AnswerOption> answers)
      throws InvalidTrainingStateException, PersistenceException, LearningCardGroupAlreadyCompletedException,
             ConsecutiveCorrectCountExceededException,
             PreviousLearningCardGroupUncompletedException, LearningCardGroupCooldownException,
             LearningCardGroupHasUncompletedLearningCardsException, InvalidParticipantException, TrainingNotSignedException; // but why ???
     // this is a mistake seen a lot with junior devs
     // exceptions are exceptionally slow and should be avoided for mere validation
     // the performance gets even worse if a stacktrace is printed, since logging and printing is one of the slowest possible actions

32. 优先使用标准异常

专家级程序员与经验不足的程序员的一个区别在于,专家们会追求并且通常也能实现高度的代码复用。

代码复用是件好事,异常也不例外。Java 类库提供了一组异常,覆盖了大多数 API 在抛出异常方面的绝大部分需求。复用标准异常有几个好处。其中最主要的是,它让你的 API 更易于学习和使用,因为它符合程序员早已熟悉的既定惯例。紧随其后的好处是,使用你的 API 的程序更易于阅读,因为其中不会充斥着陌生的异常。最后(也是最不重要的),异常类越少,内存占用就越小,加载类所花的时间也越少。

最常被复用的异常类型是 IllegalArgumentException。当调用者传入的参数值不合适时,通常就应该抛出这个异常。例如,如果某个参数表示某项操作的重复次数,而调用者传入了一个负数,就应该抛出这个异常。另一个经常被复用的异常是IllegalStateException。如果因为接收对象的状态而导致调用非法,通常就应该抛出这个异常。例如,如果调用者试图在某个对象被正确初始化之前就使用它,就应该抛出这个异常。

可以说,所有错误的方法调用归根结底都是非法参数或非法状态,但对于某些特定类型的非法参数和状态,还有其他约定俗成的异常。如果调用者在某个禁止传入 null 的参数中传入了 null,按照惯例应当抛出 NullPointerException,而不是 IllegalArgumentException。同样,如果调用者在表示序列下标的参数中传入了越界的值,就应当抛出 IndexOutOfBoundsException,而不是 IllegalArgumentException。

不要直接复用 Exception、RuntimeException、Throwable 或 Error。把这些类当作抽象类来对待。不过,如果你想把受检异常转换为运行时异常,并且确实清楚自己在做什么,也可以使用 RuntimeException。

33. 不要忽略异常

// Empty catch block ignores exception - Highly suspect!
try {
    ...
} catch (SomeException e) {
}

空的 catch 块会使异常失去存在的意义,而异常的意义就在于强制你处理异常情况。忽略异常就好比忽略火警警报,而且还把它关掉,让别人再也没有机会去确认是否真的着火了。