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

JEP 482: Flexible Constructor Bodies (Second Preview)

Flexible Constructor Bodies (гибкие тела конструкторов), вторая версия Preview (предварительная версия)

АвторArchie Cobbs & Gavin Bierman
ОтветственныйArchie Cobbs
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск23
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot org
Связан сJEP 447: Statements before super(...) (Preview)
JEP 492: Flexible Constructor Bodies (Third Preview)
РецензентыAlex Buckley, Brian Goetz
ОдобренBrian Goetz
Создан2024/02/13 22:18
Обновлён2025/02/25 16:29
Задача8325803

Аннотация

Разрешить в конструкторах языка программирования Java размещать инструкции перед явным вызовом конструктора, то есть перед super(..) или this(..). Эти инструкции не могут обращаться к создаваемому экземпляру, но могут инициализировать его поля. Если инициализировать поля до вызова другого конструктора, класс становится надёжнее при переопределении методов. Это Preview-возможность языка.

История

Эта возможность была впервые предложена под другим названием в JEP 447 и вошла в JDK 22 в статусе Preview. Здесь мы предлагаем выпустить её в статусе Preview во второй раз с одним существенным изменением:

  • Разрешить телу конструктора инициализировать поля того же класса до явного вызова конструктора. Так конструктор подкласса может гарантировать, что конструктор суперкласса никогда не выполнит код, который видит значение по умолчанию поля подкласса (например, 0, false или null). Такое возможно, когда из-за переопределения конструктор суперкласса вызывает метод подкласса, использующий это поле.

Цели

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

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

Мотивация

Конструкторы класса отвечают за создание корректных экземпляров этого класса. Предположим, например, что у экземпляров класса Person есть поле age, значение которого всегда должно быть меньше 130. Конструктор, принимающий параметр, связанный с возрастом (например, дату рождения), должен проверить его и либо записать его в поле age, обеспечив тем самым корректность экземпляра, либо выбросить исключение.

Кроме того, конструкторы класса отвечают за корректность при наличии подклассов. Предположим, например, что Employee — подкласс Person. Каждый конструктор Employee неявно или явно вызывает конструктор Person. Вместе конструкторы должны обеспечить корректность экземпляра: конструктор Employee отвечает за поля, объявленные в классе Employee, а конструктор Person — за поля, объявленные в классе Person. Поскольку код в конструкторе Employee может обращаться к полям, инициализированным конструктором Person, последний должен выполняться первым.

Таким образом, в общем случае конструкторы должны выполняться сверху вниз: сначала должен выполниться конструктор суперкласса, обеспечив корректность объявленных в этом классе полей, и только потом выполняется конструктор подкласса.

Чтобы гарантировать выполнение конструкторов сверху вниз, язык Java требует, чтобы первой инструкцией в теле конструктора был явный вызов другого конструктора, то есть super(..) или this(..). Если в теле конструктора нет явного вызова конструктора, компилятор вставляет super() первой инструкцией тела конструктора.

Кроме того, язык требует, чтобы ни один из аргументов явного вызова конструктора никак не использовал создаваемый экземпляр.

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

Пример: проверка аргументов конструктора суперкласса

Иногда нужно проверить аргумент, передаваемый в конструктор суперкласса. Проверить аргумент можно после вызова конструктора суперкласса, но тогда, возможно, будет выполнена лишняя работа:

public class PositiveBigInteger extends BigInteger {

    public PositiveBigInteger(long value) {
        super(value);                 // Potentially unnecessary work
        if (value <= 0) throw new IllegalArgumentException(..);
    }

}

Лучше было бы объявить конструктор, который завершается с ошибкой сразу, проверяя аргумент до вызова конструктора суперкласса. Сегодня это можно сделать только вызовом вспомогательного метода прямо внутри вызова super(..):

public class PositiveBigInteger extends BigInteger {

    private static long verifyPositive(long value) {
        if (value <= 0) throw new IllegalArgumentException(..);
        return value;
    }

    public PositiveBigInteger(long value) {
        super(verifyPositive(value));
    }

}

Код читался бы лучше, если бы логику проверки можно было разместить в теле конструктора:

public class PositiveBigInteger extends BigInteger {

    public PositiveBigInteger(long value) {
        if (value <= 0) throw new IllegalArgumentException(..);
        super(value);
    }

}

Пример: подготовка аргументов конструктора суперкласса

Иногда для подготовки аргументов конструктора суперкласса нужны нетривиальные вычисления. И снова приходится вызывать вспомогательный метод прямо внутри вызова super(..). Предположим, например, что конструктор принимает аргумент Certificate, но должен преобразовать его в массив byte для конструктора суперкласса:

public class Sub extends Super {

    private static byte[] prepareByteArray(Certificate certificate) {
        var publicKey = certificate.getPublicKey();
        if (publicKey == null) throw new IllegalArgumentException(..);
        return switch (publicKey) {
            case RSAKey rsaKey -> ...
            case DSAPublicKey dsaKey -> ...
            default -> ...
        };
    }

    public Sub(Certificate certificate) {
        super(prepareByteArray(certificate));
    }

}

Код читался бы лучше, если бы аргументы можно было подготовить прямо в теле конструктора:

public Sub(Certificate certificate) {
        var publicKey = certificate.getPublicKey();
        if (publicKey == null) throw ...
        byte[] certBytes = switch (publicKey) {
            case RSAKey rsaKey -> ...
            case DSAPublicKey dsaKey -> ...
            default -> ...
        };
        super(certBytes );
    }

Пример: общие аргументы конструктора суперкласса

Иногда одно и то же значение нужно передать в конструктор суперкласса несколько раз, в разных аргументах. Единственный способ сделать это — вспомогательный конструктор:

public class Super {
    public Super(C x, C y) { ... }
}

public class Sub extends Super {
    private Sub(C x)   { super(x, x); }     // Pass the argument twice to Super's constructor
    public  Sub(int i) { this(new C(i)); }  // Prepare the argument for Super's constructor
}

Код было бы проще сопровождать, если бы повторное использование значения можно было организовать в теле конструктора без вспомогательного конструктора:

public class Sub extends Super {
    public Sub(int i) {
        var x = new C(i);
        super(x, x);
    }
}

Аннотация

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

Если бы язык Java мог гарантировать создание сверху вниз при более гибких правилах, тела конструкторов было бы проще писать и сопровождать. В телах конструкторов можно было бы естественнее проверять, подготавливать и повторно использовать аргументы, не прибегая к громоздким вспомогательным методам или конструкторам. Пора отказаться от упрощённого синтаксического требования, действующего со времён Java 1.0, что super(..) или this(..) должны быть первой инструкцией в теле конструктора.

Описание

Мы изменяем грамматику тела конструктора, чтобы разрешить инструкции перед явным вызовом конструктора, то есть заменяем:

ConstructorBody:
    { [ExplicitConstructorInvocation] [BlockStatements] }

на:

ConstructorBody:
    { [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
    { [BlockStatements] }

Если опустить некоторые детали, явный вызов конструктора — это либо super(..), либо this(..).

Инструкции, стоящие перед явным вызовом конструктора, образуют пролог тела конструктора.

Инструкции, стоящие после явного вызова конструктора, образуют эпилог тела конструктора.

Явный вызов конструктора в теле конструктора можно опустить. В этом случае пролог пуст, а все инструкции тела конструктора образуют эпилог.

Инструкция return допускается в эпилоге тела конструктора, если она не содержит выражения. То есть return; допустима, а return e; — нет. Инструкция return в прологе тела конструктора — ошибка компиляции.

Выбрасывать исключение в прологе или эпилоге тела конструктора разрешено. Выбрасывание исключения в прологе будет типичным для сценариев с быстрым отказом (fail-fast).

Это Preview-возможность языка, по умолчанию отключённая

Чтобы попробовать приведённые ниже примеры в JDK 23, нужно включить Preview-возможности:

  • Скомпилируйте программу с javac --release 23 --enable-preview Main.java и запускайте её с java --enable-preview Main; или

  • При использовании средства запуска исходного кода запускайте программу с java --enable-preview Main.java; или

  • При использовании jshell запускайте его с jshell --enable-preview.

Контексты раннего конструирования

В языке Java о коде в списке аргументов явного вызова конструктора говорят, что он находится в статическом контексте. Это значит, что аргументы явного вызова конструктора обрабатываются так, как если бы это был код в методе static, иными словами, как если бы экземпляр был недоступен. Однако технические ограничения статического контекста сильнее, чем необходимо, и они не позволяют использовать в качестве аргументов конструктора полезный и безопасный код.

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

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

class A {

    int i;

    A() {

        System.out.print(this);  // Error - refers to the current instance

        var x = this.i;          // Error - explicitly refers to field of the current instance
        this.hashCode();         // Error - explicitly refers to method of the current instance

        var x = i;               // Error - implicitly refers to field of the current instance
        hashCode();              // Error - implicitly refers to method of the current instance

        super();

    }

}

Аналогично в контексте раннего конструирования запрещены любые обращения к полям, вызовы методов и ссылки на методы, квалифицированные super:

class B {
    int i;
    void m() { ... }
}

class C extends B {

    C() {
        var x = super.i;         // Error
        super.m();               // Error
        super();
    }

}

Использование объемлющих экземпляров в контекстах раннего конструирования

Когда объявления классов вложены, код внутреннего класса может обращаться к экземпляру объемлющего класса. Это возможно потому, что экземпляр объемлющего класса создаётся раньше экземпляра внутреннего класса. Код внутреннего класса — включая тела конструкторов — может обращаться к полям и вызывать методы объемлющего экземпляра, используя либо простые имена, либо квалифицированные выражения this. Соответственно, операции над объемлющим экземпляром в контексте раннего конструирования разрешены.

В приведённом ниже коде объявление Inner вложено в объявление Outer, поэтому у каждого экземпляра Inner есть объемлющий экземпляр Outer. В конструкторе Inner код в контексте раннего конструирования может обращаться к объемлющему экземпляру и его членам либо через простые имена, либо через Outer.this.

class Outer {

    int i;

    void hello() { System.out.println("Hello"); }

    class Inner {

        int j;

        Inner() {
            var x = i;             // OK - implicitly refers to field of enclosing instance
            var y = Outer.this.i;  // OK - explicitly refers to field of enclosing instance
            hello();               // OK - implicitly refers to method of enclosing instance
            Outer.this.hello();    // OK - explicitly refers to method of enclosing instance
            super();
        }

    }

}

Напротив, в показанном ниже конструкторе Outer код в контексте раннего конструирования не может создать экземпляр класса Inner с помощью new Inner(). На самом деле это выражение означает this.new Inner(), то есть в качестве объемлющего экземпляра для объекта Inner используется текущий экземпляр Outer. Согласно приведённому выше правилу, в контексте раннего конструирования запрещено любое явное или неявное использование this для обращения к текущему экземпляру.

class Outer {

    class Inner {}

    Outer() {
        var x = new Inner();       // Error - implicitly refers to the current instance of Outer
        var y = this.new Inner();  // Error - explicitly refers to the current instance of Outer
        super();
    }

}

Раннее присваивание полям

Обращаться к полям текущего экземпляра в контексте раннего конструирования запрещено, но как насчёт присваивания значений полям текущего экземпляра, пока он ещё создаётся?

Разрешив такие присваивания, конструктор подкласса мог бы защититься от того, что конструктор суперкласса увидит неинициализированные поля подкласса. Такое может произойти, когда конструктор суперкласса вызывает метод суперкласса, переопределённый методом подкласса. Хотя язык Java позволяет конструкторам вызывать переопределяемые методы, это считается плохой практикой: совет 19 из Effective Java (Third Edition) гласит: «Конструкторы не должны вызывать переопределяемые методы». Чтобы понять, почему это считается плохой практикой, рассмотрим следующую иерархию классов:

class Super {

    Super() { overriddenMethod(); }

    void overriddenMethod() { System.out.println("hello"); }

}

class Sub extends Super {

    final int x;

    Sub(int x) {
        /* super(); */    // Implicit invocation
        this.x = x;
    }

    @Override
    void overriddenMethod() { System.out.println(x); }

}

Что выведет new Sub(42)? Можно ожидать, что будет выведено 42, но на самом деле выводится 0. Дело в том, что конструктор Super вызывается неявно до присваивания поля в теле конструктора Sub. Затем конструктор Super вызывает overriddenMethod, и этот метод в Sub выполняется до того, как тело конструктора Sub успеет присвоить полю значение 42. В результате метод в Sub видит значение поля по умолчанию, то есть 0.

Этот шаблон — источник множества ошибок и дефектов. Хотя он считается плохой практикой программирования, он встречается нередко и ставит подклассы в затруднительное положение — особенно когда изменить суперкласс нельзя.

Мы решаем эту проблему, разрешая конструктору Sub инициализировать поле в Sub до явного вызова конструктора Super. Пример можно переписать следующим образом, изменив только класс Sub:

class Super {

    Super() { overriddenMethod(); }

    void overriddenMethod() { System.out.println("hello"); }

}

class Sub extends Super {

    final int x;

    Sub(int x) {
        this.x = x;    // Initialize the field
        super();       // Then invoke the Super constructor explicitly
    }

    @Override
    void overriddenMethod() { System.out.println(x); }

}

Теперь new Sub(42) выведет 42, потому что полю в Sub присваивается значение 42 до вызова overriddenMethod.

В теле конструктора в раннем контексте конструирования разрешено простое присваивание полю, объявленному в том же классе, если в объявлении поля нет инициализатора. Это означает, что в раннем контексте конструирования тело конструктора может инициализировать собственные поля класса, но не поля суперкласса.

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

Records (записи)

На конструкторы record-классов уже распространяется больше ограничений, чем на конструкторы обычных классов. В частности:

  • канонические конструкторы record-классов не должны содержать явных вызовов конструкторов, а

  • неканонические конструкторы record-классов должны содержать вызов альтернативного конструктора (this(..)), а не вызов конструктора суперкласса (super(..)).

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

Перечисления

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

Тестирование

  • Мы протестируем изменения в компиляторе с помощью существующих модульных тестов. Их мы не будем менять, кроме тестов, которые проверяют изменившееся поведение. Кроме того, при необходимости добавим новые позитивные и негативные тесты.

  • Мы скомпилируем все классы JDK предыдущей и новой версиями компилятора и проверим, что полученный байт-код идентичен.

  • Тестирование под конкретные платформы не должно потребоваться.

Риски и допущения

Предлагаемые выше изменения совместимы на уровне исходного кода и поведения. Они строго расширяют множество допустимых программ на Java и сохраняют смысл всех существующих программ на Java.

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

Зависимости

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

  • в теле конструктора может быть несколько вызовов конструкторов, при условии что на любом пути выполнения кода происходит ровно один вызов;

  • перед вызовами конструкторов может располагаться произвольный код, при условии что этот код не обращается к создаваемому экземпляру, кроме как для присваивания полям; и

  • явные вызовы конструкторов не могут находиться внутри блока try, т. е. внутри диапазона исключений байт-кода.

Правила JVM по-прежнему гарантируют инициализацию сверху вниз:

  • инициализация суперкласса всегда происходит ровно один раз: либо напрямую через вызов конструктора суперкласса, либо косвенно через вызов альтернативного конструктора; и

  • пока инициализация суперкласса не завершена, к неинициализированным экземплярам обращаться нельзя, кроме присваиваний полям, которые не влияют на результат.

Поэтому нам не нужны изменения в Java Virtual Machine Specification (спецификации виртуальной машины Java), а только в Java Language Specification (спецификации языка Java).

Расхождение между JVM, допускающей Flexible Constructor Bodies, и традиционным языком Java с более строгими правилами — исторический артефакт. Изначально JVM была строже, но это приводило к проблемам с инициализацией генерируемых компилятором полей для новых возможностей языка, таких как внутренние классы и захваченные свободные переменные. Чтобы поддержать генерируемый компилятором код, мы много лет назад ослабили требования JVM Specification, но так и не пересмотрели Java Language Specification, чтобы воспользоваться этой новой гибкостью.