Final è mal definito?


186

Innanzitutto, un puzzle: cosa stampa il seguente codice?

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;
    }
}

Risposta:

0

Spoiler di seguito.


Se si stampa Xin scala (lungo) e si ridefinisce X = scale(10) + 3, le stampe saranno X = 0quindi X = 3. Ciò significa che Xè temporaneamente impostato 0e successivamente impostato su 3. Questa è una violazione di final!

Il modificatore statico, in combinazione con il modificatore finale, viene utilizzato anche per definire le costanti. Il modificatore finale indica che il valore di questo campo non può cambiare .

Fonte: https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [corsivo aggiunto]


La mia domanda: è un bug? È finalmal definito?


Ecco il codice che mi interessa. Sono Xassegnati due valori diversi: 0e 3. Credo che questa sia una violazione di 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;
    }
}

Questa domanda è stata contrassegnata come possibile duplicato dell'ordine di inizializzazione del campo finale statico Java . Ritengo che questa domanda non sia duplicata poiché l'altra domanda riguarda l'ordine di inizializzazione mentre la mia domanda riguarda un'inizializzazione ciclica combinata con il finaltag. Dall'altra domanda da sola, non sarei in grado di capire perché il codice nella mia domanda non commetta un errore.

Ciò è particolarmente chiaro guardando l'output che ernesto ottiene: quando aviene taggato final, ottiene il seguente output:

a=5
a=5

che non coinvolge la parte principale della mia domanda: in che modo una finalvariabile cambia la sua variabile?


17
Questo modo di fare riferimento al Xmembro è come fare riferimento a un membro della sottoclasse prima che il costruttore della superclasse abbia terminato, questo è il tuo problema e non la definizione di final.
daniu

4
Da 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, non si tratta di costante ma di variabile di istanza. Ma puoi aggiungere il capitolo?
AxelH

9
Proprio come una nota: non fare mai nulla di tutto questo nel codice di produzione. È super confuso per tutti se qualcuno inizia a sfruttare le lacune nel JLS.
Zabuzard,

13
Cordiali saluti, puoi creare questa stessa identica situazione anche in C #. C # promette che i loop in dichiarazioni costanti verranno catturati al momento della compilazione, ma non fa tali promesse in merito a dichiarazioni di sola lettura e in pratica è possibile entrare in situazioni in cui il valore iniziale zero del campo viene osservato da un altro inizializzatore di campo. Se ti fa male quando lo fai, non farlo . Il compilatore non ti salverà.
Eric Lippert,

Risposte:


217

Una scoperta molto interessante. Per capirlo dobbiamo scavare nella Java Language Specification ( JLS ).

Il motivo è che finalconsente solo un compito . Il valore predefinito, tuttavia, non è un'assegnazione . In effetti, ciascuna di tali variabili ( variabile di classe, variabile di istanza, componente di matrice) punta al suo valore predefinito dall'inizio, prima delle assegnazioni . Il primo compito quindi modifica il riferimento.


Variabili di classe e valore predefinito

Dai un'occhiata al seguente esempio:

private static Object x;

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

Non abbiamo assegnato esplicitamente un valore a x, sebbene punti a null, è il valore predefinito. Confrontalo con §4.12.5 :

Valori iniziali delle variabili

Ogni variabile di classe , variabile di istanza o componente dell'array viene inizializzata con un valore predefinito quando viene creata ( §15.9 , §15.10.2 )

Nota che questo vale solo per quel tipo di variabili, come nel nostro esempio. Non vale per le variabili locali, vedere il seguente esempio:

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

Dallo stesso paragrafo JLS:

Una variabile locale ( §14.4 , §14.14 ) deve ricevere esplicitamente un valore prima di essere utilizzata, mediante inizializzazione ( §14.4 ) o assegnazione ( §15.26 ), in un modo che può essere verificato utilizzando le regole per l'assegnazione definitiva ( § 16 (Assegnazione definita) ).


Variabili finali

Ora diamo un'occhiata final, dal §4.12.4 :

Variabili finali

Una variabile può essere dichiarata finale . Un ultimo variabile può essere solo assegnato a volta . È un errore in fase di compilazione se viene assegnata una variabile finale a meno che non sia definitivamente non assegnata immediatamente prima dell'assegnazione ( §16 (Assegnazione definita) ).


Spiegazione

Ora torniamo al tuo esempio, leggermente modificato:

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;
}

Emette

Before: 0
After: 1

Ricorda ciò che abbiamo imparato. All'interno del metodo assignalla variabile non èX stato ancora assegnato un valore. Pertanto, punta al suo valore predefinito poiché è una variabile di classe e secondo la JLS tali variabili puntano sempre immediatamente ai loro valori predefiniti (contrariamente alle variabili locali). Dopo il assignmetodo alla variabile Xviene assegnato il valore 1e per finalquesto non possiamo più cambiarlo. Quindi il seguente non funzionerebbe a causa di final:

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

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

Esempio nel JLS

Grazie a @Andrew ho trovato un paragrafo JLS che copre esattamente questo scenario, lo dimostra anche.

Ma prima diamo un'occhiata

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

Perché questo non è consentito, mentre l'accesso dal metodo è? Dai un'occhiata al § 8.3.3 che parla di quando gli accessi ai campi sono limitati se il campo non è stato ancora inizializzato.

Elenca alcune regole rilevanti per le variabili di classe:

Per un riferimento in base al nome semplice a una variabile di classe fdichiarata in classe o interfaccia C, è un errore di compilazione se :

  • Il riferimento appare in un inizializzatore variabile di classe di Co in un inizializzatore statico di C( §8.7 ); e

  • Il riferimento appare o nell'inizializzatore del fproprio dichiaratore o in un punto alla sinistra del fdichiaratore; e

  • Il riferimento non si trova sul lato sinistro di un'espressione di assegnazione ( §15.26 ); e

  • La classe o l'interfaccia più interna che racchiude il riferimento è C.

È semplice, X = X + 1viene catturato da queste regole, l'accesso al metodo no. Elencano persino questo scenario e danno un esempio:

Gli accessi con i metodi non vengono controllati in questo modo, quindi:

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 l'output:

0

poiché l'inizializzatore della variabile iutilizza il metodo di classe peek per accedere al valore della variabile jprima che jsia stato inizializzato dal suo inizializzatore di variabile, a quel punto ha ancora il suo valore predefinito ( §4.12.5 ).


1
@Andrew Sì, variabile di classe, grazie. Sì, avrebbe funzionato se non ci sarebbero alcuni extra-regole che limitano tale accesso: §8.3.3 . Dai un'occhiata ai quattro punti specificati per le variabili di classe (la prima voce). L'approccio del metodo nell'esempio dei PO non è preso da quelle regole, quindi possiamo accedere Xdal metodo. Non mi dispiacerebbe così tanto. Dipende da come esattamente JLS definisce le cose per lavorare in dettaglio. Non userei mai un codice del genere, sfrutta solo alcune regole in JLS.
Zabuzard,

4
Il problema è che puoi chiamare i metodi di istanza dal costruttore, qualcosa che probabilmente non avrebbe dovuto essere permesso. D'altra parte, l'assegnazione dei locali prima di chiamare super, che sarebbe utile e sicuro, non è consentita. Vai a capire.
Ripristina Monica il

1
@Andrew sei probabilmente l'unico qui che ha effettivamente menzionato forwards references(che fanno parte anche del JLS). questo è così semplice, senza questa risposta luuungo stackoverflow.com/a/49371279/1059372
Eugene

1
"Il primo compito quindi cambia il riferimento." In questo caso non è un tipo di riferimento, ma un tipo primitivo.
fabian,

1
Questa risposta è giusta, anche se un po 'lunga. :-) Penso che il tl; dr sia che l'OP ha citato un tutorial che diceva che "un campo [finale] non può cambiare", non il JLS. Mentre i tutorial di Oracle sono abbastanza buoni, non coprono tutti i casi limite. Per la domanda del PO, dobbiamo andare alla definizione JLS effettiva di final - e quella definizione non afferma (che il PO contesta giustamente) che il valore di un campo finale non può mai cambiare.
yshavit,

23

Niente a che vedere con la finale qui.

Dal momento che è a livello di istanza o di classe, contiene il valore predefinito se non viene ancora assegnato nulla. Questo è il motivo che vedi 0quando accedi ad esso senza assegnare.

Se si accede Xsenza assegnare completamente, contiene i valori predefiniti di long che è 0, quindi i risultati.


3
La cosa difficile di questo è che se non si assegna il valore, non verrà assegnato con il valore predefinito, ma se lo si utilizzava per assegnarsi il valore "finale", ...
AxelH

2
@AxelH Capisco cosa intendi con questo. Ma è così che dovrebbe funzionare altrimenti il ​​mondo collasserà;).
Suresh Atta,

20

Non è un bug.

Quando scaleviene chiamata la prima chiamata a

private static final long X = scale(10);

Cerca di valutare return X * value. Xnon è stato ancora assegnato un valore e quindi longviene utilizzato il valore predefinito per a (che è 0).

Quindi quella riga di codice valuta X * 10cioè 0 * 10quale è 0.


8
Non credo sia questo a confondere l'OP. Ciò che confonde è X = scale(10) + 3. Dal momento che X, quando indicato dal metodo, è 0. Ma dopo lo è 3. Quindi OP ritiene che Xsiano assegnati due valori diversi, con cui sarebbe in conflitto final.
Zabuzard,

4
@Zabuza non è questo spiegato con " Cerca di valutare return X * value. XNon è stato ancora assegnato un valore e quindi assume il valore predefinito per quello longche è 0. "? Non si dice che Xsia assegnato con il valore predefinito ma che Xsia "sostituito" (per favore non citare quel termine;)) dal valore predefinito.
AxelH

14

Non è affatto un bug, in poche parole non è una forma illegale di riferimenti diretti, niente di più.

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;
}

È semplicemente consentito dalla specifica.

Per fare un esempio, questo è esattamente dove corrisponde:

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

Stai facendo un riferimento futuro a scalequesto non è illegale in alcun modo come detto prima, ma ti consente di ottenere il valore predefinito di X. ancora una volta, questo è consentito dalla specifica (per essere più precisi non è vietato), quindi funziona bene


buona risposta! Sono solo curioso di sapere perché le specifiche consentono di compilare il secondo caso. È l'unico modo per vedere lo stato "incoerente" di un campo finale?
Andrew Tobilko,

@Andrew questo mi ha infastidito anche per un bel po 'di tempo, sono propenso a pensare che sia il C ++ o C a farlo (non ho idea se questo è vero)
Eugene

@Andrew: Perché fare altrimenti sarebbe risolvere il teorema di incompletezza di Turing.
Giosuè

9
@Joshua: Penso che tu stia mescolando un certo numero di concetti diversi qui: (1) il problema dell'arresto, (2) il problema della decisione, (3) il teorema di incompletezza di Godel e (4) linguaggi di programmazione completi di Turing. Gli autori di compilatori non tentano di risolvere il problema "questa variabile è stata assegnata definitivamente prima di essere utilizzata?" perfettamente perché quel problema equivale a risolvere il problema dell'arresto, e sappiamo che non possiamo farlo.
Eric Lippert,

4
@EricLippert: Haha oops. L'incompletezza turing e l'arresto del problema occupano lo stesso posto nella mia mente.
Joshua,

4

I membri a livello di classe possono essere inizializzati in codice all'interno della definizione della classe. Il bytecode compilato non può inizializzare i membri della classe in linea. (I membri dell'istanza sono gestiti in modo simile, ma questo non è rilevante per la domanda fornita.)

Quando si scrive qualcosa di simile al seguente:

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

Il bytecode generato sarebbe simile al seguente:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

Il codice di inizializzazione viene inserito in un inizializzatore statico che viene eseguito quando il caricatore di classi carica per la prima volta la classe. Con questa conoscenza, il tuo campione originale sarebbe simile al seguente:

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 carica RecursiveStatic come punto di ingresso del vaso.
  2. Il caricatore di classe esegue l'inizializzatore statico quando viene caricata la definizione di classe.
  3. L'inizializzatore chiama la funzione scale(10)per assegnare il static finalcampo X.
  4. La scale(long)funzione viene eseguita mentre la classe viene parzialmente inizializzata leggendo il valore non inizializzato il cui valore Xpredefinito è long o 0.
  5. Il valore di 0 * 10viene assegnato Xe il caricatore di classe viene completato.
  6. La JVM esegue la chiamata al metodo principale vuoto statico pubblico scale(5)che moltiplica 5 per il Xvalore ora inizializzato di 0 che restituisce 0.

Il campo finale statico Xviene assegnato una sola volta, preservando la garanzia detenuta dalla finalparola chiave. Per la successiva query di aggiunta di 3 nell'assegnazione, il passaggio 5 sopra diventa la valutazione di 0 * 10 + 3quale sia il valore 3e il metodo principale stamperà il risultato di 3 * 5cui è il valore15 .


3

La lettura di un campo non inizializzato di un oggetto dovrebbe comportare un errore di compilazione. Sfortunatamente per Java, non lo è.

Penso che il motivo fondamentale per cui questo è il caso sia "nascosto" in profondità nella definizione di come gli oggetti sono istanziati e costruiti, anche se non conosco i dettagli dello standard.

In un certo senso, final è mal definito perché non raggiunge nemmeno lo scopo dichiarato a causa di questo problema. Tuttavia, se tutte le tue classi sono scritte correttamente, non hai questo problema. Significa che tutti i campi sono sempre impostati in tutti i costruttori e nessun oggetto viene mai creato senza chiamare uno dei suoi costruttori. Sembra naturale fino a quando non devi usare una libreria di serializzazione.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.