Herencia en la visibilidad del paquete en Java
inheritance overriding (3)
Estoy buscando una explicación para el siguiente comportamiento:
- Tengo 6 clases, {aA, bB, cC, aD, bE, cF}, cada una con un método de paquete visible m () que escribe el nombre de la clase.
- Tengo una clase a.Main con un método principal que hace algunas pruebas de estas clases.
- El resultado parece no seguir las reglas de herencia adecuadas.
Aquí están las clases:
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"); }
}
La clase principal está en el
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();
}
}
Y aquí está el resultado:
((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
Y aquí están mis preguntas:
1) Entiendo que
Dm()
esconde
Am()
, pero un reparto a
A
debería exponer el método oculto
m()
, ¿es eso cierto?
¿O es que
Dm()
anula
Am()
a pesar de que
Bm()
y
Cm()
rompen la cadena de herencia?
((A)d).m();
D
2) Aún peor, el siguiente código muestra una anulación en efecto, ¿por qué?
((A)e).m();
E
((A)f).m();
F
Y por qué no en esta parte:
((A)a).m();
A
((A)b).m();
A
((A)c).m();
A
¿y éste?
((D)d).m();
D
((D)e).m();
D
((D)f).m();
D
Estoy usando OpenJDK javac 11.0.2.
EDITAR: ¿Cómo anular un método con el alcance de visibilidad predeterminado (paquete) responde a la primera pregunta ?
Un método de instancia mD declarado o heredado por la clase D, anula de D otro método mA declarado en la clase A, si todo lo siguiente es cierto:
- A es una superclase de D.
- D no hereda mA (porque cruza los límites del paquete)
- La firma de mD es una sub firma (§8.4.2) de la firma de mA.
- Uno de los siguientes es cierto: [...]
- mA se declara con acceso al paquete en el mismo paquete que D (en este caso), y D declara mD o mA es miembro de la superclase directa de D. [...]
PERO: la segunda pregunta sigue sin resolverse.
Entiendo que
Dm()
ocultaAm()
, pero un reparto aA
debería exponer el método ocultom()
, ¿es eso cierto?
No existe tal cosa como ocultar, por ejemplo, métodos (no estáticos).
Aquí, es un ejemplo de
shadowing
.
Un reparto a
A
en la mayoría de los lugares solo ayuda a resolver la ambigüedad (por ejemplo,
cm()
ya que puede referirse tanto a
A#m
C#m
[que no es accesible desde
a
]) que de lo contrario conduciría a un error de compilación.
¿O es que
Dm()
anulaAm()
a pesar de queBm()
yCm()
rompen la cadena de herencia?
bm()
es una llamada ambigua porque tanto
A#m
B#m
son aplicables si establece el factor de visibilidad a un lado.
Lo mismo vale para
cm()
.
((A)b).m()
y
((A)c).m()
se refieren claramente a
A#m
que es accesible para la persona que llama.
((A)d).m()
es más interesante: tanto
A
como
D
residen en el mismo paquete (por lo tanto, accesible [que es diferente de los dos casos anteriores]) y
D
hereda indirectamente
A
Durante el despacho dinámico, Java podrá llamar a
D#m
porque
D#m
realmente anula a
A#m
no hay razón para no llamarlo (a pesar del desorden en la ruta de herencia [recuerde que ni
B#m
ni
C#m
anula
A#m
debido al problema de visibilidad]).
Peor aún, el siguiente código muestra una anulación en efecto, ¿por qué?
No puedo explicar esto porque no es el comportamiento que esperaba.
Me atrevo a decir que el resultado de
((A)e).m();
((A)f).m();
debe ser idéntico al resultado de
((D)e).m();
((D)f).m();
cual es
D
D
ya que no hay forma de acceder a los métodos privados del paquete en
c
desde
a
.
Este es un desafío para la mente, de hecho.
La siguiente respuesta aún no es completamente concluyente, pero mis resultados de echar un vistazo a esto. Tal vez al menos contribuye a encontrar una respuesta definitiva. Algunas partes de la pregunta ya han sido respondidas, por lo que me estoy centrando en el punto que todavía causa confusión y aún no se explica.
El caso crítico se puede resumir en cuatro clases:
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"); }
}
(Tenga en cuenta que
@Override
anotaciones de
@Override
cuando sea posible; esperaba que esto ya pudiera dar una pista, pero aún no pude sacar conclusiones de eso ...)
Y la clase principal:
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();
}
}
La salida inesperada aquí es
((A)d).m();D
((A)e).m();E
((D)d).m();D
((D)e).m();D
Entonces
-
cuando se lanza un objeto de tipo
D
aA
, el método del tipoD
se llama -
cuando se lanza un objeto de tipo
E
aA
, el método del tipoE
se llama (!) -
cuando se lanza un objeto de tipo
D
aD
, el método del tipoD
se llama -
cuando se lanza un objeto de tipo
E
aD
, el método del tipoD
se llama
Es fácil detectar el extraño aquí: naturalmente, uno esperaría que lanzar una
E
a
A
provoque que se llame al método de
D
, porque ese es el método "más alto" en el mismo paquete.
El comportamiento observado no puede explicarse fácilmente desde el JLS, aunque uno tendría que volver a leerlo,
cuidadosamente
, para asegurarse de que no haya una razón sutil para eso.
Por curiosidad, eché un vistazo al bytecode generado de la clase
Main
.
Esta es la salida completa de
javap -c -v Main
(las partes relevantes se desarrollarán a continuación):
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"
Lo interesante es la invocación de los métodos:
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
El código de bytes
se
refiere
explícitamente
al método
Am
en las dos primeras llamadas, y
se
refiere
explícitamente
al método
Dm
en las segundas llamadas.
Una conclusión que extraigo de eso: ¡El culpable
no
es el compilador, sino el manejo de la instrucción
invokevirtual
de
invokevirtual
de la JVM!
La
documentación de
invokevirtual
no contiene sorpresas, citando solo la parte relevante aquí:
Sea C la clase de objectref. El método real a invocar se selecciona mediante el siguiente procedimiento de búsqueda:
Si C contiene una declaración para un método de instancia m que anula (§5.4.5) el método resuelto, entonces m es el método a invocar.
De lo contrario, si C tiene una superclase, se realiza una búsqueda de una declaración de un método de instancia que anule el método resuelto, comenzando con la superclase directa de C y continuando con la superclase directa de esa clase, y así sucesivamente, hasta un método de anulación se encuentra o no existen más superclases. Si se encuentra un método de anulación, es el método a invocar.
De lo contrario, si hay exactamente un método máximo específico (§5.4.3.3) en las superinterfaces de C que coincide con el nombre y descriptor del método resuelto y no es abstracto, entonces es el método a invocar.
Supuestamente solo sube la jerarquía, hasta que encuentra un método que ( es o) anula el método, con las anulaciones (§5.4.5) definidas como uno esperaría naturalmente.
Todavía no hay una razón obvia para el comportamiento observado.
Luego comencé a mirar lo que realmente sucede cuando se encuentra un
invokevirtual
, y profundizo en
la función
LinkResolver::resolve_method
de OpenJDK, pero en ese momento, no estoy
completamente
seguro de si este es el lugar correcto para mirar, y Actualmente no puedo invertir más tiempo aquí ...
Quizás otros puedan continuar desde aquí, o encontrar inspiración para sus propias investigaciones.
Al menos el hecho de que el
compilador
hace lo correcto, y la peculiaridad parece estar en el manejo de
invokevirtual
, podría ser un punto de partida.
Interesante pregunta. Lo comprobé en Oracle JDK 13 y Open JDK 13. Ambos dan el mismo resultado, exactamente como lo escribió. Pero este resultado contradice la especificación del lenguaje Java .
A diferencia de la clase D, que está en el mismo paquete que A, las clases B, C, E, F están en un paquete
diferente
y debido a la declaración privada del paquete de
Am()
no puede verlo ni anularlo.
Para las clases B y C funciona como se especifica en JLS.
Pero para las clases E y F no lo hace.
Los casos con
((A)e).m()
y
((A)f).m()
son
errores
en la implementación del compilador de Java.
¿Cómo
debería
funcionar
((A)e).m()
y
((A)f).m()
?
Dado que
Dm()
anula
Am()
, esto debería ser válido también para todas sus subclases.
Por lo tanto, tanto
((A)e).m()
como
((A)f).m()
deben ser lo mismo que
((D)e).m()
y
((D)f).m()
, significa que todos deberían llamar a
Dm()
.