119 Star 1K Fork 297

lin-mt / effective-java-third-edition

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
第52项:慎用重载.md 15.57 KB
一键复制 编辑 原始数据 按行查看 历史
lin-mt 提交于 2020-01-10 20:55 . 修订51、52项

慎用重载

  下面这个程序的目的是明确的,它试图根据一个集合(collection)是 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 方法被*重载(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]。如果你不理解这段文字的意思,不要担心; 它针对的是编译器的编写者。导致混淆关键是在同一参数位置中具有不同功能接口的重载方法或构造函数。因此,不要重载一个方法,该方法在相同的参数位置可以接受不同的功能接口(do not overload methods to take different functional interfaces in the same argument position)。在该项的说法中,不同的功能接口从根本上讲并不是完全不同的。如果你使用(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),当这两个方法被传递了同样的对象引用时,它们所做的事情完全不同。没有正当的理由可以解释这一点,它应该被看作是一种反常行为,有可能会造成真正的混淆。

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

其他
1
https://gitee.com/lin-mt/effective-java-third-edition.git
git@gitee.com:lin-mt/effective-java-third-edition.git
lin-mt
effective-java-third-edition
effective-java-third-edition
master

搜索帮助