EFFECTIVE JAVA 3RD EDITION — 第八章 方法

Item 49 : Check parameters for validity

检查参数合法性

在方法或者是构造器的开始部分作必要的参数合法性检查,可以使用Objects.requireNonNull 或者是断言,断言的一个好处是如果没有开启断言的话对代码是没有任何侵入的;前者的好处是可以自定义错误信息。
同时在方法上添加 @throws 注解来说明该方法会抛出的异常,但是像空指针这种异常可以添加到类级别的注视上来避免重复劳动。

但并不是就可以滥用参数合法性检查这一点,通常,这种合法性的校验越少越好,
能够满足方法本身的计算就可以。

Item 50 : Make defensive copies when needed

在有需要的时候做保护性的复制

虽然Java是一种‘安全’的语言,编码的时候不需要考虑指针,内存之类的问题,但还是需要保护你的代码免遭外部调用者的(有意或无意的)破坏。
比如定义一个Period类:

// 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
}

上面的代码很容易遭到修改:

// 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!

解决这个可以使用不可变的LocalDateTime或者ZoneDateTime,或者在构造方法中做一个保护性的参互复制:

// 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);
}

参数合法性校验是在复制之后,而不是去校验原参数。这里没有使用Date的clone方法,因为Date不是不可变的,其clone方法返回的是一个不可靠的子类。
所以在做复制的时候不要依赖一个类型是不可靠的子类的参数。这样修改了上面的代码依旧可以被修改:

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

只需要做一个内部可变属性的复制即可:

// Repaired accessors - make defensive copies of internal fields
public Date start() {
    return new Date(start.getTime());
}
public Date end() {
    return new Date(end.getTime());
}

上面保护性的复制操作如果能够保证调用者不对类内部属性或者返回值作修改的情况下是可以省略的,因为有可能这个复制操作是十分消耗性能的,或者这个调用只是内部调用或者是包内可见也是可以省略的,同时需要在文档注释中写清楚调用者不要修改内部属性。

总的来说就是需要对内部可变对象作保护,避免外部的操作来修改他们。

Item 51 : Design method signatures carefully

小心设计方法签名

方法命名要慎重,首要就是易于理解,和包内其他方法保持一致的命名风格,然后需要和方法所在的地方保持一致,避免过长的方法名。

不要一味的提供方便的方法而忽略了每个方法应该都有其侧重。一个类过多的方法会增加阅读、测试、编写文档、学习和掌握的成本,特别是在接口上。在每一个提供的类或者接口上提供主要(核心)的方法支持。

避免出现过长的参数列表,最好少于4个。特别是构造方法,可以考虑Builder模式。
过长的参数列表为调用者增加了麻烦,参数的顺序难以记住,可以使用下面3种方式来减少参数数量:

  1. 分解方法,把需要多个参数的方法分解成多个方法,每个方法只需要其中的几个参数;
  2. 使用helper类来处理长的参数列表,通常这些helper类是静态类;
  3. 使用Builder模式来处理,如果方法有很多参数,不如设计一个类来代替,同时这个类使用Builder模式来进行构造;
    在设计参数类型的时候,尽量使用接口而不是实现类,比如一个方法需要传入一个HashMap,最好参数就使用Map接口,这样就能处理Map其他的实现类,避免了没有必要的代码复制粘贴。

偏向于使用两个参数的枚举,而不是使用boolean来作为参数,除非你的boolean参数从方法中能够很清楚的表达其意思。使用枚举的好处就是能够方便的对枚举进行修改,同时不影响原有的代码。

Item 52 : Use overloading judiciously

正确的使用重载

// 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));
    }
}

上面代码输出 会Set,List,Unknown Collection?实际上会输UnknownCollection3次,因为上面的classify方法重载了,而决定执行哪个重载方法是在编译期决定的,
HashSet,ArrayList,HashMap在编译期都是Collection,所以最终执行的是第三个方法。

而重写则是在运行时决定执行父类方法还是重写方法,刚好和重载相反,重写方法的参数在编译期类型不影响最终重写方法的执行,其实区别就是两者执行最终方法的选择一个在编译期,一个在运行时。

上面的代码是很容易误导调用者的,同时出现问题的时候也难于排查,所以上面这种重载的代码最好不要出现。同时相同个数参数的重载和有变长参数列表的重载最好不要写,可以用不同的方法名来代替。

泛型和自动拆装箱的出现也让重载更加容易出错,比如下面的代码:

public class SetList {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<>();
        List<Integer> list = new ArrayList<>();
        for (int i = -3; i < 3; i++) {
            set.add(i);
            list.add(i);
        }
        for (int i = 0; i < 3; i++) {
            set.remove(i);
            list.remove(i);
        }
        System.out.println(set + " " + list);
    }
}

Set输出[-3,-2,-1]而List则输出[-2,0,2]
因为Set的remove方法是重载的remove(E)方法,编译期int到Integer出现了自动装箱;
而List的remove有两个重载,remove(int),remove(E),编译期发现是基本类型选择前者,于是移除的元素是按照数组下标来的,所以无法得到预期的结果,解决的话就是传入Integer对象即可,不管是强制转换还是调用Integer.valueOf。

同时Java8中引入的lambda表达式和方法引用同时也会造成一些问题,比如下面的代码:

new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);

第一个正常,第二个根本无法编译,原因:

  1. System.out::println这种方法引用是不准确的方法引用,因为它有好几个重载方法,编译器只有在具体的一个方法被调用了才会知道其具体意义;
  2. 确定的参数表达式中出现隐式的类型lambda表达式或者像1中的不准确的方法引用都是会被适用性测试所忽略(无法通过编译);
    所以这里需要注意的就是,不要在同一个参数位置重载来实现不同的接口功能。可以通过设置-Xlint:overloads让编译器遇到类似的问题时候发出警告。

虽然java库中很多地方都遵循了该小节的建议,但是依然有很多的地方有着不足。比如String的两个valueOf(Object),valueOf(char[]) 方法,一个是获取对象的string值,一个是构造一个新的String对象,做的根本就是毫无相关的事情。

总的来说就是虽然可以重载方法,但并不代表可以随意的重载方法,至少要避免相同的参数列表可以传入不同的重载方法。

Item 53 : Use varargs judiciously

正确使用可变长参数

可变参数的内部采用数组来实现,允许传入0个或者多个参数,但有时候需要在可变参数前面传入必要的参数来保证程序的正常执行,这个时候就不能只使用可变参数了,可以在可变参数列表前加入必须的参数。
同时由于是数组实现的,可能需要考虑性能的问题,这个时候就看你的需求来进行必要的不同参数个数的重载,比如:

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) { }

总之就是使用可变参数列表的时候添加一些必要参数,同时需要关注可变参数列表的性能问题来决定是否需要一些重载。

Item 54 : Return empty collections or arrays, not nulls

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

返回null并不会提升程序性能,相反返回空集合或者空数组也不会降低程序性能,如果实在不放心,可以返回Collections.emptyList等等,数组可以像下面一样返回:

//The right way to return a possibly empty array
public Cheese[] getCheeses() {
    return cheesesInStock.toArray(new Cheese[0]);
}

或者:

// Optimization - avoids allocating empty arrays
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {
    return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}

这样就可以重复的返回空数组了,但千万不要像下面一样:

// Don’t do this - preallocating the array harms performance!
return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]);

这样会造成性能问题。
总之无论什么情况都不要返回null,这对调用者也是非常友好的,因为并不是所有调用者都会去做一些null检验的。

Item 55 : Return optionals judiciously

正确的返回optional

Java8中添加了Optional来支持在返回null和抛出异常都不好的场景。首先抛异常的方式是有消耗的,同时是否是一个需要抛异常的场景也需要进一步考虑;而返回null虽然没有上面的问题,但是需要调用者写额外的代码来确保不会造成空指针。

阅读Optional的源码可以发现内部维护了一个final变量来存储实际的值,私有构造器,通过empty(),of(),ofNullable()方法来构造一个Optional对象,同时提供isPresent()方法来判断内部值是否为null(是否可用),orElse()方法在值不可用的时候来提供另外的备用值,同时也支持传入一个Supplier来获取值,或者值不可用的时候抛出指定异常的方法orElseThrow()。注意不要在Optional中返回null,这样做Optional就没有任何作用了。

Optional更像是一个检查异常,让调用者知道方法可能不会返回值。

并不是所有的类型都可以用Optional来返回,容器类的如集合,Map,流,数组和Optional本身都不要用Optional来包装返回,集合类的可以使用Item53提到的返回空集合。

通常如果方法有可能不会返回值并且调用者会编写额外代码来保证值的时候就可以返回Optional。不要返回基本类型包装类的Optional,这种情况提供了OptionalInt,OptionalLong,OptionalDouble来提供支持。同时,通常不会把Optional作为一个map的key或者是放入集合或者数组中。

总的来说当方法不能保证一定返回值并且调用者需要关心返回值的时候就可以返回Optional了,不要忽略Optional的返回也是需要消耗性能的,在一些极端性能场景返回null或者是抛出异常。

Item 56 : Write doc comments for all exposed API elements

为所有暴露(提供使用)的API对象编写文档

给你需要暴露出去的API每一个属性,方法,构造器,接口和类添加注释,如果可以序列化的话说明序列化的形式,文档对API开发者和调用者都要足够简洁。

使用@params标签来注释每一个方法参数,@throws来标明所有的异常,@return来说明返回值,同时注释中支持使用html标签,使用@code来格式化生成的文档,比如:

{@code index < 0 || index >= this.size()}

同时使用使用@implSpec来标明该方法可以被依赖,如果方法被继承或者被super()方式调用。同时为了避免一些html标签被转义,可以使用@literal标签来包裹,比如:

A geometric series converges if {@literal |r| < 1}.

实际文档则输出:A geometric series converges if |r| < 1.
同时该标签还可以防止不必要的文档结束,因为文档描述的结束是以第一个句号(.)后包含一个空格结束的,比如这样的注释:
A college degree, such as B.S., M.S. or Ph.D
实际生成的文档只有 A college degree,such as B.S., M.S.

为了避免这种情况,可以使用literal标签:

A college degree, such as B.S., {@literal M.S.} or Ph.D.

文档应该同时保证在源码和生成的html中具备可读性,如果不能同时保证,那至少要保证后者。

同时在Java9中添加了@index标签,Java9的文档在右上角添加了一个搜索框(可以参考Java9官方文档)这个标签的作用就是在搜索的时候能够显示你使用标签的内容,正常是显示所有的类,方法和属性,使用这个标签就可以显示你认为API中重要的内容(出现在搜索下拉框中)。

出现泛型和枚举的时候,务必对每一个泛型和枚举常量注释清楚。 容易忽略的就是类或者静态方法的线程安全性,都应该注释清楚线程安全级别。同时为了注释的重用,比如继承的时候,可以使用@inheritDoc标签。

总的来说就是写注释,使用好各种标签来生成完整可读的html文档。


本章主要讲的是在普通方法编写过程中需要注意的一些地方,从方法参数到正确的返回,然后是注释的编写,整个过程中需要遵守的一些点都清楚的罗列了出来,还是有很多地方值得学习的。

版权所有丨转载请注明出处:https://minei.me/archives/497.html