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 aAdeberí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
DaA, el método del tipoDse llama -
cuando se lanza un objeto de tipo
EaA, el método del tipoEse llama (!) -
cuando se lanza un objeto de tipo
DaD, el método del tipoDse llama -
cuando se lanza un objeto de tipo
EaD, el método del tipoDse 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()
.