Урок 4 из 11 базовый около 30 мин

Records: неизменяемые данные без шаблонного кода

Объявляем запись одной строкой, получаем конструктор, equals и toString бесплатно и добавляем проверки в компактном конструкторе.

Зачем нужны записи

Большая часть классов в прикладных программах — это просто контейнеры данных: точка, адрес, строка отчёта, ответ сервера. Для каждого такого класса в старой Java приходилось писать поля, конструктор, геттеры, equals, hashCode и toString, то есть сорок строк ради двух полей. Запись (record, стандарт с Java 16) описывает то же самое одной строкой и гарантирует, что данные не изменятся после создания.

record Point(int x, int y) {}

var p = new Point(3, 4);
System.out.println(p.x());        // 3
System.out.println(p);            // Point[x=3, y=4]
System.out.println(p.equals(new Point(3, 4))); // true

Компилятор сам создаёт канонический конструктор с параметрами в порядке объявления, методы доступа с именами компонентов без приставки get, а также equals, hashCode и toString, основанные на всех компонентах.

Что запись гарантирует и чего не гарантирует

  • Все компоненты — final-поля: у записи нет сеттеров, и она не может наследоваться от другого класса.
  • Сама запись final: расширить её тоже нельзя, но она может реализовывать интерфейсы.
  • Неизменяемость поверхностная: если компонент — изменяемый список, его содержимое по-прежнему можно поменять снаружи. Об этом ниже.
  • Две записи равны, когда равны все компоненты. Ссылочное сравнение == по-прежнему сравнивает адреса.

Компактный конструктор: проверки и нормализация

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

record Money(long kopecks, String currency) {
    Money {
        if (kopecks < 0) {
            throw new IllegalArgumentException("Сумма не может быть отрицательной");
        }
        currency = currency.strip().toUpperCase();   // нормализация до присваивания
    }

    static Money rub(long rubles) {
        return new Money(rubles * 100, "RUB");
    }

    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Разные валюты");
        }
        return new Money(kopecks + other.kopecks, currency);
    }
}

var total = Money.rub(150).plus(Money.rub(49));
System.out.println(total);   // Money[kopecks=19900, currency=RUB]

Этот пример не проверить в JShell

Оболочка JShell из JDK 21 не умеет объявлять записи с компактным конструктором: она отвечает invalid canonical constructor in record Money (attempting to assign stronger access privileges; was public). Это ограничение самой оболочки, а не ошибка в коде — сохраните пример в файл и запустите командой java Money.java. Записи без компактного конструктора JShell обрабатывает нормально.

Запись может содержать статические фабричные методы и обычные методы экземпляра. Изменить состояние они не могут, поэтому «модифицирующие» операции возвращают новую запись, как plus в примере.

Защита изменяемых компонентов

Если компонент — коллекция, сделайте в компактном конструкторе защитную копию. List.copyOf создаёт неизменяемый список и отвергает null-элементы:

record Group(String name, java.util.List<String> members) {
    Group {
        members = java.util.List.copyOf(members);
    }
}

var source = new java.util.ArrayList<>(java.util.List.of("Анна", "Борис"));
var group = new Group("Первая", source);
source.add("Вера");
System.out.println(group.members().size());   // 2 — копия не изменилась

Записи как ключи и элементы множеств

Благодаря корректным equals и hashCode записи безопасно использовать в HashMap и HashSet. Это избавляет от классической ошибки, когда самодельный класс без hashCode «теряется» в множестве:

record Cell(int row, int column) {}

var visited = new java.util.HashSet<Cell>();
visited.add(new Cell(1, 1));
System.out.println(visited.contains(new Cell(1, 1)));   // true

Локальные и вложенные записи

Запись можно объявить прямо внутри метода, чтобы временно связать несколько значений. Это удобнее, чем массив или пара из библиотеки:

static String longestWord(java.util.List<String> words) {
    record Scored(String word, int length) {}
    return words.stream()
            .map(w -> new Scored(w, w.length()))
            .max(java.util.Comparator.comparingInt(Scored::length))
            .map(Scored::word)
            .orElse("");
}

Локальные записи неявно статические: они не видят переменные окружающего метода и не захватывают this.

Когда запись не подходит

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

Записи и сериализация в JSON

Современные библиотеки (Jackson, Gson последних версий, встроенные средства фреймворков) создают записи из JSON и обратно без дополнительных аннотаций: имена компонентов совпадают с ключами. Это ещё одна причина описывать структуры запросов и ответов именно записями.

Попробуйте сами

  1. Объявите запись Temperature(double celsius) с компактным конструктором, который запрещает значения ниже абсолютного нуля (−273,15 °C), и методом fahrenheit().
  2. Создайте запись Student(String name, java.util.List<Integer> grades) с защитной копией списка и методом average(). Проверьте, что изменение исходного списка не влияет на запись.
  3. Положите десять записей Cell с повторяющимися координатами в HashSet и объясните, почему размер множества меньше десяти.