Czy ostateczne jest źle zdefiniowane?

186

Po pierwsze, układanka: co drukuje następujący kod?

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

Odpowiedź:

0

Spojlery poniżej.


Jeśli drukujesz Xw skali (długiej) i redefiniujesz X = scale(10) + 3, wydruki będą X = 0wtedy X = 3. Oznacza to, że Xjest tymczasowo ustawiony na, 0a później ustawiony na 3. To jest naruszenie final!

Modyfikator statyczny w połączeniu z modyfikatorem końcowym służy również do definiowania stałych. Ostatni modyfikator wskazuje, że wartość tego pola nie może się zmienić .

Źródło: https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [podkreślenie dodane]


Moje pytanie: czy to błąd? Jest finalźle zdefiniowany?


Oto kod, który mnie interesuje. XPrzypisano mu dwie różne wartości: 0i 3. Uważam, że jest to naruszenie 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;
    }
}

To pytanie zostało oznaczone jako możliwy duplikat statycznej kolejności inicjalizacji pola końcowego Java . Uważam, że to pytanie nie jest duplikatem, ponieważ drugie pytanie dotyczy kolejności inicjalizacji, podczas gdy moje pytanie dotyczy cyklicznej inicjalizacji połączonej ze finalznacznikiem. Na podstawie samego drugiego pytania nie byłbym w stanie zrozumieć, dlaczego kod w moim pytaniu nie powoduje błędu.

Jest to szczególnie wyraźne, patrząc na wyniki, które otrzymuje Ernest: gdy ajest oznaczony final, otrzymuje następujące dane wyjściowe:

a=5
a=5

co nie obejmuje głównej części mojego pytania: w jaki sposób finalzmienna zmienia swoją zmienną?

Mały pomocnik
źródło
17
Ten sposób odwoływania się do Xczłonka jest jak odwoływanie się do członka podklasy przed zakończeniem konstruktora superklasy, to twój problem, a nie definicja final.
daniu
4
Od 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, Tu nie chodzi o stałą, ale o zmienną instancji. Ale czy możesz dodać rozdział?
AxelH
9
Uwaga: nigdy nie rób tego w kodzie produkcyjnym. Jest to bardzo mylące dla wszystkich, jeśli ktoś zacznie wykorzystywać luki w JLS.
Zabuzard,
13
Do Twojej dyspozycji możesz również stworzyć tę samą sytuację w C #. C # obiecuje, że pętle w stałych deklaracjach zostaną przechwycone w czasie kompilacji, ale nie daje takich obietnic dotyczących deklaracji tylko do odczytu , aw praktyce można dostać się do sytuacji, w których początkowa zerowa wartość pola jest obserwowana przez inny inicjator pola. Jeśli to boli, nie rób tego . Kompilator cię nie uratuje.
Eric Lippert,

Odpowiedzi:

217

Bardzo interesujące znalezisko. Aby to zrozumieć, musimy zagłębić się w specyfikację języka Java ( JLS ).

Powodem jest to, że finalzezwala tylko na jedno zadanie . Jednak wartością domyślną nie jest przypisanie . W rzeczywistości każda taka zmienna ( zmienna klasy, zmienna instancji, komponent tablicowy) wskazuje na wartość domyślną od początku, przed przypisaniem . Pierwsze przypisanie następnie zmienia odniesienie.


Zmienne klasy i wartość domyślna

Spójrz na następujący przykład:

private static Object x;

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

Nie przypisaliśmy jawnie wartości x, choć wskazuje na nullto, że jest to wartość domyślna. Porównaj to z §4.12.5 :

Początkowe wartości zmiennych

Każda zmienna klasy, zmienna instancji lub komponent tablicowy jest inicjowany z wartością domyślną podczas tworzenia ( §15.9 , §15.10.2 )

Zauważ, że dotyczy to tylko takich zmiennych, jak w naszym przykładzie. Nie dotyczy zmiennych lokalnych, zobacz następujący przykład:

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

Z tego samego akapitu JLS:

Zmienna lokalna ( §14.4 , §14.14 ) musi być wyraźnie podana wartość przed użyciem przez którąkolwiek inicjalizacji ( §14.4 ) lub cesji ( §15.26 ), w taki sposób, że mogą być zweryfikowane za pomocą zasady określonej Przyporządkowanie ( § 16 (Określone przypisanie) ).


Ostateczne zmienne

Teraz spójrzmy na final, z §4.12.4 :

końcowe zmienne

Zmienna może być zadeklarowana jako ostateczna . Końcowy zmienna może być tylko przypisany raz . Jest to błąd czasu kompilacji, jeśli przypisana jest zmienna końcowa, chyba że jest ona zdecydowanie nieprzypisana bezpośrednio przed przypisaniem ( §16 (Określone przypisanie) ).


Wyjaśnienie

Wracając do twojego przykładu, nieco zmodyfikowany:

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

Wyprowadza

Before: 0
After: 1

Przypomnij sobie, czego się nauczyliśmy. Wewnątrz metody assignzmienna Xbyła nie przypisuje wartości do wyświetlenia. Dlatego wskazuje na wartość domyślną, ponieważ jest to zmienna klasowa i zgodnie z JLS zmienne te zawsze natychmiast wskazują na ich wartości domyślne (w przeciwieństwie do zmiennych lokalnych). Po assignmetodzie zmiennej Xprzypisuje się wartość 1i z tego powodu finalnie możemy jej już zmieniać. Dlatego następujące działania nie będą działać z powodu final:

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

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

Przykład w JLS

Dzięki @Andrew znalazłem akapit JLS, który obejmuje dokładnie ten scenariusz, a także pokazuje to.

Ale najpierw spójrzmy

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

Dlaczego nie jest to dozwolone, podczas gdy dostęp z tej metody jest? Spójrz na § 8.3.3, który mówi o ograniczeniu dostępu do pól, jeśli pole nie zostało jeszcze zainicjowane.

Wymienia niektóre reguły istotne dla zmiennych klas:

W przypadku odwołania prostą nazwą do zmiennej klasy fzadeklarowanej w klasie lub interfejsie Cjest to błąd czasu kompilacji, jeśli :

  • Odwołanie pojawia się albo w inicjalizatorze zmiennych klasowych, Calbo w inicjatorze statycznym C( §8.7 ); i

  • Odwołanie pojawia się albo w inicjalizatorze fwłasnego deklaratora, albo w punkcie na lewo od fdeklaratora; i

  • Odwołanie nie znajduje się po lewej stronie wyrażenia przypisania ( §15.26 ); i

  • Najbardziej wewnętrzna klasa lub interfejs otaczający odwołanie to C.

To proste, X = X + 1reguły podlegają regułom, dostęp do metody nie. Podają nawet ten scenariusz i podają przykład:

Dostęp metodami nie jest sprawdzany w ten sposób, więc:

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

daje wynik:

0

ponieważ zmienna initializer dla izastosowań metody klasy peek dostęp wartość zmiennej jzanim jzostał zainicjowany przez jego zmiennej inicjatora, w którym momencie to nadal ma swoją wartość domyślną ( §4.12.5 ).

Zabuzard
źródło
1
@Andrew Tak, zmienna klasy, dzięki. Tak, to będzie działać, jeśli nie byłoby kilka dodatkowych reguł, które ograniczają takiego dostępu: §8.3.3 . Spójrz na cztery punkty określone dla zmiennych klas (pierwszy wpis). Podejście do metody w przykładzie PO nie jest objęte tymi regułami, dlatego możemy uzyskać dostęp Xz tej metody. Nie miałbym nic przeciwko temu. To zależy tylko od tego, jak dokładnie JLS definiuje rzeczy do działania w szczegółach. Nigdy nie używałbym takiego kodu, po prostu wykorzystuje pewne reguły w JLS.
Zabuzard,
4
Problem polega na tym, że można wywoływać metody instancji z konstruktora, co prawdopodobnie nie powinno być dozwolone. Z drugiej strony, przypisywanie miejscowych przed wywołaniem super, które byłoby przydatne i bezpieczne, jest niedozwolone. Domyśl.
Przywróć Monikę
1
@Andrew, jesteś prawdopodobnie jedyną osobą, która tak naprawdę wspomniała forwards references(która również jest częścią JLS). to jest takie proste bez tej długiej odpowiedzi stackoverflow.com/a/49371279/1059372
Eugene
1
„Pierwsze zadanie zmienia następnie odniesienie”. W tym przypadku nie jest to typ odniesienia, ale typ pierwotny.
fabian
1
Ta odpowiedź jest słuszna, choć trochę długa. :-) Myślę, że tl; dr jest taki, że OP zacytował tutorial, w którym powiedziano, że „[końcowe] pole nie może się zmienić”, a nie JLS. Chociaż samouczki Oracle są całkiem dobre, nie obejmują one wszystkich przypadków brzegowych. W przypadku pytania PO musimy przejść do faktycznej, ostatecznej definicji JLS ostatecznej - i ta definicja nie wysuwa twierdzenia (że OP słusznie kwestionuje), że wartość pola końcowego nigdy nie może się zmienić.
yshavit
22

Nie ma tu nic wspólnego z finałem.

Ponieważ jest na poziomie instancji lub klasy, zachowuje wartość domyślną, jeśli nic jeszcze nie zostanie przypisane. To jest powód, dla którego widzisz 0dostęp do niego bez przypisywania.

Jeśli uzyskujesz dostęp Xbez całkowitego przypisania, zachowuje domyślne wartości long, które są 0, stąd wyniki.

Suresh Atta
źródło
3
Trudne w tym jest to, że jeśli nie przypiszesz wartości, nie zostanie ona przypisana z wartością domyślną, ale jeśli użyjesz jej do przypisania sobie „ostatecznej” wartości, to ...
AxelH
2
@AxelH Rozumiem, co przez to rozumiesz. Ale tak to powinno działać, inaczej świat się zawali;).
Suresh Atta
20

To nie błąd.

Kiedy pierwsze połączenie z scalejest wywoływane z

private static final long X = scale(10);

Próbuje to ocenić return X * value. Xnie została jeszcze przypisana wartość, dlatego longużywana jest domyślna wartość a (która jest 0).

Tak więc ten wiersz kodu ocenia, X * 10tzn. 0 * 10Który jest 0.

OldCurmudgeon
źródło
8
Nie sądzę, żeby to myliło OP. Co jest mylące X = scale(10) + 3. Ponieważ Xprzywoływany z metody jest 0. Ale potem tak jest 3. Więc OP uważa, że Xprzypisano dwie różne wartości, z którymi byłoby w konflikcie final.
Zabuzard
4
@Zabuza nie wyjaśnia tego słowem „ Próbuje ocenić return X * value. XNie przypisano jeszcze wartości i dlatego przyjmuje wartość domyślną dla wartości, longktóra jest 0. ”? Nie jest powiedziane, że Xma przypisaną wartość domyślną, ale że Xjest ona „zastąpiona” (proszę nie cytować tego terminu;)) wartością domyślną.
AxelH
14

To wcale nie jest błąd, po prostu nie jest to nielegalna forma przekazywania referencji, nic więcej.

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

Jest to po prostu dozwolone przez specyfikację.

Weźmy twój przykład, to jest dokładnie to, co pasuje:

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

Robisz odniesienie do przodu,scale które nie jest nielegalne w żaden sposób, jak powiedziano wcześniej, ale pozwala uzyskać domyślną wartość X. znowu jest to dozwolone przez Spec (a ściślej mówiąc, nie jest zabronione), więc działa dobrze

Eugene
źródło
dobra odpowiedź! Jestem tylko ciekawy, dlaczego specyfikacja pozwala na kompilację drugiego przypadku. Czy to jedyny sposób, aby zobaczyć „niespójny” stan pola końcowego?
Andrew Tobilko
@Andrew też mnie to martwiło od dłuższego czasu, jestem skłonny myśleć, że to C ++ lub C to robi (nie wiem, czy to prawda)
Eugene
@Andrew: Ponieważ inaczej byłoby rozwiązać twierdzenie o niekompletności Turinga.
Joshua
9
@Joshua: Myślę, że mieszacie tutaj różne koncepcje: (1) problem zatrzymania, (2) problem decyzyjny, (3) twierdzenie o niekompletności Godela i (4) kompletne języki programowania Turinga. Autorzy kompilatorów nie próbują rozwiązać problemu „czy ta zmienna jest definitywnie przypisana przed użyciem?” doskonale, ponieważ problem ten jest równoważny rozwiązaniu problemu „Stop” i wiemy, że nie możemy tego zrobić.
Eric Lippert
4
@EricLippert: Haha, ups. Rozwiązywanie problemu niepełności i zatrzymania zajmuje to samo miejsce w moim umyśle.
Joshua
4

Elementy na poziomie klasy mogą być inicjowane w kodzie w ramach definicji klasy. Skompilowany kod bajtowy nie może zainicjować elementów klasy bezpośrednio. (Członkowie instancji są traktowani podobnie, ale nie dotyczy to zadanego pytania).

Kiedy ktoś pisze coś takiego:

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

Wygenerowany kod bajtowy byłby podobny do następującego:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

Kod inicjujący jest umieszczony w inicjatorze statycznym, który jest uruchamiany, gdy moduł ładujący klasy ładuje klasę po raz pierwszy. Przy tej wiedzy Twoja oryginalna próbka byłaby podobna do następującej:

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. JVM ładuje RecursiveStatic jako punkt wejścia jar.
  2. Moduł ładujący uruchamia inicjalizator statyczny po załadowaniu definicji klasy.
  3. Inicjator wywołuje funkcję scale(10)do przypisania static finalpola X.
  4. W scale(long)biegnie funkcji, gdy klasa częściowo inicjowane odczytu wartości niezainicjowanego z Xktórym jest domyślnym długi lub 0.
  5. Wartość 0 * 10jest przypisana do Xi moduł ładujący klasy się kończy.
  6. JVM uruchamia publiczne wywołanie głównej metody static void, scale(5)która zwielokrotnia 5 przez teraz zainicjowaną Xwartość 0, zwracając 0.

Statyczne pole końcowe Xjest przypisywane tylko raz, zachowując gwarancję finalsłowa kluczowego. W przypadku kolejnego zapytania dodania 3 w przypisaniu krok 5 powyżej staje się oceną, 0 * 10 + 3której wartością jest wartość, 3a główną metodą wydrukuje wynik, 3 * 5którego wartością jest wartość 15.

psaxton
źródło
3

Odczyt niezainicjowanego pola obiektu powinien spowodować błąd kompilacji. Niestety w przypadku Javy tak nie jest.

Myślę, że podstawowy powód, dla którego tak się dzieje, jest „ukryty” głęboko w definicji tworzenia instancji i konstruowania obiektów, chociaż nie znam szczegółów standardu.

W pewnym sensie finał jest źle zdefiniowany, ponieważ nawet nie osiąga zamierzonego celu z powodu tego problemu. Jeśli jednak wszystkie twoje zajęcia są poprawnie napisane, nie masz tego problemu. Oznacza to, że wszystkie pola są zawsze ustawione we wszystkich konstruktorach i żaden obiekt nigdy nie jest tworzony bez wywołania jednego z jego konstruktorów. Wydaje się to naturalne, dopóki nie będziesz musiał użyć biblioteki serializacji.

Kafein
źródło