第五十二条:慎用重载

下面这个程序的意图是好的,它试图根据一个集合是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(),
      new ArrayList(),
      new HashMap().values()
  }

  for (Collection c : collections) 
      System.out.println(classify(c));
  } 
}

你可能期望这个程序会打印初Set,紧接着是List,以及Unkown Collection,但实际上不是这样的。它打印了三次Unkown Collection。为什么会这样呢?因为classify方法被重载(overloaded)了,而要调用哪个重载方法是在编译时做出决定的。对于for循环中的全部三次迭代,参数的编译时类型都是相同的:Collection每次迭代的运行时类型都是不同的,但这并不影响对重载方法的选择。因为该参数的编译时类型为Collection,所以,唯一合适的重载方法是classify(Collection),在循环的每次迭代中,都会调用这个重载方法。

这个程序的行为有违常理,因为对于重载方法的选择是静态的,而对于被覆盖的方法的选择则是动态的选择被覆盖的方法的正确版本是在运行时进行的,选择的依据是被调用方法所在对象的运行时类型。这里重新说明一下,当一个子类包含的方法声明与其祖先类中的方法声明具有同样的签名时,方法就被覆盖了。如果实例方法在子类中被覆盖了,并且这个方法是在该子类的实例上被调用的,那么子类中的覆盖方法将会执行,而不管该子类实例的编译时类型到底是什么。为了进行更具体地说明,以下面地程序为例:

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方法所做的那样,方法重载机制完全没有提供这样的功能。假设需要有个静态方法,这个程序的最佳修正方案是,用单个方法来替换这三个重载的classify方法,并在这个方法中做一个显示的instanceof测试:

public static String classify(Collection c) { 
  return c instanceof Set ? 
            "Set" 
            : c instanceof List ? 
              "List" 
              : "Unknown Collection"; 
}

因为覆盖机制是标准规范,而重载机制是例外,所以,覆盖机制满足了人们对方法调用行为的期望。正如CollectionClassifier例子所示,重载机制很容易使这些期望落空。如果编写出来的代码的行为可能是程序员感到困惑,那么它就是很糟糕的实践。对于API来说尤其如此。如果API的普通用户根本不知道对于一组给定的参数,其中的哪个重载方法会被调用,那么使用这样的API就很可能导致错误。这些错误要等到运行时发生了怪异的行为之后才会显现出来,导致许多程序员无法诊断出这样的错误。因此,应该避免胡乱的使用重载机制

到底是什么造成胡乱使用重载机制呢?这个问题仍有争议。安全而保守的策略是,永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数,除第53条中所述的情形之外,保守的策略是根本不要重载它。如果你遵守这些限制,程序员永远也不会陷入对于任何一组实际的参数,哪个重载方法才是适用的这样的疑问中。这项限制并不麻烦,因为你始终可以给方法起不同的名称,而不使用重载机制

例如,以ObjectOutputStrema类为例。对于每个基本类型,以及几种引用类型,它的write方法都有一种变形。这些变形方法并不是重载write方法,而是具有诸如writeBoolean(boolean)、writeInt(int) 和 writeLong(long)这样的签名。与重载方案相比较,这种命名模式带来的好处是,可以提供相应名称的读方法,比如readBoolean()、readInt()和readLong()。实际上,ObjectInputStream类正是提供了这样的读方法。

对于构造器,你没有选择使用不同的名称的机会:一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器(详见第1条)。对于构造器,还不用担心重载和覆盖的相互影响,因为构造器不可能被覆盖。或许你有可能导出多个具有相同参数数目的构造器,所以有必要了解一下如果安全的做到这一点。

如果对于任何一组给定的实际参数将应用于哪个重载方法上始终非常清楚,那么导出多个具有相同参数数目的重载方法就不可能使程序员感到混淆。对于每一对重载方法,至少有一个对应的参数在两个重载方法中具有根本不同的类型,就属于这种不会感到混淆的情形了。如果显然不可能把一种类型的实例转换为另一种类型,这两种类型就是根本不同的。在这种情况下,一组给定的实际参数应用于哪个重载方法上就完全由参数的运行时类型来决定,不可能受到其编译时类型的影响,所以主要的混淆就根本消除了。例如,ArrayList有一个构造器带有一个int参数,另一个构造器带有一个Collection参数。难以想象在任何情况下,这两个构造器被调用时哪一个会产生混淆。

在Java5发行版本之前,所有的基本类型都根本不同于所有的引用类型,但是当自动装箱出现之后,就不再如此了,它会导致真正的麻烦。以下面这个程序为例:

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之间的整数添加到了排序好的集合和列表中,然后在集合和列表中进行3次相同的remvoe调用。如果像大多数人一样,希望程序从集合和列表中去除非整数值(0、1和2),并打印出[-3,-2,-1] [-3,-2,-1]。事实上,程序从集合中去除了非整数,还从列表中去除了奇数值,打印出[-3,-2,-1] [-2,0,2]。我们将这种行为称之为混乱,已是保守的说法。

实际发生的情况是:set.remove(i)调用选择重载方法remvoe(E),这里的E是集合(Integer)的元素类型,将i从int自动装箱到Integer中。这是你所期望的行为,因此程序不会从集合中去除正值。另一方面,list.remove(i)调用选择重载方法remvoe(int i),它从列表的指定位置上去除元素。如果从列表[-3,-2,-1,0,1,2]开始,去除第零个元素,接着去除第一个、第二个,得到的是[-2,0,2],这个秘密被揭开了。为了解决这个问题,要将lis.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))
}

Thread构造器调用和submit方法调用看起来很相似,但前者会进行编译,而后者不会。参数都是一样的(System.out::println),构造器和方法都带有一个有Runnable的重载。这里发生了什么呢?令人感到意外的是:submit方法有一个带有Callable的重载,而Thread构造器则没有。也许你会认为这应该没什么区别,因为所有的println重载都返回void,因此这个方法引用或许不会是一个Callable。这种想法是完美的,但是重载方案的算法却不是这么做的。也许同样令人感到惊奇的是,如果println方法也没有被重载,submit方法调用则是合法的。这是被引用的方法(println)的重载,与被调用方法(submit)的结合,阻止了重载方案算法按你预期的方式完成。

从技术的角度来看,问题在于,System.out::println是一个不精确的方法引用,而且某些包含隐式类型lambda表达式或者不精确方法引用的参数表达式会被可用性测试忽略,因为它们的含义会要到选择好目标类型之后才能确定。如果你不理解这段话的意思也没关系,这是针对编译器作者而言的。重点是在同一个参数位置,重载带有不同函数接口的方法或者构造器会造成混淆。因此,不要在相同的参数位置调用带有不同函数接口的方法。按照本条目的说法,不同的函数接口并非根本不同。如果传入命令行参数:-Xlint:overloads,Java编译器会对这种问题的重载发出警告。

数组类型和Object之外的类截然不同。数组类型和Serializable与Cloneable之外的接口也截然不同。如果两个类都不是对方的后代,这两种独特的类就是不相关的。例如,String和Throwable就是不相关的。任何对象都不可能是两个不相关的类的实例,因此不相关的类也是根本不同的。

还有其他一些类型对的例子也是不能相互转换的,但是,一旦超出了上述这些简单的情形,大多数程序员要想搞清楚一组实际的参数应用于哪个重载方法上就会非常困难。确定选择哪个重载方法的规则是非常复杂的,这些规则在每个发行版本中都变得越来越复杂。很少有程序员能够理解其中的所有微妙之处。

有时候,尤其是在更新现有类的时候,可能会被迫违反本条目的指导原则。例如,自从Java4发行版本以来,String类就已经有一个contenEquals(StringBuffer)方法。在Java5版本中,新增了一个称作CharSequence的接口,用来为StringBuffer、StringBuilder、String、CharBuffer以及其他类似的类型提供接口。在Java平台中增加CharSequence的同时,String也配备了重载的contenEquals方法,即contenEquals(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),当这两个方法被传递了同样的对象引用时,它们所做的事情完全不同。没有正当的理由可以解释这一点,它应该被看作是一种反常行为,有可能会造成真正的混淆。

简而言之,能够重载方法并不意味就应该重载方法。一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造器的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需经过类型转换就可以传递给不同的重载方法如果不能避免这种情形,例如,因为正在改造一个现有的类以实现新的接口,就应该保证:当传递同样的参数时,所有重载方法的行为必须一致。如果不能做到这一点,程序员就很难有效的使用被重载的方法或者构造器,同时也不能理解它为什么不能正常工作。

你可能感兴趣的:(第五十二条:慎用重载)