Effective-Java学习笔记(七)

第八章 方法

49. 检查参数的有效性

大多数方法和构造函数都对入参有一些限制,例如非空非负,你应该在方法主体的开头检查参数并在Javadoc中用@throws记录参数限制

Java7 添加了 Objects.requireNonNull 方法执行空检查

Java9 在 Objects 中添加了范围检查:checkFromIndexSize、checkFromToIndex 和 checkIndex

对于未导出的方法,作为包的作者,你应该定制方法调用的环境,确保只传递有效的参数值。因此,非 public 方法可以使用断言检查参数

// Private helper function for a recursive sort
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
}

断言只适合用来调试,可以关闭可以打开,public 方法一定要显示检查参数并抛出异常,断言失败只会抛出AssertionError,不利于定位错误

总而言之,每次编写方法或构造函数时,都应该考虑参数存在哪些限制。你应该在文档中记录这些限制,并在方法主体的开头显式地检查。

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

Java 是一种安全的语言,这是它的一大优点。这意味着在没有 native 方法的情况下,它不受缓冲区溢出、数组溢出、非法指针和其他内存损坏错误的影响,这些错误困扰着 C 和 C++ 等不安全语言。在一种安全的语言中,可以编写一个类并确定它们的不变量将保持不变,而不管在系统的任何其他部分发生了什么。在将所有内存视为一个巨大数组的语言中,这是不可能的。

即使使用一种安全的语言,如果你不付出一些努力,也无法与其他类隔离。你必须进行防御性的设计,并假定你的类的客户端会尽最大努力破坏它的不变量。

通过可变参数破坏类的不变量

虽然如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但是要提供这样的帮助却出奇地容易。例如,考虑下面的类,它表示一个不可变的时间段:

// Broken "immutable" time period class
public final class Period {
    private final Date start;
    private final Date end;

    /**
    * @param start the beginning of the period
    * @param end the end of the period; must not precede start
    * @throws IllegalArgumentException if start is after end
    * @throws NullPointerException if start or end is null
    */
    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;
    }
    ... // Remainder omitted
}

乍一看,这个类似乎是不可变的,并且要求一个时间段的开始时间不能在结束时间之后。然而,利用 Date 是可变的这一事实很容易绕过这个约束:

// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // Modifies internals of p!

从 Java8 开始,解决这个问题可以用 Instant 或 LocalDateTime 或 ZonedDateTime 来代替 Date,因为它们是不可变类。

防御性副本

但是有时必须在 API 和内部表示中使用可变值类型,这时候可以对可变参数进行防御性副本而不是使用原始可变参数。对上面的 Period 类改进如下

// Repaired constructor - makes defensive copies of parameters
public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
    if (this.start.compareTo(this.end) > 0)
        throw new IllegalArgumentException(this.start + " after " + this.end);
}
  1. 先进行防御性复制,再在副本上检查参数,保证在检查参数和复制参数之间的空窗期,类不受其他线程更改参数的影响,这个攻击也叫time-of-check/time-of-use 或 TOCTOU 攻击
  2. 对可被不受信任方子类化的参数类型,不要使用 clone 方法进行防御性复制。
  3. 访问器也要对可变字段进行防御性复制
  4. 如果类信任它的调用者不会破坏不变量,比如类和调用者都是同一个包下,那么应该避免防御性复制
  5. 当类的作用就是修改可变参数时不用防御性复制,客户端承诺不直接修改对象
  6. 破坏不变量只会对损害客户端时不用防御性复制,例如包装类模式,客户端在包装对象之后可以直接访问对象,破坏类的不变量,但这通常只会损害客户端

总而言之,如果一个类具有从客户端获取或返回给客户端的可变组件,则该类必须防御性地复制这些组件。如果复制的成本过高,并且类信任它的客户端不会不适当地修改组件,那么可以不进行防御性的复制,取而代之的是在文档中指明客户端的职责是不得修改受到影响的组件。

51. 仔细设计方法签名

  1. 仔细选择方法名称。目标是选择可理解的、与同一包中其它名称风格一致的名称;选择广泛认可的名字;避免长方法名
  2. 不要提供过于便利的方法。每种方法都应该各司其职。太多的方法使得类难以学习、使用、记录、测试和维护。对于接口来说更是如此,在接口中,太多的方法使实现者和用户的工作变得复杂。对于类或接口支持的每个操作,请提供一个功能齐全的方法。
  3. 避免长参数列表。可以通过分解方法减少参数数量;也可以通过静态成员类 helper 类来存参数;也可以从对象构建到方法调用都采用建造者模式
  4. 参数类型优先选择接口而不是类
  5. 双元素枚举类型优于 boolean 参数。枚举比 boolean 可读性强且可以添加更多选项

52. 明智地使用重载

考虑下面使用了重载的代码:

// Broken! - What does this program print?
public class CollectionClassifier {
    public static String classify(Set<?> s) {
        return "Set";
    }

    public static String classify(List<?> lst) {
        return "List";
    }

    public static String classify(Collection<?> c) {
        return "Unknown Collection";
    }

    public static void main(String[] args) {
        Collection<?>[] collections = {
            new HashSet<String>(),new ArrayList<BigInteger>(),new HashMap<String, String>().values()
        };
        for (Collection<?> c : collections)
            System.out.println(classify(c));
    }
}

这段代码打印了三次 Unknown Collection。因为 classify 方法被重载,并且在编译时就决定了要调用哪个重载,编译时是 Collections<?> 类型,所以调用的就是第三个重载方法

重载VS覆盖

重载方法的选择是静态的,在编译时决定要调用哪个重载方法。而覆盖方法的选择是动态的, 在运行时根据调用方法的对象的运行时类型选择覆盖方法的正确版本

因为覆盖是常态,而重载是例外,所以覆盖满足了人们对方法调用行为的期望,重载很容易混淆这些期望。

重载

  1. 安全、保守的策略是永远不导出具有相同数量参数的两个重载。你可以为方法提供不同的名称而不是重载它们。
  2. 构造函数只能重载,我们可以用静态工厂代替构造函数
  3. 不要在重载方法的相同参数位置上使用不同的函数式接口。不同的函数式接口并没有本质的不同

总而言之,方法可以重载,但并不意味着就应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造函数的情况下,可能难以遵循这个建议。在这些情况下,你至少应该避免同一组参数只需经过类型转换就可以被传递给不同的重载方法。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果你做不到这一点,程序员将很难有效地使用重载方法或构造函数,他们将无法理解为什么它不能工作。

53. 明智地使用可变参数

可变参数首先创建一个数组,其大小是在调用点上传递的参数数量,然后将参数值放入数组,最后将数组传递给方法。

当你需要定义具有不确定数量参数的方法时,可变参数是非常有用的。在可变参数之前加上任何必需的参数,并注意使用可变参数可能会引发的性能后果。

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

在方法中用空集合或空数组代替 null 返回可以让客户端不用显示判空

55. 明智地返回 Optional

在 Java8 之前,方法可能无法 return 时有两种处理方法,一种是抛异常,一种是 返回 null。抛异常代价高,返回 null 需要客户端显示判空

Java8 添加了第三种方法来处理可能无法返回值的方法。Optional<T> 类表示一个不可变的容器,它可以包含一个非空的 T 引用,也可以什么都不包含。不包含任何内容的 Optional 被称为空。一个值被认为存在于一个非空的 Optional 中。Optional 的本质上是一个不可变的集合,它最多可以容纳一个元素。

理论上应返回 T,但在某些情况下可能无法返回 T 的方法可以将返回值声明为 Optional<T>。这允许该方法返回一个空结果来表明它不能返回有效的结果。具备 Optional 返回值的方法比抛出异常的方法更灵活、更容易使用,并且比返回 null 的方法更不容易出错。

Item-30 有一个求集合最大值方法

// Returns maximum value in collection - throws exception if empty
public static <E extends Comparable<E>> E max(Collection<E> c) {
    if (c.isEmpty())
        throw new IllegalArgumentException("Empty collection");
    E result = null;
    for (E e : c)
        if (result == null || e.compareTo(result) > 0)
            result = Objects.requireNonNull(e);
    return result;
}

当入参集合为空时,这个方法会抛出 IllegalArgumentException。更好的方式是返回 Optional

// Returns maximum value in collection as an Optional<E>
public static <E extends Comparable<E>> Optional<E> max(Collection<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.empty() 返回一个空的 Optional,Optional.of(value) 返回一个包含给定非空值的 Optional。将 null 传递给 Optional.of(value) 是一个编程错误。如果你这样做,该方法将通过抛出 NullPointerException 来响应。Optional.ofNullable(value) 方法接受一个可能为空的值,如果传入 null,则返回一个空的 Optional。永远不要让返回 optional 的方法返回 null : 它违背了这个功能的设计初衷。

为什么选择返回 Optional 而不是返回 null 或抛出异常?Optional 在本质上类似于受检查异常(Item-71),因为它们迫使 API 的用户面对可能没有返回值的事实。抛出不受检查的异常或返回 null 允许用户忽略这种可能性,抛出受检查异常会让客户端添加额外代码

如果一个方法返回一个 Optional,客户端可以选择如果该方法不能返回值该采取什么操作。你可以使用 orElse 方法指定一个默认值,或者使用 orElseGet 方法在必要时生成默认值;也可以使用 orElseThrow 方法抛出异常

关于 Optional 用法的一些Tips:

  1. isPresent 方法可以判断 Optional中有没有值,谨慎使用 isPrensent 方法,它的许多用途可以用上面的方法代替
  2. 并不是所有的返回类型都能从 Optional 处理中获益。容器类型,包括集合、Map、流、数组和 Optional,不应该封装在 Optional 中。 你应该简单的返回一个空的 List<T>,而不是一个空的 Optional<List<T>>
  3. 永远不应该返回包装类的 Optional,除了「次基本数据类型」,如 Boolean、Byte、Character、Short 和 Float 之外
  4. 在集合或数组中使用 Optional 作为键、值或元素几乎都是不合适的。

总之,如果你发现自己编写的方法不能总是返回确定值,并且你认为该方法的用户在每次调用时应该考虑这种可能性,那么你可能应该让方法返回一个 Optional。但是,你应该意识到,返回 Optional 会带来实际的性能后果;对于性能关键的方法,最好返回 null 或抛出异常。最后,除了作为返回值之外,你几乎不应该以任何其他方式使用 Optional。

56. 为所有公开的 API 元素编写文档注释