Java中包可见性下的继承

22

我正在寻找以下行为的解释:

  • 我有6个类,{a.A,b.B,c.C,a.D,b.E,c.F},每个类都有一个包可见的m()方法,该方法会输出类名。
  • 我有一个a.Main类,其中包含一个主方法,用于对这些类进行一些测试。
  • 输出结果似乎不遵循正确的继承规则。

以下是这些类:

package a;

public class A {
    void m() { System.out.println("A"); }
}

// ------ 

package b;

import a.A;

public class B extends A {
    void m() { System.out.println("B"); }
}

// ------ 

package c;

import b.B;

public class C extends B {
    void m() { System.out.println("C"); }
}

// ------ 

package a;

import c.C;

public class D extends C {
    void m() { System.out.println("D"); }
}

// ------ 

package b;

import a.D;

public class E extends D {
    void m() { System.out.println("E"); }
}

// ------ 

package c;

import b.E;

public class F extends E {
    void m() { System.out.println("F"); }
}

主类在a包中:

package a;

import b.B;
import b.E;
import c.C;
import c.F;

public class Main {

    public static void main(String[] args) {
        A a = new A();
        B b = new B();
        C c = new C();
        D d = new D();
        E e = new E();
        F f = new F();

        System.out.println("((A)a).m();"); ((A)a).m();
        System.out.println("((A)b).m();"); ((A)b).m();
        System.out.println("((A)c).m();"); ((A)c).m();
        System.out.println("((A)d).m();"); ((A)d).m();
        System.out.println("((A)e).m();"); ((A)e).m();
        System.out.println("((A)f).m();"); ((A)f).m();

        System.out.println("((D)d).m();"); ((D)d).m();
        System.out.println("((D)e).m();"); ((D)e).m();
        System.out.println("((D)f).m();"); ((D)f).m();
    }
}

这里是输出结果:

((A)a).m();
A
((A)b).m();
A
((A)c).m();
A
((A)d).m();
D
((A)e).m();
E
((A)f).m();
F
((D)d).m();
D
((D)e).m();
D
((D)f).m();
D

以下是我的问题:

1) 我理解 D.m() 隐藏了 A.m(),但是强制转换为 A 应该会暴露被隐藏的 m() 方法,这是正确的吗?还是说 D.m() 覆盖了 A.m(),尽管 B.m()C.m() 打破了继承链?

((A)d).m();
D

2) 更糟糕的是,下面的代码表现出覆盖效果,为什么?

((A)e).m();
E
((A)f).m();
F

为什么不在这部分呢:

((A)a).m();
A
((A)b).m();
A
((A)c).m();
A

这个呢?

((D)d).m();
D
((D)e).m();
D
((D)f).m();
D

我正在使用OpenJDK javac 11.0.2。


编辑:第一个问题已经在如何覆盖具有默认(包)可见性范围的方法?中得到解答。

如果D类中声明或继承的实例方法mD覆盖了A类中声明的另一个方法mA,则必须满足以下所有条件:

  • A是D的超类。
  • D没有继承mA(因为跨越包边界)。
  • mD的签名是mA的子签名(§8.4.2)。
  • 以下两者之一为真: [...]
    • 在与D相同的包中以包访问权限声明mA(此情况下),并且D声明了mD,或mA是D直接超类的成员。 [...]

但是:第二个问题仍未解决。


12
我喜欢这个问题,但是伙计,你真的需要用六个课程来表达这个观点吗? - Andrew Tobilko
1
@Andrew Tobilko:有道理,抱歉。我猜你对最初的12个类的写作也不会满意吧 :-) - TFuto
@dyukha:我还没有看到建议的重复链接如何解释虚拟调度...但也许只是我自己的问题。 - TFuto
你第一个问题的答案是:false。强制类型转换不会改变运行时调用的方法。如果你将一个List强制转换为一个Object并调用它的toString方法,你不会调用Object的默认toString方法。在我看来,你的第二个问题揭示了Java的一个bug。在另一个包中调用包私有功能是不可能的。 - VGR
1
显然,混淆始于20年前...请参见链接1链接2链接3的错误数据库。例如,如果一个类仅因为有一个抽象的包可见方法而是抽象的,那么从前一个类派生的另一个包中的类是否也是抽象的?等等... - TFuto
我收到了确认,这是一个错误 - TFuto
4个回答

7
我了解到 D.m() 隐藏了 A.m(),但是将其强制转换为 A 后是否可以暴露隐藏的 m() 方法呢?
对于实例(非静态)方法,不存在隐藏这一说法。在这里,它是一个 shadowing 的例子。在大多数情况下,将其强制转换为 A 只是为了解决歧义(例如,c.m() 可以同时引用 A#mC#m [由于不能从 a 访问后者而导致编译错误])。
那么 D.m() 是否会覆盖 A.m() 呢?尽管 B.m()C.m() 打破了继承链,但 b.m() 是一个模棱两可的调用,因为如果您忽略可见性因素,那么 A#mB#m 都适用。对于 c.m() 也是同理。 ((A)b).m()((A)c).m() 明显是指 A#m,对于调用者是可访问的。
对于 ((A)d).m() 更有意思:由于 AD 都在同一个包中(因此可以访问[与上述两个情况不同]),而 D 间接继承了 A。在动态调度期间,Java 将能够调用 D#m,因为 D#m 实际上覆盖了 A#m,并且没有理由不调用它(尽管继承路径上出现了混乱[请记住,由于可见性问题,既不是B#m 也不是 C#m 覆盖了 A#m])。
更糟糕的是,下面的代码显示出覆盖的效果,为什么呢?
我无法解释这个问题,因为它不是我预期的行为。
我敢说该问题的结果是
((A)e).m();
((A)f).m();

应该与结果完全一致。
((D)e).m();
((D)f).m();

这是

D
D

由于无法从 a 访问包私有方法 bc,因此需要注意。


3
有趣的问题。我在Oracle JDK 13和Open JDK 13中进行了检查,两者都给出了与您所写完全相同的结果。但是这个结果与Java语言规范Java Language Specification相矛盾。
与D类不同,D类与A类在同一个包中,而B、C、E、F类在一个不同的包中,并且由于A.m()的包私有声明,它们无法看到它也无法重写它。对于B和C类,它按照JLS的规定工作。但是对于E和F类,它则不符合规定。((A)e).m()((A)f).m()的情况是Java编译器实现中的错误

((A)e).m()((A)f).m() 应该如何工作?由于D.m() 覆盖了 A.m(),因此这也应适用于它们所有的子类。因此,((A)e).m()((A)f).m() 应该与 ((D)e).m()((D)f).m() 相同,这意味着它们都应该调用D.m()


我认为((A)e).m()((A)f).m()两者都没有问题 - 它们应该能够编译。不过它们产生的结果很奇怪。 - Andrew Tobilko
它们确实可以编译。但是,无论您将其转换为层次结构中的哪个类,如果方法可见,则执行结果必须相同。 - mentallurg

3
我已经报告了这个问题,并且确认在多个Java版本中存在一个bug。 问题报告
我将此答案标记为解决方案,但仍要感谢所有提供答案和留言的人,我学到了很多。 :-)

3
这确实是个脑筋急转弯。
以下答案尚未完全得出结论,但我通过简要查看得出了一些结果。也许它至少有助于找到一个明确的答案。问题的部分已经被回答,因此我将重点放在仍然引起困惑并且尚未解释的关键点上。
关键情况可以归结为四类:
package a;

public class A {
    void m() { System.out.println("A"); }
}

package a;

import b.B;

public class D extends B {
    @Override
    void m() { System.out.println("D"); }
}

package b;

import a.A;

public class B extends A {
    void m() { System.out.println("B"); }
}

package b;

import a.D;

public class E extends D {
    @Override
    void m() { System.out.println("E"); }
}

(请注意,我在可能的情况下添加了@Override注释——我希望这可以提供一些提示,但我还不能从中得出结论...)
主类:
package a;

import b.E;

public class Main {

    public static void main(String[] args) {

        D d = new D();
        E e = new E();
        System.out.print("((A)d).m();"); ((A) d).m();
        System.out.print("((A)e).m();"); ((A) e).m();

        System.out.print("((D)d).m();"); ((D) d).m();
        System.out.print("((D)e).m();"); ((D) e).m();
    }

}

这里的意外输出是:
((A)d).m();D
((A)e).m();E
((D)d).m();D
((D)e).m();D

因此,

  • 将类型为D的对象转换为A类型时,调用来自D类型的方法
  • 将类型为E的对象转换为A类型时,调用来自E类型的方法(!)
  • 将类型为D的对象转换为D类型时,调用来自D类型的方法
  • 将类型为E的对象转换为D类型时,调用来自D类型的方法

很容易发现这里有一个奇怪的点:人们自然会期望将E转换为A应该会导致调用D的方法,因为它是同一包中最高的方法。观察到的行为不能轻松地从JLS中解释,尽管需要仔细重新阅读它,以确保没有微妙的原因。


出于好奇,我查看了Main类的生成字节码。这是javap -c -v Main的完整输出(相关部分将在下面详细说明):

public class a.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Class              #2             // a/Main
   #2 = Utf8               a/Main
   #3 = Class              #4             // java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Methodref          #3.#9          // java/lang/Object."<init>":()V
   #9 = NameAndType        #5:#6          // "<init>":()V
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               La/Main;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Class              #17            // a/D
  #17 = Utf8               a/D
  #18 = Methodref          #16.#9         // a/D."<init>":()V
  #19 = Class              #20            // b/E
  #20 = Utf8               b/E
  #21 = Methodref          #19.#9         // b/E."<init>":()V
  #22 = Fieldref           #23.#25        // java/lang/System.out:Ljava/io/PrintStream;
  #23 = Class              #24            // java/lang/System
  #24 = Utf8               java/lang/System
  #25 = NameAndType        #26:#27        // out:Ljava/io/PrintStream;
  #26 = Utf8               out
  #27 = Utf8               Ljava/io/PrintStream;
  #28 = String             #29            // ((A)d).m();
  #29 = Utf8               ((A)d).m();
  #30 = Methodref          #31.#33        // java/io/PrintStream.print:(Ljava/lang/String;)V
  #31 = Class              #32            // java/io/PrintStream
  #32 = Utf8               java/io/PrintStream
  #33 = NameAndType        #34:#35        // print:(Ljava/lang/String;)V
  #34 = Utf8               print
  #35 = Utf8               (Ljava/lang/String;)V
  #36 = Methodref          #37.#39        // a/A.m:()V
  #37 = Class              #38            // a/A
  #38 = Utf8               a/A
  #39 = NameAndType        #40:#6         // m:()V
  #40 = Utf8               m
  #41 = String             #42            // ((A)e).m();
  #42 = Utf8               ((A)e).m();
  #43 = String             #44            // ((D)d).m();
  #44 = Utf8               ((D)d).m();
  #45 = Methodref          #16.#39        // a/D.m:()V
  #46 = String             #47            // ((D)e).m();
  #47 = Utf8               ((D)e).m();
  #48 = Utf8               args
  #49 = Utf8               [Ljava/lang/String;
  #50 = Utf8               d
  #51 = Utf8               La/D;
  #52 = Utf8               e
  #53 = Utf8               Lb/E;
  #54 = Utf8               SourceFile
  #55 = Utf8               Main.java
{
  public a.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #8                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 5: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   La/Main;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #16                 // class a/D
         3: dup
         4: invokespecial #18                 // Method a/D."<init>":()V
         7: astore_1
         8: new           #19                 // class b/E
        11: dup
        12: invokespecial #21                 // Method b/E."<init>":()V
        15: astore_2
        16: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
        19: ldc           #28                 // String ((A)d).m();
        21: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        24: aload_1
        25: invokevirtual #36                 // Method a/A.m:()V
        28: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
        31: ldc           #41                 // String ((A)e).m();
        33: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        36: aload_2
        37: invokevirtual #36                 // Method a/A.m:()V
        40: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
        43: ldc           #43                 // String ((D)d).m();
        45: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        48: aload_1
        49: invokevirtual #45                 // Method a/D.m:()V
        52: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
        55: ldc           #46                 // String ((D)e).m();
        57: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        60: aload_2
        61: invokevirtual #45                 // Method a/D.m:()V
        64: return
      LineNumberTable:
        line 9: 0
        line 10: 8
        line 11: 16
        line 12: 28
        line 14: 40
        line 15: 52
        line 16: 64
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      65     0  args   [Ljava/lang/String;
            8      57     1     d   La/D;
           16      49     2     e   Lb/E;
}
SourceFile: "Main.java"

有趣的是方法的调用:
16: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
19: ldc           #28                 // String ((A)d).m();
21: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
24: aload_1
25: invokevirtual #36                 // Method a/A.m:()V

28: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
31: ldc           #41                 // String ((A)e).m();
33: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
36: aload_2
37: invokevirtual #36                 // Method a/A.m:()V

40: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
43: ldc           #43                 // String ((D)d).m();
45: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
48: aload_1
49: invokevirtual #45                 // Method a/D.m:()V

52: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
55: ldc           #46                 // String ((D)e).m();
57: invokevirtual #30                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
60: aload_2
61: invokevirtual #45                 // Method a/D.m:()V

这段字节码明确地在前两个调用中引用了方法A.m,并且在第二次调用中明确地引用了方法D.m

我从中得出的一个结论是:罪魁祸首不是编译器,而是JVM对invokevirtual指令的处理!

invokevirtual文档没有任何意外 - 这里仅引用相关部分:

让C成为objectref类。选择要调用的实际方法是通过以下查找过程进行的: 1.如果C包含一个声明覆盖(§5.4.5)已解析方法的实例方法m,则m是要调用的方法。 2.否则,如果C有一个超类,则从C的直接超类开始执行搜索,继续搜索该类的直接超类,依此类推,直到找到覆盖方法或不再存在其他超类。如果找到覆盖方法,则它是要调用的方法。 3.否则,如果在C的超级接口中恰好有一个最具特定性的方法(§5.4.3.3)与已解析方法的名称和描述符匹配且不是抽象的,则它是要调用的方法。
据说它只会沿着层次结构向上查找,直到找到一个方法(或者)覆盖该方法,其中覆盖(§5.4.5)的定义如人们自然地期望的那样。
对于观察到的行为仍然没有明显的原因。

我开始研究当遇到invokevirtual时实际发生了什么,深入研究了OpenJDK的LinkResolver::resolve_method函数,但此时我不完全确定这是否是要查看的正确位置,目前无法再投入更多时间...


也许其他人可以从这里继续,或者从中获得启发进行自己的调查。至少编译器做了正确的事情,而问题似乎在于处理"invokevirtual"时存在一些怪异情况,这可能是一个起点。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接