openjdk.ruOpenJDK на русском

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 была более строгой, но это приводило к проблемам с инициализацией полей, генерируемых компилятором для новых возможностей языка, таких как внутренние классы и захваченные свободные переменные. В результате спецификацию ослабили, чтобы она допускала код, генерируемый компилятором, но эта новая гибкость так и не дошла обратно до уровня языка.