Записи (records) в Java


Первый взгляд на то, как добавленные в Java 14 записи данных изменят ваш стиль кодирования
В этой статье я рассмотрю новую фичу Java — записи (records). Записи представляют собой новую форму класса Java, спроектированного для:
  • Обеспечения первоклассных средств моделирования агрегации данных;
  • Закрыть возможный пробел в системе типов Java;
  • Предоставить синтаксис на уровне языка для общих шаблонов программирования;
  • Уменьшить избыточность класса
Функционал записей активно разрабатывался и планировался к появлению как предварительная фича в Java 14. Чтобы извлечь из статьи максимальную пользу, вы должны ориентироваться в программировании на Java, а также, интересоваться разработкой и реализацией языков программирования.

Давайте начнем с изучения основной идеи о том, что такое запись в Java.
Что такое запись в Java?
Одной из наибольших претензий к Java является то, что для того, чтобы класс мог стать полезным, необходимо написать большое количество избыточного кода. Очень часто вам необходимо дописывать следующее:
  • toString()
  • hashCode() и equals()
  • Геттеры
  • Публичные конструкторы
Для простых классов, такие методы обычно скучны, повторяются и часто могут генерироваться механически (IDE часто предоставляет такую возможность), но в настоящее время, сам язык не предоставляет какой-либо способ сделать это.

Это бывает неприятно когда вы читаете чужой код. Например, со стороны может выглядеть, как будто автор использовал сгенерируемый IDE hashCode() и equals(), которые используют все поля класса. Но мы не можем быть в этом до конца уверены, не проверив каждую строку реализации. Но что произойдет, если какое-то поле было добавлено во время рефакторинга и соответствующие методы повторно не сгенерированы?

Цель записей — расширить синтаксис языка Java и создать способ сообщить, что класс — это «поля, только поля и ничего иного, кроме полей». Если вы можете сказать это о классе, то компилятор поможет с созданием всех методов автоматически (подобных hashCode()) с участием в них всех полей
Пример без применения записей
В этой статье для объяснения записей я собираюсь использовать в качестве примера торговлю иностранными валютами. Я покажу, как вы можете использовать записи для улучшения моделирования вашего домена и в конечном результате, как сделать код чистым, менее подробным и более простым.

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

Давайте рассмотрим, как мы можем разместить заказ при торговле валютой. Базовый порядок может быть таким:
  • Количество единиц, которые вы продаете или покупаете (в миллионах денежных единиц);
  • «Сторона сделки» — в зависимости от того, покупаете вы или продаете (часто называется «ставка» и «запрос цены» соответственно);
  • Валюты, с которыми вы работаете («валютная пара»);
  • Время размещения заказа;
  • Время действия заказа (время жизни заказа — «TTL»).
Таким образом, если у вас есть 1 миллион фунтов стерлингов (£) и вы хотите продать их, чтобы купить американские доллары ($), при этом по курсу 1.25 доллара за каждый фунт стерлингов, то говоря на торговом жаргоне, вы «покупаете 1 миллион GBP/USD по курсу 1.25». Трейдеры также сообщают, когда заказ был создан — обычно это настоящее время, и как долго заказ будет действителен, что обычно составляет 1 секунду или менее.

Давайте объявим класс, реализующий описанные выше процессы:

public final class FXOrderClassic {
    private final int units;
    private final CurrencyPair pair;
    private final Side side;
    private final double price;
    private final LocalDateTime sentAt;
    private final int ttl;

    public FXOrderClassic(int units, 
               CurrencyPair pair, 
               Side side, 
               double price, 
               LocalDateTime sentAt, 
               int ttl) {
        this.units = units;
        this.pair = pair; // CurrencyPair is a simple enum
        this.side = side; // Side is a simple enum
        this.price = price;
        this.sentAt = sentAt;
        this.ttl = ttl;
    }

    public int getUnits() {
        return units;
    }

    public CurrencyPair getPair() {
        return pair;
    }

    public Side getSide() {
        return side;
    }

    public double getPrice() { 
        return price;
    }

    public LocalDateTime getSentAt() {
        return sentAt;
    }

    public int getTtl() {
        return ttl;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) 
            return false;

        FXOrderClassic that = (FXOrderClassic) o;

        if (units != that.units) return false;
        if (Double.compare(that.price, price) != 0) 
            return false;
        if (ttl != that.ttl) return false;
        if (pair != that.pair) return false;
        if (side != that.side) return false;
        return sentAt != null ? 
            sentAt.equals(that.sentAt) : that.sentAt == null;
    }

    @Override
    public int hashCode() {
        int result;
        long temp;
        result = units;
        result = 31 * result + 
                   (pair != null ? pair.hashCode() : 0);
        result = 31 * result + 
                   (side != null ? side.hashCode() : 0);
        temp = Double.doubleToLongBits(price);
        result = 31 * result + 
                   (int) (temp ^ (temp >>> 32));
        result = 31 * result + 
                   (sentAt != null ? sentAt.hashCode() : 0);
        result = 31 * result + ttl;
        return result;
    }

    @Override
    public String toString() {
        return "FXOrderClassic{" +
                "units=" + units +
                ", pair=" + pair +
                ", side=" + side +
                ", price=" + price +
                ", sentAt=" + sentAt +
                ", ttl=" + ttl +
                '}';
    }
}
Далее, заказ может быть создан следующим образом:

var order = new FXOrderClassic(1, 
                    CurrencyPair.GBPUSD, 
                    Side.Bid, 1.25, 
                    LocalDateTime.now(), 1000);
Но в реальности, сколько в действительности нужно кода при объявлении класса? В существующей версии Java большинство разработчиков, возможно, всего лишь определят поля и, используя IDE, выполнят автоматическую генерацию всех методов. Давайте посмотрим, как записи улучшили ситуацию.
Пример с записями
«Класс записей» (обычно называется просто запись) является сравнительно новой концепцией. Это неизменяемый (в обычном низкоуровневом понимании Java) носитель фиксированного набора значений, известных как компоненты записи (records components). Каждый компонент порождает неизменяемое (final) поле, которое содержит предоставленное значение и метод доступа для извлечения значения. Имена поля и метода доступа совпадают с именем компонента.

Для обеспечения возможности создать новый экземпляр записи, также, создается конструктор, называемый каноническим (canonical constructor), который имеет список параметров, в точности соответствующий объявленному описанию состояния.

Язык программирования Java (как предварительная функциональность (preview feature) в Java 14) предоставляет лаконичный синтаксис для объявления записей, в котором программист должен всего лишь определить типы и имена компонентов, что и определяет запись, например, следующим образом:

public record FXOrder(int units,
                      CurrencyPair pair,
                      Side side,
                      double price,
                      LocalDateTime sentAt,
                      int ttl) {}
Написав такое определение записи вы не только не печатаете лишний текст, но также создаете более сильное семантическое выражение: что тип FXOrder всего лишь предоставляемое состояние, а любой экземпляр является всего лишь агрегатором значений полей.

Одним из следствий этого является то, что имена полей становятся вашим API и соответственно, придумывать полям хорошие имена становится еще важнее. (Например, Pair не очень хорошее имя для типа, поскольку это может относиться к паре туфель.)

Чтобы активировать новую функциональность, вам нужно код, который объявляет записи, компилировать со специальным флагом:

javac --enable-preview -source 14 FXOrder.java
Если вы исследуете получившийся файл с помощью javap, то увидите, что компилятор автоматически сгенерировал кучу стандартного кода. (В декомпиляции ниже я показываю только методы и их сигнатуры).

$ javap FXOrder.class
Compiled from "FXOrder.java"
public final class FXOrder extends java.lang.Record {
    public FXOrder(int, CurrencyPair, Side, double, 
        java.time.LocalDateTime, int);
    public java.lang.String toString();
    public final int hashCode();
    public final boolean equals(java.lang.Object);
    public int units();
    public CurrencyPair pair();
    public Side side();
    public double price();
    public java.time.LocalDateTime sentAt();
    public int ttl();
}
Это выглядит замечательно, подобно набору методов в реализации основанной на классе. Фактически, методы конструктора и методы доступа имеют точно такое же поведение, как и ранее.
Особенности записей
Однако, такие методы как toString() и equals() используют реализацию, которая может удивить некоторых разработчиков:
То есть, метод toString() (а также equals() и hashCode()) реализованы с использованием механизма основанного на invokedynamic. Подобным образом, с использованием invokedynamic, в последних версиях Java реализуется конкатенация строк.

Вы также можете увидеть здесь новый класс, java.lang.Record, который является суперклассом для всех классов записей. Класс является абстрактным и объявляет equals(), hashCode(), и toString() как абстрактные методы.

Класс java.lang.Record не может быть расширен напрямую, что вы можете увидеть пытаясь скомпилировать код подобный следующему:

public final class FXOrderClassic extends Record {
    private final int units;
    private final CurrencyPair pair;
    private final Side side;
    private final double price;
    private final LocalDateTime sentAt;
    private final int ttl;
    
    // ... rest of class elided
}
Компилятор отклонит попытку следующим образом:
Это означает, что единственным способом получить запись является явное объявление записи и создание файла класса при помощи javac. Такой подход гарантирует, что все классы записи созданы как финальные.

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

Во-первых, записи должны соблюдать специальный контракт относительно метода equals():

Если запись R имеет компоненты с1, с2, … сn, то если запись экземпляра копируется следующим образом:

R copy = new R(r.c1(), r.c2(), ..., r.cn());
То это должен быть тот случай, при котором выражение r. equals (copy) является верным. Обратите внимание, это является дополнением к обычному и всем нам известному контракту относительно equals() и hashCode(), он не заменяет его.

Во-вторых, сериализация записей в Java отличается от сериализации обычных классов. Это хорошо, так как сейчас широко признано, что механизм сериализации в Java имеет серьезные недостатки. Как говорит Брайан Гетц (Brian Goetz), архитектор языка Java: «Сериализация представляет собой невидимый, но публичный конструктор, а также невидимый, но публичный набор методов доступа к полям вашего внутреннего состояния».

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

Кроме того, serialVersionUID записи равен 0L до тех пор, пока он не будет объявлен явно. Также, отказались от требований согласования значений serialVersionUID для класса записей.

Прежде чем перейти к следующей части, я хочу сделать ударение на том, что по сути это новый паттерн программирования, а также, новый синтаксис для объявления класса с низким количеством ненужного кода и это не связано с функциональностью «inline classes» разработанной в рамках проекта Valhalla.
Обсуждение на уровне проектирования
Давайте рассмотрим некоторые аспекты проектирования функционала записей. Для этого полезно вспомнить, как работают перечисления (enum) в Java. Перечисления в Java является особой формой класса, которая реализует определенный шаблон, но с минимальной синтаксической избыточностью — компилятор генерирует множество кода для нас.

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

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

Когда записи впервые обсуждались, было рассмотрено много различных вариантов проектирования, например:
  • Уменьшение избыточности кода объектов POJO;
  • JavaBeans 2.0;
  • Именованные кортежи (named tuples);
  • Типы продукта (в форме алгебраического типа данных).
Эти варианты подробно описаны в деталях Брайаном Гетцом в его эскизах проектирования.
Каждый вариант проектирования сопровождается дополнительными вторичными вопросами, которые появляются в зависимости от выбора основы проектирования записей, например:
  • Сможет ли Hibernate обрабатывать их?
  • Полностью ли они совместимы с классическими JavaBeans?
  • Являются ли две записи, которые объявляют одни и те же поля, в одном и том же порядке, одним и тем же типом?
  • Появятся ли они вместе с технологией сопоставления с образцом и деструктуризации?
Было бы правильно основывать функционал записей на любом из этих подходов, поскольку каждый из них имеет свои преимущества и недостатки. Однако, в качестве окончательного проектного решения для записей были выбраны именованные кортежи.

Этот выбор большей частью основан на ключевых идеях системы типов Java, известной как номинальная типизация (nominal typing), которая заключается в том, что каждая единица хранения в Java (переменная, поле) имеет определенный тип и что каждый тип имеет имя, которое должно быть значимым для человека.

Даже в случае с анонимными классами типы все еще имеют имена. Компилятор назначает имена, но они не являются действительными именами для типов в Java (но они действительны внутри виртуальной машины), например:

jshell> var o = new Object() {
   ...>   public void bar() { System.out.println("bar!"); }
   ...> }
o ==> $0@37f8bb67

jshell> var o2 = new Object() {
   ...>   public void bar() { System.out.println("bar!"); }
   ...> }
o2 ==> $1@31cefde0

jshell> o = o2;
|  Error:
|  incompatible types: $1 cannot be converted to $0
|  o = o2;
|      ^^
Обратите внимание, что даже несмотря на то, что анонимные классы были определены тем же самым способом, компилятор по прежнему создает два различных анонимных класса, $ 0 и $ 1, и не разрешает присваивание, поскольку в системе типов в Java эти переменные имеют различные типы.

Было бы серьезным изменением, если бы записи порвали с наследием Java и принесли бы структурную типизацию для записей. (Существуют другие (не Java) языки, где общий состав класса (например, какие поля и методы он имеет) может использоваться в качестве типа (а не явного имени типа) — это называется структурной типизацией.) Как результат, выбранный вариант — «записи являются номинальными кортежами», означает, что вы должны ожидать, что записи будут работать лучше, когда вы сможете использовать кортежи на других языках. Это включает такие варианты использования как составные ключи карт или имитация многократного возврата из метода. Пример составного ключа карты может выглядеть так:

record OrderPartition(CurrencyPair pair, Side side) {}
Кстати, записи не обязательно будут работать как хорошая замена существующего кода, который использует JavaBeans. Для этого есть несколько причин: JavaBeans являются изменяемыми (mutable), тогда как записи — нет, и у них есть различные соглашения для их методов доступа.

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

Хорошее практическое правило заключается в следующем: чем более заманчиво желание добавить дополнительные методы к базовому носителю данных (или заставить его реализовать интерфейс), тем более вероятно, что следует использовать полноценный класс, а не запись.
Компактные конструкторы
Одним из важных возможных исключений из этого правила является использование компактных конструкторов, которые описаны следующим образом в спецификации Java:
Целью объявления компактного конструктора является то, что в теле канонического конструктора должен быть указан только код проверки и/или нормализации; оставшийся код инициализации предоставляется компилятором.
Например, вы можете проверить заказы, чтобы убедиться, что они не покупаются и не продаются в отрицательном количестве, а также допустимость значения TTL:

public record FXOrder(int units, 
                      CurrencyPair pair, 
                      Side side, 
                      double price, 
                      LocalDateTime sentAt, 
                      int ttl) {
    public FXOrder {
        if (units < 1) {
            throw new IllegalArgumentException(
                "FXOrder units must be positive");
        }
        if (ttl < 0) {
            throw new IllegalArgumentException(
                "FXOrder TTL must be positive, or 0 for market orders");
        }
        if (price <= 0.0) {
            throw new IllegalArgumentException(
                "FXOrder price must be positive");
        }
    }
}
Компактный конструктор не приводит к тому, что компилятор генерирует отдельный конструктор. Вместо этого код, который вы указываете в компактном конструкторе, появляется как дополнительный код в начале канонического конструктора. Вам не нужно указывать назначение параметров конструктора полям, которые все еще генерируются автоматически и появляются в конструкторе обычным способом.

Одно преимущество, которое имеют записи в Java по сравнению с анонимными кортежами, существующими в других языках, заключается в том, что тело конструктора записи позволяет запускать код при создании записей. Это позволяет выполнять проверку (и генерировать исключения, если передано недопустимое состояние). Это было бы невозможно в чисто структурных кортежах.
Альтернативные конструкторы
Также, можно использовать некоторые статические фабричные методы в теле записи, например, чтобы обойти отсутствие значений параметров по умолчанию в Java. В примере торговли вы можете включить статическую фабрику, подобную этой, чтобы объявить быстрый способ создания заказов с параметрами по умолчанию:

public static FXOrder of(CurrencyPair pair, Side side, double price) {
        return new FXOrder(1, pair, side, price, 
                           LocalDateTime.now(), 1000);
}
Конечно, это также может быть объявлено как альтернативный конструктор. Вы должны выбрать, какой подход имеет смысл в каждом конкретном случае.

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

record OrderPartition(CurrencyPair pair, Side side) {
    public OrderPartition(FXOrder order) {
        this(order.pair(), order.side());
    }
}
Тип OrderPartition может быть легко использован в качестве ключа карты. Например, вы можете создать книгу заказов для использования в механизме сопоставления сделок:

public final class MatchingEngine {
    private final Map<OrderPartition, RankedOrderBook> 
      orderBooks = new TreeMap<>();

    public void addOrder(final FXOrder o) {
        orderBooks.get(new OrderPartition(o)).addAndRank(o);
        checkForCrosses(o.pair());
    }

    public void checkForCrosses(final CurrencyPair pair) {
        // Do any buy orders match with sells now?
    }

    // ...
}
Затем, когда получен новый заказ, метод addOrder() извлекает соответствующий раздел заказа (состоящий из кортежа валютной пары и операции — покупки/продажи) и использует его для добавления нового заказа в соответствующую книгу заказов с ценовой категорией. Новый заказ может совпадать с уже существующими заказами в книгах (что называется «пересечением» заказов), поэтому мне нужно проверить, соответствует ли он методу checkForCrosses().

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

Однако, как и для изолированных типов (sealed types), некоторые из наиболее важных вариантов использования записей появятся в будущем. Сопоставление с образцом и, в частности, шаблоны деконструкции, которые позволяют разбить запись на ее компоненты, показывают большие надежды и могут изменить способ, которым многие разработчики программируют на Java. Комбинация изолированных типов (sealed types) и записей также предоставляют для Java вариант функциональности языка, известный как алгебраические типы данных.

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

В то же время вы всегда должны помнить, что до тех пор, пока не будет выпущена окончательная версия Java, содержащая определенный функционал языка, вы не должны полагаться на него. Говоря о возможных будущих функционалах, как я уже писал в этой статье, всегда следует понимать, что этот функционал обсуждается только в целях исследования.
Автор: Бен Эванс
Бен Эванс (@kittylyst) — Java чемпион и главный инженер New Relic. Он является автором пяти книг по программированию, в том числе недавно изданной «Optimizing Java» (O`Reilly). Ранее он был основателем jClarity (приобретена Microsoft) и членом исполнительного комитета Java SE/EE.
Оригинал статьи «Records Come to Java»
Оцените статью, если она вам понравилась!