本文代码实践基于 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} ≤ 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.Instant
或 java.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> (π), 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,本期内容就总结到这里,下期再见。