下面的程序是一个善意的尝试,根据集合、列表或其他类型的集合对集合进行分类:
您可能期望这个程序打印Set,然后是List和Unknown Collection,但是它没有这样做。它打印 Unknown Collection 三次。为什么会这样?因为classify 方法重载了,在编译时选择要调用哪个重载。对于循环的所有三个迭代,参数的编译时类型是相同的:Collection<?>。运行时类型在每个迭代中是不同的,但这并不影响重载的选择。因为参数的编译时类型是Collection<?唯一适用的重载是第三个,classification (Collection<?>),这个重载在循环的每个迭代中调用。
这个程序的行为是违反直觉的,因为重载方法之间的选择是静态的,而覆写方法之间的选择是动态的。被覆写的方法的正确版本在运行时被选择,基于调用方法的对象的运行时类型。提醒一下,当子类包含与祖先中的方法声明具有相同签名的方法声明时,方法将被重写。如果在子类中重写实例方法,并且在子类的实例上调用此方法,则执行子类的重写方法,而不考虑子类实例的编译时类型。要使其具体化,请考虑以下程序:
name方法在Wine类中声明,并在SparklingWine和Champagne子类中重写。正如您所期望的,这个程序打印出wine、sparkling wine和champagne,即使实例的编译时类型是循环每次迭代中的wine。对象的编译时类型对调用覆写方法时执行的方法没有影响;“最特定的”覆写方法总是被执行。将此与重载进行比较,在重载中,对象的运行时类型对执行重载没有影响;选择是在编译时进行的,完全基于参数的编译时类型。
在CollectionClassifier示例中,程序的目的是通过根据参数的运行时类型自动分派到适当的方法重载来识别参数的类型,就像Wine示例中的name方法所做的那样。方法重载不提供这种功能。假设需要一个静态方法,修复CollectionClassifier程序的最佳方法是用一个执行显式instanceof测试的方法替换classification的所有三个重载:
因为覆写是规范,而重载是例外,所以覆写设置了人们对方法调用行为的期望。正如CollectionClassifier示例所示,重载很容易混淆这些期望。编写行为可能使程序员感到困惑的代码是不好的实践。对于api尤其如此。如果API的典型用户不知道为给定的一组参数调用几种方法重载中的哪一种,那么使用API很可能会导致错误。这些错误很可能在运行时表现为不稳定的行为,许多程序员将很难诊断它们。因此,您应该避免混淆重载的用法。
究竟是什么构成了重载的混乱用法还有待商榷。一个安全、保守的策略是永远不要导出具有相同数量参数的两个重载。如果一个方法使用了varargs,保守策略是根本不重载它,除非如第53项所述。如果遵守这些限制,程序员就不会怀疑哪些重载适用于任何一组实际参数。这些限制并不十分繁重,因为您总是可以为方法赋予不同的名称,而不是重载它们。
例如,考虑ObjectOutputStream类。对于每个基本类型和几个引用类型,它都有其写方法的变体。这些变体都有不同的名称,例如writeBoolean(boolean)、writeInt(int)和writeLong(long),而不是重载write方法。与重载相比,这种命名模式的另一个好处是,可以为read方法提供相应的名称,例如readBoolean()、readInt()和readLong()。ObjectInputStream类实际上提供了这样的读取方法。
于构造函数,您没有使用不同名称的选项:一个类的多个构造函数总是重载。在很多情况下,您可以选择导出静态工厂而不是构造函数( item1)。同样,使用构造函数,您不必担心重载和覆盖之间的交互,因为构造函数不能被覆盖。你可能会需要导出具有相同数量参数的多个构造函数,因此知道如何安全地进行导出是值得的。
如果总是清楚哪个重载将应用于任何给定的实际参数集,那么用相同数量的参数导出多个重载不太可能让程序员感到困惑。在这种情况下,每对重载中至少有一个对应的形式参数在这两个重载中具有“完全不同的”类型。如果显然不可能将任何非空表达式强制转换为这两种类型,那么这两种类型是完全不同的。在这些情况下,应用于给定实际参数集的重载完全由参数的运行时类型决定,且不受其编译时类型的影响,因此消除了一个主要的混淆源。例如,ArrayList有一个接受int的构造函数和第二个接受集合的构造函数。很难想象在任何情况下这两个构造函数中哪个会被调用。
在Java 5之前,所有原始类型都与所有引用类型完全不同,但在自动装箱时并非如此,这造成了真正的麻烦。考虑以下方案:
首先,程序将从- 3到2的整数(包括)添加到已排序的集合和列表中。然后,它执行三个相同的调用来删除集合和列表。如果您和大多数人一样,您希望程序从集合和列表中删除非负值(0,1和2),并打印[ - 3,2,1 [ - 3,2,1 ]。实际上,程序从集合中删除非负值,从列表中删除奇数值,并输出- 3,2,1。把这种行为称为混乱是一种保守的说法。
如下是发生的情况:调用set.remove(i)选择重载remove(E),其中E是set (Integer)的元素类型,而autoboxes 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 。然后将结果传递给list.remove。无论哪种方式,程序都会按预期打印[-3, -2,-1[-3,-2,-1]:
上例中出现的混淆行为是因为List<E>接口对remove方法有两个重载:删除(E)和删除(int)。在Java 5之前,当列表接口被“一般化”时,它有一个remove(Object)方法代替remove(E),而相应的参数类型Object和int则完全不同。但是,在泛型和自动装箱的存在下,这两种参数类型不再完全不同。换句话说,在语言中添加泛型和自动装箱破坏了列表接口。幸运的是,Java库中的其他api几乎没有受到类似的破坏,但是这个故事清楚地表明,自动装箱和泛型在重载时增加了谨慎的重要性。
Java 8中添加的lambdas和方法引用进一步增加了重载中混淆的可能性。例如,考虑以下两个片段:
虽然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: overloading, Java编译器将警告您这类有问题的重载。
数组类型和对象以外的类类型是完全不同的。此外,除了Serializable和Cloneable之外,数组类型和接口类型也完全不同。如果两个不同的类都不是另一个类的后代[JLS, 5.5],则称它们是不相关的。例如,String和Throwable是不相关的。任何对象都不可能是两个不相关类的实例,所以不相关的类也是完全不同的。
还有其他类型对不能在任何方向转换[JLS, 5.1.12],但是一旦超出上面描述的简单情况,大多数程序员就很难辨别哪些重载(如果有的话)适用于一组实际参数。决定选择哪个重载的规则非常复杂,并且随着每个版本的发布而变得越来越复杂。很少有程序员能理解它们所有的微妙之处。
有时候,您可能觉得有必要违反这一项中的指导原则,特别是在演化现有类时。例如,考虑String,它从Java 4开始就有一个contenttequals (StringBuffer)方法。在Java 5中,添加了CharSequence来为StringBuffer、StringBuilder、String、CharBuffer和其他类似类型提供公共接口。在添加CharSequence的同时,String还配备了一个重载的contenttequals方法,该方法接受CharSequence。
虽然结果重载明显违反了此项中的指导原则,但它不会造成任何危害,因为当在同一个对象引用上调用这两个重载方法时,它们做的是完全相同的事情。程序员可能不知道将调用哪个重载,但只要它们的行为相同,就没有什么后果。:确保这种行为的标准方法是将更具体的重载转发给更一般的:
虽然Java库在很大程度上遵循了这一项中的建议的精神,但是有一些类违反了它。例如,String导出两个重载的静态工厂方法valueOf(char[])和valueOf(Object),它们在传递相同的对象引用时执行完全不同的操作。这样做没有真正的理由,它应该被视为一种异常现象,有可能造成真正的混乱。
总而言之,仅仅因为您可以重载方法并不意味着您应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造函数的情况下,可能不可能遵循这个建议。在这些情况下,您至少应该避免通过添加强制转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果您做不到这一点,程序员将很难有效地使用重载方法或构造函数,他们将无法理解为什么它不能工作。
本文写于2019.7.17,历时1天