JEP 447: Statements before super(...) (Preview)
Инструкции перед super(...), Preview (предварительная версия)
| Автор | Archie Cobbs & Gavin Bierman |
| Ответственный | Archie Cobbs |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 22 |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot java dot net |
| Связан с | JEP 482: Flexible Constructor Bodies (Second Preview) |
| Рецензенты | Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2023/01/20 17:33 |
| Обновлён | 2025/02/25 16:30 |
| Задача | 8300786 |
Аннотация
Разрешить в конструкторах языка программирования Java размещать перед явным вызовом конструктора инструкции, которые не обращаются к создаваемому экземпляру. Это языковая возможность в статусе Preview.
Цели
-
Дать разработчикам больше свободы в описании поведения конструкторов. Логику, которую сейчас приходится выносить во вспомогательные статические методы, вспомогательные промежуточные конструкторы или аргументы конструктора, можно будет разместить более естественно.
-
Сохранить существующую гарантию того, что при создании экземпляра класса конструкторы выполняются сверху вниз, чтобы код в конструкторе подкласса не мог помешать созданию экземпляра суперкласса.
-
Не требовать никаких изменений в Java Virtual Machine. Эта возможность языка Java опирается только на уже имеющуюся способность JVM проверять и выполнять код, который находится в конструкторах перед явными вызовами конструкторов.
Мотивация
Когда один класс расширяет другой, подкласс наследует функциональность суперкласса и может добавлять функциональность, объявляя собственные поля и методы. Начальные значения полей, объявленных в подклассе, могут зависеть от начальных значений полей, объявленных в суперклассе, поэтому крайне важно инициализировать поля суперкласса первыми, раньше полей подкласса. Например, если класс B расширяет класс A, то сначала должны быть инициализированы поля невидимого класса Object, затем поля класса A, а затем поля класса B.
Такой порядок инициализации полей означает, что конструкторы должны выполняться сверху вниз: конструктор суперкласса должен закончить инициализацию полей, объявленных в этом классе, до того как будет выполнен конструктор подкласса. Так инициализируется всё состояние объекта.
Также крайне важно, чтобы к полям класса не обращались до их инициализации. Чтобы не допустить обращения к неинициализированным полям, конструкторы приходится ограничивать: тело конструктора не должно обращаться к полям, объявленным в его собственном классе или в любом суперклассе, пока не завершится конструктор суперкласса.
Чтобы гарантировать выполнение конструкторов сверху вниз, язык Java требует, чтобы в теле конструктора любой явный вызов другого конструктора был первой инструкцией; если явного вызова конструктора нет, компилятор добавляет его сам.
Чтобы гарантировать, что конструкторы не обращаются к неинициализированным полям, язык Java требует, чтобы при наличии явного вызова конструктора ни один из его аргументов никаким образом не обращался к текущему объекту this.
Эти требования гарантируют поведение «сверху вниз» и отсутствие обращений до инициализации, но они слишком жёсткие: из-за них ряд идиом, применяемых в обычных методах, трудно или даже невозможно использовать в конструкторах. Следующие примеры показывают эти проблемы.
Пример: проверка аргументов конструктора суперкласса
Иногда нужно проверить аргумент, который передаётся конструктору суперкласса. Можно проверить аргумент задним числом, но тогда, возможно, будет выполнена лишняя работа:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
super(value); // Potentially unnecessary work
if (value <= 0)
throw new IllegalArgumentException("non-positive value");
}
}
Лучше было бы объявить конструктор, который быстро завершается с ошибкой, проверяя свои аргументы до вызова конструктора суперкласса. Сегодня это можно сделать только прямо в выражении, с помощью вспомогательного метода static:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
super(verifyPositive(value));
}
private static long verifyPositive(long value) {
if (value <= 0)
throw new IllegalArgumentException("non-positive value");
return value;
}
}
Этот код читался бы лучше, если бы логику проверки можно было поместить прямо в конструктор. Хотелось бы написать так:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
if (value <= 0)
throw new IllegalArgumentException("non-positive value");
super(value);
}
}
Пример: подготовка аргументов конструктора суперкласса
Иногда, чтобы подготовить аргументы для конструктора суперкласса, приходится выполнять нетривиальные вычисления, и снова прибегать к вспомогательным методам:
public class Sub extends Super {
public Sub(Certificate certificate) {
super(prepareByteArray(certificate));
}
// Auxiliary method
private static byte[] prepareByteArray(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null)
throw new IllegalArgumentException("null certificate");
return switch (publicKey) {
case RSAKey rsaKey -> ...
case DSAPublicKey dsaKey -> ...
...
default -> ...
};
}
}
Конструктор суперкласса принимает аргумент-массив byte, а конструктор подкласса принимает аргумент Certificate. Чтобы выполнить ограничение, согласно которому вызов конструктора суперкласса должен быть первой инструкцией в конструкторе подкласса, мы объявляем вспомогательный метод prepareByteArray, который готовит аргумент для этого вызова.
Этот код читался бы лучше, если бы код подготовки аргумента можно было встроить прямо в конструктор. Хотелось бы написать так:
public Sub(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null)
throw new IllegalArgumentException("null certificate");
final byte[] byteArray = switch (publicKey) {
case RSAKey rsaKey -> ...
case DSAPublicKey dsaKey -> ...
...
default -> ...
};
super(byteArray);
}
Пример: общие аргументы конструктора суперкласса
Иногда нужно вычислить значение и использовать его в нескольких аргументах вызова конструктора суперкласса. Из-за требования, что вызов конструктора должен стоять первым, добиться этого можно только через промежуточный вспомогательный конструктор:
public class Super {
public Super(F f1, F f2) {
...
}
}
public class Sub extends Super {
// Auxiliary constructor
private Sub(int i, F f) {
super(f, f); // f is shared here
... i ...
}
public Sub(int i) {
this(i, new F());
}
}
В публичном конструкторе Sub мы хотим создать новый экземпляр класса F и передать две ссылки на этот экземпляр конструктору суперкласса. Для этого мы объявляем вспомогательный приватный конструктор.
Код, который хотелось бы написать, выполняет копирование прямо в конструкторе, и вспомогательный конструктор становится не нужен:
public Sub(int i) {
var f = new F();
super(f, f);
... i ...
}
Итоги
Во всех этих примерах код конструктора, который хотелось бы написать, содержит инструкции перед явным вызовом конструктора, но не обращается ни к каким полям через this до завершения конструктора суперкласса. Сегодня компилятор отвергает эти конструкторы, хотя все они безопасны: они не нарушают выполнение конструкторов сверху вниз и не обращаются к неинициализированным полям.
Если бы язык Java мог гарантировать создание сверху вниз и отсутствие обращений до инициализации с помощью более гибких правил, код было бы легче писать и сопровождать. Конструкторы могли бы более естественно проверять, готовить и совместно использовать аргументы, не выполняя эту работу через неуклюжие вспомогательные методы или конструкторы. Нужно выйти за рамки упрощённых синтаксических требований, действующих с Java 1.0, то есть «super(..) или this(..) должны быть первой инструкцией», «нельзя использовать this» и так далее.
Описание
Мы изменяем грамматику тел конструкторов (JLS §8.8.7) следующим образом:
ConstructorBody:
{ [BlockStatements] }
{ [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
Инструкции блока, которые стоят перед явным вызовом конструктора, составляют пролог тела конструктора. Инструкции в теле конструктора без явного вызова конструктора, а также инструкции после явного вызова конструктора составляют эпилог.
Контексты до создания
Что касается семантики, Java Language Specification относит код в списке аргументов явного вызова конструктора в теле конструктора к статическому контексту (JLS §8.1.3). Это значит, что аргументы такого вызова конструктора обрабатываются так, как если бы они находились в методе static, иными словами, как если бы экземпляра не было. Однако технические ограничения статического контекста сильнее, чем нужно, и не дают использовать в качестве аргументов конструктора полезный и безопасный код.
Вместо того чтобы менять понятие статического контекста, мы вводим новое, строго более слабое понятие контекста до создания (pre-construction context), которое охватывает и аргументы явного вызова конструктора, и все инструкции перед ним. В контексте до создания действуют правила, похожие на правила обычных методов экземпляра, за исключением того, что код не может обращаться к создаваемому экземпляру.
Оказывается, определить, что считается обращением к создаваемому экземпляру, на удивление непросто. Рассмотрим несколько примеров.
Начнём с простого случая: любое неквалифицированное выражение this в контексте до создания запрещено:
class A {
int i;
A() {
this.i++; // Error
this.hashCode(); // Error
System.out.print(this); // Error
super();
}
}
По тем же причинам в контексте до создания запрещены любые обращения к полям, вызовы методов и ссылки на методы, квалифицированные через super:
class D {
int i;
}
class E extends D {
E() {
super.i++; // Error
super();
}
}
В более сложных случаях недопустимое обращение может и не содержать ключевого слова this или super:
class A {
int i;
A() {
i++; // Error
hashCode(); // Error
super();
}
}
Ещё запутаннее то, что иногда выражение с this относится не к текущему экземпляру, а к объемлющему экземпляру внутреннего класса:
class B {
int b;
class C {
int c;
C() {
B.this.b++; // Allowed - enclosing instance
C.this.c++; // Error - same instance
super();
}
}
}
Неквалифицированные вызовы методов тоже усложняются из-за семантики внутренних классов:
class Outer {
void hello() {
System.out.println("Hello");
}
class Inner {
Inner() {
hello(); // Allowed - enclosing instance method
super();
}
}
}
Вызов hello() в контексте до создания конструктора Inner разрешён, потому что он относится к объемлющему экземпляру Inner (в данном случае имеющему тип Outer), а не к создаваемому экземпляру Inner (JLS §8.8.1).
В предыдущем примере объемлющий экземпляр Outer уже был создан и поэтому доступен, а экземпляр Inner находился в процессе создания и поэтому был недоступен. Возможна и обратная ситуация:
class Outer {
class Inner {
}
Outer() {
new Inner(); // Error - 'this' is enclosing instance
super();
}
}
Выражение new Inner() недопустимо, потому что для него нужно передать конструктору Inner объемлющий экземпляр Outer, а экземпляр Outer, который был бы передан, ещё находится в процессе создания и поэтому недоступен.
Аналогично в контексте до создания выражения создания экземпляра класса, объявляющие анонимные классы, не могут иметь новый создаваемый объект в качестве неявного объемлющего экземпляра:
class X {
class S {
}
X() {
var tmp = new S() { }; // Error
super();
}
}
Здесь объявляемый анонимный класс является подклассом S, который является внутренним классом X. Значит, у анонимного класса тоже был бы объемлющий экземпляр X, и поэтому неявным объемлющим экземпляром выражения создания экземпляра класса был бы новый создаваемый объект. Поскольку это снова происходит в контексте до создания, возникает ошибка компиляции. Если бы класс S был объявлен как static или был бы интерфейсом, а не классом, у него не было бы объемлющего экземпляра и ошибки компиляции не было бы.
А этот пример, напротив, разрешён:
class O {
class S {
}
class U {
U() {
var tmp = new S() { }; // Allowed
super();
}
}
}
Здесь объемлющим экземпляром выражения создания экземпляра класса является не новый создаваемый объект U, а лексически объемлющий экземпляр O.
Инструкцию return можно использовать в эпилоге тела конструктора, если она не содержит выражения (т. е. return; разрешено, а return e; нет). Если инструкция return находится в прологе тела конструктора, это ошибка компиляции.
Выбрасывать исключение в прологе тела конструктора разрешено. Более того, в сценариях быстрого завершения с ошибкой это будет обычным делом.
В отличие от статического контекста, код в контексте до создания может ссылаться на тип создаваемого экземпляра, если он не обращается к самому экземпляру:
class A<T> extends B {
A() {
super(this); // Error - refers to 'this'
}
A(List<?> list) {
super((T)list.get(0)); // Allowed - refers to 'T' but not 'this'
}
}
Records (записи)
На конструкторы record-классов уже наложено больше ограничений, чем на обычные конструкторы (JLS §8.10.4). В частности:
-
канонические конструкторы record-классов не могут содержать явных вызовов конструкторов, и
-
неканонические конструкторы record-классов должны вызывать другой конструктор (вызов
this(...)) и не могут вызывать конструктор суперкласса (вызовsuper(...)).
Эти ограничения сохраняются, но в остальном конструкторы record-классов получат пользу от описанных выше изменений, прежде всего в том, что неканонические конструкторы record-классов смогут содержать инструкции перед явными вызовами другого конструктора.
Перечисления
Сейчас конструкторы классов enum могут содержать явные вызовы другого конструктора, но не вызовы конструктора суперкласса. Классы-перечисления получат пользу от описанных выше изменений, прежде всего в том, что их конструкторы смогут содержать инструкции перед явными вызовами другого конструктора.
Тестирование
Мы протестируем изменения компилятора с помощью существующих модульных тестов, не меняя их, за исключением тестов, проверяющих изменённое поведение, а также добавим при необходимости новые позитивные и негативные тесты.
Мы скомпилируем все классы JDK предыдущей и новой версиями компилятора и проверим, что полученный байт-код идентичен.
Тестирование для конкретных платформ не должно потребоваться.
Риски и допущения
Предлагаемые выше изменения совместимы на уровне исходного кода и поведения. Они строго расширяют множество допустимых программ на Java и сохраняют смысл всех существующих программ на Java.
Эти изменения, хотя сами по себе и невелики, существенно меняют давнее требование, согласно которому вызов конструктора, если он есть, всегда должен быть первой инструкцией в теле конструктора. Это требование глубоко заложено в анализаторы кода, средства проверки стиля, средства подсветки синтаксиса, среды разработки и другие инструменты экосистемы Java. Как и при любом изменении языка, пока инструменты обновляются, возможен болезненный период.
Зависимости
Эта возможность языка Java зависит от способности JVM верифицировать и выполнять произвольный код, который находится в конструкторах перед вызовами конструкторов, при условии, что этот код не обращается к создаваемому экземпляру. К счастью, JVM уже допускает более гибкую обработку тел конструкторов:
-
В конструкторе может быть несколько вызовов конструкторов при условии, что на любом пути выполнения кода есть ровно один вызов;
-
Перед вызовами конструкторов может находиться произвольный код при условии, что этот код не обращается к создаваемому экземпляру, кроме как для присваивания значений полям; и
-
Явные вызовы конструкторов не могут находиться внутри блока
try, то есть внутри диапазона исключений байт-кода.
Эти менее строгие правила по-прежнему гарантируют инициализацию сверху вниз:
-
Инициализация суперкласса всегда выполняется ровно один раз: либо напрямую через вызов конструктора суперкласса, либо косвенно через вызов альтернативного конструктора; и
-
Пока инициализация суперкласса не завершена, обращаться к неинициализированным экземплярам нельзя, за исключением присваивания значений полям, которое не влияет на результат.
Иными словами, никаких изменений в Java Virtual Machine Specification (спецификация виртуальной машины Java) нам не требуется.
Нынешнее расхождение между JVM и языком — исторический пережиток. Изначально JVM была более строгой, но это приводило к проблемам с инициализацией полей, генерируемых компилятором для новых возможностей языка, таких как внутренние классы и захваченные свободные переменные. В результате спецификацию ослабили, чтобы она допускала код, генерируемый компилятором, но эта новая гибкость так и не дошла обратно до уровня языка.