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

JEP 492: Flexible Constructor Bodies (Third Preview)

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

АвторArchie Cobbs & Gavin Bierman
ОтветственныйArchie Cobbs
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск24
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot org
Связан сJEP 482: Flexible Constructor Bodies (Second Preview)
JEP 513: Flexible Constructor Bodies
РецензентыBrian Goetz
ОдобренBrian Goetz
Создан2024/08/13 09:47
Обновлён2025/04/02 16:08
Задача8338287

Аннотация

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

История

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

Цели

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

  • Ввести в теле конструктора две отдельные фазы: пролог содержит код, который выполняется до вызова конструктора суперкласса, а эпилог выполняется после вызова конструктора суперкласса.

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

Мотивация

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

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

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

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

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

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

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

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

public class PositiveBigInteger extends BigInteger {

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

}

Лучше было бы объявить конструктор, который сразу завершается с ошибкой (fail fast), проверяя свой аргумент до вызова конструктора суперкласса. Сейчас это можно сделать только с помощью встроенного вызова вспомогательного метода внутри вызова 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(..) должен быть первым оператором в теле конструктора. В новой модели тело конструктора состоит из двух отдельных фаз: пролог — это код до вызова конструктора, а эпилог — код после вызова конструктора.

Для иллюстрации рассмотрим такую иерархию классов:

class Object {
    Object() {
        // Object constructor body
    }
}

class A extends Object {
    A() {
        super();
        // A constructor body
    }
}

class B extends A {
    B() {
        super();
        // B constructor body
    }
}

class C extends B {
    C() {
        super();
        // C constructor body
    }
}

class D extends C {
    D() {
        super();
        // D constructor body
    }
}

Сейчас при создании нового экземпляра класса D с помощью new D() выполнение тел конструкторов можно представить так:

D
--> C
    --> B
        --> A
            --> Object constructor body
        --> A constructor body
    --> B constructor body
--> C constructor body
D constructor body

Поэтому текущий подход языка Java к безопасной инициализации объектов называют нисходящим: тела конструкторов выполняются, начиная с вершины иерархии, с класса Object, и далее по очереди вниз по подклассам.

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

class Object {
    Object() {
        // Object constructor body
    }
}

class A extends Object {
    A() {
        // A prologue
        super();
        // A epilogue
    }
}

class B extends A {
    B() {
        // B prologue
        super();
        // B epilogue
    }
}

class C extends B {
    C() {
        // C prologue
        super();
        // C epilogue
    }
}

class D extends C {
    D() {
        // D prologue
        super();
        // D epilogue
    }
}

Соответствующее выполнение тел конструкторов при вычислении new D() можно представить так:

D prologue
--> C prologue
    --> B prologue
        --> A prologue
            --> Object constructor body
        --> A epilogue
    --> B epilogue
--> C epilogue
D epilogue

При новом подходе тела конструкторов не выполняются сверху вниз: сначала снизу вверх выполняются прологи, а затем сверху вниз — эпилоги.

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

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

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

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

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

Синтаксис

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

ConstructorBody:
    { [ExplicitConstructorInvocation] [BlockStatements] }

на:

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

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

Операторы, расположенные перед явным вызовом конструктора, составляют пролог тела конструктора.

Операторы, расположенные после явного вызова конструктора, составляют эпилог тела конструктора.

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

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

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

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

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

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

Это значит, что в раннем контексте конструирования запрещено любое явное или неявное использование 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(), то есть в нём текущий экземпляр Outer используется как объемлющий экземпляр для объекта Inner. По ранее приведённому правилу любое явное или неявное использование 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 Language Specification.

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