Окончательно ли плохо определен?

186

Сначала загадка: что печатает следующий код?

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

Ответ:

0

Спойлеры ниже.


Если вы печатаете Xв масштабе (долго) и переопределяете X = scale(10) + 3, печать будет X = 0тогда X = 3. Это означает, что Xвременно установлено значение, 0а затем установлено значение 3. Это нарушение final!

Модификатор static в сочетании с модификатором final также используется для определения констант. Последний модификатор указывает, что значение этого поля не может измениться .

Источник: https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [выделение добавлено]


Мой вопрос: это ошибка? Это finalплохо определено?


Вот код, который меня интересует. XЕму присваиваются два разных значения: 0и 3. Я считаю это нарушением 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;
    }
}

Этот вопрос был помечен как возможный дубликат статического конечного порядка инициализации поля Java . Я считаю, что этот вопрос не является дубликатом, так как другой вопрос касается порядка инициализации, в то время как мой вопрос касается циклической инициализации в сочетании с finalтегом. Только из другого вопроса я не смог бы понять, почему код в моем вопросе не делает ошибку.

Это особенно ясно, если посмотреть на вывод, который получает Эрнесто: когда aон помечен final, он получает следующий вывод:

a=5
a=5

который не затрагивает основную часть моего вопроса: как finalпеременная меняет свою переменную?

Маленький помощник
источник
17
Этот способ ссылки на Xчлен похож на обращение к члену подкласса до того, как конструктор суперкласса закончил, это ваша проблема, а не определение final.
Даниу
4
От 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.
Иван
1
@Ivan, речь идет не о константе, а о переменной экземпляра. Но вы можете добавить главу?
AxelH
9
Как примечание: никогда не делайте этого в рабочем коде. Это очень запутанно для всех, если кто-то начинает использовать лазейки в JLS.
Забузард
13
К вашему сведению, вы можете создать точно такую ​​же ситуацию в C #. C # обещает, что циклы в объявлениях констант будут перехвачены во время компиляции, но не дает таких обещаний относительно объявлений только для чтения , и на практике вы можете попасть в ситуации, когда начальное нулевое значение поля наблюдается другим инициализатором поля. Если тебе больно, когда ты это делаешь, не делай этого . Компилятор не спасет вас.
Эрик Липперт

Ответы:

217

Очень интересная находка. Чтобы понять это, нам нужно углубиться в спецификацию языка Java ( JLS). ).

Причина в том, что finalдопускается только одно назначение . Однако значением по умолчанию не является присвоение . Фактически, каждая такая переменная ( переменная класса, переменная экземпляра, компонент массива) указывает на свое значение по умолчанию с начала, до присвоений . Первое назначение затем меняет ссылку.


Переменные класса и значение по умолчанию

Взгляните на следующий пример:

private static Object x;

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

Мы явно не присваивали значение x, хотя оно указывает на nullего значение по умолчанию. Сравните это с §4.12.5 :

Начальные значения переменных

Каждая переменная класса, переменная экземпляра или компонент массива инициализируется значением по умолчанию при его создании ( §15.9 , §15.10.2 )

Обратите внимание, что это верно только для таких переменных, как в нашем примере. Это не относится к локальным переменным, см. Следующий пример:

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

Из того же абзаца JLS:

Локальная переменная ( §14.4 , §14.14 ) должно быть явно присвоено значение , прежде чем она используется, либо инициализации ( §14.4 ) или присваивания ( §15.26 ), таким образом , что можно проверить , используя правила для определенного присваивания ( § 16 (Определенное задание) ).


Конечные переменные

Теперь мы посмотрим на final, из §4.12.4 :

окончательные переменные

Переменная может быть объявлена ​​как финальная . Окончательная переменная может быть только назначены один раз . Это ошибка времени компиляции, если конечная переменная назначена, если только она не была определенно назначена непосредственно перед назначением ( §16 (Определенное назначение) ).


объяснение

Теперь вернемся к вашему примеру, слегка модифицированному:

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

Выводит

Before: 0
After: 1

Вспомните, что мы узнали. Внутри метода assignпеременной Xбыл не назначен значение пока. Следовательно, он указывает на свое значение по умолчанию, поскольку он является переменной класса, и в соответствии с JLS эти переменные всегда сразу указывают на свои значения по умолчанию (в отличие от локальных переменных). После assignметода переменной Xприсваивается значение, 1и из-за этого finalмы больше не можем его менять. Таким образом, следующее не будет работать из-за final:

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

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

Пример в JLS

Благодаря @Andrew я нашел абзац JLS, который охватывает именно этот сценарий, он также демонстрирует его.

Но сначала давайте посмотрим на

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

Почему это не разрешено, а доступ из метода есть? Взгляните на §8.3.3 котором говорится о том, когда доступ к полям ограничен, если поле еще не было инициализировано.

В нем перечислены некоторые правила, относящиеся к переменным класса:

Для ссылки простым именем на переменную класса, fобъявленную в классе или интерфейсе C, это ошибка времени компиляции, если :

  • Ссылка появляется либо в инициализаторе переменной класса, Cлибо в статическом инициализаторе C( §8.7 ); и

  • Ссылка появляется либо в инициализаторе fсобственного декларатора, либо в точке слева от fдекларатора; и

  • Ссылка не находится на левой стороне выражения присваивания ( §15.26 ); и

  • Внутренний класс или интерфейс, содержащий ссылку, это C.

Все просто, X = X + 1эти правила попадают в ловушку, а метод доступа - нет. Они даже перечисляют этот сценарий и приводят пример:

Доступ по этим методам не проверяется, поэтому:

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

производит вывод:

0

потому что инициализатор переменной for iиспользует метод класса peek для доступа к значению переменной jдо того, как jон был инициализирован ее инициализатором переменной, и в этот момент он все еще имеет значение по умолчанию ( §4.12.5 ).

Zabuzard
источник
1
@ Андрей Да, переменная класса, спасибо. Да, это будет работать , если бы не некоторые дополнительные-правил , которые ограничивают доступ к такому: §8.3.3 . Взгляните на четыре точки, указанные для переменных класса (первая запись). Методный подход в примере OPs не учитывается этими правилами, поэтому мы можем получить доступ Xиз метода. Я бы не возражал против этого. Это зависит только от того, как именно JLS определяет вещи для работы в деталях. Я бы никогда не использовал такой код, он просто использует некоторые правила в JLS.
Забузард
4
Проблема в том, что вы можете вызывать методы экземпляра из конструктора, что, вероятно, не должно быть разрешено. С другой стороны, назначение местных жителей перед вызовом super, что было бы полезно и безопасно, запрещено. Пойди разберись.
Восстановить Монику
1
@ Andrew, вы, наверное, единственный здесь, кто на самом деле упомянул forwards references(который также является частью JLS). это так просто без этого лунного ответа stackoverflow.com/a/49371279/1059372
Евгений
1
«Первое назначение затем меняет ссылку». В данном случае это не ссылочный тип, а примитивный тип.
Фабиан
1
Этот ответ правильный, если он немного длинный. :-) Я думаю, что tl; dr состоит в том, что ОП цитировал учебник, в котором говорилось, что «[окончательное] поле не может быть изменено», а не JLS. Хотя учебники Oracle довольно хороши, они не охватывают все крайние случаи. Что касается вопроса OP, нам нужно перейти к фактическому, JLS-определению final - и это определение не утверждает (что OP справедливо оспаривает), что значение окончательного поля никогда не может измениться.
ишавит
23

Ничего общего с финалом здесь.

Поскольку он находится на уровне экземпляра или класса, он содержит значение по умолчанию, если еще ничего не назначено. Это причина, которую вы видите, 0когда получаете доступ к ней без назначения.

Если вы Xполучаете доступ без полного назначения, он содержит значения по умолчанию long 0, а значит, и результаты.

Суреш Атта
источник
3
Хитрость в том, что если вы не назначите значение, оно не будет назначено со значением по умолчанию, но если вы использовали его для присвоения себе «окончательного» значения, оно будет ...
AxelH
2
@AxelH Я понимаю, что ты имеешь в виду под этим. Но так оно и должно работать, иначе мир рухнет;).
Суреш Атта
20

Не ошибка

Когда первый вызов scaleвызывается из

private static final long X = scale(10);

Он пытается оценить return X * value. Xеще не было присвоено значение, и поэтому используется значение по умолчанию для a long(которое0 ).

Так , что строка кода принимает значение X * 10т.е.0 * 10 что есть 0.

OldCurmudgeon
источник
8
Я не думаю, что это то, что ОП смущает. Что смущает это X = scale(10) + 3. Так как Xпри ссылке на метод, есть 0. Но потом это так 3. Таким образом, OP считает, что Xему присвоены два разных значения, которые могут конфликтовать с final.
Забузард
4
@Забуза, разве это не объясняется тем, что « Он пытается оценить return X * value. Ему Xеще не присвоено значение и, следовательно, он принимает значение по умолчанию, longкоторое есть 0. »? Не сказано, что Xему присваивается значение по умолчанию, но Xон «заменяется» (пожалуйста, не заключайте в кавычки этот термин;)) значением по умолчанию.
AxelH
14

Это вовсе не ошибка, проще говоря, это не незаконная форма прямых ссылок, не более того.

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

Это просто разрешено спецификацией.

Чтобы взять ваш пример, это именно то, где это соответствует:

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

Вы делаете прямую ссылку на это, scaleчто не является незаконным, как сказано выше, но позволяет вам получить значение по умолчанию X. Опять же, это разрешено Spec (точнее, это не запрещено), поэтому он работает просто отлично

Евгений
источник
хороший ответ! Мне просто любопытно, почему спецификация позволяет компилировать второй случай. Это единственный способ увидеть «противоречивое» состояние конечного поля?
Андрей Тобилко
@ Эндрю это тоже беспокоило меня довольно долго, я склонен думать, что это делает C ++ или C (не знаю, так ли это)
Евгений
@ Андрей: Потому что поступить иначе - это решить теорему о неполноте Тьюринга.
Джошуа
9
@ Джошуа: Я думаю, что вы смешиваете здесь несколько различных понятий: (1) проблему остановки, (2) проблему решения, (3) теорему Гёделя о неполноте и (4) языки программирования, полные по Тьюрингу. Авторы компиляторов не пытаются решить проблему "определенно ли назначена эта переменная перед ее использованием?" совершенно потому, что эта проблема эквивалентна решению проблемы остановки, и мы знаем, что не можем этого сделать.
Эрик Липперт
4
@EricLippert: Хаха, упс. Незавершенность Тьюринга и проблема остановки занимают одно и то же место в моей голове.
Джошуа
4

Члены уровня класса могут быть инициализированы в коде в пределах определения класса. Скомпилированный байт-код не может инициализировать встроенные члены класса. (Члены экземпляра обрабатываются аналогично, но это не относится к предоставленному вопросу.)

Когда кто-то пишет что-то вроде следующего:

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

Сгенерированный байт-код будет похож на следующее:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

Код инициализации помещается в статический инициализатор, который запускается при первой загрузке класса загрузчиком классов. С этим знанием ваш оригинальный образец будет похож на следующее:

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 загружает RecursiveStatic в качестве точки входа в банку.
  2. Загрузчик классов запускает статический инициализатор при загрузке определения класса.
  3. Инициализатор вызывает функцию scale(10)для назначения static finalполяX .
  4. scale(long)Функция работает в то время как класс частично инициализирован чтение неинициализированного значенияX которых является по умолчанию длинных или 0.
  5. Значение 0 * 10присваиваетсяX и загрузчик классов завершается.
  6. JVM запускает открытый метод static static void main, scale(5)который умножает 5 на теперь инициализированное Xзначение 0, возвращающее 0.

Статическое окончательное поле Xприсваивается только один раз, сохраняя гарантию, сохраняемую finalключевым словом. Для последующего запроса добавления 3 в присваивании вышеуказанный шаг 5 становится оценкой, 0 * 10 + 3являющейся значением, 3а основной метод напечатает результат, 3 * 5которым является значение 15.

psaxton
источник
3

Чтение неинициализированного поля объекта должно привести к ошибке компиляции. К сожалению для Java, это не так.

Я думаю, что основная причина, почему это так, «скрыта» глубоко в определении того, как объекты создаются и создаются, хотя я не знаю деталей стандарта.

В некотором смысле, final плохо определен, потому что он даже не выполняет своей заявленной цели из-за этой проблемы. Однако, если все ваши классы написаны правильно, у вас нет этой проблемы. Это означает, что все поля всегда установлены во всех конструкторах, и ни один объект не создается без вызова одного из его конструкторов. Это кажется естественным, пока вы не используете библиотеку сериализации.

Kafein
источник