Effective Java:07 方法

Demon.Lee 2024年10月07日 218次浏览

本文代码实践基于 JDK-21:java 21.0.4 2024-07-16 LTS

这一篇我们来总结一下 Java 类中的方法,结合代码的可读性、可维护性、健壮性和灵活性,讨论如何处理参数和返回值,如何设计方法签名等问题。


49 检查参数的有效性

基于快速失败的原则,这一条看起来理所当然,但真正落实到位也没那么简单:

  • 在文档中指出方法参数的限制;
  • 在方法体的开头处检查这些参数,以强制施加这些限制。

下面是一个样例,对于公有或受保护的方法,要用 Javadoc 中的 @throws 标签在文档中说明违反参数值限制时会抛出的异常:

/**
 * Returns a BigInteger whose value is {@code (this mod m}).  This method
 * differs from {@code remainder} in that it always returns a
 * <i>non-negative</i> BigInteger.
 *
 * @param  m the modulus.
 * @return {@code this mod m}
 * @throws ArithmeticException {@code m} &le; 0
 * @see    #remainder
 */
public BigInteger mod(BigInteger m) {
  if (m.signum <= 0)
    throw new ArithmeticException("BigInteger: modulus not positive");

  BigInteger result = this.remainder(m);
  return (result.signum >= 0 ? result : result.add(m));
}

Objects#requireNonNull(T obj) 方法可以用来验证非空参数,但它还有一个重载版本,可以自定义错误信息:

public static <T> T requireNonNull(T obj, String message) {
  if (obj == null)
    throw new NullPointerException(message);
  return obj;
}

在方法体的入口处进行校验同样适用于构造器方法,毕竟这些参数如果不检查,它们就是后面其他业务功能的雷,随时会爆,那时再找问题,成本可就高多了。如果是非公有方法,则因为开发者自己可以控制参数的有效性,通常使用断言(assertion)进行检查。默认情况下,断言在 JVM 中是被禁用的,除非使用 -ea 标记将其开启(当我们需要调试时,此时,如果 assertion 失败,则会抛出 AssertionError)。也就是说,默认情况下,断言不会有成本开销。

不过,凡事都有例外,不是啥情况都要把每个参数都检查到位,也要考虑成本,比如 Collections 类中的 sort 方法:

public static <T extends Comparable<? super T>> void sort(List<T> list) {
  list.sort(null);
}

能将 list 参数中的每个元素都检查一遍吗?成本太高了,只要方法能合理的工作,限制应该是越少越好。


50 必要时进行保护性拷贝

Java 是一门安全的语言,这里安全的意思是:

对于缓冲区溢出、数组越界、非法指针以及其他的内存破坏错误都自动免疫。

但安全不代表不会出现业务逻辑问题,一类常见的错误是类之间没有隔离开来,导致类的约束经常被破坏掉,比如:

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

咋一看,上面的代码好像没啥问题,但其实有很大的隐患,因为 java.util.Date 是可变的类,客户端在使用 Period 类时完全有可能会改变 start 或 end 的值:

Date start = new Date();
Date end = new Date();
Period period = new Period(start, end);
end.setYear(98);

这就是为什么我们需要进行保护性拷贝,特别是构造器方法中的可变参数:

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 start;
    }

    public Date end() {
        return end;
    }
}

这样就万无一失了吗?非也。start() 和 end() 方法把两个属性都暴露出去了,所以上面的问题又重现了。为此我们不得不将这两个方法也进行保护性拷贝处理,输出一个副本出去。其实,针对这个问题本身,更好的方案是使用 Java 8 版本开始引入的 java.time.Instantjava.time.LocalDateTime 等类,因为这些类都是不可变的,没有副作用。

细心的小伙伴可能看到,在上面的构造器方法中,if (start.compareTo(end) > 0) 这一行的校验放在了保护性拷贝后面,即参数检查是针对拷贝之后的对象,为什么?因为一旦检查原始对象,在检查完成后,保护性拷贝之前这段时间内,这些原始对象可能已经被其他线程变更过,其值已经不可靠。在计算机安全社区中,这被称作 Time Of-Check/Time Of-Use 或者 TOCTOU 攻击。

所以,无论是构造器方法还是导出方法,只要是可变类,参数流入或流出时都要考虑保护性拷贝。一个质疑的声音还是性能,其实没必要纠结,当你确认出现性能瓶颈时,再优化也不迟。如果一开始就知道拷贝成本高,那另当别论,比如在文档中指明客户端的责任。而如果只是包内访问,此时不进行拷贝也没关系,毕竟调用者也是“自己人”。


51 谨慎设计方法签名

为了让我们设计出的 API 具备良好的可读性和可维护性,方法签名不应复杂,下面是一些优秀实践:

1)谨慎地选择方法的名称

方法名称要一致,这里的一致包括包内风格一致和团队内的规范一致,如果团队没有统一的风格规范,则可以参考行业标准(大众认可),比如 JDK 中的某些类库。

2)不过于追求提供便利的方法

可以理解为单一职责原则,方法不能太多,一旦多,各种成本就高,比如学习、使用、测试和文档化等。另外,不要轻易添加方法的快捷方式,比如为 List 添加一个 addThree(一次性添加三个元素)方法:

// 反面案例
list.addThree("apple", "banana", "cherry"); 

3)避免过长的参数列表

关于这一点,笔者曾经总结过一篇《代码之丑》学习笔记–长参数列表,这里作者提供了 3 种解决思路:

  • 把一个方法分解成多个子方法(这些子方法之间尽量满足正交),每个方法只需要这些参数的子集;
  • 引入辅助类(helper class),即将这些参数抽象成一个独立的模型;
  • 使用 Builder 模式,这个我们在《Effective Java:01 创建和销毁对象》一文中已经总结过。

4)针对参数类型,要优先考虑接口而不是类

抽象优于实现,更能适应需求变化,比如 java.util.Collections 类中的 sort 方法,其参数是 List,而不是 ArrayList:

public static <T extends Comparable<? super T>> void sort(List<T> list) {
  list.sort(null);
}

5)针对 boolean 类型的参数,优先考虑枚举类型

虽然只是两个元素的枚举类型,但其可扩展性显然要高于写死的 True 和 False。


52 慎用重载

方法重载容易引起混淆,所以要谨慎,举个例子:

public class OverloadTests {

    // ⑥
    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    // ②
    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    // ③
    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    // ④
    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    // ①
    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    // ⑦
    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }

    // ⑤
    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

当我们把 ① 处的方法 public static void sayHello(char arg) 注释之后,再运行该方法,会打印 hello int。一旦这种重载的方法很多,就会造成使用者不知所措。当然,现实中应该不会有人写出上面这种代码,因为它们没有意义,除了某些公司拿这些来为难面试者。

重载在编译期决定如何调用,是静态的,而重写则是运行时决定,属于动态分派。 永远不要导出两个具有相同参数数目的重载方法,如果可以,使用不同的名称或许是更好的选择。

/**
 * Writes a 32-bit int.
 *
 * @param   val the integer value to be written
 * @throws  IOException if I/O errors occur while writing to the underlying
 *          stream
 */
public void writeInt(int val)  throws IOException {
  bout.writeInt(val);
}

/**
 * Writes a 64-bit long.
 *
 * @param   val the long value to be written
 * @throws  IOException if I/O errors occur while writing to the underlying
 *          stream
 */
public void writeLong(long val)  throws IOException {
  bout.writeLong(val);
}

/**
 * Writes a 32-bit float.
 *
 * @param   val the float value to be written
 * @throws  IOException if I/O errors occur while writing to the underlying
 *          stream
 */
public void writeFloat(float val) throws IOException {
  bout.writeFloat(val);
}

不过,对于构造器方法,它天生就是重载的,但我们依然可以使用静态工厂方法来曲线救国。但构造器方法有其优势,它不能被覆盖,也就不存在重载和覆盖的相互影响。

另外,还有两个场景因为 Java 版本的升级导致重载混淆。一个是在 Java 5 之后,由自动装拆箱机制造成的,比如:

List<Integer> list = new ArrayList<>();
Set<Integer> set = new TreeSet<>();

for (int i = -3; i < 3; i++) {
  list.add(i);
  set.add(i);
}

for (int i = 0; i < 3; i++) {
  list.remove(i);
  // list.remove((Integer)i);
  set.remove(i);
}

System.out.println("list: " + list); // [-2, 0, 2]
System.out.println("set: " + set); // [-3,-2,-1]

在 ArrayList 中有两个重载方法:

/**
 * Removes the element at the specified position in this list.
 * Shifts any subsequent elements to the left (subtracts one from their
 * indices).
 *
 * @param index the index of the element to be removed
 * @return the element that was removed from the list
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E remove(int index) {
  Objects.checkIndex(index, size);
  final Object[] es = elementData;

  @SuppressWarnings("unchecked") E oldValue = (E) es[index];
  fastRemove(es, index);

  return oldValue;
}

/**
 * Removes the first occurrence of the specified element from this list,
 * if it is present.  If the list does not contain the element, it is
 * unchanged.  More formally, removes the element with the lowest index
 * {@code i} such that
 * {@code Objects.equals(o, get(i))}
 * (if such an element exists).  Returns {@code true} if this list
 * contained the specified element (or equivalently, if this list
 * changed as a result of the call).
 *
 * @param o element to be removed from this list, if present
 * @return {@code true} if this list contained the specified element
 */
public boolean remove(Object o) {
  final Object[] es = elementData;
  final int size = this.size;
  int i = 0;
  found: {
    if (o == null) {
      for (; i < size; i++)
        if (es[i] == null)
          break found;
    } else {
      for (; i < size; i++)
        if (o.equals(es[i]))
          break found;
    }
    return false;
  }
  fastRemove(es, i);
  return true;
}

另一个则是 Java 8 中的方法引用,比如下面的代码会编译失败:

ExecutorService executorService = Executors.newCachedThreadPool();
executorService.submit(System.out::println); // compile error: reference to submit is ambiguous

这是因为 ExecutorService 中的 submit 方法有两个重载,而 System.out::println 并不是一个精确的方法引用:

Future<?> submit(Runnable task);

<T> Future<T> submit(Callable<T> task);

53 慎用可变参数

当我们不确定参数的数目时,便可以考虑可变参数,比如我们经常使用的 printf 方法(注意把必要的参数放前面):

public PrintStream printf(String format, Object... args) {
  return format(format, args);
}

这类方法又被称为可变参数方法(variable arity method),这里 args 其实是一个数组。也就是说,当我们像下面这样调用 printf 方法时,就会创建这样一个数组,这也是可变参数性能不足的地方:

System.out.printf("%s", "Hello World");
System.out.printf("%s, %d", "Hello World", 100);

为了避开性能瓶颈,一个常用的优化的方式是,对参数的个数进行重载,把大多数情况下使用的参数都覆盖到,比如 Set 接口中 of 方法(Java 9 开始支持):

static <E> Set<E> of();
static <E> Set<E> of(E e1);
static <E> Set<E> of(E e1, E e2);
static <E> Set<E> of(E e1, E e2, E e3);
static <E> Set<E> of(E e1, E e2, E e3, E e4);
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5);
//...
static <E> Set<E> of(E... elements);

54 返回零长度的数组或者集合,而不是 null

这一条比较简单,毕竟返回 null 会给使用方增加负担,因为需要额外的 null 校验。而一旦使用方忘记,就有可能引来万恶的 NullPointerException,基于此:

  • 针对返回集合的方法,我们可以用空集合代替 null,比如 Collections.emptyList();
  • 针对返回数组的方法,我们可以用零长度的数组代替,比如 new String[0]。

如果觉得零长度的数组会有性能影响,则可以将这个零长度数组声明成一个 static final 变量,从而达到复用的目的,这与 Collections.emptyList() 是一致的。


55 谨慎返回 optional

针对 Optional 的使用,多年前笔者也曾总结过:Optional——让你远离NullPointerException的利器

在上一条中,我们知道在返回集合或数组的方法中,用零长度的数组或者集合来代替 null,那如果是返回引用类型呢?没错,可以用 Optional 来包装 null 或具体的值。这也从另一个角度在暗示:不要用 Optional 来包装集合或数组,比如 Optional<List<T>>。另外,用 Optional 作为类的实例域、键、值、集合或数组的元素等,都是不合适的。因为这些用法多数情况下都没有清晰地表达业务意图,反而增加了复杂度。最极端的用法可能是在返回 Optional 的方法中,返回一个 null,这显然违背了 Optional 的本意。

从本质上讲,Optional 的用法跟 Checked Exception 是类似的。它们与返回一个 null 相比,其实是在告诉客户端(调用方):你们要处理没有值的情况。或者反过来讲,因为客户端需要在没有值时执行一些特殊处理,所以设计 API 时才考虑使用 Optional。此时,客户端就会做出选择,如果没有值我需要做什么?Optional.orElse/orElseGet/orElseThrow…

在 Stream 流中,经常会遇到 Stream<Optional<T>>,一般我们会按照下面的方式处理:

streamOfOptionals.filter(Optional::isPresent).map(Optional::get)

跟直接返回 null 相比,返回一个 Optional 对象肯定是有性能开销的。因此,我们不能在基本类型上使用 Optional,比如 Optional<Integer>,毕竟这跟 int 相比,多了两层开销。更好的选择是,官方为我们单独准备的 OptionalLong,OptionalInt 和 OptionalDouble,不得不说,这真是用心良苦。


56 为所有导出的 API 元素编写文档注释

这一条的标题虽然说的是导出的 API 元素,但其实,非导出的 API 元素也应编写文档注释:

  • API 元素包括类,接口,构造器,方法和域等;
  • 导出的 API 是为了描述它与客户端之间的约定;
  • 非导出的 API 则更多的是为了代码的可维护性,内部使用时不容易出错。

虽说一个好的命名能代替很多注释,但还有一些细节是需要输出的。

/**
 * Returns the element at the specified position in this list.
 *
 * @param index index of the element to return
 * @return the element at the specified position in this list
 * @throws IndexOutOfBoundsException if the index is out of range
 *         ({@code index < 0 || index >= size()})
 */
E get(int index);

完整的文档注释一般包括下面几点:

1)概要描述

一般是第一句。对于方法而言,此处是个完整的动词短语,用来描述该方法所执行的动作。如果是类,接口或域,概要描述往往是一个名词短语,描述接口或类的实例,或是域本身所代表的事物,下面是两个样例:

/**
 * An instantaneous point on the time-line.
 * ...
 * This is a <a href="{@docRoot}/java.base/java/lang/doc-files/ValueBased.html">value-based</a>
 * class; programmers should treat instances that are
 * {@linkplain #equals(Object) equal} as interchangeable and should not
 * use instances for synchronization, or unpredictable behavior may
 * occur. For example, in a future release, synchronization may fail.
 * The {@code equals} method should be used for comparisons.
 *
 * @implSpec
 * This class is immutable and thread-safe.
 *
 * @since 1.8
 */
@jdk.internal.ValueBased
public final class Instant
        implements Temporal, TemporalAdjuster, Comparable<Instant>, Serializable {
  // ...
}



public final class Math {
  // ...

  /**
   * The {@code double} value that is closer than any other to
   * <i>pi</i> (&pi;), the ratio of the circumference of a circle to
   * its diameter.
   */
  public static final double PI = 3.141592653589793;

  // ...
}

需要强调的是,这里应该描述这个方法或类是做什么,而不是怎么做,除非实现逻辑非常复杂,则可简单补充一下实现的路径。

2)前置条件/后置条件

precondition:客户端调用该类或方法时必须要满足的条件,一般情况下,@throws 标签中的内容就是。当然,也有通过 @param 标签指定前置条件。

postcondition:后置条件指的是,调用成功后,哪些条件必须满足。

3)副作用(side effect)

副作用指的是,调用结束后,系统中可以观察到的变化,但这些变化并包含在后置条件要求的变化列表中。

4)参数及返回值

每个参数都用一个 @param 标签进行说明,而返回值则用 @return 标签,跟在它们后面的应该是一个名词短语,表示这个参数或返回值所表示的值。对于异常,则使用 @throws 标签,在它的后面一般以单词 if 开头,后面再跟上一个名词短语,描述这个异常将在什么条件下抛出。

5)泛型,枚举和注解下的注释

泛型需要说明所有的类型参数,比如:

/**
 * An object that maps keys to values.  A map cannot contain duplicate keys;
 * each key can map to at most one value.
 * ...
 * ...
 * @param <K> the type of keys maintained by this map
 * @param <V> the type of mapped values
 */
public interface Map<K, V> {
  // ...
}

枚举中每个常量都要有说明,比如:

public enum DayOfWeek implements TemporalAccessor, TemporalAdjuster {

    /**
     * The singleton instance for the day-of-week of Monday.
     * This has the numeric value of {@code 1}.
     */
    MONDAY,
    /**
     * The singleton instance for the day-of-week of Tuesday.
     * This has the numeric value of {@code 2}.
     */
    TUESDAY,
    /**
     * The singleton instance for the day-of-week of Wednesday.
     * This has the numeric value of {@code 3}.
     */
    WEDNESDAY,
    /**
     * The singleton instance for the day-of-week of Thursday.
     * This has the numeric value of {@code 4}.
     */
    THURSDAY,
    /**
     * The singleton instance for the day-of-week of Friday.
     * This has the numeric value of {@code 5}.
     */
    FRIDAY,
    /**
     * The singleton instance for the day-of-week of Saturday.
     * This has the numeric value of {@code 6}.
     */
    SATURDAY,
    /**
     * The singleton instance for the day-of-week of Sunday.
     * This has the numeric value of {@code 7}.
     */
    SUNDAY;
  
   // ...
}

注解中成员,类型等都要有说明,比如:

public @interface Table {

    /**
     * (Optional) The name of the table.
     * <p> Defaults to the entity name.
     */
    String name() default "";
  
   // ...
}

如果想了解更多细节,官方的《How to Write Doc Comments for the Javadoc Tool》文档依然是最权威的指导。

最后,笔者用 javadoc 命令简单跑个示例,代码如下:

/**
 * 这是一个类示例
 * <p>这里可以添加更多描述</p>
 *
 * @author Demon.Lee
 * @date 2024-10-03 09:18
 */
public class CollectionTests {

  /**
   * main 函数
   * <p>这里可以添加更多描述</p>
   *
   * @param args 命令行参数
   */
  public static void main(String[] args) {
  }
}

运行命令:javadoc CollectionTests.java -d ./CollectionTests-java-doc ,此时会生成相关内容到 CollectionTests-java-doc 目录中:

# ls CollectionTests-java-doc
COPYRIGHT               element-list            jquery-ui.min.css       member-search-index.js  resources               stylesheet.css
CollectionTests.html    glass.png               jquery-ui.min.js        module-search-index.js  script-dir              tag-search-index.js
LICENSE                 help-doc.html           jquery.md               overview-tree.html      script.js               type-search-index.js
allclasses-index.html   index-all.html          jqueryUI.md             package-search-index.js search-page.js          x.png
allpackages-index.html  index.html              legal                   package-summary.html    search.html
copy.svg                jquery-3.6.1.min.js     link.svg                package-tree.html       search.js

打开 index.html,点击对应的类名,即可看到下面的页面:


Okay,本期内容就总结到这里,下期再见。