首先说明一下,在实际项目中出现重载还是很少的,一般重写的情况会比较多。
下面这个程序的意图是好的,它试图根据一个集合(collection)是Set、List,还是其他的集合类型,对它进行分类:
// 存在问题 - 这个程序会打印么?
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方法被重载(overloaded)了,而要调用哪个重载方法是在编译时做出决定的。对于for循环中的全部三次迭代,参数的编译时类型都是想用的:Collection<?>。每次迭代的运行时类型都是不同的,但这并不影响对重载方法的选择。因为该参数的编译时类型为Collection<?>,所以,唯一合适的重载方法时第三个:Collection<?>,在循环的每次迭代中,都会调用这个重载方法。
这个程序的行为有悖常理,因为对于重载方法(overloaded method)的选择是静态的,而对于被覆盖的方法(overridden method)的选择则是动态的。 选择被覆盖的方法的正确版本是在运行时进行的,选择的依据是被调用方法所在对象的运行时类型。这里重新说明一下,当一个子类包含的方法声明与其祖先类中的方法声明具有相同的签名时,方法就被覆盖了。如果实例方法在子类中被覆盖了,并且这个方法是在该子类的实例上被调用,那么子类中的覆盖方法(overriding method)将会执行,而不管该子类实例的编译时类型到底是什么。为了更具体地说明,考虑下面这个程序:
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,sparking wine和champagne”,尽管在循环的每次迭代中,实例的编译时类型都为Wine。当调用被覆盖的方法时,对象的编译时类型不会影响到哪个方法奖被执行;“最为具体地(most specific)”那个覆盖版本总是会得到执行。这与重载的情形相比,对象的运行时类型并不影响“哪个重载版本将被执行”;选择工作是在编译时进行的,完全基于参数的编译时类型。
在CollectionClassifier这个示例中,该程序的意图是:根据参数的运行时类型自动将调用分发给适当的重载方法,以此来识别出参数的类型,就好像Wine的例子中的name方法所做的那样。方法重载机制完全没有提供这样的功能。假设需要有个静态方法,CollectionClassifier程序的最佳修正方案是,用单个方法来替换这三个重载的classify方法,并在这个方法中做一个显示的instanceof测试:
public static String classify(Collection<?> c) {
return c instanceof Set ? "Set" : c instanceof List ? "List" : "Unknown Collection";
}
因为覆盖机制是规范,而重载机制是例外,所以,覆盖机制满足了人们对于方法调用行为的期望。正如CollectionClassifier例子所示,重载机制很容易使这些期望落空。如果编写出来的代码的行为可能使程序猿感到困惑,他就是很糟糕的实践。对于API来说尤其如此。如果API的普通用户根本不知道“对于一组给定的参数,其中的哪个重载方法将会被调用”,那么,使用这样的API就很可能出错。这些错误要等到运行时发生了怪异的行为之后才会显现出来,许多程序猿无法诊断出这样的错误。因此,应该避免乱用重载机制。
到底怎样才算乱用重载机制呢?这个问题仍然存在争议。安全而保守的策略是,永远不要导出两个具有相同参数数目的重载方法(实际项目中几乎没人会写,因为这样的代码被领导看到了会被喷,除非你们公司不审核代码)。如果方法使用可变参数(varargs),保守的策略是根本不需要重载它,除了第53项中描述的情形之外。如果你遵守这些限制,程序猿永远也不会陷入到“对于任何一组实际的参数,哪个重载方法是适用的”这样的疑问中。这项限制并不麻烦,因为你始终可以给方法起不同的名称,而不使用重载机制(项目里基本都是这种情况)。
例如,考虑ObjectOutputStream这个类。对于每个基本类型,以及几种引用类型,它的write方法都有一种变形。这些变形方法都有不一样的名字,而不是重载write方法,比如writeBoolean(boolean), writeInt(int)和writeLong(long)。实际上,ObjectInputStream类正是提供了这样的读方法。
对于构造器,你没有选择使用不同名称的机会:一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器(第1项)。而且,对于构造器,还不用担心重载和覆盖的相互影响,因为构造器不可能被覆盖(重写)。或许你有可能导出多个具有相同参数数目的构造器,所以有必要了解一下如何安全地做到这一点。
如果对于“任何一组给定的实际参数将应用在哪个重载方法上”始终非常清楚,那么,导出多个具有相同参数数目的重载方法就不可能使程序猿感到困惑。如果对于每一对重载方法,至少有一个对应的参数在两个重载方法中具有“根本不同(radically different)”的类型,就属于这种情况。如果使用任何非空表达式都无法将两种类型相互转换,那么这两种类型就是完全不同的(Two types are radically different if it is clearly impossible to cast any non-null expression to both types)。在这种情况下,一组给定的实际参数应用于哪个重载方法上就完全由参数的运行时类型来决定,不可能受到其编译时类型的影响,所以主要的混淆根源就消除了。例如,ArrayList有一个构造器带一个int参数,另一个构造器带一个Collection参数。难以想象在什么情况下,会不清楚要调用哪一个构造器。
在Java 1.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之间的整数添加到了排好序的集合列表中,然后在集合和列表中都进行3次相同的remove调用。如果你像大多数人一样,希望程序从集合和列表中去除非负数(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]开始,去除第零个元素,接着去除第一个、第二个,得到的是[-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接口有两个重载的remove方法:remove(E)和remove(int)。当它在Java 1.5 发行版中被泛型化之前,List接口有一个remove(Object)而不是remove(E),相应的参数类型:Object和int,则根本不用。但是自从有了泛型和自动装箱之后,这两种参数类型就不再根本不同了。换句话说,Java语言中添加了泛型和自动装箱之后,破坏了List接口。幸运的是,Java类库中几乎再没有API受到同样的破坏,但是这种情形清楚地说明了,自动装箱和泛型成了Java语言的一部分之后,谨慎重载显得更加重要了。
在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 【参数】的重载,然而Thread构造函数并没有。你可能认为这不应该有任何区别,因为println的所有重载都返回void,因此方法引用不可能是Callable。这很有道理,但这不是重载解析算法的工作方式。也许同样令人惊讶的是,如果println方法也没有重载,则submit方法调用将是合法的。它是重载引用方法(println)和调用方法(submit)的组合,它可以防止重载决策算法按照你的预期运行(It is the combination of the overloading of the referenced method (println) and the invoked method (submit) that prevents the overload resolution algorithm from behaving as you’d expect)。
从技术上讲,问题是System.out :: println是一个不精确的方法引用[JLS,15.13.1],并且“包含隐式类型的lambda表达式或不精确的方法引用的某些参数表达式被适用性测试忽略,因为它们的在选择目标类型之前无法确定含义[JLS,15.12.2]。如果你不理解这段文字的意思,不要担心; 它针对的是编译器的编写者。导致混淆关键是在同一参数位置中具有不同功能接口的重载方法或构造函数。因此,不要让多个重载方法在相同的参数位置接受不同的功能接口。在这个项目的说法中,不同的功能接口从根本上讲并不是完全不同的【也就是有一些相同点】。如果你使用(pass)命令行开关-Xlint:overloads,出现这种有问题的重载时,Java编译器就会警告你。
数组类型和Object之外的类截然不同。数组类型和Serializable与Cloneable之外的接口也截然不同。如果两类都不是对方的后代,这两个独特的类就是不相关的(unrelated)[JLS, 5.5]。例如,String和Throwable就是不相关的。任何对象都不可能是两个不相关的类的实例,因此不相关的类也是截然不同的。
还有其他一些“类型对”的例子也是不能相互转换的[JLS, 5.1.12]。但是,一旦超出了上述这些简单的情形,大多数程序猿要想搞清楚“一组实际的参数应用于哪个重载方法上”就会非常困难。确定选择哪个重载方法的规则是非常复杂得,并且每个版本都会变得更加复杂。很少有程序猿能够理解其中的所有微妙之处。
有时候,尤其是在更新现有类的时候,可能会被迫违反本项中的指导原则。例如,例如,考虑String,它自Java 4以来就有一个contentEquals(StringBuffer)方法。在Java 5中,新增了一个CharSequence接口,用来为StringBuffer,StringBuilder,String,CharBuffer和其他类似的类型提供公共接口。在添加CharSequence接口的同时,String也加(outfitted)了一个接受一个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),当这两个方法被传递了同样的对象引用时,它们所做的事情完全不同。没有正当的理由可以解释这一点,它应该被看作是一种反常行为,有可能会造成真正的混淆。
简而言之,能够重载方法并不意味着就应该重载方法。一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造函数的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需要经过类型转换就可以被传递给不用的重载方法。如果不能避免这种情形,例如,因为正在改造一个现有的类来实现新的接口,就应该保证:当传递同样的参数时,所有重载方法的行为必须一致。如果不能做到这一点,程序猿就很难有效地使用被重载的方法或者构造器,它们就不能理解它为什么不能正常地工作。
所有文章无条件开放,顺手点个赞不为过吧!