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(),
new ArrayList(),
new HashMap().values()
};
for (Collection> c : collections)
System.out.println(classify(c));
}
}
您可能期望这个程序打印 "Set",然后是 "List" 和 "Unknown Collection",但是它没有这样做。它打印 "Unknown Collection" 三次。为什么会这样?因为 classification 方法是重载的,并且在编译时选择要调用哪个重载。对于循环的所有三个迭代,参数的编译时类型是相同的: Collection。运行时类型在每个迭代中是不同的,但这并不影响重载的选择。因为参数的编译时类型是Collection 唯一适用的重载是第三个,classify(Collection>),这个重载在循环的每次迭代中调用。
这个程序的行为是违反直觉的,因为 overloaded 的选择是静态的,而 override 的选择是动态的。重写方法的正确版本是在运行时根据调用该方法的对象的运行时类型选择的。提醒一下,当子类包含与祖先中的方法声明具有相同签名的方法声明时,方法将被重写。如果在子类中重写了实例方法,并且在子类的实例上调用了此方法,则执行子类的重写方法,而不管子类实例的编译时类型如何。具体来说,考虑以下程序:
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 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。调用被覆盖的方法时,对象的编译时类型对执行哪个方法没有影响,“最特定的”重写方法总是被执行。与重载进行比较,对象的运行时类型对执行重载没有影响;选择是在编译时进行的,完全基于参数的编译时类型。
在 CollectionClassifier 示例中,程序的目的是通过根据参数的运行时类型自动分派到适当的方法重载来识别参数的类型,就像 Wine 示例中的 name 方法所做的那样。方法重载根本不提供此功能。假设需要一个静态方法,修复 CollectionClassifier 程序的最佳方法是用一个执行显式instanceof 测试的方法替换这三个 classification 重载:
public static String classify(Collection> c) {
return c instanceof Set ?
"Set"
: c instanceof List ?
"List"
: "Unknown Collection";
}
因为重写是规范,而重载是例外,所以重写设置了人们对方法调用行为的期望。正如CollectionClassifier 示例所演示的,重载很容易混淆这些期望。编写行为可能使程序员感到困惑的代码是不好的实践。对于 API 尤其如此。如果 API 的典型用户不知道给定的一组参数将调用几种方法重载中的哪一种,那么使用 API 可能会导致错误。这些错误很可能在运行时表现为不稳定的行为,许多程序员很难诊断它们。因此,您应该避免混淆重载的用法。
究竟是什么构成了重载的一种令人困惑的用法还存在一些争议。一个安全、保守的策略是永远不导出具有相同数量参数的两个重载。如果一个方法使用了 varargs,保守策略是根本不重载它,除非如 item 53 所述。如果您坚持这些限制,程序员将永远不会怀疑哪个重载适用于任何一组实际参数。这些限制不是很麻烦,因为您总是可以为方法提供不同的名称,而不是重载它们。
例如,考虑 ObjectOutputStream 类。对于每个基本类型和几个引用类型,它都有一个不同的写方法。这些变量都有不同的名称,如 writeBoolean(boolean)、writeInt(int) 和 writeLong(long),而不是重载 write 方法。与重载相比,这种命名模式的另一个好处是可以提供具有相应名称的read 方法,例如 readBoolean()、readInt() 和 readLong()。实际上,ObjectInputStream 类确实提供了这样的读取方法。
对于构造函数,您没有使用不同名称的选项:一个类的多个构造函数总是重载的。在很多情况下,你可以选择导出静态的工厂而不是构造函数(item 1)。您可能会有机会导出具有相同数量参数的多个构造函数,因此知道如何安全地进行导出是值得的。
如果总是清楚哪个重载将应用于给定的实际参数集,那么使用相同数量的参数导出多个重载不太可能使程序员感到困惑。当每一对重载中至少有一个对应的形式参数在两个重载中具有“完全不同的”类型时,就是这种情况。如果显然不可能将任何非空表达式强制转换为这两种类型,则这两种类型完全不同。在这种情况下,对给定的实际参数集应用哪种重载完全由参数的运行时类型决定,并且不受它们的编译时类型的影响,因此消除了主要的混淆。例如,ArrayList有一个接受int的构造函数和第二个接受集合的构造函数。很难想象在任何情况下这两个构造函数中哪个会被调用。
在Java 5之前,所有的基本类型都与所有的引用类型完全不同,但是在自动装箱的情况下并不是这样,这造成了真正的问题。考虑以下方案:
public class SetList {
public static void main(String[] args) {
Set set = new TreeSet<>();
List 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,加到一个排序集和一个列表中。然后,它执行三个相同的调用来删除集合和列表。如果您与大多数人一样,您可能希望程序从集合和列表中删除非负的值(0、1和2),并打印[-3、-2、-1][-3、-2、-1]。实际上,程序从集合中删除非负的值,从列表中删除奇数值,并输出[- 3, -2, -1] [- 2,0,2]。把这种行为称为混乱是保守的说法。
实际情况是这样的:调用 set.remove(i) 选择重载 remove(E),其中 E 是集合(Integer) 的元素类型,而 自动装箱 i 从 int 到 Integer。这是您所期望的行为,因此程序最终会从集合中删除正值。另一方面,对 list.remove(i) 的调用会选择重载remove(int i),它会删除列表中指定位置的元素。如果从列表 [-3,-2,-1,0,1,2] 开始,删除第 0 个元素,然后第一个,然后第二个,就只剩下 [-2,0,2],谜底就解开了。若要修复此问题,请强制转换列表。移除整型参数,强制选择正确的重载。或者,您可以调用 list.remove(Integer)。不管采用哪种方式,程序都会按预期打印[-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))
}
前一个示例所演示的混乱行为是由于列表接口对 remove 方法有两个重载: remove(E) 和 remove(int)。在Java 5之前,当列表接口被“一般化”时,它用一个remove(Object) 方法代替 remove(E),而相应的参数类型 Object 和 int 则完全不同。但是,在泛型和自动装箱的情况下,这两种参数类型不再是完全不同的。换句话说,向语言中添加泛型和自动装箱破坏了列表接口。幸运的是,Java 库中的其他 api 几乎没有受到类似的破坏,但是这个故事清楚地表明,自动装箱和泛型增加了重载时谨慎的重要性。
Java 8 中增加的 lambdas 和方法引用进一步增加了重载中混淆的可能性。例如,考虑以下两个片段:
new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool(); exec.submit(System.out::println);
虽然线程构造函数调用和提交方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的(System.out::println),构造函数和方法都有一个重载,该重载接受一个Runnable。这是怎么回事?令人惊讶的答案是,submit 方法有一个重载,它接受一个 Callable
从技术上讲,问题出在这个系统上。println 是一个不精确的方法引用[JLS, 15.13.1],并且“某些包含隐式类型化 lambda 表达式或不精确的方法引用的参数表达式将被适用性测试忽略,因为它们的含义在选择目标类型[JLS, 15.12.2]之前无法确定。”
如果你不明白这段话,不要担心;它的目标是编译器作者。关键是在相同的参数位置上重载具有不同功能接口的方法或构造函数会导致混淆。因此,不要重载方法来将不同的功能接口放在相同的参数位置。用这个术语来说,不同的功能接口并不是完全不同的。如果您通过命令行开关(-Xlint:overloads),Java编译器将警告您此类有问题的重载。
数组类型和对象以外的类类型是完全不同的。此外,除了 Serializable 和 Cloneable之外,数组类型和接口类型也有根本的不同。如果两个不同的类都不是另一个类的后代[JLS, 5.5],则称它们是不相关的。例如,String 和 Throwable 是不相关的。任何对象都不可能是两个不相关的类的实例,所以不相关的类也完全不同。
还有其他类型对不能在任何方向上转换[JLS, 5.1.12],但是一旦您超越了上面描述的简单情况,大多数程序员就很难辨别哪些重载(如果有的话)适用于一组实际参数。决定选择哪个重载的规则非常复杂,并且随着每个版本的发布而变得越来越复杂。很少有程序员了解它们所有的微妙之处。
有时您可能觉得需要违反此项中的准则,特别是在演化现有类时。例如,考虑String,它从 Java 4 开始就有一个内容 quals(StringBuffer) 方法。在 Java 5 中,添加了 CharSequence 来为 StringBuffer、StringBuilder、String、CharBuffer和其他类似类型提供公共接口。在添加 CharSequence的同时,String 还配备了一个获取CharSequence 的 contentEquals 方法的重载。
虽然结果重载明显违反了此项中的准则,但它不会造成任何损害,因为两个重载方法在调用相同的对象引用时执行的是完全相同的操作。程序员可能不知道将调用哪个重载,但只要它们的行为相同,就没有任何后果。确保这种行为的标准方法是将更具体的重载转发给更一般的:
// Ensuring that 2 methods have identical behavior by forwarding
public boolean contentEquals(StringBuffer sb) {
return contentEquals((CharSequence) sb);
}
虽然 Java 库在很大程度上遵循了这项建议的精神,但是有许多类违反了它。例如,String 导出两个重载的静态工厂方法 valueOf(char[])和 valueOf(Object),它们在传递相同的对象引用时执行完全不同的操作。这样做没有真正的理由,它应该被视为一种可能引起真正混乱的反常现象。
总之,您可以重载方法并不意味着您应该这样做。通常最好避免使用具有相同数量参数的多个签名的方法重载。在某些情况下,特别是涉及到构造函数时,可能不可能遵循这个建议。在这些情况下,至少应该避免通过添加强制类型转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为您正在对现有类进行改造以实现新的接口,那么您应该确保在传递相同的参数时,所有重载的行为都是相同的。如果不这样做,程序员将很难有效地使用重载的方法或构造函数,他们将无法理解为什么它不能工作。