Record-классы удобны, пока класс = «состояние, всё состояние и ничего кроме». Любое отклонение (API канонического конутруктора не равно внутреннему представлению, нужно наследование) ломает «автогенерацию» и паттерн-деструктурирование (destructuring).

В новом переводе от команды Spring АйО статьи Brian`а Goetz`а, архитектора Java Language, предлагается следующий шаг в направлении data-oriented programming in Java: классы-носители и интерфейсы-носители (они же Carrier classes & interfaces). Концептуально, carrier классы родились из record-ов путем ослабления части их ограничений.

Комментарий от Михаила Поливаха: Друзья, помните, пожалуйста, что данная статья по сути является суммированием обсуждения Carrier классов из JDK Project Amber Mailing List. Я это к тому, что пока непонятно, в какой версии языка carrier классы появятся, и появятся ли они в том виде, в котором представлены в статье. Статью стоит рассматривать как пищу для размышления.


Все любят record-классы: они позволяют создавать классы-хранилища данных с поверхностной неизменяемостью — их можно рассматривать как «номинальные кортежи», — выведенные из лаконичного описания состояния, а также деструктурировать record-классы через сопоставление с образцом.

Комментарий от Михаила Поливаха

Системы типов можно разным образом группировать, и один из возможных способов — это деление на номинальные и структурные (nominal vs structural typing).

Номинальная типизация опирается на имена и явные декларации. Чтобы один тип был совместим с другим, между ними обязательно должна быть прямая связь (например, наследование или реализация интерфейса). Мы все пишем на Java, и Java как раз пример номинально типизированного ЯП.

Структурная же типизация проверяет только общий shape объекта: если у объекта А есть все необходимые "members" (условно поля и методы, но тут все сложнее), что есть у В, то А и В считаются эквивалентными, даже если между А и В нет никакой формальной связи.

Для того, чтобы было понятнее:

class Dog { public String name = "Fido"; }
class Person { public String name = "Alice"; }

// это не скомпилируется
Dog myPet = new Person();

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

А теперь на похожтий код на TypeScript:

class Dog {
name: string = "Fido";
}

class Person {
name: string = "Alice";
}

// tsc не ругается! всё работает!
let myPet: Dog = new Person();

Код скомпилируется успешно, т.к, структурно, типы эквивалентны

Но у record-классов есть жёсткие ограничения, и далеко не все классы-хранилища данных укладываются в рамки этих ограничений. Возможно, у них есть изменяемое состояние или производное либо кэшируемое состояние, не входящее в описание состояния; или их представление и API не совпадают полностью; или им нужно распределить состояние по иерархии. В таких классах, хотя они тоже могут быть «хранилищами данных», пользовательский опыт напоминает падение с обрыва. Даже небольшое отклонение от идеала record-класса означает, что приходится возвращаться к чистому листу и писать явные объявления конструкторов, объявления методов доступа и реализации методов Object — и отказаться от деструктурирования через сопоставление с образцом.

С самого начала процесса проектирования record-классов мы держали в уме цель — дать более широкому кругу классов доступ к «плюшкам record-классов»: снижению когнетивной нагрузки разработчиков, участию в деструктурировании и, вскоре, в реконструкции. При проектировании record-классов мы также исследовали ряд более слабых семантических моделей, которые обеспечивали бы большую гибкость. Хотя тогда ни одна из них не дотягивала до целей, поставленных для record-классов, существует более мягкий набор семантических ограничений, который можно наложить так, чтобы получить больше гибкости и при этом сохранить нужные возможности, а также добиться некоторой синтаксической краткости, соразмерной удалённости от идеала record-класса, без «эффекта обрыва».

Record-классы, sealed-классы и деструктурирование с помощью record-паттернов составляют первую дугу возможностей «Data Oriented Programming» для Java. Рассмотрев множество идей, мы теперь готовы двигаться дальше, к следующей дуге возможностей «Data Oriented Programming»: классам-носителям, они же carrier классы (и интерфейсам).

За пределами record-паттернов

Record-паттерны позволяют деструктурировать экземпляр record-класса на его компоненты. Record-паттерны можно использовать в instanceof и switch, а когда record-паттерн также является исчерпывающим, его можно будет применять в готовящемся к появлению операторе присваивания с паттернами.

Изучая вопрос «как классы смогут участвовать в деструктурировании того же рода, что и record-классы», мы поначалу сосредоточились на новой форме объявления в классе — «деконструкторе», — работающем как конструктор, только наоборот. Подобно тому как конструктор принимает значения компонентов и создаёт агрегированный экземпляр, деконструктор принимал бы агрегированный экземпляр и извлекал его компонентные значения.

Но по мере развития этого исследования более интересным оказался другой вопрос: какие классы вообще подходят для деструктурирования? И ответ на него привёл нас к иному подходу к выражению деконструкции. Для деструктурирования подходят те классы, которые, как и record-классы, представляют собой немногим больше, чем носители конкретного кортежа данных. Это не просто нечто, чем класс обладает — как конструктором или методом, — а то, чем класс является. Поэтому разумнее описывать деконструкцию как свойство класса верхнего уровня. Это, в свою очередь, приводит к ряду упрощений.

Сила описания состояния

Record-классы — это семантическая возможность; их лаконичность лишь сопутствующее свойство. Но они действительно лаконичны: когда мы объявляем record-класс

record Point(int x, int y) { ... }

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

Record-класс — это поверхностно неизменяемый, final-класс, чьи API и представление полностью определяются описанием его состояния. (Лозунг record-классов: «состояние, только состояние, и ничего, кроме состояния».) Описание состояния — это упорядоченный список компонентов record-класса, объявленный в заголовке record. Компонент — это не просто поле или метод доступа; это самостоятельный элемент API, описывающий элемент состояния, которым обладают экземпляры класса.

Описание состояния record-класса обладает несколькими желательными свойствами:

Компоненты в указанном порядке являются каноническим описанием состояния record-класса.
Компоненты являются полным описанием состояния record-класса.
Компоненты номинальны; их имена — зафиксированная часть API record-класса.

Комментарий от Михаила Поливаха

Вот это крайне важная деталь. 

Во-первых, тут Брайн не пояснил, но я на всякий случай поясню - компонент это параметр канонического конструктора record-a.

И вот как довольно много Брайен говорил на Devoxx, у рекордов есть как раз одно свойство, которое на самом деле часто становится преградой для их исопльзования в библиотеках:

Имя поля в рамках record и имя API accessor метода к нему гвоздями прибиты и равны имени компонента. И соответсвенно меняя имя компонента, Вы, потенциально, меняете публичный API record-а.

Опять же, это можно объехать, но факт именно в наличии данной связи.

Record-классы получают свои преимущества за счёт двух обязательств:

Внешнее обязательство: API доступа к данным record-класса (конструктор, паттерн деконструкции и методы доступа к компонентам) определяется описанием состояния.

Внутреннее обязательство: представление record-класса (его поля) также полностью определяется описанием состояния.

Именно эти семантические свойства позволяют вывести почти всё, что относится к record-классам. Мы можем вывести API канонического конструктора, потому что описание состояния канонично. Мы можем вывести API методов доступа к компонентам, потому что описание состояния номинально. И мы можем вывести паттерн деконструкции из методов доступа, потому что описание состояния полно (вместе с разумными реализациями зависящих от состояния методов Object).

Комментарий от Михаила Поливаха

Речь про то, что вот как раз из-за такой связи имен параметров канонического сонутруктора и accessor-ов + field-ов record-а, можно делать такие конструкции:

if (obj instanceof Point(int x, int y)) {
System.out.println("Coordinates: " + x + ", " + y);
}

При это БЕЗ (!) необходимости писать deconstruction pattern в record-е. Для других классов так уже будет сделать нельзя.

Внутреннее обязательство, согласно которому описание состояния является также представлением, позволяет полностью вывести остальную реализацию. Record-классы получают (private, final) поле для каждого компонента, но, что важнее, существует чёткое соответствие между этими полями и их компонентами, и именно оно позволяет вывести реализации канонического конструктора и методов доступа.

Record-классы также могут объявлять компактный конструктор, позволяющий опустить шаблонные аспекты конструкторов record-классов — список аргументов и присваивания полям — и указать только код, который нельзя вывести механически. Это короче, менее подвержено ошибкам и легче читается:

record Rational(int num, int denom) {
    Rational {
        if (denom == 0)
            throw new IllegalArgumentException("denominator cannot be zero");
    }
}

это сокращённая запись более явного варианта:

record Rational(int num, int denom) {
    Rational(int num, int denom) {
        if (denom == 0)
            throw new IllegalArgumentException("denominator cannot be zero");
        this.num = num;
        this.denom = denom;
    }
}

Хотя компактные конструкторы приятно лаконичны, более важное преимущество в том, что, устраняя механически выводимый код, они выводят на первый план «более интересный» код.

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

Реконструкция экземпляров record-классов, позволяющая создать видимость контролируемой мутации состояния record-класса.

Автоматический маршаллинг и анмаршаллинг экземпляров record-классов.

Создание или деструктурирование экземпляров record-классов с идентификацией компонентов по именам, а не по позициям.

Реконструкция

JEP 468 предлагает механизм, с помощью которого новый экземпляр record-класса может быть получен из существующего с использованием синтаксиса, напоминающего прямую мутацию, — через with-выражение:

record Complex(double re, double im) { }
Complex c = ...
Complex cConjugate = c with { im = -im; };
Комментарий от Михаила Поливаха

Этой фичи если что еще нет в JDK. По сути, это воможность создать in-place немного измененный record. Аналогичный методу copy в Kotlin Data Class-ах:

val oldPerson = Person(name = "Alice", age = 25)
val newPerson = oldPerson.copy(age = 26)

Блок справа от with может содержать любые операторы Java, а не только присваивания. Он дополняется изменяемыми переменными (переменными компонентов) для каждого компонента record-класса, инициализированными значением соответствующего компонента в экземпляре record-класса слева; затем выполняется блок, и создаётся новый экземпляр record-класса, значения компонентов которого равны итоговым значениям переменных компонентов.

Выражение реконструкции неявно деструктурирует экземпляр record-класса с использованием канонического паттерна деконструкции, выполняет блок в области видимости, дополненной переменными компонентов, а затем создаёт новый record-класс с помощью канонического конструктора. Проверка инвариантов централизована в каноническом конструкторе, поэтому если новое состояние недопустимо, реконструкция завершится неудачей. JEP 468 уже некоторое время находится «на паузе» — главным образом потому, что мы ждали достаточной уверенности в том, что существует путь распространить этот механизм на некоторые другие классы, прежде чем закреплять его для record-классов. Идеальным путём было бы то, чтобы такие ряд других классов тоже поддерживал понятие канонического конструктора и паттерна деконструкции.

Внимательные читатели заметят сходство между блоком преобразования в with-выражении и телом компактного конструктора. В обоих случаях блок «предзагружается» набором переменных компонентов, инициализированных подходящими начальными значениями; блок может изменять эти переменные по мере необходимости; и при нормальном завершении блока эти переменные передаются в канонический конструктор, чтобы получить итоговый результат. Главное различие — в источнике начальных значений: для компактного конструктора это параметры конструктора, а для выражения реконструкции — канонический паттерн деконструкции исходного record-класса слева от with.

Разрушая «эффект обрыва»

Record-классы берут на себя сильное семантическое обязательство — выводить и свой API, и своё представление из описания состояния, — и взамен получают значительную помощь от языка. Теперь мы можем сосредоточиться на сглаживании «обрыва»: выявить более слабые семантические обязательства, которые классы могли бы брать на себя и которые всё же позволили бы им получить часть помощи от языка. И в идеале объём помощи, от которой приходится отказаться, должен быть пропорционален степени отклонения от идеала record-класса.

В случае record-классов мы многое выиграли благодаря наличию полного, канонического, номинального описания состояния. Там, где контракт record-класса иногда оказывается слишком жёстким, речь идёт об имплементационном контракте: что представление точно соответствует описанию состояния, что класс является final, что поля являются final, и что класс не может расширять ничего, кроме Record.

Наш путь здесь делает шаг назад и шаг вперёд: сохраняя внешнее обязательство относительно описания состояния, мы отказываемся от внутреннего обязательства, что описание состояния и есть представление, — а затем добавляем простой механизм для сопоставления полей, представляющих компоненты, с соответствующими им компонентами там, где это практично. (В record-классах, поскольку мы выводим представление из описания состояния, это сопоставление можно безопасно вывести автоматически.)

В качестве мысленного эксперимента представим класс, который берёт на себя внешнее обязательство относительно описания состояния — что описание состояния является полным, каноническим, номинальным описанием его состояния, — но сам отвечает за своё внутренне представление. Что мы можем сделать для такого класса? На самом деле довольно много. По тем же причинам, что и для record-классов, мы можем вывести требования к API канонического конструктора и методов доступа к компонентам. Отсюда мы можем вывести и требование канонического паттерна деконструкции, и реализацию паттерна деконструкции (поскольку она задаётся через методы доступа). А поскольку описание состояния полно, мы можем также вывести разумные реализации по умолчанию методов Object equals, hashCode и toString — тоже через методы доступа. И при наличии канонического конструктора и паттерна деконструкции такой класс сможет участвовать и в реконструкции. Автору останется лишь предоставить поля, методы доступа и канонический конс��руктор. Это хороший прогресс, но хотелось бы большего.

То, что позволяет нам вывести остальную часть реализации для record-классов (поля, конструктор, методы доступа и методы Object), — это знание того, как представление сопоставляется с описанием состояния. Record-классы обязуются тем, что их описание состояния и есть представление, поэтому от этого до полной реализации — один небольшой шаг.

Чтобы сделать всё вышесказанное более конкретным, рассмотрим типичный класс «почти record-класс»: носитель описания состояния (int x, int y, Optional s), который, однако, принял решение о представлении — хранить s внутри как допускающий null String.

class AlmostRecord {

    private final int x;
    private final int y;
    private final String s;                                 // *

    public AlmostRecord(int x, int y, Optional<String> s) {
        this.x = x;
        this.y = y;
        this.s = s.orElse(null);                            // *
    }

    public int x() { return x; }

    public int y() { return y; }

    public Optional<String> s() {
        return Optional.ofNullable(s);                      // *
    }

    public boolean equals(Object other) { ... }     // derived from x(), y(), s()

    public int hashCode() { ... }                   //    "

    public String toString() { ... }                //    "

}

Основные различия между этим классом и развёрнутым вариантом его record-аналога — строки, помеченные *. Именно они связаны с расхождением между описанием состояния и фактическим представлением. Было бы здорово, если бы автору этого класса приходилось писать только тот код, который отличается от того, что мы могли бы вывести для record-класса; это было бы не только приятно лаконично, но и означало бы, что весь присутствующий код существует для фиксации различий между его представлением и его API.

Классы-носители (Carrier Classes)

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

class Point(int x, int y) {                // class, not record!

    // explicitly declared representation

    ...

    // must have a constructor taking (int x, int y)
    // must have accessors for x and y
    // supports a deconstruction pattern yielding (int x, int y)
}

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

То, что описание состояния является «полным», означает, что оно включает всё «важное» состояние класса — если извлечь это состояние и заново создать объект, это должно дать «эквивалентный» экземпляр. Как и в record-классах, это можно зафиксировать, связав воедино поведение конструирования, методов доступа и равенства:

Point p = ...
Point q = new Point(p.x(), p.y());
assert p.equals(q);

Мы также можем вывести часть реализации из имеющейся информации: можно вывести разумные реализации методов Object (реализованные через методы доступа к компонентам), а также вывести канонический паттерн деконструкции (снова через методы доступа к компонентам). А дальше — вывести поддержку реконструкции (with-выражений). К сожалению, мы (пока что) не можем вывести основную часть связанной с состоянием реализации: канонический конструктор и методы доступа к компонентам.

Поля компонентов и методы доступа

Один из самых утомительных аспектов классов-хранилищ данных — методы доступа: их часто много, и почти всегда это чистый шаблонный код. Даже если IDE снижает нагрузку на написание, генерируя такие методы за нас, читателям всё равно приходится продираться через массу низкоинформативного кода — лишь чтобы понять, что продираться через него им, вообще-то, не было нужно. Мы можем вывести реализацию методов доступа для record-классов, потому что record-классы берут на себя внутреннее обязательство: все компоненты опираются на отдельные поля, чьи имена и типы согласованы с описанием состояния.

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

Мы достигаем этого, разрешая объявлять подходящие поля класса-носителя как поля компонентов. (Как обычно на этой стадии, синтаксис предвар��тельный, но сейчас не является предметом обсуждения.) Поле компонента должно иметь то же имя и тип, что и компонент текущего класса (хотя оно не обязано быть private или final, как поля record-классов). Это сигнализирует, что данное поле является представлением соответствующего компонента, и, следовательно, мы можем вывести и метод доступа для этого компонента.

class Point(int x, int y) {

    private /* mutable */ component int x;
    private /* mutable */ component int y;

    // must have a canonical constructor, but (so far) must be explicit
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    // derived implementations of accessors for x and y
    // derived implementations of equals, hashCode, toString
}

Становится лучше: автору класса пришлось принести с собой представление и сопоставление от представления к компонентам (в виде модификатора component), а также канонический конструктор.

Компактные конструкторы

Точно так же, как мы можем вывести реализацию accessor методов (условно getter/setter), если нам дано явное соответствие между полем и компонентом, мы можем сделать то же самое и для конструкторов. Для этого мы опираемся на понятие компактных конструкторов, введённое для record-классов.

Как и в record-классе, компактный конструктор в классе-носителе — это сокращённая запись канонического конструктора, который имеет ту же форму, что и описание состояния, но освобождён от обязанности фактически «зафиксировать» итоговое значение параметров компонентов в полях. Главное отличие в том, что в record-классе все компоненты подкреплены полем компонента, тогда как в классе-носителе — лишь некоторые из них могут быть (см. ключевое слово component). Но мы можем обобщить компактные конструкторы, освободив автора от обязанности инициализировать поля компонентов, при этом оставив за ним ответственность инициализировать остальные поля. В предельном случае, когда все компоненты подкреплены полями компонентов и в конструкторе не требуется никакой дополнительной логики, компактный конструктор можно опустить.

Для нашего изменяемого класса Point это означает, что можно опустить почти всё, кроме самих объявлений полей:

class Point(int x, int y) {

    private /* mutable */ component int x;
    private /* mutable */ component int y;

    // derived compact constructor

    // derived accessors for x, y

    // derived implementations of equals, hashCode, toString

}

Можно считать, что у этого класса есть неявный пустой компактный конструктор, что, в свою очередь, означает: поля компонентов x и y инициализируются из соответствующих параметров конструктора. Также неявно выводятся методы доступа для каждого компонента и реализации методов Object на основе описания состояния.

Это отлично работает для класса, где все компоненты подкреплены полями, но что насчёт нашего класса AlmostRecord? Здесь история тоже хороша: мы можем вывести методы доступа для компонентов, подкреплённых полями компонентов, и можем опустить инициализацию полей компонентов в компактном конструкторе, а значит, нам нужно указать только код для тех частей, которые отклоняются от «идеала record-класса»:

class AlmostRecord(int x,
                   int y,
                   Optional<String> s) {

    private final component int x;
    private final component int y;
    private final String s;

    public AlmostRecord {
        this.s = s.orElse(null);
        // x and y fields implicitly initialized
    }

    public Optional<String> s() {
        return Optional.ofNullable(s);
    }

    // derived implementation of x and y accessors
    // derived implementation of equals, hashCode, toString
}

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

Производное состояние

Одна из самых частых претензий к record-классам — невозможность выводить состояние из компонентов и кэшировать его для быстрого доступа. В классах-носителях это просто: объявите некомпонентное поле для производной величины, инициализируйте его в конструкторе и предоставьте метод доступа:

class Point(int x, int y) {

    private final component int x;
    private final component int y;
    private final double norm;

    Point {
        norm = Math.hypot(x, y);
    }

    public double norm() { return norm; }

    // derived implementation of x and y accessors

    // derived implementation of equals, hashCode, toString

Деконструкция и реконструкция

Как и record-классы, классы-носители автоматически получают паттерны деконструкции, соответствующие каноническому конструктору, поэтому мы можем деструктурировать наш класс Point так, как если бы он был record-классом:

case Point(var x, var y):

Поскольку реконструкция (with) выводится из канонического конструктора и соответствующего паттерна деконструкции, то, когда мы поддержим реконструкцию record-классов, мы сможем сделать то же самое и для классов-носителей:

point = point with { x = 3; }

Интерфейсы-носители (Carrier Interfaces)

Описание состояния имеет смысл и для интерфейсов. Оно утверждает, что описание состояния является полным, каноническим, номинальным описанием состояния интерфейса (подклассам разрешено добавлять дополнительное состояние), и, соответственно, реализации должны предоставлять методы доступа к компонентам.

Комментарий от Михаила Поливаха

Можете думать о carrier interface-е как об интерфейсе, который говорит, что у реализации точно будут такие-то "component-ные" (опять же, пока терминология не ясна) свойства. И, соотвественно, на carrier интерфейсу уже можно будет благодаря наличию этого в декларации строить деструктуризацию, о чем как раз будет ниже с примером Map.Entry

Это позволяет таким интерфейсам участвовать в сопоставлении с образцом:

interface Pair<T,U>(T first, U second) {

    // implicit abstract accessors for first() and second()

}

...

if (o instanceof Pair(var a, var b)) { ... }

В сочетании с готовящейся возможностью «паттерн-присваивания» в заголовках foreach-циклов, если Map.Entry станет интерфейсом-носителем (а он станет), мы сможем итерировать Map так:

for (Map.Entry(var key, var val) : map.entrySet()) { ... }

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

public sealed interface Pair<T,U>(T first, U second) { }
private record PairImpl<T, U>(T first, U second) implements Pair<T, U> { }

По сравнению со старым способом мы получаем более богатую семантику, лучшую проверку типов и большую лаконичность.

Расширение

Главная обязанность автора класса-носителя — обеспечить, чтобы фундаментальное утверждение, что описание состояния является полным, каноническим, номинальным описанием состояния объекта, действительно было истинным. Это не исключает того, что представление класса-носителя может быть распределено по иерархии, поэтому, в отличие от record-классов, классы-носители не обязаны быть final или конкретными и не ограничены в наследовании.

При наследовании с участием классов-носителей возникает несколько случаев:

  • класс-носитель наследует от не-носителя;

  • не-носитель наследует от класса-носителя;

  • класс-носитель наследует от другого класса-носителя, причём все компоненты суперкласса поглощаются описанием состояния подкласса;

  • класс-носитель наследует от другого класса-носителя, но есть один или несколько компонентов суперкласса, которые не поглощаются описанием состояния подкласса.

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

Когда один класс-носитель наследует от другого, более простой случай — он просто добавляет новые компоненты к описанию состояния суперкласса. Например, имея наш класс Point:

class Point(int x, int y) {
    component int x;
    component int y;
    // everything else for free!
}

мы можем использовать его как базовый класс для трёхмерной точки:

class Point3d(int x, int y, int z) extends Point {

    component int z;

    Point3d {
        super(x, y);
    }
}

В этом случае — поскольку все компоненты суперкласса входят в описание состояния подкласса — мы фактически можем опустить и конструктор, потому что можем вывести соответствие между компонентами подкласса и компонентами суперкласса, а значит, вывести и необходимый вызов super-конструктора. Поэтому мы могли бы написать так:

class Point3d(int x, int y, int z) extends Point {
    component int z;
    // everything else for free!
}

Можно подумать, что нам понадобилась бы какая-то разметка на компонентах x и y в Point3d, чтобы указать, что они сопоставляются с соответствующими компонентами Point — так же, как мы делали для сопоставления полей компонентов с их компонентами. Но здесь никакой разметки не требуется, потому что не существует способа, при котором компонент int x у Point и компонент int x у его подкласса могли бы означать разные вещи — ведь оба привязаны к одному и тому же методу доступа int x(). Поэтому мы можем безопасно выводить, какие компоненты подкласса управляются суперклассами, просто сопоставляя их имена и типы.

В другом случае наследования «носитель → носитель», когда один или несколько компонентов суперкласса не поглощаются описанием состояния подкласса, необходимо явно вызвать super-конструктор в конструкторе подкласса.

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

Абстрактные record-классы

Эта схема также даёт нам возможность ослабить одно из ограничений record-классов: что record-классы не могут наследовать ничего, кроме java.lang.Record. Мы также можем разрешить объявлять record-классы как abstract и позволить record-классам наследовать от абстрактных record-классов.

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

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

Record-классы тоже являются носителями

При наличии этой схемы record-классы теперь можно рассматривать как «просто» классы-носители, которые неявно являются final, наследуют java.lang.Record, неявно имеют private final поля компонентов для каждого компонента и не могут иметь никаких других полей.

Совместимость при миграции

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

Совместимая эволюция record-классов и классов-носителей

До сих пор библиотеки неохотно использовали record-классы в публичных API из-за сложности их совместимой эволюции. Для record-класса:

record R(A a, B b) { }

который хочет эволюционировать, добавив новые компоненты:

record R(A a, B b, C c, D d) { }

нам приходится решать несколько задач совместимости. Пока мы только добавляем, а не удаляем/переименовываем, вызовы методов доступа продолжат работать. А существующие вызовы конструктора можно оставить рабочими, явно добавив обратно конструктор со старой формой:

record R(A a, B b, C c, D d) {
    // Explicit constructor for old shape required
    public R(A a, B b) {
        this(a, b, DEFAULT_C, DEFAULT_D);
    }
}

Но что делать с существующими использованиями record-паттернов? Хотя трансляция record-паттернов и сделала бы добавление компонентов бинарно совместимым, это не было бы совместимо на уровне исходного кода, и нет способа явно добавить паттерн деконструкции старой формы так же, как мы сделали это для конструктора.

Мы можем воспользоваться упрощением, связанным с тем, что существует только канонический паттерн деконструкции, и разрешить использованиям паттернов деконструкции задавать вложенные паттерны для любого префикса списка компонентов. Так, для эволюционировавшего record-класса R:

case R(P1, P2)

будет интерпретироваться как:

case R(P1, P2, , )

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

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.