欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
49.检查参数有效性
本章(第8章)讨论了方法设计的几个方面:如何处理参数和返回值,如何设计方法签名以及如何记载方法文档。 本章中的大部分内容适用于构造方法和其他普通方法。 与第4章一样,本章重点关注可用性,健壮性和灵活性上。
大多数方法和构造方法对可以将哪些值传递到其对应参数中有一些限制。 例如,索引值必须是非负数,对象引用必须为非null。 你应该清楚地在文档中记载所有这些限制,并在方法主体的开头用检查来强制执行。 应该尝试在错误发生后尽快检测到错误,这是一般原则的特殊情况。 如果不这样做,则不太可能检测到错误,并且一旦检测到错误就更难确定错误的来源。
如果将无效参数值传递给方法,并且该方法在执行之前检查其参数,则它抛出适当的异常然后快速且清楚地以失败结束。 如果该方法无法检查其参数,可能会发生一些事情。 在处理过程中,该方法可能会出现令人困惑的异常。 更糟糕的是,该方法可以正常返回,但默默地计算错误的结果。 最糟糕的是,该方法可以正常返回但是将某个对象置于受损状态,在将来某个未确定的时间在代码中的某些不相关点处导致错误。 换句话说,验证参数失败可能导致违反故障原子性(failure atomicity )(条目 76)。
对于公共方法和受保护方法,请使用Java文档@throws注解来记在在违反参数值限制时将引发的异常(条目 74)。 通常,生成的异常是IllegalArgumentException,IndexOutOfBoundsException或NullPointerException(条目 72)。 一旦记录了对方法参数的限制,并且记录了违反这些限制时将引发的异常,那么强制执行这些限制就很简单了。 这是一个典型的例子:
/**
* Returns a BigInteger whose value is (this mod m). This method
* differs from the remainder method in that it always returns a
* non-negative BigInteger.
*
* @param m the modulus, which must be positive
* @return this mod m
* @throws ArithmeticException if m is less than or equal to 0
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0)
throw new ArithmeticException("Modulus <= 0: " + m);
... // Do the computation
}
请注意,文档注释没有说“如果m为null,mod抛出NullPointerException”,尽管该方法正是这样做的,这是调用m.sgn()的副产品。这个异常记载在类级别文档注释中,用于包含的BigInteger类。类级别的注释应用于类的所有公共方法中的所有参数。这是避免在每个方法上分别记录每个NullPointerException的好方法。它可以与@Nullable或类似的注释结合使用,以表明某个特定参数可能为空,但这种做法不是标准的,为此使用了多个注解。
在Java 7中添加的Objects.requireNonNull方法灵活方便,因此没有理由再手动执行空值检查。 如果愿意,可以指定自定义异常详细消息。 该方法返回其输入的值,因此可以在使用值的同时执行空检查:
// Inline use of Java's null-checking facility
this.strategy = Objects.requireNonNull(strategy, "strategy");
你也可以忽略返回值,并使用Objects.requireNonNull作为满足需求的独立空值检查。
在Java 9中,java.util.Objects类中添加了范围检查工具。 此工具包含三个方法:checkFromIndexSize,checkFromToIndex和checkIndex。 此工具不如空检查方法灵活。 它不允许指定自己的异常详细消息,它仅用于列表和数组索引。 它不处理闭合范围(包含两个端点)。 但如果它能满足你的需要,那就很方便了。
对于未导出的方法,作为包的作者,控制调用方法的环境,这样就可以并且应该确保只传入有效的参数值。因此,非公共方法可以使用断言检查其参数,如下所示:
// 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
}
本质上,这些断言声称断言条件将成立,无论其客户端如何使用封闭包。与普通的有效性检查不同,断言如果失败会抛出AssertionError。与普通的有效性检查不同的是,除非使用-ea(或者-enableassertions)标记传递给java命令来启用它们,否则它们不会产生任何效果,本质上也不会产生任何成本。有关断言的更多信息,请参阅教程assert。
检查方法中未使用但存储以供以后使用的参数的有效性尤为重要。例如,考虑第101页上的静态工厂方法,它接受一个int数组并返回数组的List视图。如果客户端传入null,该方法将抛出NullPointerException,因为该方法具有显式检查(调用Objects.requireNonNull方法)。如果省略了该检查,则该方法将返回对新创建的List实例的引用,该实例将在客户端尝试使用它时立即抛出NullPointerException。 到那时,List实例的来源可能很难确定,这可能会使调试任务大大复杂化。
构造方法是这个原则的一个特例,你应该检查要存储起来供以后使用的参数的有效性。检查构造方法参数的有效性对于防止构造对象违反类不变性(class invariants)非常重要。
你应该在执行计算之前显式检查方法的参数,但这一规则也有例外。 一个重要的例外是有效性检查昂贵或不切实际的情况,并且在进行计算的过程中隐式执行检查。 例如,考虑一种对对象列表进行排序的方法,例如Collections.sort(List)。 列表中的所有对象必须是可相互比较的。 在对列表进行排序的过程中,列表中的每个对象都将与其他对象进行比较。 如果对象不可相互比较,则某些比较操作抛出ClassCastException异常,这正是sort方法应该执行的操作。 因此,提前检查列表中的元素是否具有可比性是没有意义的。 但请注意,不加选择地依赖隐式有效性检查会导致失败原子性( failure atomicity)的丢失(条目 76)。
有时,计算会隐式执行必需的有效性检查,但如果检查失败则会抛出错误的异常。 换句话说,计算由于无效参数值而自然抛出的异常与文档记录方法抛出的异常不匹配。 在这些情况下,你应该使用条目 73中描述的异常翻译( exception translation)习惯用法将自然异常转换为正确的异常。
不要从本条目中推断出对参数的任意限制都是一件好事。 相反,你应该设计一些方法,使其尽可能通用。 假设方法可以对它接受的所有参数值做一些合理的操作,那么对参数的限制越少越好。 但是,通常情况下,某些限制是正在实现的抽象所固有的。
总而言之,每次编写方法或构造方法时,都应该考虑对其参数存在哪些限制。 应该记在这些限制,并在方法体的开头使用显式检查来强制执行这些限制。 养成这样做的习惯很重要。 在第一次有效性检查失败时,它所需要的少量工作将会得到对应的回报。
50. 必要时进行防御性拷贝
愉快使用Java的原因,它是一种安全的语言(safe language)。 这意味着在缺少本地方法(native methods)的情况下,它不受缓冲区溢出,数组溢出,野指针以及其他困扰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
}
乍一看,这个类似乎是不可变的,并强制执行不变式,即period实例的开始时间并不在结束时间之后。然而,利用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!
从Java 8开始,解决此问题的显而易见的方法是使用Instant(或LocalDateTime或ZonedDateTime)代替Date,因为Instant和其他java.time包下的类是不可变的(条目17)。Date已过时,不应再在新代码中使用。 也就是说,问题仍然存在:有时必须在API和内部表示中使用可变值类型,本条目中讨论的技术也适用于这些时间。
为了保护Period实例的内部不受这种攻击,必须将每个可变参数的防御性拷贝应用到构造方法中,并将拷贝用作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);
}
有了新的构造方法后,前面的攻击将不会对Period实例产生影响。注意,防御性拷贝是在检查参数(条目49)的有效性之前进行的,有效性检查是在拷贝上而不是在原始实例上进行的。虽然这看起来不自然,但却是必要的。它在检查参数和拷贝参数之间的漏洞窗口期间保护类不受其他线程对参数的更改的影响。在计算机安全社区中,这称为 time-of-check/time-of-use或TOCTOU攻击[Viega01]。
还请注意,我们没有使用Date的clone方法来创建防御性拷贝。因为Date是非final的,所以clone方法不能保证返回类为java.util.Date的对象,它可以返回一个不受信任的子类的实例,这个子类是专门为恶意破坏而设计的。例如,这样的子类可以在创建时在私有静态列表中记录对每个实例的引用,并允许攻击者访问该列表。这将使攻击者可以自由控制所有实例。为了防止这类攻击,不要使用clone方法对其类型可由不可信任子类化的参数进行防御性拷贝。
虽然替换构造方法成功地抵御了先前的攻击,但是仍然可以对Period实例进行修改,因为它的访问器提供了对其可变内部结构的访问:
// 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());
}
使用新的构造方法和新的访问器,Period是真正不可变的。 无论程序员多么恶意或不称职,根本没有办法违反一个period实例的开头不跟随其结束的不变量(不使用诸如本地方法和反射之类的语言外方法)。 这是正确的,因为除了period本身之外的任何类都无法访问period实例中的任何可变属性。 这些属性真正封装在对象中。
在访问器中,与构造方法不同,允许使用clone方法来制作防御性拷贝。 这是因为我们知道Period的内部Date对象的类是java.util.Date,而不是一些不受信任的子类。 也就是说,由于条目13中列出的原因,通常最好使用构造方法或静态工厂来拷贝实例。
参数的防御性拷贝不仅仅适用于不可变类。 每次编写在内部数据结构中存储对客户端提供的对象的引用的方法或构造函数时,请考虑客户端提供的对象是否可能是可变的。 如果是,请考虑在将对象输入数据结构后,你的类是否可以容忍对象的更改。 如果答案是否定的,则必须防御性地拷贝对象,并将拷贝输入到数据结构中,以替代原始数据结构。 例如,如果你正在考虑使用客户端提供的对象引用作为内部set实例中的元素或作为内部map实例中的键,您应该意识到如果对象被修改后插入,对象的set或map的不变量将被破坏。
在将内部组件返回给客户端之前进行防御性拷贝也是如此。无论你的类是否是不可变的,在返回对可拜年的内部组件的引用之前,都应该三思。可能的情况是,应该返回一个防御性拷贝。记住,非零长度数组总是可变的。因此,在将内部数组返回给客户端之前,应该始终对其进行防御性拷贝。或者,可以返回数组的不可变视图。这两项技术都记载于条目15。
可以说,所有这些的真正教训是,在可能的情况下,应该使用不可变对象作为对象的组件,这样就不必担心防御性拷贝(条目17)。在我们的Period示例中,使用Instant(或LocalDateTime或ZonedDateTime),除非使用的是Java 8之前的版本。如果使用的是较早的版本,则一个选项是存储Date.getTime()返回的基本类型long来代替Date引用。
可能存在与防御性拷贝相关的性能损失,并且它并不总是合理的。如果一个类信任它的调用者不修改内部组件,也许是因为这个类和它的客户端都是同一个包的一部分,那么它可能不需要防御性的拷贝。在这些情况下,类文档应该明确指出调用者不能修改受影响的参数或返回值。
即使跨越包边界,在将可变参数集成到对象之前对其进行防御性拷贝也并不总是合适的。有些方法和构造方法的调用指示参数引用的对象的显式切换。当调用这样的方法时,客户端承诺不再直接修改对象。希望获得客户端提供的可变对象的所有权的方法或构造方法必须在其文档中明确说明这一点。
包含方法或构造方法的类,这些方法或构造方法的调用指示控制权的转移,这些类无法防御恶意客户端。 只有当一个类和它的客户之间存在相互信任,或者当对类的不变量造成损害时,除了客户之外,任何人都不会受到损害。 后一种情况的一个例子是包装类模式(第18项)。 根据包装类的性质,客户端可以通过在包装后直接访问对象来破坏类的不变性,但这通常只会损害客户端。
总之,如果一个类有从它的客户端获取或返回的可变组件,那么这个类必须防御性地拷贝这些组件。如果拷贝的成本太高,并且类信任它的客户端不会不适当地修改组件,则可以用文档替换防御性拷贝,该文档概述了客户端不得修改受影响组件的责任。
51. 仔细设计方法签名
这一条目是API设计提示的大杂烩,但它们本身并足以设立一个单独的条目。综合起来,这些设计提示将帮助你更容易地学习和使用API,并且更不容易出错。
仔细选择方法名名称。名称应始终遵守标准命名约定(条目68)。你的主要目标应该是选择与同一包中的其他名称一致且易于理解的名称。其次是应该是选择与更广泛的共识一致的名称。避免使用较长的方法名。如果有疑问,可以从Java类库API中寻求指导。尽管类库中也存在许多不一致之处(考虑到这些类库的规模和范围,这是不可避免的),也提供了相当客观的认可和共识。
不要过分地提供方便的方法。每种方法都应该“尽其所能”。太多的方法使得类难以学习、使用、文档化、测试和维护。对于接口更是如此,在接口中,太多的方法使实现者和用户的工作变得复杂。对于类或接口支持的每个操作,提供一个功能完整的方法。只有在经常使用时,才考虑提供“快捷方式(shortcut)”。如果有疑问,请将其删除。
避免过长的参数列表。目标是四个或更少的参数。大多数程序员不能记住更长的参数列表。如果你的许多方法超过了这个限制,如果未经常引用其文档的情况下,那么你的API将无法使用。现代IDE编辑器会提供帮助,但是使用简短的参数列表仍然会更好。相同类型参数的长序列尤其有害。用户不仅不能记住参数的顺序,而且当他们意外地弄错参数顺序时,他们的程序仍然会编译和运行。只是不会按照作者的意图去执行。
有三种技术可以缩短过长的参数列表。 一种方法是将方法分解为多个方法,每个方法只需要参数的一个子集。 如果不小心,这可能会导致太多方法,但它也可以通过增加正交性(orthogonality)来减少方法个数。 例如,考虑java.util.List接口。 它没有提供查找子列表中元素的第一个或最后一个索引的方法,这两个索引都需要三个参数。 相反,它提供了subList方法,该方法接受两个参数并返回子列表的视图。 此方法可以与indexOf或lastIndexOf方法结合使用,这两个方法都有一个参数,以生成所需的功能。 此外,subList方法可以与在List实例上操作的任何方法组合,以对子列表执行任意计算。 得到的API具有非常高的功率重量( power-to-weight)比。
缩短过长参数列表的第二种技术是创建辅助类来保存参数组。这些辅助类通常是静态成员类(条目24)。如果看到一个频繁出现的参数序列表示某个不同的实体,建议使用这种技术。例如,假设正在编写一个表示纸牌游戏的类,并且发现不断地传递一个由两个参数组成的序列,这些参数表示纸牌的点数和花色。如果添加一个辅助类来表示卡片,并用辅助类的单个参数替换参数序列的每次出现,那么API和类的内部结构可能会受益。
结合前两个方面的第三种技术是,从对象构造到方法调用采用Builder模式(条目2)。如果你有一个方法有许多参数,特别是其中一些是可选的,那么可以定义一个对象来表示所有的参数,并允许客户端在这个对象上进行多个“setter”调用,每次设置一个参数或较小相关的组。设置好所需的参数后,客户端调用对象的“execute”方法,该方法对参数进行最后的有效性检查,并执行实际的计算。
对于参数类型,优先选择接口而不是类(条目64)。如果有一个合适的接口来定义一个参数,那么使用它来支持一个实现该接口的类。例如,没有理由在编写方法时使用HashMap作为输入参数,相反,而是使用Map作为参数,这允许传入HashMap、TreeMap、ConcurrentHashMap、TreeMap的子Map(submap)或任何尚未编写的Map实现。通过使用的类而不是接口,就把客户端限制在特定的实现中,如果输入数据碰巧以其他形式存在,则强制执行不必要的、代价高昂的复制操作。
与布尔型参数相比,优先使用两个元素枚举类型,除非布尔型参数的含义在方法名中是明确的。枚举类型使代码更容易阅读和编写。此外,它们还可以方便地在以后添加更多选项。例如,你可能有一个Thermometer类型的静态工厂方法,这个方法的签名是以下这个枚举:
public enum TemperatureScale { FAHRENHEIT, CELSIUS }
Thermometer.newInstance(TemperatureScale.CELSIUS)不仅比Thermometer.newInstance(true)更有意义,而且可以在将来的版本中将KELVIN添加到TemperatureScale中,而无需向Thermometer添加新的静态工厂。 此外,还可以将温度刻度(temperature-scale)依赖关系重构为枚举常量的方法(条目34)。 例如,每个刻度常量可以有一个采用double值并将其转换为Celsius的方法。
52. 明智而审慎地使用重载
下面的程序是一个善意的尝试,根据Set、List或其他类型的集合对它进行分类:
// 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字符串,实际上并没有。 而是打印了三次Unknown Collection字符串。 为什么会这样? 因为classify方法被重载了,在编译时选择要调用哪个重载方法。 对于循环的所有三次迭代,参数的编译时类型是相同的:Collection<?>。 运行时类型在每次迭代中都不同,但这不会影响对重载方法的选择。 因为参数的编译时类型是Collection<?>,,所以唯一适用的重载是第三个classify(Collection<?> c)方法,并且在循环的每次迭代中调用这个重载。
此程序的行为是违反直觉的,因为重载(overloaded)方法之间的选择是静态的,而重写(overridden)方法之间的选择是动态的。 根据调用方法的对象的运行时类型,在运行时选择正确版本的重写方法。 作为提醒,当子类包含与父类中具有相同签名的方法声明时,会重写此方法。 如果在子类中重写实例方法并且在子类的实例上调用,则无论子类实例的编译时类型如何,都会执行子类的重写方法。 为了具体说明,请考虑以下程序:
class Wine {
String name() { return "wine"; }
}
class SparklingWine extends Wine {
@Override String name() { return "sparkling wine"; }
}
class Champagne extends SparklingWine {
@Override String name() { return "champagne"; }
}
public class Overriding {
public static void main(String[] args) {
List<Wine> wineList = List.of(
new Wine(), new SparklingWine(), new Champagne());
for (Wine wine : wineList)
System.out.println(wine.name());
}
}
name方法在Wine类中声明,并在子类SparklingWine和Champagne中重写。 正如你所料,此程序打印出wine,sparkling wine和champagne,即使实例的编译时类型在循环的每次迭代中都是Wine。 当调用重写方法时,对象的编译时类型对执行哪个方法没有影响; 总是会执行“最具体(most specific)”的重写方法。 将此与重载进行比较,其中对象的运行时类型对执行的重载没有影响; 选择是在编译时完成的,完全基于参数的编译时类型。
在CollectionClassifier示例中,程序的目的是通过基于参数的运行时类型自动调度到适当的方法重载来辨别参数的类型,就像Wine类中的name方法一样。 方法重载根本不提供此功能。 假设需要一个静态方法,修复CollectionClassifier程序的最佳方法是用一个执行显式instanceof测试的方法替换classify的所有三个重载:
public static String classify(Collection<?> c) {
return c instanceof Set ? "Set" :
c instanceof List ? "List" : "Unknown Collection";
}
因为重写是规范,而重载是例外,所以重写设置了人们对方法调用行为的期望。 正如CollectionClassifier示例所示,重载很容易混淆这些期望。 编写让程序员感到困惑的代码的行为是不好的实践。 对于API尤其如此。 如果API的日常用户不知道将为给定的参数集调用多个方法重载中的哪一个,则使用API可能会导致错误。 这些错误很可能表现为运行时的不稳定行为,许多程序员很难诊断它们。 因此,应该避免混淆使用重载。
究竟是什么构成了重载的混乱用法还有待商榷。一个安全和保守的策略是永远不要导出两个具有相同参数数量的重载。如果一个方法使用了可变参数,除非如第53条目所述,保守策略是根本不重载它。如果遵守这些限制,程序员就不会怀疑哪些重载适用于任何一组实际参数。这些限制并不十分繁重,因为总是可以为方法赋予不同的名称,而不是重载它们。
例如,考虑ObjectOutputStream类。对于每个基本类型和几个引用类型,它都有其write方法的变体。这些变体都有不同的名称,例如writeBoolean(boolean)、writeInt(int)和writeLong(long),而不是重载write方法。与重载相比,这种命名模式的另一个好处是,可以为read方法提供相应的名称,例如readBoolean()、readInt()和readLong()。ObjectInputStream类实际上提供了这样的读取方法。
对于构造方法,无法使用不同的名称:类的多个构造函数总是被重载。 在许多情况下,可以选择导出静态工厂而不是构造方法(条目1)。 此外,使用构造方法,不必担心重载和重写之间的影响,因为构造方法不能被重写。 你可能有机会导出具有相同数量参数的多个构造函数,因此知道如何安全地执行它是值得的。
如果总是清楚哪个重载将应用于任何给定的实际参数集,那么用相同数量的参数导出多个重载不太可能让程序员感到困惑。在这种情况下,每对重载中至少有一个对应的形式参数在这两个重载中具有“完全不同的”类型。如果显然不可能将任何非空表达式强制转换为这两种类型,那么这两种类型是完全不同的。在这些情况下,应用于给定实际参数集的重载完全由参数的运行时类型决定,且不受其编译时类型的影响,因此消除了一个主要的混淆。例如,ArrayList有一个接受int的构造方法和第二个接受Collection的构造方法。很难想象在任何情况下,这两个构造方法在调用时哪个会产生混淆。
在Java 5之前,所有基本类型都与引用类型完全不同,但在自动装箱存在的情况下,则并非如此,并且它已经造成了真正的麻烦。 考虑以下程序:
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);
}
}
首先,程序将从-3到2的整数添加到有序集合和列表中。 然后,它在集合和列表上进行三次相同的remove方法调用。 如果你和大多数人一样,希望程序从集合和列表中删除非负值(0,1和2)并打印[-3,-2,-1] [ - 3,-2,-1]。 实际上,程序从集合中删除非负值,从列表中删除奇数值,并打印[-3,-2,-1] [-2,0,2]。 称这种混乱的行为是一种保守的说法。
实际情况是:调用set.remove(i)选择重载remove(E)方法,其中E是set (Integer)的元素类型,将基本类型i由int自动装箱为Integer中。这是你所期望的行为,因此程序最终会从集合中删除正值。另一方面,对list.remove(i)的调用选择重载remove(int i)方法,它将删除列表中指定位置的元素。如果从列表[-3,-2,-1,0,1,2]开始,移除第0个元素,然后是第1个,然后是第二个,就只剩下[-2,0,2],谜底就解开了。若要修复此问题,请强制转换list.remove的参数为Integer类型,迫使选择正确的重载。或者,也可以调用Integer.valueOf(i),然后将结果传递给list.remove方法。无论哪种方式,程序都会按预期打印[-3,-2,-1][-3,-2,-1]:
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove((Integer) i); // or remove(Integer.valueOf(i))
}
前一个示例所演示的令人混乱的行为是由于List<E>接口对remove方法有两个重载:remove(E)和remove(int)。在Java 5之前,当List接口被“泛型化”时,它有一个remove(Object)方法代替remove(E),而相应的参数类型Object和int则完全不同。但是,在泛型和自动装箱的存在下,这两种参数类型不再完全不同了。换句话说,在语言中添加泛型和自动装箱破坏了List接口。幸运的是,Java类库中的其他API几乎没有受到类似的破坏,但是这个故事清楚地表明,自动装箱和泛型在重载时增加了谨慎的重要性。
在Java 8中添加lambda表达式和方法引用以后,进一步增加了重载混淆的可能性。 例如,考虑以下两个代码片段:
new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);
虽然Thread构造方法调用和submit方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的(System.out::println),两者都有一个带有Runnable的重载。这里发生了什么?令人惊讶的答案是,submit方法有一个带有Callable <T>参数的重载,而Thread构造方法却没有。你可能认为这不会有什么区别,因为println方法的所有重载都会返回void,因此方法引用不可能是Callable
。这很有道理,但重载解析算法不是这样工作的。也许同样令人惊讶的是,如果println方法没有被重载,那么submit方法调用是合法的。正是被引用的方法(println)的重载和被调用的方法(submit)相结合,阻止了重载解析算法按照你所期望的方式运行。
从技术上讲,问题是System.out :: println是一个不精确的方法引用[JLS,15.13.1],并且『包含隐式类型的lambda表达式或不精确的方法引用的某些参数表达式被适用性测试忽略,因为在选择目标类型之前无法确定它们的含义[JLS,15.12.2]。』如果你不理解这段话也不要担心; 它针对的是编译器编写者。 关键是在同一参数位置中具有不同功能接口的重载方法或构造方法会导致混淆。 因此,不要在相同参数位置重载采用不同函数式接口的方法。 在此条目的说法中,不同的函数式接口并没有根本不同。 如果传递命令行开关-Xlint:overloads,Java编译器将警告这种有问题的重载。
数组类型和Object以外的类是完全不同的。此外,除了Serializable和Cloneable之外,数组类型和其他接口类型也完全不同。如果两个不同的类都不是另一个类的后代[JLS, 5.5],则称它们是不相关的。例如,String和Throwable是不相关的。任何对象都不可能是两个不相关类的实例,所以不相关的类也是完全不同的。
还有其他『类型对(pairs of types)』不能在任何方向转换[JLS, 5.1.12],但是一旦超出上面描述的简单情况,大多数程序员就很难辨别哪些重载(如果有的话)适用于一组实际参数。决定选择哪个重载的规则非常复杂,并且随着每个版本的发布而变得越来越复杂。很少有程序员能理解它们所有的微妙之处。
有时候,可能觉得有必要违反这一条目中的指导原则,特别是在演化现有类时。例如,考虑String,它从Java 4开始就有一个contenttequals (StringBuffer)方法。在Java 5中,添加了CharSequence接口,来为StringBuffer、StringBuilder、String、CharBuffer和其他类似类型提供公共接口。在添加CharSequence的同时,String还配备了一个重载的contenttequals方法,该方法接受CharSequence参数。
虽然上面的重载明显违反了此条目中的指导原则,但它不会造成任何危害,因为当在同一个对象引用上调用这两个重载方法时,它们做的是完全相同的事情。程序员可能不知道将调用哪个重载,但只要它们的行为相同,就没有什么后果。确保这种行为的标准方法是,将更具体的重载方法调用转发给更一般的重载方法:
// Ensuring that 2 methods have identical behavior by forwarding
public boolean contentEquals(StringBuffer sb) {
return contentEquals((CharSequence) sb);
}
虽然Java类库在很大程度上遵循了这一条目中的建议,但是有一些类违反了它。例如,String导出两个重载的静态工厂方法valueOf(char[])和valueOf(Object),它们在传递相同的对象引用时执行完全不同的操作。对此没有任何正当的理由理由,它应该被视为一种异常现象,有可能造成真正的混乱。
总而言之,仅仅可以重载方法并不意味着应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造方法的情况下,可能无法遵循此建议。在这些情况下,至少应该避免通过添加强制转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果做不到这一点,程序员将很难有效地使用重载方法或构造方法,也无法理解为什么它不能工作。
53. 明智而审慎地使用可变参数
可变参数方法正式名称称为可变的参数数量方法『variable arity methods』 [JLS, 8.4.1],接受零个或多个指定类型的参数。 可变参数机制首先创建一个数组,其大小是在调用位置传递的参数数量,然后将参数值放入数组中,最后将数组传递给方法。
例如,这里有一个可变参数方法,它接受一系列int类型的参数并返回它们的总和。如你所料,sum(1,2,3)的值为6,sum()的值为0:
// Simple use of varargs
static int sum(int... args) {
int sum = 0;
for (int arg : args)
sum += arg;
return sum;
}
有时,编写一个需要某种类型的一个或多个参数的方法是合适的,而不是零或更多。 例如,假设要编写一个计算其多个参数最小值的方法。 如果客户端不传递任何参数,则此方法定义不明确。 你可以在运行时检查数组长度:
// The WRONG way to use varargs to pass one or more arguments!
static int min(int... args) {
if (args.length == 0)
throw new IllegalArgumentException("Too few arguments");
int min = args[0];
for (int i = 1; i < args.length; i++)
if (args[i] < min)
min = args[i];
return min;
}
该解决方案存在几个问题。 最严重的是,如果客户端在没有参数的情况下调用此方法,则它在运行时而不是在编译时失败。 另一个问题是它很难看。 必须在args参数上包含显式有效性检查,除非将min初始化为Integer.MAX_VALUE,否则不能使用for-each循环,这也很难看。
幸运的是,有一种更好的方法可以达到预期的效果。 声明方法采用两个参数,一个指定类型的普通参数,另一个此类型的可变参数。 该解决方案纠正了前一个示例的所有缺陷:
// The right way to use varargs to pass one or more arguments
static int min(int firstArg, int... remainingArgs) {
int min = firstArg;
for (int arg : remainingArgs)
if (arg < min)
min = arg;
return min;
}
从这个例子中可以看出,在需要参数数量可变的方法时,可变参数是有效的。可变参数是为printf方法而设计的,该方法与可变参数同时添加到Java 平台中,以及包括经过改造的核心反射机制。printf和反射机制都从可变参数中受益匪浅。
在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。如果你从经验上确定负担不起这个成本,但是还需要可变参数的灵活性,那么有一种模式可以让你鱼与熊掌兼得。假设你已确定95%的调用是三个或更少的参数的方法,那么声明该方法的五个重载。每个重载方法包含0到3个普通参数,当参数数量超过3个时,使用一个可变参数方法:
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) { }
现在你知道,在所有参数数量超过3个的方法调用中,只有5%的调用需要支付创建数组的成本。与大多数性能优化一样,这种技术通常不太合适,但一旦真正需要的时候,它是一个救星。
EnumSet的静态工厂使用这种技术将创建枚举集合的成本降到最低。这是适当的,因为枚举集合为比特属性提供具有性能竞争力的替换(performance-competitive replacement for bit fields)是至关重要的(条目 36)。
总之,当需要使用可变数量的参数定义方法时,可变参数非常有用。 在使用可变参数前加上任何必需的参数,并注意使用可变参数的性能后果。
54. 返回空的数组或集合不要返回null
看到类似这样的方法并不罕见:
// Returns null to indicate an empty collection. Don't do this!
private final List<Cheese> cheesesInStock = ...;
/**
* @return a list containing all of the cheeses in the shop,
* or null if no cheeses are available for purchase.
*/
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? null
: new ArrayList<>(cheesesInStock);
}
没有理由对没有奶酪(Cheese)可供购买的情况进行特殊处理。这样需要在客户端做额外的代码处理可能为null的返回值,例如:
List<Cheese> cheeses = shop.getCheeses();
if (cheeses != null && cheeses.contains(Cheese.STILTON))
System.out.println("Jolly good, just the thing.");
在几乎每次使用返回null来代替空集合或数组的方法时,都需要使用这种迂回的方式。 它容易出错,因为编写客户端的程序员可能忘记编写特殊情况代码来处理null返回。 多年来这种错误可能会被忽视,因为这种方法通常会返回一个或多个对象。 此外,返回null代替空容器会使返回容器的方法的实现变得复杂。
有时有人认为,null返回值比空集合或数组更可取,因为它避免了分配空容器的开销。这个论点有两点是不成立的。首先,除非测量结果表明所讨论的分配是性能问题的真正原因,否则不宜担心此级别的性能(条目67)。第二,可以在不分配空集合和数组的情况下返回它们。下面是返回可能为空的集合的典型代码。通常,这就是你所需要的:
//The right way to return a possibly empty collection
public List<Cheese> getCheeses() {
return new ArrayList<>(cheesesInStock);
}
如果有证据表明分配空集合会损害性能,可以通过重复返回相同的不可变空集合来避免分配,因为不可变对象可以自由共享(条目17)。下面的代码就是这样做的,使用了Collections.emptyList方法。如果你要返回一个Set,可以使用Collections.emptySet;如果要返回Map,则使用Collections.emptyMap。但是请记住,这是一个优化,很少需要它。如果你认为你需要它,测量一下前后的性能表现,确保它确实有帮助:
// Optimization - avoids allocating empty collections
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? Collections.emptyList()
: new ArrayList<>(cheesesInStock);
}
数组的情况与集合的情况相同。 永远不要返回null,而是返回长度为零的数组。 通常,应该只返回一个正确长度的数组,这个长度可能为零。 请注意,我们将一个长度为零的数组传递给toArray方法,以指示所需的返回类型,即Cheese []:
//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);
}
在优化的版本中,我们将相同的空数组传递到每个toArray调用中,当cheesesInStock为空时,这个数组将从getCheeses返回。不要为了提高性能而预先分配传递给toArray的数组。研究表明,这样做会适得其反[Shipilev16]:
// Don’t do this - preallocating the array harms performance!
return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]);
总之,永远不要返回null来代替空数组或集合。它使你的API更难以使用,更容易出错,并且没有性能优势。
55. 明智而审慎地返回Optional
在Java 8之前,编写在特定情况下无法返回任何值的方法时,可以采用两种方法。要么抛出异常,要么返回null(假设返回类型是对象是引用类型)。但这两种方法都不完美。应该为异常条件保留异常(条目 69),并且抛出异常代价很高,因为在创建异常时捕获整个堆栈跟踪。返回null没有这些缺点,但是它有自己的缺陷。如果方法返回null,客户端必须包含特殊情况代码来处理null返回的可能性,除非程序员能够证明null返回是不可能的。如果客户端忽略检查null返回并将null返回值存储在某个数据结构中,那么会在将来的某个时间在与这个问题不相关的代码位置上,抛出NullPointerException异常的可能性。
在Java 8中,还有第三种方法来编写可能无法返回任何值的方法。Optional<T>类表示一个不可变的容器,它可以包含一个非null的T引用,也可以什么都不包含。不包含任何内容的Optional被称为空(empty)。非空的包含值称的Optional被称为存在(present)。Optional的本质上是一个不可变的集合,最多可以容纳一个元素。Optional<T>没有实现Collection<T>接口,但原则上是可以。
在概念上返回T的方法,但在某些情况下可能无法这样做,可以声明为返回一个Optional<T>。这允许该方法返回一个空结果,以表明不能返回有效的结果。返回Optional的方法比抛出异常的方法更灵活、更容易使用,而且比返回null的方法更不容易出错。
在条目 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异常。我们在条目30中提到,更好的替代方法是返回Optional<E>。下面是修改后的方法:
// 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很简单。 你所要做的就是使用适当的静态工厂创建Optional。 在这个程序中,我们使用两个:Optional.empty()返回一个空的Optional,Optional.of(value)返回一个包含给定非null值的Optional。 将null传递给Optional.of(value)是一个编程错误。 如果这样做,该方法通过抛出NullPointerException异常作为回应。 Optional.of(value)方法接受一个可能为null的值,如果传入null则返回一个空的Optional。永远不要通过返回Optional的方法返回一个空值:它破坏Optional设计的初衷。
Stream上的很多终止操作返回Optional。如果我们重写max方法来使用一个Stream,那么Stream的max操作会为我们生成Optional的工作(尽管我们还是传递一个显式的Comparator):
// Returns max val in collection as Optional<E> - uses stream
public static <E extends Comparable<E>>
Optional<E> max(Collection<E> c) {
return c.stream().max(Comparator.naturalOrder());
}
那么,如何选择返回Optional而不是返回null或抛出异常呢?Optional在本质上类似于检查异常(checked exceptions)(条目 71),因为它们迫使API的用户面对可能没有返回任何值的事实。抛出未检查的异常或返回null允许用户忽略这种可能性,从而带来潜在的可怕后果。但是,抛出一个检查异常需要在客户端中添加额外的样板代码。
如果方法返回一个Optional,则客户端可以选择在方法无法返回值时要采取的操作。 可以指定默认值:
// Using an optional to provide a chosen default value
String lastWordInLexicon = max(words).orElse("No words...");
或者可以抛出任何适当的异常。注意,我们传递的是异常工厂,而不是实际的异常。这避免了创建异常的开销,除非它真的实际被抛出:
// Using an optional to throw a chosen exception
Toy myToy = max(toys).orElseThrow(TemperTantrumException::new);
如果你能证明Optional非空,你可以从Optional获取值,而不需要指定一个操作来执行。但是如果Optional是空的,你判断错了,代码会抛出一个NoSuchElementException异常:
// Using optional when you know there’s a return value
Element lastNobleGas = max(Elements.NOBLE_GASES).get();
有时候,可能会遇到这样一种情况:获取默认值的代价很高,除非必要,否则希望避免这种代价。对于这些情况,Optional提供了一个方法,该方法接受Supplier<T>,并仅在必要时调用它。这个方法被称为orElseGet,但是或许应该被称为orElseCompute,因为它与以compute开头的三个Map方法密切相关。有几个Optional的方法来处理更特殊的用例:filter、map、flatMap和ifPresent。在Java 9中,又添加了两个这样的方法:or和ifPresentOrElse。如果上面描述的基本方法与你的用例不太匹配,请查看这些更高级方法的文档,并查看它们是否能够完成任务。
如果这些方法都不能满足你的需要,Optional提供isPresent()方法,可以将其视为安全阀。如果Optional包含值,则返回true;如果为空,则返回false。你可以使用此方法对可选结果执行任何喜欢的处理,但请确保明智地使用它。isPresent的许多用途都可以被上面提到的一种方法所替代。生成的代码通常更短、更清晰、更符合习惯。
例如,请考虑此代码段,它打印一个进程的父进程ID,如果进程没有父进程,则打印N/A. 该代码段使用Java 9中引入的ProcessHandle类:
Optional<ProcessHandle> parentProcess = ph.parent();
System.out.println("Parent PID: " + (parentProcess.isPresent() ?
String.valueOf(parentProcess.get().pid()) : "N/A"));
上面的代码可以被如下代码所替代,使用了Optional的map方法:
System.out.println("Parent PID: " +
ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A"));
当使用Stream进行编程时,通常会发现使用的是一个Stream<Optional<T>>,并且需要一个Stream<T>,其中包含非Optional中的所有元素,以便继续进行。如果你正在使用Java 8,下面是弥补这个差距的代码:
streamOfOptionals
.filter(Optional::isPresent)
.map(Optional::get)
在Java 9中,Optional配备了一个stream()方法。这个方法是一个适配器, 此方法是一个适配器,它将Optional变为包含一个元素的Stream,如果Optional为空,则不包含任何元素。此方法与Stream的flatMap方法(条目45)相结合,这个方法可以简洁地替代上面的方法:
streamOfOptionals.
.flatMap(Optional::stream)
并不是所有的返回类型都能从Optional的处理中获益。容器类型,包括集合、映射、Stream、数组和Optional,不应该封装在Optional中。与其返回一个空的Optional<List<T>>,不还如返回一个空的List<T>(条目 54)。返回空容器将消除客户端代码处理Optional的需要。ProcessHandle类确实有arguments方法,它返回Optional<String[]>,但是这个方法应该被视为一种异常,不该被效仿。
那么什么时候应该声明一个方法来返回Optional <T>而不是T呢? 通常,如果可能无法返回结果,并且在没有返回结果,客户端还必须执行特殊处理的情况下,则应声明返回Optional 的方法。也就是说,返回Optional <T>并非没有成本。 Optional是必须分配和初始化的对象,从Optional中读取值需要额外的迂回。 这使得Optional不适合在某些性能关键的情况下使用。 特定方法是否属于此类别只能通过仔细测量来确定(条目 67)。
与返回装箱的基本类型相比,返回包含已装箱基本类型的Optional的代价高得惊人,因为Optional有两个装箱级别,而不是零。因此,类库设计人员认为为基本类型int、long和double提供类似Option是合适的。这些Option是OptionalInt、OptionalLong和OptionalDouble。它们包含Optional<T>上的大多数方法,但不是所有方法。因此,除了“次要基本类型(minor primitive types)”Boolean,Byte,Character,Short和Float之外,永远不应该返回装箱的基本类型的Optional。
到目前为止,我们已经讨论了返回Optional并在返回后处理它们的方法。我们还没有讨论其他可能的用法,这是因为大多数其他Optional的用法都是可疑的。例如,永远不要将Optional用作映射值。如果这样做,则有两种方法可以表示键(key)在映射中逻辑上的缺失:键要么不在映射中,要么存在的话映射到一个空的Optional。这反映了不必要的复杂性,很有可能导致混淆和错误。更通俗地说,在集合或数组中使用Optional的键、值或元素几乎都是不合适的。
这里留下了一个悬而未决的大问题。在实例中存储Optional属性是否合适吗?通常这是一种“不好的味道”:它建议你可能应该有一个包含Optional属性的子类。但有时这可能是合理的。考虑条目2中的NutritionFacts类的情况。NutritionFacts实例包含许多不需要的属性。不可能为这些属性的每个可能组合都提供一个子类。此外,属性包含基本类型,这使得很难直接表示这种缺失。对于NutritionFacts最好的API将为每个Optional属性从getter方法返回一个Optional,因此将这些Optional作为属性存储在对象中是很有意义的。
总之,如果发现自己编写的方法不能总是返回值,并且认为该方法的用户在每次调用时考虑这种可能性很重要,那么或许应该返回一个Optional的方法。但是,应该意识到,返回Optional会带来实际的性能后果;对于性能关键的方法,最好返回null或抛出异常。最后,除了作为返回值之外,不应该在任何其他地方中使用Optional。
56. 为所有已公开的API元素编写文档注释
如果API要可用,就必须对其进行文档化。传统上,API文档是手工生成的,保持文档与代码的同步是一件苦差事。Java编程环境使用Javadoc实用程序简化了这一任务。Javadoc使用特殊格式的文档注释(通常称为doc注释),从源代码自动生成API文档。
虽然文档注释约定不是Java语言的正式一部分,但它们构成了每个Java程序员都应该知道的事实上的API。“如何编写文档注释(How to Write Doc Comments web page)”的网页[Javadoc-guide]中介绍了这些约定。 虽然自Java 4发布以来该页面尚未更新,但它仍然是一个非常宝贵的资源。 Java 9中添加了一个重要的文档标签,{@ index}; Java 8中有一个,{@implSpec};Java 5中有两个,{@literal}和{@code}。 上述网页中缺少这些标签的介绍,但在此条目中进行讨论。
要正确地记录API,必须在每个导出的类、接口、构造方法、方法和属性声明之前加上文档注释。如果一个类是可序列化的,还应该记录它的序列化形式(条目87)。在没有文档注释的情况下,Javadoc可以做的最好的事情是将声明重现为受影响的API元素的唯一文档。使用缺少文档注释的API是令人沮丧和容易出错的。公共类不应该使用默认构造方法,因为无法为它们提供文档注释。要编写可维护的代码,还应该为大多数未导出的类、接口、构造方法、方法和属性编写文档注释,尽管这些注释不需要像导出API元素那样完整。
方法的文档注释应该简洁地描述方法与其客户端之间的契约。除了为继承而设计的类中的方法(条目 19)之外,契约应该说明方法做什么,而不是它如何工作的。文档注释应该列举方法的所有前置条件(这些条件必须为真,以便客户端调用它们),以及后置条件(这些条件是在调用成功完成后才为真)。通常,对于未检查的异常,前置条件由@throw标签隐式地描述;每个未检查异常对应于一个先决条件违反( precondition violation.)。此外,可以在受影响的参数的@param标签中指定前置条件。
除了前置条件和后置条件之外,方法还应在文档中记录它的副作用(side effort)。 副作用是系统状态的可观察到的变化,这对于实现后置条件而言显然不是必需的。 例如,如果方法启动后台线程,则文档应记录它。
完整地描述方法的契约,文档注释应该为每个参数都有一个@param标签,一个@return标签(除非方法有void返回类型),以及一个@throw标签(无论是检查异常还是非检查异常)(条目 74)。如果@return标签中的文本与方法的描述相同,则可以忽略它,这取决于你所遵循的编码标准。
按照惯例,@param或@retur标签后面的文本应该是一个名词短语,描述参数或返回值所表示的值。 很少使用算术表达式代替名词短语; 请参阅BigInteger的示例。@throw标签后面的文本应该包含单词“if”,后面跟着一个描述抛出异常的条件的子句。按照惯例,@param、@return或@throw标签后面的短语或子句不以句号结束。以下的文档注释说明了所有这些约定:
/**
* Returns the element at the specified position in this list.
*
* <p>This method is <i>not</i> guaranteed to run in constant
* time. In some implementations it may run in time proportional
* to the element position.
*
* @param index index of element to return; must be
* non-negative and less than the size of this list
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= this.size()})
*/
E get(int index);
请注意在此文档注释(<p>和<i>)中使用HTML标记。 Javadoc实用工具将文档注释转换为HTML,文档注释中的任意HTML元素最终都会生成HTML文档。 有时候,程序员甚至会在他们的文档注释中嵌入HTML表格,尽管这种情况很少见。
还要注意在@throw子句中的代码片段周围使用Javadoc的 {@code}标签。这个标签有两个目的:它使代码片段以代码字体形式呈现,并且它抑制了代码片段中HTML标记和嵌套Javadoc标记的处理。后一个属性允许我们在代码片段中使用小于号(<),即使它是一个HTML元字符。要在文档注释中包含多行代码示例,请使用包装在HTML <pre>标记中的Javadoc{@code}标签。换句话说,在代码示例前面加上字符<pre>{@code,然后在代码后面加上}</pre>。这保留了代码中的换行符,并消除了转义HTML元字符的需要,但不需要转义at符号(@),如果代码示例使用注释,则必须转义at符号(@)。
最后,请注意文档注释中使用的单词“this list”。按照惯例,“this”指的是在实例方法的文档注释中,指向方法调用所在的对象。
正如条目15中提到的,当你为继承设计一个类时,必须记录它的自用模式( self-use patterns),以便程序员知道重写它的方法的语义。这些自用模式应该使用在Java 8中添加的@implSpec标签来文档记录。回想一下,普通的问问昂注释描述了方法与其客户端之间的契约;相反,@implSpec注释描述了方法与其子类之间的契约,如果它继承了方法或通过super调用方法,那么允许子类依赖于实现行为。下面是实际应用中的实例:
/**
* Returns true if this collection is empty.
*
* @implSpec
* This implementation returns {@code this.size() == 0}.
*
* @return true if this collection is empty
*/
public boolean isEmpty() { ... }
从Java 9开始,Javadoc实用工具仍然忽略@implSpec标签,除非通过命令行开关:-tag "implSpec:a:Implementation Requirements:"。希望在后续的版本中可以修正这个错误。
不要忘记,你必须采取特殊操作来生成包含HTML元字符的文档,例如小于号(<),大于号(>)和and符号(&)。 将这些字符放入文档的最佳方法是使用{@literal}标签将它们包围起来,该标签禁止处理HTML标记和嵌套的Javadoc标记。 它就像{@code}标签一样,除了不会以代码字体呈现文本以外。 例如,这个Javadoc片段:
* A geometric series converges if {@literal |r| < 1}.
它会生成文档:“A geometric series converges if |r| < 1.”。{@literal}标签可能只放在小于号的位置,而不是整个不等式,并且生成的文档是一样的,但是文档注释在源代码中的可读性较差。 这说明了文档注释在源代码和生成的文档中都应该是可读的通用原则。 如果无法实现这两者,则生成的文档的可读性要胜过在源代码中的可读性。
每个文档注释的第一个“句子”(如下定义)成为注释所在元素的概要描述。 例如,第255页上的文档注释中的概要描述为:“返回此列表中指定位置的元素”。概要描述必须独立描述其概述元素的功能。 为避免混淆,类或接口中的两个成员或构造方法不应具有相同的概要描述。 要特别注意重载方法,为此通常使用相同的第一句话是自然的(但在文档注释中是不可接受的)。
请小心,如果预期的概要描述包含句点,因为句点可能会提前终止描述。例如,以“A college degree, such as B.S., M.S. or Ph.D.”会导致概要描述为“A college degree, such as B.S., M.S”。问题在于概要描述在第一个句点结束,然后是空格、制表符或行结束符(或第一个块标签处)[Javadoc-ref]。这里是缩写“M.S.”中的第二个句号后面跟着一个空格。最好的解决方案是用{@literal}标签来包围不愉快的句点和任何相关的文本,这样源代码中的句点后面就不会有空格了:
/**
* A college degree, such as B.S., {@literal M.S.} or Ph.D.
*/
public class Degree { ... }
说概要描述是文档注释中的第一句子,其实有点误导人。按照惯例,它很少应该是一个完整的句子。对于方法和构造方法,概要描述应该是一个动词短语(包括任何对象),描述了该方法执行的操作。例如:
rrayList(int initialCapacity)—— 构造具有指定初始容量的空列表。Collection.size()—— 返回此集合中的元素个数。
如这些例子所示,使用第三人称陈述句时态(“returns the number”)而不是第二人称祈使句(“return the number”)。
对于类,接口和属性,概要描述应该是描述由类或接口的实例或属性本身表示的事物的名词短语。 例如:
Instant—— 时间线上的瞬时点。Math.PI—— 更加接近pi的double类型数值,即圆的周长与其直径之比。
在Java 9中,客户端索引被添加到Javadoc生成的HTML中。这个索引以页面右上角的搜索框的形式出现,它简化了导航大型API文档集的任务。当你在框中键入时,得到一个匹配页面的下拉菜单。API元素(如类、方法和属性)是自动索引的。有时,可能希望索引对你的API很重要的其他术语。为此添加了{@index}标签。对文档注释中出现的术语进行索引,就像将其包装在这个标签中一样简单,如下面的片段所示:
* This method complies with the {@index IEEE 754} standard.
泛型,枚举和注释需要特别注意文档注释。 记录泛型类型或方法时,请务必记录所有类型参数:
/**
* An object that maps keys to values. A map cannot contain
* duplicate keys; each key can map to at most one value.
*
* (Remainder omitted)
*
* @param <K> the type of keys maintained by this map
* @param <V> the type of mapped values
*/
public interface Map<K, V> { ... }
在记录枚举类型时,一定要记录常量,以及类型和任何公共方法。注意,如果文档很短,可以把整个文档注释放在一行:
/**
* An instrument section of a symphony orchestra.
*/
public enum OrchestraSection {
/** Woodwinds, such as flute, clarinet, and oboe. */
WOODWIND,
/** Brass instruments, such as french horn and trumpet. */
BRASS,
/** Percussion instruments, such as timpani and cymbals. */
PERCUSSION,
/** Stringed instruments, such as violin and cello. */
STRING;
}
在为注解类型记录文档时,一定要记录任何成员,以及类型本身。用名词短语表示的文档成员,就好像它们是属性一样。对于类型的概要描述,请使用动词短语,它表示当程序元素具有此类型注解的所表示的含义:
/**
* Indicates that the annotated method is a test method that
* must throw the designated exception to pass.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
/**
* The exception that the annotated test method must throw
* in order to pass. (The test is permitted to throw any
* subtype of the type described by this class object.)
*/
Class<? extends Throwable> value();
}
包级别文档注释应放在名为package-info.java的文件中。 除了这些注释之外,package-info.java还必须包含一个包声明,并且可以在此声明中包含注解。 同样,如果使用模块化系统(条目 15),则应将模块级别注释放在module-info.java文件中。
在文档中经常忽略的API的两个方面,分别是线程安全性和可序列化性。无论类或静态方法是否线程安全,都应该在文档中描述其线程安全级别,如条目 82中所述。如果一个类是可序列化的,应该记录它的序列化形式,如条目 87中所述。
Javadoc具有“继承(inherit)”方法注释的能力。 如果API元素没有文档注释,Javadoc将搜索最具体的适用文档注释,接口文档优先于超类文档。 搜索算法的详细信息可以在The Javadoc Reference Guide [Javadoc-ref]中找到。 还可以使用{@inheritDoc}标签从超类继承部分文档注释。 这意味着,除其他外,类可以重用它们实现的接口的文档注释,而不是复制这些注释。 该工具有可能减轻维护多组几乎相同的文档注释的负担,但使用起来很棘手并且有一些限制。 详细信息超出了本书的范围。
关于文档注释,应该添加一个警告说明。虽然有必要为所有导出的API元素提供文档注释,但这并不总是足够的。对于由多个相互关联的类组成的复杂API,通常需要用描述API总体架构的外部文档来补充文档注释。如果存在这样的文档,相关的类或包文档注释应该包含到外部文档的链接。
Javadoc会自动检查是否符合此条目中的许多建议。在Java 7中,需要命令行开关-Xdoclint来获得这种行为。在Java 8和Java 9中,默认情况下启用了此检查。诸如checkstyle之类的IDE插件会进一步检查是否符合这些建议[Burn01]。还可以通过HTML有效性检查器运行Javadoc生成的HTML文件来降低文档注释中出现错误的可能性。可以检测HTML标记的许多错误用法。有几个这样的检查器可供下载,可以使用 W3C markup validation service 在线验证HTML格式。在验证生成的HTML时,请记住,从Java 9开始,Javadoc就能够生成HTML5和HTML 4.01,尽管默认情况下仍然生成HTML 4.01。如果希望Javadoc生成HTML5,请使用-html5命令行开关。
本条目中描述的约定涵盖了基本内容。尽管撰写本文时已经有15年的历史,但编写文档注释的最终指南仍然是《 How to Write Doc Comments》[Javadoc-guide]。
如果你遵循本项目中的指导原则,生成的文档应该提供对API的清晰描述。然而,唯一确定的方法,是阅读Javadoc实用工具生成的web页面。对于其他人将使用的每个API,都值得这样做。正如测试程序几乎不可避免地会导致对代码的一些更改一样,阅读文档通常也会导致对文档注释的一些少许的修改。
总之,文档注释是记录API的最佳、最有效的方法。对于所有导出的API元素,它们的使用应被视为必需的。 采用符合标准惯例的一致风格 。请记住,在文档注释中允许任意HTML,但必须转义HTML的元字符。