Java 8 三元条件和未装箱基元的方法重载歧义

2022-09-01 19:25:24

以下是在 Java 7 中编译的代码,但不是 openjdk-1.8.0.45-31.b13.fc21。

static void f(Object o1, int i) {}
static void f(Object o1, Object o2) {}

static void test(boolean b) {
    String s = "string";
    double d = 1.0;
    // The supremum of types 'String' and 'double' is 'Object'
    Object o = b ? s : d;
    Double boxedDouble = d;
    int i = 1;
    f(o,                   i); // fine
    f(b ? s : boxedDouble, i); // fine
    f(b ? s : d,           i); // ERROR!  Ambiguous
}

编译器声明最后一个方法调用不明确。

如果我们将第二个参数 from 的类型更改为 ,则代码将在两个平台上编译。为什么发布的代码不能在Java 8中编译?fintInteger


答案 1

让我们首先考虑一个没有三元条件且无法在 Java HotSpot VM 上编译的简化版本(内部版本 1.8.0_25-b17):

public class Test {

    void f(Object o1, int i) {}
    void f(Object o1, Object o2) {}

    void test() {
        double d = 1.0;

        int i = 1;
        f(d, i); // ERROR!  Ambiguous
    }
}

编译器错误是:

Error:(12, 9) java: reference to f is ambiguous
both method f(java.lang.Object,int) in test.Test and method f(java.lang.Object,java.lang.Object) in test.Test match

根据 JLS 15.12.2。编译时步骤 2:确定方法签名

如果通过严格调用 (§15.12.2.2)、松散调用 (§15.12.2.3) 或可变 arity 调用 (§15.12.2.4) 之一适用该方法,则该方法适用。

调用与调用上下文有关,此处将对此进行 JLS 5.3 的解释。调用上下文

如果方法调用不涉及装箱或取消装箱,则应用严格的调用。当方法调用涉及装箱或取消装箱时,则松散调用适用。

确定适用的方法分为3个阶段。

第一阶段 (§15.12.2.2) 执行重载解析,不允许装箱或取消装箱转换,也不允许使用可变 arity 方法调用。如果在此阶段找不到适用的方法,则处理将继续到第二阶段。

第二阶段 (§15.12.2.3) 在允许装箱和取消装箱的同时执行重载解析,但仍排除使用可变 arity 方法调用。如果在此阶段找不到适用的方法,则处理将继续到第三阶段。

第三阶段 (§15.12.2.4) 允许将重载与可变 arity 方法、装箱和取消装箱相结合。

对于我们的情况,没有通过严格调用适用的方法。这两种方法都可以通过松散调用来应用,因为必须对双精度值进行装箱。

根据 JLS 15.12.2.5 选择最具体的方法

如果多个成员方法既可访问又适用于方法调用,则需要选择一个成员方法来为运行时方法调度提供描述符。Java 编程语言使用选择最具体方法的规则。

然后:

一个适用的方法 m1 比另一个适用的方法 m2 更具体,用于使用参数表达式 e1, ..., ek 的调用(如果满足以下任一条件):

  1. m2 是通用的,对于参数表达式 e1, ..., ek,§18.5.4,m1 被推断为比 m2 更具体。

  2. m2 不是泛型的,m1 和 m2 可以通过严格或松散调用来应用,其中 m1 具有形式参数类型 S1, ...,Sn 和 m2 具有形式参数类型 T1, ..., Tn,对于所有 i 的参数 ei,类型 Si 比 Ti 更具体(1 ≤ i ≤ n, n = k)。

  3. m2 不是泛型的,m1 和 m2 通过变量 arity 调用适用,其中 m1 的第一个 k 变量 arity 参数类型是 S1, ...,Sk 和 m2 的第一个 k 个变量 arity 参数类型是 T1, ..., Tk,对于所有 i 的参数 ei,Si 类型比 Ti 更具体(1 ≤ i ≤ k)。此外,如果 m2 具有 k+1 个参数,则 m1 的第 k+1 个变量 arity 参数类型是 m2 的第 k+1'个变量 arity 参数类型的子类型。

上述条件是一种方法可能比另一种方法更具体的唯一情况。

对于任何表达式,如果 S <:T (§4.10),则类型 S 比类型 T 更具体。

看起来第二个条件与这种情况匹配,但实际上它不匹配,因为int不是Object的子类型:int <:Object。但是,如果我们在 f 方法签名中将 int 替换为 Integer,则此条件将匹配。请注意,方法中的第 1 个参数与此条件匹配,因为对象<:对象为 true。

根据 $4.10,基元类型和类/接口类型之间没有定义子类型/超类型关系。因此,例如,int 不是 Object 的子类型。因此,int 并不比 Object 更具体。

由于在2种方法中没有更具体的方法,因此不可能有严格更具体的方法,也不能是最具体的方法(JLS在同一段中给出了这些术语的定义 JLS 15.12.2.5 选择最具体的方法)。因此,这两种方法都具有最大的特异性

在这种情况下,JLS给出了2个选项:

如果所有最大特定方法都具有覆盖等效签名 (§8.4.2) ...

这不是我们的情况,因此

否则,方法调用是不明确的,并且会发生编译时错误。

根据 JLS,我们案例的编译时错误看起来有效。

如果我们将方法参数类型从 int 更改为 Integer 会发生什么情况?

在这种情况下,这两种方法仍然可以通过松散调用来应用。但是,具有 Integer 参数的方法比具有 2 个 Object 参数的方法更具体,因为 Integer <:Object。具有 Integer 参数的方法严格来说更具体,最具体,因此编译器会选择它,而不会引发编译错误。

如果我们在此行中将双精度更改为双精度,会发生什么情况:双精度 d = 1.0;?

在这种情况下,严格调用恰好有1种方法:调用此方法不需要装箱或取消装箱:f(Object o1,int i)。对于另一种方法,您需要执行int值的装箱,以便通过松散调用使其适用。编译器可以通过严格调用来选择适用的方法,因此不会引发编译器错误。

正如Marco13在他的评论中指出的那样,这篇文章中讨论了类似的情况,为什么这种方法重载是模棱两可的?

正如答案中所解释的,Java 7 和 Java 8 之间有一些与方法调用机制相关的重大变化。这就解释了为什么代码在 Java 7 中编译,而不是在 Java 8 中编译。


现在是有趣的部分!

让我们添加一个三元条件运算符:

public class Test {

    void f(Object o1, int i) {
        System.out.println("1");
    }
    void f(Object o1, Object o2) {
        System.out.println("2");
    }

    void test(boolean b) {
        String s = "string";
        double d = 1.0;
        int i = 1;

        f(b ? s : d, i); // ERROR!  Ambiguous
    }

    public static void main(String[] args) {
        new Test().test(true);
    }
}

编译器抱怨不明确的方法调用。JLS 15.12.2 在执行方法调用时,不会规定与三元条件运算符相关的任何特殊规则。

但是有 JLS 15.25 条件运算符 ? :JLS 15.25.3。引用条件表达式。前者将条件表达式分为 3 个子类别:布尔表达式、数字表达式和引用条件表达式。条件表达式的第二个和第三个操作数分别具有 String 和 double 类型。根据 JLS,我们的条件表达式是一个参考条件表达式。

然后根据 JLS 15.25.3。引用条件表达式 我们的条件表达式是多引用条件表达式,因为它出现在调用上下文中。因此,我们的 poly 条件表达式的类型是 Object(调用上下文中的目标类型)。从这里我们可以继续这些步骤,就好像第一个参数是Object一样,在这种情况下,编译器应该选择使用int作为第二个参数的方法(而不是引发编译器错误)。

棘手的部分是JLS的这个注释:

它的第二个和第三个操作数表达式同样出现在与目标类型 T 相同类型的上下文中。

由此我们可以假设(名称中的“poly”也暗示了这一点),在方法调用的上下文中,应该独立考虑2个操作数。这意味着,当编译器必须决定此类参数是否需要装箱操作时,它应该查看每个操作数,并查看是否需要装箱。对于我们的特定情况,String不需要装箱,双打需要装箱。因此,编译器决定对于两个重载方法,它都应该是松散的方法调用。进一步的步骤与使用双精度值代替三元条件表达式的情况相同。

从上面的解释来看,当应用于重载方法时,JLS本身在与条件表达式相关的部分中似乎是模糊和含糊不清的,因此我们不得不做出一些假设。

有趣的是,我的IDE(IntelliJ IDEA)没有将最后一种情况(使用三元条件表达式)检测为编译器错误。所有其他情况它根据JDK的java编译器检测到。这意味着 JDK Java 编译器或内部 IDE 解析器都有 bug。


答案 2

总之:

编译器不知道该选择哪种方法,因为在选择最具体的方法时,JLS 中没有定义基元类型和引用类型之间的排序。

使用 Integer 而不是 int 时,编译器会选择 Integer 的方法,因为 Integer 是 Object 的子类型。

使用 Double 而不是 double 时,编译器将选取不涉及装箱或取消装箱的方法。

在Java 8之前,规则是不同的,所以这段代码可以编译。