¿El final está mal definido?

186

Primero, un rompecabezas: ¿qué imprime el siguiente código?

public class RecursiveStatic {
    public static void main(String[] args) {
        System.out.println(scale(5));
    }

    private static final long X = scale(10);

    private static long scale(long value) {
        return X * value;
    }
}

Responder:

0 0

Spoilers a continuación.


Si imprime Xen escala (largo) y redefine X = scale(10) + 3, las impresiones serán X = 0entonces X = 3. Esto significa que Xse establece temporalmente en 0y luego se establece en 3. Esto es una violación de final!

El modificador estático, en combinación con el modificador final, también se utiliza para definir constantes. El modificador final indica que el valor de este campo no puede cambiar .

Fuente: https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [énfasis agregado]


Mi pregunta: ¿es esto un error? ¿Está finalmal definido?


Aquí está el código que me interesa. XSe le asignan dos valores diferentes: 0y 3. Creo que esto es una violación de final.

public class RecursiveStatic {
    public static void main(String[] args) {
        System.out.println(scale(5));
    }

    private static final long X = scale(10) + 3;

    private static long scale(long value) {
        System.out.println("X = " + X);
        return X * value;
    }
}

Esta pregunta se ha marcado como un posible duplicado del orden de inicialización de campo final estático de Java . Creo que esta pregunta no es un duplicado, ya que la otra pregunta aborda el orden de inicialización, mientras que mi pregunta aborda una inicialización cíclica combinada con la finaletiqueta. Solo con la otra pregunta, no podría entender por qué el código en mi pregunta no comete un error.

Esto es especialmente claro al observar el resultado que obtiene ernesto: cuando ase etiqueta con finalél, obtiene el siguiente resultado:

a=5
a=5

que no involucra la parte principal de mi pregunta: ¿Cómo finalcambia una variable su variable?

Pequeño ayudante
fuente
17
Esta forma de hacer referencia al Xmiembro es como referirse a un miembro de la subclase antes de que el constructor de la superclase haya terminado, ese es su problema y no la definición de final.
daniu
44
De JLS:A blank final instance variable must be definitely assigned (§16.9) at the end of every constructor (§8.8) of the class in which it is declared; otherwise a compile-time error occurs.
Ivan
1
@ Ivan, no se trata de constante sino de variable de instancia. ¿Pero puedes agregar el capítulo?
AxelH
9
Solo como una nota: nunca haga nada de esto en el código de producción. Es muy confuso para todos si alguien comienza a explotar las lagunas en el JLS.
Zabuzard
13
Para su información, también puede crear esta misma situación en C #. C # promete que los bucles en las declaraciones constantes se capturarán en el momento de la compilación, pero no hace tales promesas sobre las declaraciones de solo lectura , y en la práctica puede entrar en situaciones en las que otro inicializador de campo observa el valor cero inicial del campo. Si duele cuando haces eso, no lo hagas . El compilador no te salvará.
Eric Lippert

Respuestas:

217

Un hallazgo muy interesante. Para entenderlo, debemos profundizar en la Especificación del lenguaje Java ( JLS ).

La razón es que finalsolo permite una asignación . El valor predeterminado, sin embargo, no es una asignación . De hecho, cada una de esas variables ( variable de clase, variable de instancia, componente de matriz) apunta a su valor predeterminado desde el principio, antes de las asignaciones . La primera asignación luego cambia la referencia.


Variables de clase y valor predeterminado

Eche un vistazo al siguiente ejemplo:

private static Object x;

public static void main(String[] args) {
    System.out.println(x); // Prints 'null'
}

No asignamos explícitamente un valor a x, aunque apunta a null, su valor predeterminado. Compare eso con §4.12.5 :

Valores iniciales de variables

Cada variable de clase , variable de instancia o componente de matriz se inicializa con un valor predeterminado cuando se crea ( §15.9 , §15.10.2 )

Tenga en cuenta que esto solo es válido para ese tipo de variables, como en nuestro ejemplo. No es válido para las variables locales, consulte el siguiente ejemplo:

public static void main(String[] args) {
    Object x;
    System.out.println(x);
    // Compile-time error:
    // variable x might not have been initialized
}

Del mismo párrafo de JLS:

A una variable local ( §14.4 , §14.14 ) se le debe dar un valor explícito antes de usarla, ya sea por inicialización ( §14.4 ) o asignación ( §15.26 ), de manera que se pueda verificar usando las reglas para la asignación definitiva ( § 16 (Asignación definida) ).


Variables finales

Ahora echamos un vistazo a final, desde §4.12.4 :

Variables finales

Una variable puede ser declarada final . Una variable final solo se puede asignar a una vez . Es un error en tiempo de compilación si se asigna una variable final a menos que esté definitivamente sin asignar inmediatamente antes de la asignación ( §16 (Asignación definida) ).


Explicación

Ahora volviendo al ejemplo, ligeramente modificado:

public static void main(String[] args) {
    System.out.println("After: " + X);
}

private static final long X = assign();

private static long assign() {
    // Access the value before first assignment
    System.out.println("Before: " + X);

    return X + 1;
}

Sale

Before: 0
After: 1

Recordemos lo que hemos aprendido. Dentro del método, a assignla variable todavía noX se le asignó un valor. Por lo tanto, apunta a su valor predeterminado, ya que es una variable de clase y, según el JLS, esas variables siempre apuntan inmediatamente a sus valores predeterminados (en contraste con las variables locales). Después del assignmétodo, a la variable Xse le asigna el valor 1y por finaleso ya no podemos cambiarlo. Entonces, lo siguiente no funcionaría debido a final:

private static long assign() {
    // Assign X
    X = 1;

    // Second assign after method will crash
    return X + 1;
}

Ejemplo en el JLS

Gracias a @Andrew encontré un párrafo JLS que cubre exactamente este escenario, también lo demuestra.

Pero primero echemos un vistazo a

private static final long X = X + 1;
// Compile-time error:
// self-reference in initializer

¿Por qué esto no está permitido, mientras que el acceso desde el método sí lo está? Eche un vistazo a §8.3.3, que trata sobre cuándo se restringe el acceso a los campos si el campo aún no se ha inicializado.

Enumera algunas reglas relevantes para las variables de clase:

Para una referencia por nombre simple a una variable de clase fdeclarada en clase o interfaz C, es un error en tiempo de compilación si :

  • La referencia aparece en un inicializador de variable de clase de Co en un inicializador estático de C( §8.7 ); y

  • La referencia aparece en el inicializador del fpropio declarador o en un punto a la izquierda del fdeclarador; y

  • La referencia no está en el lado izquierdo de una expresión de asignación ( §15.26 ); y

  • La clase o interfaz más interna que encierra la referencia es C.

Es simple, el X = X + 1es atrapado por esas reglas, el método no tiene acceso. Incluso enumeran este escenario y dan un ejemplo:

Los accesos por métodos no se verifican de esta manera, así que:

class Z {
    static int peek() { return j; }
    static int i = peek();
    static int j = 1;
}
class Test {
    public static void main(String[] args) {
        System.out.println(Z.i);
    }
}

produce la salida:

0

porque el inicializador de variables iusa el método de clase peek para acceder al valor de la variable jantes de que jhaya sido inicializado por su inicializador de variables, en cuyo punto todavía tiene su valor predeterminado ( §4.12.5 ).

Zabuzard
fuente
1
@ Andrew Sí, variable de clase, gracias. Sí, se podría trabajar si no habría algunos-reglas adicionales que restringen dicho acceso: §8.3.3 . Eche un vistazo a los cuatro puntos especificados para las variables de clase (la primera entrada). El enfoque del método en el ejemplo de los OP no está captado por esas reglas, por lo tanto, podemos acceder Xdesde el método. No me importaría tanto. Solo depende de cómo exactamente JLS define las cosas para trabajar en detalle. Nunca usaría un código así, solo está explotando algunas reglas en el JLS.
Zabuzard
44
El problema es que puede llamar a métodos de instancia desde el constructor, algo que probablemente no debería haberse permitido. Por otro lado, no se permite asignar locales antes de llamar a super, que sería útil y seguro. Imagínate.
Restablecer Monica
1
@ Andrew probablemente eres el único aquí que realmente mencionó forwards references(eso también es parte de JLS). esto es tan simple sin esta respuesta tan apilada stackoverflow.com/a/49371279/1059372
Eugene
1
"La primera asignación cambia la referencia". En este caso no es un tipo de referencia, sino un tipo primitivo.
fabian
1
Esta respuesta es correcta, aunque un poco larga. :-) Creo que el tl; dr es que el OP citó un tutorial que decía que "un campo [final] no puede cambiar", no el JLS. Si bien los tutoriales de Oracle son bastante buenos, no cubren todos los casos límite. Para la pregunta del OP, debemos ir a la definición real de JLS de final, y esa definición no hace la afirmación (que el OP desafía legítimamente) de que el valor de un campo final nunca puede cambiar.
yshavit
23

Nada que ver con la final aquí.

Como está en el nivel de instancia o clase, mantiene el valor predeterminado si todavía no se asigna nada. Esa es la razón por la que ve 0cuando accede a él sin asignar.

Si accede Xsin asignar completamente, contiene los valores predeterminados de long que es 0, de ahí los resultados.

Suresh Atta
fuente
3
Lo complicado de esto es que si no asigna el valor, no se asignará con el valor predeterminado, pero si lo usó para asignarse el valor "final", será ...
AxelH
2
@AxelH Ya veo a qué te refieres con eso. Pero así es como debería funcionar, de lo contrario el colapso mundial;).
Suresh Atta
20

No es un error

Cuando la primera llamada a scalese llama desde

private static final long X = scale(10);

Intenta evaluar return X * value. Xaún no se le ha asignado un valor y, por lo tanto, longse utiliza el valor predeterminado para a (que es 0).

Entonces esa línea de código se evalúa, X * 10es decir, 0 * 10cuál es 0.

OldCurmudgeon
fuente
8
No creo que eso sea lo que OP confunde. Lo que confunde es X = scale(10) + 3. Desde X, cuando se hace referencia desde el método, es 0. Pero luego lo es 3. Entonces OP piensa que Xse le asignan dos valores diferentes, lo que entraría en conflicto final.
Zabuzard
44
@Zabuza no se explica esto con el " Se trata de evaluar return X * value. XNo se le ha asignado un valor todavía y, por tanto, toma el valor por defecto para una longque es 0. "? No se dice que Xse le asigna el valor predeterminado, sino que Xse "reemplaza" (no cite ese término;)) por el valor predeterminado.
AxelH
14

No es un error en absoluto, simplemente no es una forma ilegal de referencias directas, nada más.

String x = y;
String y = "a"; // this will not compile 


String x = getIt(); // this will compile, but will be null
String y = "a";

public String getIt(){
    return y;
}

Simplemente lo permite la Especificación.

Para tomar su ejemplo, esto es exactamente donde esto coincide:

private static final long X = scale(10) + 3;

Está haciendo una referencia directa a scaleeso que no es ilegal de ninguna manera como se dijo anteriormente, pero le permite obtener el valor predeterminado de X. De nuevo, esto está permitido por la especificación (para ser más exactos, no está prohibido), por lo que funciona bien

Eugene
fuente
¡buena respuesta! Tengo curiosidad por saber por qué la especificación permite que se compile el segundo caso. ¿Es la única forma de ver el estado "inconsistente" de un campo final?
Andrew Tobilko
@ Andrew esto me ha molestado durante bastante tiempo también, me inclino a pensar que es C ++ o C lo hace (no tengo idea si esto es cierto)
Eugene
@ Andrew: Porque hacer lo contrario sería resolver el teorema de incompletitud de Turing.
Joshua
9
@Joshua: Creo que estás mezclando varios conceptos diferentes aquí: (1) el problema de detención, (2) el problema de decisión, (3) el teorema de incompletitud de Godel y (4) los lenguajes de programación completos de Turing. Los escritores del compilador no intentan resolver el problema "¿esta variable está definitivamente asignada antes de ser utilizada?" perfectamente porque ese problema es equivalente a resolver el Problema de detención, y sabemos que no podemos hacerlo.
Eric Lippert
44
@EricLippert: Jaja vaya. Turing incompleto y el problema de detención ocupan el mismo lugar en mi mente.
Joshua
4

Los miembros de nivel de clase se pueden inicializar en código dentro de la definición de clase. El bytecode compilado no puede inicializar los miembros de la clase en línea. (Los miembros de la instancia se manejan de manera similar, pero esto no es relevante para la pregunta proporcionada).

Cuando uno escribe algo como lo siguiente:

public class Demo1 {
    private static final long DemoLong1 = 1000;
}

El código de bytes generado sería similar al siguiente:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

El código de inicialización se coloca dentro de un inicializador estático que se ejecuta cuando el cargador de clases carga por primera vez la clase. Con este conocimiento, su muestra original sería similar a la siguiente:

public class RecursiveStatic {
    private static final long X;

    private static long scale(long value) {
        return X * value;
    }

    static {
        X = scale(10);
    }

    public static void main(String[] args) {
        System.out.println(scale(5));
    }
}
  1. La JVM carga el RecursiveStatic como punto de entrada del jar.
  2. El cargador de clases ejecuta el inicializador estático cuando se carga la definición de clase.
  3. El inicializador llama a la función scale(10)para asignar el static finalcampo X.
  4. La scale(long)función se ejecuta mientras la clase se inicializa parcialmente leyendo el valor no inicializado Xcuyo valor predeterminado es long o 0.
  5. Se 0 * 10asigna el valor de Xy se completa el cargador de clases.
  6. La JVM ejecuta la llamada al método principal público void estático scale(5)que multiplica 5 por el Xvalor ahora inicializado de 0 que devuelve 0.

El campo final estático Xsolo se asigna una vez, preservando la garantía de la finalpalabra clave. Para la consulta posterior de sumar 3 en la asignación, el paso 5 anterior se convierte en la evaluación de 0 * 10 + 3cuál es el valor 3y el método principal imprimirá el resultado de 3 * 5cuál es el valor 15.

psaxton
fuente
3

La lectura de un campo no inicializado de un objeto debería dar como resultado un error de compilación. Desafortunadamente para Java, no lo hace.

Creo que la razón fundamental por la que este es el caso está "oculta" en lo profundo de la definición de cómo se instancian y construyen los objetos, aunque no conozco los detalles del estándar.

En cierto sentido, final no está bien definido porque ni siquiera cumple con su propósito debido a este problema. Sin embargo, si todas sus clases están escritas correctamente, no tiene este problema. Lo que significa que todos los campos siempre se establecen en todos los constructores y nunca se crea ningún objeto sin llamar a uno de sus constructores. Eso parece natural hasta que tenga que usar una biblioteca de serialización.

Kafein
fuente