La finale est-elle mal définie?

186

Tout d'abord, un puzzle: qu'est-ce que le code suivant imprime?

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

Répondre:

0

Spoilers ci-dessous.


Si vous imprimez Xà l'échelle (longue) et redéfinissez X = scale(10) + 3, les impressions seront X = 0alors X = 3. Cela signifie qu'il Xest temporairement défini sur 0et ultérieurement défini sur 3. Ceci est une violation de final!

Le modificateur statique, en combinaison avec le modificateur final, est également utilisé pour définir des constantes. Le modificateur final indique que la valeur de ce champ ne peut pas changer .

Source: https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [italiques ajoutés]


Ma question: est-ce un bug? Est final-ce mal défini?


Voici le code qui m'intéresse. XDeux valeurs différentes sont attribuées: 0et 3. Je pense que c'est une violation 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;
    }
}

Cette question a été signalée comme une duplication possible de l'ordre d'initialisation final des champs statiques Java . Je crois que cette question n'est pas un doublon puisque l'autre question porte sur l'ordre d'initialisation tandis que ma question porte sur une initialisation cyclique combinée avec la finalbalise. De la seule autre question, je ne pourrais pas comprendre pourquoi le code de ma question ne fait pas d'erreur.

Ceci est particulièrement clair en regardant la sortie qu'ernesto obtient: lorsqu'il aest étiqueté final, il obtient la sortie suivante:

a=5
a=5

ce qui n'implique pas l'essentiel de ma question: comment une finalvariable change-t-elle sa variable?

Petit assistant
la source
17
Cette façon de référencer le Xmembre revient à faire référence à un membre de sous-classe avant que le constructeur de la super classe n'ait fini, c'est votre problème et non la définition de final.
daniu
4
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, Il ne s'agit pas de constante mais de variable d'instance. Mais pouvez-vous ajouter le chapitre?
AxelH
9
Juste une note: ne faites jamais rien de tout cela dans le code de production. C'est très déroutant pour tout le monde si quelqu'un commence à exploiter les failles du JLS.
Zabuzard
13
Pour info, vous pouvez également créer exactement la même situation en C #. C # promet que les boucles dans les déclarations constantes seront interceptées au moment de la compilation, mais ne fait aucune promesse concernant les déclarations en lecture seule , et en pratique, vous pouvez vous retrouver dans des situations où la valeur zéro initiale du champ est observée par un autre initialiseur de champ. Si ça fait mal quand tu fais ça, ne le fais pas . Le compilateur ne vous sauvera pas.
Eric Lippert

Réponses:

217

Une trouvaille très intéressante. Pour le comprendre, nous devons creuser dans la spécification du langage Java ( JLS ).

La raison en est que finaln'autorise qu'une seule affectation . Cependant, la valeur par défaut est aucune affectation . En fait, chacune de ces variables ( variable de classe, variable d'instance, composant de tableau) pointe vers sa valeur par défaut depuis le début, avant les affectations . La première affectation modifie ensuite la référence.


Variables de classe et valeur par défaut

Jetez un œil à l'exemple suivant:

private static Object x;

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

Nous n'avons pas explicitement attribué de valeur à x, bien qu'elle pointe vers null, c'est la valeur par défaut. Comparez cela au §4.12.5 :

Valeurs initiales des variables

Chaque variable de classe, variable d' instance ou composant de tableau est initialisé avec une valeur par défaut lors de sa création ( §15.9 , §15.10.2 )

Notez que cela ne vaut que pour ce type de variables, comme dans notre exemple. Il ne tient pas pour les variables locales, voir l'exemple suivant:

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

Du même paragraphe JLS:

Une variable locale ( §14.4 , §14.14 ) doit recevoir une valeur explicite avant d'être utilisée, soit par initialisation ( §14.4 ), soit par affectation ( §15.26 ), d'une manière qui peut être vérifiée à l'aide des règles d'assignation définie ( § 16 (Affectation définitive) ).


Variables finales

Maintenant, nous regardons final, à partir du §4.12.4 :

Variables finales

Une variable peut être déclarée définitive . Une dernière variable ne peut être affectée qu'une seule fois . Il s'agit d'une erreur de compilation si une variable finale est affectée à moins qu'elle ne soit définitivement non affectée immédiatement avant l'affectation ( §16 (Affectation définitive) ).


Explication

Revenons maintenant à votre exemple, légèrement modifié:

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

Il sort

Before: 0
After: 1

Rappelez-vous ce que nous avons appris. À l'intérieur de la méthode, aucune valeur n'a encore été attribuée àassign la variable . Par conséquent, il pointe vers sa valeur par défaut puisqu'il s'agit d'une variable de classe et, selon le JLS, ces variables pointent toujours immédiatement vers leurs valeurs par défaut (contrairement aux variables locales). Après la méthode, la valeur est attribuée à la variable et à cause de cela, nous ne pouvons plus la modifier. Ainsi, les éléments suivants ne fonctionneraient pas en raison de :XassignX1finalfinal

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

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

Exemple dans le JLS

Grâce à @Andrew, j'ai trouvé un paragraphe JLS qui couvre exactement ce scénario, il le démontre également.

Mais regardons d'abord

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

Pourquoi n'est-ce pas autorisé, alors que l'accès à partir de la méthode l'est? Jetez un œil au §8.3.3 qui parle du moment où les accès aux champs sont restreints si le champ n'a pas encore été initialisé.

Il répertorie quelques règles pertinentes pour les variables de classe:

Pour une référence par simple nom à une variable de classe fdéclarée dans la classe ou l'interface C, il s'agit d'une erreur de compilation si :

  • La référence apparaît soit dans un initialiseur de variable de classe de, Csoit dans un initialiseur statique de C( §8.7 ); et

  • La référence apparaît soit dans l'initialiseur du fpropre déclarateur de 's ou à un point à gauche du fdéclarateur de ' s; et

  • La référence n'est pas sur le côté gauche d'une expression d'affectation ( §15.26 ); et

  • La classe ou l'interface la plus interne englobant la référence est C.

C'est simple, le X = X + 1est pris par ces règles, l'accès à la méthode non. Ils énumèrent même ce scénario et donnent un exemple:

Les accès par méthodes ne sont pas vérifiés de cette manière, donc:

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

produit la sortie:

0

car l'initialiseur de variable pour iutilise la méthode de classe peek pour accéder à la valeur de la variable javant qu'elle jait été initialisée par son initialiseur de variable, à quel point elle a toujours sa valeur par défaut ( §4.12.5 ).

Zabuzard
la source
1
@Andrew Oui, variable de classe, merci. Oui, ce serait travailler s'il n'y aurait pas des extra-règles qui limitent cet accès: §8.3.3 . Jetez un œil aux quatre points spécifiés pour les variables de classe (la première entrée). L'approche de méthode dans l'exemple des OP n'est pas capturée par ces règles, nous pouvons donc y accéder à Xpartir de la méthode. Cela ne me dérangerait pas beaucoup. Cela dépend simplement de la façon dont le JLS définit exactement les choses pour fonctionner en détail. Je n'utiliserais jamais un code comme celui-là, il exploite simplement certaines règles du JLS.
Zabuzard
4
Le problème est que vous pouvez appeler des méthodes d'instance depuis le constructeur, ce qui n'aurait probablement pas dû être autorisé. D'autre part, l'affectation de locaux avant d'appeler super, ce qui serait utile et sûr, est interdite. Allez comprendre.
Réintégrer Monica
1
@Andrew, vous êtes probablement le seul ici à avoir réellement mentionné forwards references(qui font également partie du JLS). c'est si simple sans cette longue réponse stackoverflow.com/a/49371279/1059372
Eugene
1
"La première affectation change alors la référence." Dans ce cas, ce n'est pas un type de référence, mais un type primitif.
fabian
1
Cette réponse est juste, même si elle est un peu longue. :-) Je pense que le tl; dr est que l'OP a cité un tutoriel qui disait que "[un dernier] champ ne peut pas changer," pas le JLS. Bien que les didacticiels d'Oracle soient assez bons, ils ne couvrent pas tous les cas extrêmes. Pour la question du PO, nous devons aller à la définition JLS réelle de final - et cette définition ne prétend pas (que l'OP conteste à juste titre) que la valeur d'un champ final ne peut jamais changer.
yshavit
23

Rien à voir avec la finale ici.

Puisqu'il est au niveau de l'instance ou de la classe, il contient la valeur par défaut si rien n'est encore attribué. C'est la raison pour laquelle vous voyez 0lorsque vous y accédez sans attribuer.

Si vous accédez Xsans attribuer complètement, il contient les valeurs par défaut de long 0, d'où les résultats.

Suresh Atta
la source
3
Ce qui est délicat à ce sujet, c'est que si vous n'attribuez pas la valeur, elle ne sera pas affectée avec la valeur par défaut, mais si vous l'avez utilisée pour
s'attribuer la
2
@AxelH Je vois ce que tu veux dire par là. Mais c'est ainsi que cela devrait fonctionner sinon le monde s'effondrerait;).
Suresh Atta
20

Pas un bug.

Lorsque le premier appel à scaleest appelé de

private static final long X = scale(10);

Il essaie d'évaluer return X * value. Xn'a pas encore reçu de valeur et, par conséquent, la valeur par défaut de a longest utilisée (ce qui est 0).

Donc, cette ligne de code évalue à X * 10ie 0 * 10qui est 0.

VieuxCurmudgeon
la source
8
Je ne pense pas que ce soit ce que OP confond. Ce qui confond, c'est X = scale(10) + 3. Depuis X, lorsqu'il est référencé à partir de la méthode, est 0. Mais après ça l'est 3. OP pense donc que Xdeux valeurs différentes sont attribuées à la final.
Zabuzard
4
@Zabuza n'est-ce pas expliqué avec le " Il essaie d'évaluer return X * value.Xlong0 N'a pas encore reçu de valeur et prend donc la valeur par défaut pour un qui est . "? Il n'est pas dit qu'il a Xla valeur par défaut mais que le Xest "remplacé" (veuillez ne pas citer ce terme;)) par la valeur par défaut.
AxelH
14

Ce n'est pas du tout un bogue, ce n'est simplement pas une forme illégale de références directes, rien de plus.

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

C'est simplement autorisé par la spécification.

Pour prendre votre exemple, c'est exactement là que cela correspond:

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

Vous faites une référence directe à scalecela n'est en aucun cas illégale comme indiqué précédemment, mais vous permet d'obtenir la valeur par défaut de X. encore une fois, cela est autorisé par la spécification (pour être plus exact, ce n'est pas interdit), donc cela fonctionne très bien

Eugène
la source
bonne réponse! Je suis juste curieux de savoir pourquoi la spécification permet au deuxième cas de se compiler. Est-ce la seule façon de voir l'état "incohérent" d'un champ final?
Andrew Tobilko
@Andrew, cela me dérange depuis pas mal de temps aussi, je suis enclin à penser que c'est le C ++ ou le C (je ne sais pas si c'est vrai)
Eugene
@Andrew: Parce que faire autrement reviendrait à résoudre le théorème d'incomplétude de Turing.
Joshua
9
@Joshua: Je pense que vous mélangez un certain nombre de concepts différents ici: (1) le problème de l'arrêt, (2) le problème de décision, (3) le théorème d'incomplétude de Godel et (4) les langages de programmation complets de Turing. Les rédacteurs de compilateurs n'essaient pas de résoudre le problème "Cette variable est-elle définitivement affectée avant d'être utilisée?" parfaitement parce que ce problème équivaut à résoudre le problème de l'arrêt, et nous savons que nous ne pouvons pas le faire.
Eric Lippert
4
@EricLippert: Haha oups. L'incomplétude et l'arrêt du problème occupent la même place dans mon esprit.
Joshua
4

Les membres de niveau de classe peuvent être initialisés dans le code dans la définition de classe. Le bytecode compilé ne peut pas initialiser les membres de la classe en ligne. (Les membres de l'instance sont traités de la même manière, mais cela n'est pas pertinent pour la question fournie.)

Quand on écrit quelque chose comme ce qui suit:

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

Le bytecode généré serait similaire à ce qui suit:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

Le code d'initialisation est placé dans un initialiseur statique qui est exécuté lorsque le chargeur de classe charge pour la première fois la classe. Avec cette connaissance, votre échantillon d'origine serait similaire à ce qui suit:

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 charge le RecursiveStatic comme point d'entrée du fichier jar.
  2. Le chargeur de classe exécute l'initialiseur statique lorsque la définition de classe est chargée.
  3. L'initialiseur appelle la fonction scale(10)pour affecter le static finalchamp X.
  4. La scale(long)fonction s'exécute pendant que la classe est partiellement initialisée en lisant la valeur non initialisée Xdont la valeur par défaut est long ou 0.
  5. La valeur de 0 * 10est attribuée à Xet le chargeur de classe se termine.
  6. La machine virtuelle Java exécute l'appel de la méthode principale de void statique public scale(5)qui multiplie 5 par la Xvaleur désormais initialisée de 0 et renvoie 0.

Le champ final statique Xn'est attribué qu'une seule fois, en préservant la garantie détenue par le finalmot - clé. Pour la requête suivante d'ajout de 3 dans l'affectation, l'étape 5 ci-dessus devient l'évaluation 0 * 10 + 3dont est la valeur 3et la méthode principale imprimera le résultat 3 * 5dont est la valeur 15.

Psaxton
la source
3

La lecture d'un champ non initialisé d'un objet devrait entraîner une erreur de compilation. Malheureusement pour Java, ce n'est pas le cas.

Je pense que la raison fondamentale pour laquelle c'est le cas est «cachée» profondément dans la définition de la façon dont les objets sont instanciés et construits, bien que je ne connaisse pas les détails de la norme.

En un sens, final est mal défini car il n'accomplit même pas son objectif déclaré en raison de ce problème. Cependant, si toutes vos classes sont correctement écrites, vous n'avez pas ce problème. Cela signifie que tous les champs sont toujours définis dans tous les constructeurs et qu'aucun objet n'est jamais créé sans appeler l'un de ses constructeurs. Cela semble naturel jusqu'à ce que vous deviez utiliser une bibliothèque de sérialisation.

Kafein
la source