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

JEP 513: Flexible Constructor Bodies

Flexible Constructor Bodies (гибкие тела конструкторов)

АвторArchie Cobbs & Gavin Bierman
ОтветственныйGavin Bierman
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск25
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot org
Связан сJEP 492: Flexible Constructor Bodies (Third Preview)
РецензентыAlex Buckley, Brian Goetz
ОдобренBrian Goetz
Создан2024/11/21 12:03
Обновлён2026/06/02 23:44
Задача8344702

Аннотация

Разрешить в теле конструктора инструкции перед явным вызовом конструктора, то есть перед super(...) или this(...). Такие инструкции не могут обращаться к создаваемому объекту, но могут инициализировать его поля и выполнять другие безопасные вычисления. С этим изменением многие конструкторы можно записать естественнее. Кроме того, поля можно инициализировать до того, как они станут видны другому коду в классе, например методам, вызываемым из конструктора суперкласса. Так повышается безопасность.

История

Flexible Constructor Bodies впервые предложены в статусе Preview (предварительная версия) в JEP 447 (JDK 22) под другим названием. Затем они были доработаны и снова вышли в статусе Preview в JEP 482 (JDK 23), а потом ещё раз, без изменений, в JEP 492 (JDK 24). Здесь мы предлагаем сделать эту возможность окончательной в JDK 25 без изменений.

Цели

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

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

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

Мотивация

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

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

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

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

Конструкторы слишком ограничены

Правило «сверху вниз» для конструкторов помогает гарантировать корректность только что созданных экземпляров, но запрещает некоторые привычные и разумные приёмы программирования. Разработчиков часто раздражает, что в конструкторах нельзя написать совершенно безопасный код.

Например, пусть у нашего класса Person есть поле age, но возраст сотрудников должен быть от 18 до 67 лет. В конструкторе Employee мы хотели бы проверить аргумент age до передачи его в конструктор Person, но вызов конструктора должен идти первым. Можно проверить аргумент после вызова, но тогда придётся выполнить, возможно, лишнюю работу по вызову конструктора суперкласса:

class Person {

    ...
    int age;

    Person(..., int age) {
        if (age < 0)
            throw new IllegalArgumentException(...);
        ...
        this.age = age;
    }

}

class Employee extends Person {

    Employee(..., int age) {
        super(..., age);        // Potentially unnecessary work
        if (age < 18 || age > 67)
            throw new IllegalArgumentException(...);
    }

}

Лучше было бы объявить конструктор Employee, который завершается с ошибкой сразу (fail fast), проверяя свой аргумент до вызова конструктора Person. Это очевидно безопасно, но поскольку вызов конструктора должен идти первым, единственный способ сделать так — вызвать вспомогательный метод прямо в выражении вызова конструктора:

class Employee extends Person {

    private static int verifyAge(int value) {
        if (age < 18 || age > 67)
            throw new IllegalArgumentException(...);
        return value;
    }

    Employee(..., int age) {
        super(..., verifyAge(age));
    }

}

Требование, чтобы конструктор суперкласса вызывался первым, создаёт проблемы и в других сценариях. Например, может понадобиться выполнить нетривиальные вычисления, чтобы подготовить аргументы для вызова конструктора суперкласса. Или может понадобиться подготовить сложное значение, общее для нескольких аргументов вызова конструктора суперкласса.

Конструкторы суперкласса могут нарушить целостность подклассов

У каждого класса есть явная или подразумеваемая спецификация допустимых состояний его собственных полей. Реализация класса, если она написана правильно, создаёт и сохраняет только допустимые состояния. Она делает это независимо от действий суперклассов, подклассов и всех остальных классов программы. Иначе говоря, каждый класс должен обладать целостностью. Экземпляр обладает целостностью в той мере, в какой ею обладают его класс и все его суперклассы.

Правило «сверху вниз» гарантирует, что конструктор суперкласса всегда выполняется раньше конструктора подкласса и поля суперкласса инициализируются правильно. К сожалению, этого правила недостаточно, чтобы гарантировать целостность нового экземпляра в целом. Конструктор суперкласса может косвенно обратиться к полям подкласса до того, как их инициализирует конструктор подкласса. Например, пусть у класса Employee есть поле officeID, а конструктор в Person вызывает метод, переопределённый в Employee:

class Person {

    ...
    int age;

    void show() {
        System.out.println("Age: " + this.age);
    }

    Person(..., int age) {
        if (age < 0)
            throw new IllegalArgumentException(...);
        ...
        this.age = age;
        show();
    }

}

class Employee extends Person {

    String officeID;

    @Override
    void show() {
        System.out.println("Age: " + this.age);
        System.out.println("Office: " + this.officeID);
    }

    Employee(..., int age, String officeID) {
        super(..., age);        // Potentially unnecessary work
        if (age < 18  || age > 67)
            throw new IllegalArgumentException(...);
        this.officeID = officeID;
    }

}

Что выведет new Employee(42, "CAM-FORA")? Можно ожидать, что будет выведено Age: 42 и, возможно, ещё Office: CAM-FORA, но на самом деле выводится Age: 42 и Office: null! Дело в том, что конструктор Person выполняется до того, как конструктор Employee инициализирует поле officeID. Конструктор Person вызывает метод show, из-за чего выполняется переопределяющий метод show в Employee, и всё это до того, как конструктор Employee присвоит полю officeID значение "CAM-FORA". В результате метод show выводит значение поля officeID по умолчанию, то есть null.

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

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

К большей выразительности и безопасности

Итак, правило «сверху вниз» часто ограничивает выразительность конструкторов. Кроме того, класс почти ничего не может сделать, чтобы защитить свою целостность от нарушений со стороны собственных суперклассов или другого кода. Нам нужно решение обеих проблем.

Описание

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

Это изменение позволяет писать понятные конструкторы, которые проверяют свои аргументы до вызова конструкторов суперкласса. Например, наш конструктор Employee можно записать прямо и понятнее, чтобы он сразу завершался с ошибкой:

class Employee extends Person {

    String officeID;

    Employee(..., int age, String officeID) {
        if (age < 18  || age > 67)
            // Now fails fast!
            throw new IllegalArgumentException(...);
        super(..., age);
        this.officeID = officeID;
    }

}

Это изменение также позволяет гарантировать, что конструкторы подклассов обеспечивают целостность, инициализируя свои поля до вызова конструкторов суперкласса. Например, конструктор Employee можно доработать так, чтобы он инициализировал поле officeID до вызова конструктора суперкласса:

class Employee extends Person {

    String officeID;

    Employee(..., int age, String officeID) {
        if (age < 18  || age > 67)
            // Now fails fast!
            throw new IllegalArgumentException(...);
        this.officeID = officeID;   // Initialize before calling superclass constructor!
        super(..., age);
    }

}

Теперь new Employee(42, "CAM-FORA") выводит Age: 42 и Office: CAM-FORA, как и ожидалось. Целостность класса Employee сохраняется.

Новая модель тела конструктора

Отказ от правила «сверху вниз» означает новую семантическую модель тела конструктора. Теперь у тела конструктора две отдельные фазы: пролог — код до вызова следующего конструктора, и эпилог — код после этого вызова.

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

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

То есть конструкторы вызываются снизу вверх, начиная с нижнего уровня иерархии, а тела конструкторов выполняются сверху вниз, начиная с верхнего уровня иерархии, с класса 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
    }
}

Вызовы конструкторов и выполнение прологов и эпилогов происходят так:

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

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

Синтаксис

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

ConstructorBody:
    { [ExplicitConstructorInvocation] [BlockStatements] }

на:

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

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

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

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

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

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

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

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

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

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

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

Например:

class X {

    int i;
    String s = "hello";

    X() {

        System.out.print(this);  // Error - explicitly 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 y = i;               // Error - implicitly refers to field of the current instance
        hashCode();              // Error - implicitly refers to method of the current instance

        i = 42;                  // OK - assignment to an uninitialized declared field

        s = "goodbye";           // Error - assignment to an initialized declared field

        super();

    }

}

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

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

class Z extends Y {

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

}

Records (записи)

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

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

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

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

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

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

Вложенные классы

Когда объявления классов вложены, код внутреннего класса может ссылаться на экземпляр объемлющего класса. Это возможно потому, что экземпляр объемлющего класса создаётся раньше экземпляра внутреннего класса. Код внутреннего класса, включая тела конструкторов, может обращаться к полям и вызывать методы объемлющего экземпляра, используя либо простые имена, либо квалифицированные выражения 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();
    }

}

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

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

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

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

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

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

Value Classes (классы-значения)

JEP 401 из проекта Valhalla предлагает Value Classes и опирается на эту работу. Если конструктор value-класса не содержит явного вызова конструктора, то считается, что неявный вызов конструктора неявно находится в конце тела конструктора, а не в начале. Таким образом, все инструкции такого конструктора составляют его пролог, а его эпилог пуст.