Наследование при видимости пакета в Java

Я ищу объяснение следующего поведения:

  • У меня есть 6 классов: {a.A, b.B, c.C, a.D, b.E, c.F}, у каждого из которых есть видимый для пакета метод m(), который записывает имя класса.
  • У меня есть класс a.Main с методом 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"); }
}

Основной класс находится в package 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.


ОБНОВЛЕНИЕ: На первый вопрос отвечает Как переопределить метод с областью видимости по умолчанию (пакет)?

Метод экземпляра mD, объявленный или унаследованный классом D, переопределяет из D другой метод mA, объявленный в классе A, если все следующее верно:

  • A является суперклассом D.
  • D не наследует мА (потому что пересекает границы пакета)
  • Подпись mD является подписью (§8.4.2) подписи mA.
  • Одно из следующего верно: [...]
    • mA объявляется с доступом к пакету в том же пакете, что и D (в этом случае), и либо D объявляет mD, либо mA является членом прямого суперкласса D. [...]

НО: второй вопрос до сих пор не решен.

Ответы

Ответ 1

Я сообщил об этой проблеме, и была подтверждена ошибка для нескольких версий Java.

Сообщение об ошибке.

Я отмечаю этот ответ как решение, но хотел бы поблагодарить всех за все ответы и сообщения, я многому научился. :-)

Ответ 2

  Я понимаю, что D.m() скрывает A.m(), но приведение к A должно раскрыть скрытый метод m(), это правда?

Нет такой вещи, как скрытие, например, (нестатических) методов. Здесь это пример затенения. Приведение к A в большинстве мест просто помогает устранить неоднозначность (например, c.m() как есть, может относиться как к A#m, так и к C#m [который недоступен из a]), что в противном случае привело бы к ошибка компиляции.

Или D.m() переопределяет A.m(), несмотря на то, что B.m() и C.m() разрывает цепочку наследования?

b.m() является неоднозначным вызовом, потому что оба A#m и B#m применимы, если вы оставите в стороне фактор видимости. То же самое касается c.m(). ((A)b).m() и ((A)c).m() четко ссылаются на A#m, который доступен для вызывающего абонента.

((A)d).m() более интересен: и A, и D находятся в одном и том же пакете (таким образом, доступно [что отличается от двух вышеупомянутых случаев]), а 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

поскольку в b и c из a нет доступа к частным методам пакета.

Ответ 3

Интересный вопрос Я проверил это в Oracle JDK 13 и Open JDK 13. Оба дают одинаковый результат, точно так же, как вы написали. Но этот результат противоречит спецификации языка Java.

В отличие от класса 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().

Ответ 4

Это действительно дразнилка мозга.

Следующий ответ еще не полностью окончательный, но мои результаты краткого обзора этого. Может быть, это по крайней мере способствует нахождению определенного ответа. На некоторые части вопроса уже дан ответ, поэтому я сосредоточился на том, что все еще вызывает путаницу и пока не объясняется.

Критический случай можно свести к четырем классам:

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 во вторых вызовах.

Из этого я могу сделать один вывод: виновником является не компилятор, а обработка инструкции invokevirtual JVM!

документация invokevirtual не содержит никаких сюрпризов - здесь приведена только соответствующая часть:

Пусть C будет классом objectref. Фактический метод, который будет вызван, выбирается следующей процедурой поиска:

  1. Если C содержит объявление для метода экземпляра m, которое переопределяет (§5.4.5) разрешенный метод, то m является вызываемым методом.

  2. В противном случае, если C имеет суперкласс, выполняется поиск объявления метода экземпляра, который переопределяет разрешенный метод, начиная с прямого суперкласса C и продолжая с прямого суперкласса этого класса и т.д., До переопределения метода найдено или больше не существует суперклассов. Если найден переопределяющий метод, это метод, который нужно вызвать.

  3. В противном случае, если в суперинтерфейсах C существует ровно один максимально специфический метод (§5.4.3.3), который соответствует разрешенному имени и дескриптору метода и не является абстрактным, то это метод, который нужно вызвать.

Предположительно, он просто идет вверх по иерархии, пока не найдет метод, который (is или) переопределяет метод, причем переопределяет (§5.4.5), определяемый так, как можно было бы ожидать.

До сих пор нет очевидной причины наблюдаемого поведения.


Затем я начал смотреть на то, что на самом деле происходит при обнаружении invokevirtual, и углубился в функцию LinkResolver::resolve_method OpenJDK, но в этот момент я не совсем уверен, является ли это это подходящее место, и я в настоящее время не могу уделять здесь больше времени...


Может быть, другие могут продолжить отсюда или найти вдохновение для своих собственных исследований. По крайней мере тот факт, что компилятор делает правильные вещи, и причуды, по-видимому, связаны с обработкой invokevirtual, может быть отправной точкой.