Как Java определяет порядок инициализации экземпляра при создании объекта?
Сначала объект получает значения по умолчанию для всех полей. Затем выполняется цепочка конструкторов от базового класса к производному: для каждого класса сначала вызывается конструктор родителя, после него в текстовом порядке выполняются инициализаторы полей и блоки инициализации экземпляра, затем тело конструктора этого класса.
Поэтому поля производного класса ещё имеют значения по умолчанию, когда выполняется конструктор базового класса. Порядок объявлений инициализаторов внутри одного класса значим.
В Java состояние объекта может задаваться несколькими средствами: значениями полей, инициализаторами, блоками инициализации и конструкторами. При наследовании эти средства принадлежат разным классам, поэтому языку нужен однозначный порядок их выполнения.
Такой порядок позволяет совместить повторное использование логики базового класса с настройкой состояния производного класса. Он также делает поведение создания объекта предсказуемым независимо от того, какой конструктор производного класса выбран.
Ошибка возникает, когда разработчик предполагает, что все поля производного объекта уже инициализированы до входа в конструктор родителя. Это неверно: во время выполнения конструктора родителя часть состояния производного класса ещё содержит значения по умолчанию.
Особенно опасен вызов переопределяемого метода из конструктора. Динамическая диспетчеризация может передать управление в производный класс до завершения его инициализации, что приводит к чтению нулевых значений, null или false вместо ожидаемого состояния.
Для нового объекта JVM сначала выделяет память и устанавливает значения полей по умолчанию: 0 для числовых типов, false для boolean, \u0000 для char и null для ссылочных типов.
Затем конструктор производного класса начинает выполнение с явного или неявного вызова конструктора родителя. Цепочка продолжается до Object. После завершения конструктора родителя выполняются инициализаторы полей и блоки инициализации текущего класса в порядке их появления в исходном тексте, а затем выполняется тело его конструктора.
Минимальный пример:
При создании Child вызов show() происходит из конструктора Parent. Метод переопределён в Child, но инициализатор value ещё не выполнялся, поэтому будет напечатано 0, а не 42.
Главное практическое ограничение состоит в том, что конструктор базового класса не должен рассчитывать на полностью готовое состояние производного класса. Обычно из конструкторов избегают вызова переопределяемых методов и передают необходимые данные через параметры либо используют приватные методы, не предназначенные для переопределения.
В базовом классе компонента конструктор вызывал метод configure(), чтобы подготовить диагностические данные. В производном классе этот метод использовал поле, инициализируемое выражением в объявлении. В тестах проявлялось неожиданное пустое значение, хотя после завершения конструктора поле имело правильное содержимое.
Рассматривались два варианта. Перенос вызова в конструктор производного класса сохранял переопределяемость, но требовал дисциплины от всех наследников и создавал риск забыть вызвать настройку. Запретить переопределение метода было безопаснее, но уменьшало расширяемость базового класса.
Выбрали явный шаблон инициализации: базовый конструктор сохранял только переданные параметры, а отдельный финальный этап запуска выполнялся после полного создания объекта. Это устранило обращение к незаполненным полям и сделало момент готовности объекта явным.
Что произойдёт с полем производного класса, если его инициализатор зависит от поля базового класса?
К моменту инициализации производного класса поля базового класса уже прошли свою инициализацию и конструктор базового класса завершился. Поэтому производное поле может корректно использовать доступное состояние базового класса, если оно не было скрыто другим полем и не нарушены правила доступа.
Влияет ли порядок полей в исходном тексте на результат, если одно поле использует другое?
Да. Инициализаторы экземпляра выполняются сверху вниз в порядке объявления. Если первое поле читает второе до выполнения его инициализатора, оно увидит значение по умолчанию; перестановка объявлений может изменить результат или привести к ошибке компиляции для недопустимой ссылки на переменную.
Почему вызов переопределяемого метода из конструктора считается опасным даже без исключения?
Потому что выбор реализации метода происходит по фактическому типу объекта, а не по текущему конструктору. Производственная реализация может обратиться к полям, инвариантам или ресурсам, которые инициализируются только позже; объект формально существует, но ещё не находится в состоянии, обещанном его обычными методами.