Если говорить о синтаксисе, записи (Records) кажутся лаконичным способом создания нового типа данных для хранения неизменяемых данных. Но как насчет его семантики? Другими словами, что означает термин «запись»? Каковы его особенности и ограничения? В каких местах лучше всего использовать записи, а в каких - не стоит даже думать о них?

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

Давайте начнем с нашего первого примера.

1. Лаконичный, чистый и простой способ определения доменных значений, также называемых объектами значений (VO)

С помощью записей вы можете представлять значение домена выразительным и лаконичным способом по сравнению с представлением того же значения домена с помощью обычного класса.

Прежде чем двигаться дальше, давайте быстро разберемся, что такое объект-значение. Представьте, что вы создаете приложение для студентов. Одно из их требований - иметь возможность просматривать набор цветов, скажем, 2, обычно называемый «палитрой из 2 цветов», представленный в виде печатных копий. Они часто используются, чтобы выбрать набор цветов для своих проектов. Вот пример того, как они их используют, идентифицируя каждый цвет с помощью названия (например, летний, зимний, золотой, пастельный и т.д.):

В этой ситуации «Палитра из 2 цветов» является объектом-значением — концепцией из модели домена (реальный бизнес или сценарий использования проблемы), которая представляет одно значение или набор значений. Цвета палитры редко меняются после печати; другими словами, они неизменны. На оборотной стороне каждой распечатанной страницы написан HEX-код, чтобы студенты могли использовать это значение для воссоздания того же цвета в своих онлайн-инструментах. Кроме того, они используют эти коды, чтобы удостовериться со своими товарищами по команде, что они используют одну и ту же цветовую палитру:

Мы можем инкапсулировать эту концепцию с помощью объекта значения, скажем, Palette2Colors, определив запись с компонентами name (типа String), color1 и color2 (типа java.awt.Color):

Вот предыдущий код для справки:

public record Palette2Colors(String name,
                             Color color1,
                             Color color2) {}

Запись — это выразительный способ объявить, что она инкапсулирует состояние с помощью 3 значений — namecolor1 и color2. Замысел записи может быть выражен всего одной строкой кода, которая фиксирует ее компоненты.

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

public class Palette2Colors {
   private final String name;
   private final Color color1;
   private final Color color2;

   public Palette2Colors(String name, Color color1, Color color2) {
       this.name = name;
       this.color1 = color1;
       this.color2 = color2;
   }

   public String name() {
       return name;
   }

   public Color color1() {
       return color1;
   }

   public Color color2() {
       return color2;
   }

   @Override
   public boolean equals(Object obj) {
       if (obj == this) return true;
       if (obj == null || obj.getClass() != this.getClass()) return false;
       var that = (Palette2Colors) obj;
       return Objects.equals(this.name, that.name) &&
               Objects.equals(this.color1, that.color1) &&
               Objects.equals(this.color2, that.color2);
   }

   @Override
   public int hashCode() {
       return Objects.hash(name, color1, color2);
   }
}

Давайте сравним оба эти определения. Лаконичный код записей снижает когнитивную нагрузку — вы можете выяснить, какие данные инкапсулирует Palette2Colors, прочитав всего несколько строк кода:

Не позволяйте лаконичности записей обмануть вас. За кулисами Java компилятор генерирует конструктор по умолчанию, методы-сеттеры для всех компонентов записи и реализации методов по умолчанию toString() и hashCode()equals().

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

Во-первых, существует разрыв между намерениями и соответствующими действиями. Несмотря на то, что каждый разработчик может это сделать, не все это делают. Записи устраняют эту неопределенность и обеспечивают ее на уровне языка, что лучше. Вы можете быть на 100% уверены, что если у вас есть запись, то у вас обязательно будут реализации всех этих методов. Возможно, вы не понимаете, но классы коллекций используют методы equals() и hashCode() для определения равенства ваших экземпляров. Следующий тест пройдет для записи Palette2Colors, но не пройдет, если преобразовать ее в класс и удалить методы hashCode() и equals():

public void testWorkingWithCollections() {
   var pastel = new Palette2Colors("Pastel", Color.decode("#EAC7C7"), Color.decode("#A0C3D2"));
   var random = new Palette2Colors("Pastel", Color.decode("#EAC7C7"), Color.decode("#A0C3D2"));

   List<palette2colors> palette = new ArrayList<>();
   palette.add(pastel);

   Assert.assertTrue(palette.contains(random));
}

Теперь давайте вернемся к нашей записи Palette2Colors. Нам нужно добавить проверки, чтобы убедиться, что ее компонентам передаются значения non-null. Компилятор Java создает реализацию конструктора по умолчанию для записи. Чтобы изменить это поведение, вы можете переопределить его.

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

Вот код конструктора для справки:

public Palette2Colors{
   Objects.requireNonNull(name, "Palette name shouldn't be null");
   Objects.requireNonNull(color1, "Palette's 1st color shouldn't be null");
   Objects.requireNonNull(color2, "Palette's 2nd color shouldn't be null");
}

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

Вот предыдущий код для справки:

public record Palette2Colors(String name,
                            Color color1,
                            Color color2) {
   public Palette2Colors(String name, Color color1, Color color2) {
       Objects.requireNonNull(name, "Palette name shouldn't be null");
       Objects.requireNonNull(color1, "Palette's 1st color shouldn't be null");
       Objects.requireNonNull(color2, "Palette's 2nd color shouldn't be null");
       this.name = name;
       this.color1 = color1;
       this.color2 = color2;
   }
}

Кроме того, у вас могут быть альтернативные способы создания экземпляра Palette2Colors, например, передача массива для color1 и color2 вместо передачи их как отдельных цветов. Когда вы создаете дополнительные конструкторы в записи, они должны вызывать канонические конструкторы:

Вот код для справки:

public Palette2Colors(String name, Color[] colors) {
   this(name,
        Objects.requireNonNull(colors,"Palette Colors shouldn't be null")[0],
        colors[1]);
}

В этом разделе я рассказала об объектах-значениях. Давайте поговорим о других подобных концепциях, таких как объекты передачи данных (DTO). Как следует из названия, DTO — это объекты, используемые для перемещения данных внутри или между приложениями, или по сети. Несколько вызовов для передачи отдельных компонентов данных менее эффективны, чем один вызов для передачи совокупности данных. Если вы не планируете изменять значения экземпляра DTO после его создания, вам хорошо подходят записи.

2. Простота определения выразительных моделей данных

Представьте себе приложение, в котором вы работаете с данными, загруженными в виде файлов CSV (Comma Separated Values — значения, разделенные запятыми), например, с данными о денежных операциях по нескольким банкам. Это приложение может использоваться, скажем, для определения общей суммы расходов по транзакциям NEFT на определенное имя счета или общей суммы наличных, снятых в определенную дату или время в определенном городе. Давайте посмотрим, как с помощью записей можно легко смоделировать эту бизнес-область.

Ниже приведен пример для различных видов транзакций, хранящихся в CSV-файле для банковских транзакций, таких как переводы NEFT, создание фиксированного депозита, считывание карты в магазине (Point-Of-Sale — точка продажи) и снятие наличных в банкомате (другие типы транзакций опущены для упрощения примера):

14-04-2022,NEFT/92665604/SU JOHN/ICICI/PrintFee,50000,0,52030.59
12-05-2022,TO For 920040032703660,100727,0,8263.23
24-07-2022,POS/THE DELHI COLLEGE/DELHI/240722/00:21,2200,0,2263.23
20-08-2022,ATM-CASH/DPRH114804/3092/200521/DELHI,,0,

Позвольте мне распределить приведенные данные по категориям в виде таблицы:

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

По своему опыту я видела (и писала) код, который извлекает описание из CSV-файлов и сохраняет его как строку в базе данных или в другом месте. Скажите честно, вы тоже так делали?

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

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

public record BankTransaction (LocalDate date,
                              TranDetails details,
                              Money debitAmt,
                              Money creditAmt,
                              Money accountBalance) { }

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

public interface TranDetails {}

Теперь давайте создадим записи, представляющие данные для разных видов транзакций — POS, NEFT, ATMCash, FD, следующим образом:

   record POS(
           String paidTo,
           String city,
           LocalDate date,
           LocalTime time
   ) implements TranDetails { }

   record NEFT(
           String referenceNo,
           String accountFrom,
           String accountTo,
           String desc
   ) implements TranDetails { }

   record ATMCash(
           String ATMCode,
           String transCode,
           LocalDate date,
           LocalTime time,
           String city
   ) implements TranDetails { }

   record FD(String AccountNumber) 
     implements TranDetails { }

Позвольте мне модифицировать предыдущий код, объявив интерфейс TransDetails как запечатанный интерфейс, который допускает реализацию записями POS, NEFT, ATMCash и FD. Определяя интерфейс (или класс) как запечатанный, вы как разработчик можете контролировать, каким другим классам разрешено его реализовывать. Это приводит к улучшению моделирования данных, поскольку ни один класс не может реализовать закрытый интерфейс без разрешения разработчика. Позже в этом разделе вы увидите, как запечатанные интерфейсы помогают в деконструкции записей:

sealed public interface TranDetails permits POS, NEFT, ATMCash, FD {}

record POS(String paidTo,
           String city,
           LocalDate date,
           LocalTime time) 
       implements TranDetails { }

record NEFT(String referenceNo,
            String accountFrom,
            String accountTo,
            String desc) 
       implements TranDetails { }

record ATMCash(String ATMCode,
               String transCode,
               LocalDate date,
               LocalTime time,
               String city) 
implements TranDetails { }

record FD(String AccountNumber) 
implements TranDetails { }

Когда вы сохраняете свои данные, используя правильные типы данных Java, вы можете расшифровать неверные или недопустимые данные и определить правила их обработки до того, как они будут переданы для обработки остальному коду вашего приложения. Я была свидетелем того, как данные, загруженные из одного и того же банка, произвольно меняли формат даты (ДД-ММ-ГГГ, ДД/ММ/ГГГ и ДД/ММ/ГГ). В одном из приложений я перехватила это изменение формата даты и создала правильные экземпляры LocalDate, прежде чем использовать CSV-данные в своем приложении.

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

Давайте вернемся к данным CSV, о которых мы говорили ранее:

14-04-2022,NEFT/92665604/SU JOHN/ICICI/PrintFee,50000,0,52030.59
12-05-2022,TO For 920040032703660,100727,0,8263.23
24-07-2022,POS/THE DELHI COLLEGE/DELHI/140421/00:21,2200,0,2263.23
20-08-2022,ATM-CASH/DPRH114804/3092/200521/DELHI,,0,

Record patterns (шаблоны записей), представленные в Java 19, упрощают деконструкцию записи (и вложенных записей), извлекая ее компоненты. Ниже приведен пример метода processTransaction, который принимает на вход объект типа транзакции и может переключать детали транзакции, выполняя для каждого случая свой метод:

void processTransaction(BankTransaction transaction) {
   Objects.requireNonNull(transaction);
   switch (transaction.tranDetails()) {
       case TranDetails.POS pos -> log(pos.paidTo());
       case TranDetails.FD fd -> logFD(fd.AccountNumber());
       case TranDetails.ATMCash atmCash -> traveledTo(atmCash.city());
       case TranDetails.NEFT (var referenceNo,
                              var from,
                              var to,
                              var desc) -> processNEFT(from.toUpperCase() + to.toUpperCase());
   }
}

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

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

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

{
    "Date": "14-04-2022",
    "Transaction": "NEFT/92665604/SU JOHN/ICICI/PrintFee",
    "Debit": "50000",
    "Credit": "0",
    "Balance": "52030.59"
}

А также следующим образом:

{
  "date": "14-04-2022",
  "transaction_details": {
    "type": "NEFT",
    "reference_number": "92665604",
    "beneficiary_name": "SU JOHN",
    "bank": "ICICI",
    "description": "PrintFee"
  },
  "debit_amount": 50000,
  "credit_amount": 0,
  "balance": 52030.59
}

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

  • Детали транзакции: тип + номер ссылки + имя_бенефициара + банк + описание Имя получателя (имя + фамилия),

  • Дата (день + месяц + год),

  • Суммы транзакций (валюта + сумма)?

3. Рефакторинг методов с помощью локальных записей

Часто вы рефакторите код, чтобы разделить проблемы внутри метода. Например, в следующем примере первые три строки кода используются для вычисления длины окружности и площади круга на основе радиуса, который передается в метод в качестве параметра. Последняя строка в этом методе выводит рассчитанные значения.

void processShape(double radius) {
   double PI = 3.14;
   double circumference = 2 * PI * radius;
   double area = PI * radius * radius;
   System.out.println("circumference: " + circumference + "; area: " + area);
}

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

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

4. Фреймворки или библиотеки, изменяющие состояния

Вы не можете использовать записи с фреймворками или библиотеками, такими как Hibernate, для определения классов сущностей JPA. Существует несоответствие между спецификациями JPA и неявным поведением записей.

Спецификации JPA предписывают, что класс сущности не может быть final, должен предоставлять конструктор по умолчанию, то есть без аргументов, должен определять как минимум один компонент и несколько других.

С другой стороны, записи неявно являются final классами и предназначены для представления неизменяемых данных. У записи нет конструктора без аргументов. Конструкторы без аргументов по умолчанию присваивают переменным экземпляра класса значения по умолчанию (обычно 0, 0.0 или null). Записи не имеет смысла присваивать такие значения по умолчанию, поскольку ее компоненты являются final; им нельзя присвоить другое значение, если для них выполняется конструктор без аргументов по умолчанию.

А как насчет других фреймворков или библиотек? Ответ на этот вопрос зависит от того, как они обрабатывают записи. Если они пытаются изменить состояние записи с помощью рефлексии, это не сработает. Рассмотрим следующую запись, скажем, MyPointAsRecord, которая определяет два компонента – xPos и yPos:

public record MyPointAsRecord(int xPos, int yPos) { }
}

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

void changeFinalForRecords() throws NoSuchFieldException, IllegalAccessException {
   final var myPointAsRecord = new MyPointAsRecord(12, 35);

   Field xPosField = myPointAsRecord.getClass().getDeclaredField("xPos");
   xPosField.setAccessible(true);

   xPosField.setInt(myPointAsRecord, 1000);
   System.out.println(myPointAsRecord);
}

Теперь сравните ту же запись, определенную как обычный класс, но с компонентами или переменными экземпляра, определенными как private и final члены.

public final class MyPointAsClass {
   private final int xPos;
   private final int yPos;

   public MyPointAsClass(int xPos, int yPos) {
       this.xPos = xPos;
       this.yPos = yPos;
   }

   public int xPos() {
       return xPos;
   }

   public int yPos() {
       return yPos;
   }

   @Override
   public boolean equals(Object obj) {
       if (obj == this) return true;
       if (obj == null || obj.getClass() != this.getClass()) return false;
       var that = (MyPointAsClass) obj;
       return this.xPos == that.xPos && this.yPos == that.yPos;
   }

   @Override
   public int hashCode() {
       return Objects.hash(xPos, yPos);
   }
}

В этом случае библиотека или фреймворк может иметь возможность изменить значение своего private и final поля с помощью рефлексии:

void changeFinalForNonRecords() throws NoSuchFieldException, IllegalAccessException {
   final var myPointAsClass = new MyPointAsClass(10, 20);

   Field xPosField = myPointAsClass.getClass().getDeclaredField("xPos");
   xPosField.setAccessible(true);

   xPosField.setInt(myPointAsClass, 1000);
   System.out.println(myPointAsClass);
}

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

5. Мощная и лаконичная обработка данных

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

sealed interface TwoDimensional {}
record Point (int x, int y) implements TwoDimensional { }
record Line    ( Point start,
                Point end) implements TwoDimensional { }
record Triangle( Point pointA,
                Point pointB,
                Point PointC) implements TwoDimensional { }
record Square  ( Point pointA,
                Point pointB,
                Point PointC,
                Point pointD) implements TwoDimensional { }

Следующий метод определяет рекурсивный процесс метода, который использует конструкцию switch для возврата суммы координат x и y для всех точек двумерной фигуры, такой как LineTriangle или Square:

static int process(TwoDimensional twoDim) {
  return switch (twoDim) {
      case Point(int x, int y) -> x + y;
      case Line(Point a, Point b) -> process(a) + process(b);
      case Triangle(Point a, Point b, Point c) ->
                                process(a) + process(b) + process(c);
      case Square(Point a, Point b, Point c, Point d) ->
                                process(a) + process(b) + process(c) + process(d);
  };
}

IntelliJ IDEA также отображает значок рекурсивного вызова в поле gutter для этого метода:

Резюме

В этой статье я рассмотрела не только синтаксис записей, но рассказала об их семантике и где их можно использовать на 5 практических примерах.

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

Комментарии (1)


  1. vzhilin
    24.04.2023 13:05
    +1

    Выглядит странным, что в посте, помеченном тегами records, sealed и pattern matching нет ни слова про алгебраические типы данных. Record это же просто product type, можно сказать что это tuple чьи компоненты проиндексированы именами полей. Sealed class это просто sum type.