$ sudo teach IT
МОДУЛЬ 9 · УРОК 2

Records — Компактные классы данных

Начиная с Java 16, records позволяют создавать компактные классы для хранения данных. Автоматические конструкторы, методы, валидация — всё из коробки

~30 минут Для новичков Java 16+

Проблема: шаблонный код

Представьте, что вы создаёте класс для хранения данных о человеке: имя, возраст, email. Что вам нужно написать?

public class Person {
    private final String name;
    private final int age;
    private final String email;

    public Person(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age &&
               Objects.equals(name, person.name) &&
               Objects.equals(email, person.email);
    }

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

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age +
               ", email='" + email + "'}";
    }
}

Это 40+ строк шаблонного кода, который можно было бы сократить. Каждый раз, когда вы создаёте класс данных, вы пишете одно и то же: конструктор, геттеры, equals, hashCode, toString. Это утомительно и подвержено ошибкам.

Решение: record

С помощью record тот же класс записывается в одну строку:

public record Person(String name, int age, String email) {
}

Вот и всё! Компилятор автоматически создаёт:

  • Приватные final-поля для каждого компонента
  • Конструктор со всеми параметрами (canonical constructor)
  • Публичные методы-геттеры для каждого поля
  • Метод equals() для сравнения по значениям
  • Метод hashCode() для хеширования по значениям
  • Метод toString() для красивого вывода

Давайте попробуем:

public class PersonDemo {
    public static void main(String[] args) {
        Person alice = new Person("Алиса", 25, "alice@mail.ru");
        Person bob = new Person("Боб", 30, "bob@mail.ru");
        Person alice2 = new Person("Алиса", 25, "alice@mail.ru");

        System.out.println(alice);

        System.out.println("Имя: " + alice.name());
        System.out.println("Возраст: " + alice.age());
        System.out.println("Email: " + alice.email());

        System.out.println("alice.equals(bob): " + alice.equals(bob));
        System.out.println("alice.equals(alice2): " + alice.equals(alice2));

        System.out.println("hashCode alice: " + alice.hashCode());
        System.out.println("hashCode alice2: " + alice2.hashCode());
    }
}

Вывод программы:

Person[name=Алиса, age=25, email=alice@mail.ru]
Имя: Алиса
Возраст: 25
Email: alice@mail.ru
alice.equals(bob): false
alice.equals(alice2): true
hashCode alice: -1865219972
hashCode alice2: -1865219972

Как видите, equals() сравнивает по значениям, hashCode() вычисляется одинаково для одинаковых данных, а toString() показывает все поля.

Аналогия: Record — это как бланк анкеты. Вы заполняете поля (имя, возраст, email), и система автоматически формирует карточку с вашими данными, помечает её как прочитанную и создаёт копию. Всё это происходит без вашего участия.

Именованные компоненты

В record каждый элемент в скобках называется компонентом. Имена компонентов становятся именами методов-геттеров:

public record Point(double x, double y) {
}

public class PointDemo {
    public static void main(String[] args) {
        Point p = new Point(3.0, 4.0);
        System.out.println(p);
        System.out.println("X: " + p.x());
        System.out.println("Y: " + p.y());
    }
}

Вывод программы:

Point[x=3.0, y=4.0]
X: 3.0
Y: 4.0

Важно: Методы-геттеры в record называются без префикса get. То есть name(), а не getName(). Это сделано для компактности.

Иммутабельность

Record создаёт неизменяемый (immutable) объект. Все поля — private final. Это значит:

  • Нельзя изменить значение после создания
  • Объекты безопасно передавать между потоками
  • Идеально подходят как ключи в Map
public record Color(String name, int red, int green, int blue) {
}

public class ImmutableDemo {
    public static void main(String[] args) {
        Color red = new Color("Красный", 255, 0, 0);
        System.out.println(red);

        // red.name = "Новый";  // ОШИБКА КОМПИЛЯЦИИ!
        // поля private final, изменить нельзя

        System.out.println("Имя: " + red.name());
        System.out.println("R: " + red.red());
        System.out.println("G: " + red.green());
        System.out.println("B: " + red.blue());
    }
}

Вывод программы:

Color[name=Красный, red=255, green=0, blue=0]
Имя: Красный
R: 255
G: 0
B: 0

Канонический конструктор

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

public record Age(int value) {
    public Age {
        if (value < 0 || value > 150) {
            throw new IllegalArgumentException(
                "Возраст должен быть от 0 до 150, получено: " + value
            );
        }
    }
}

public class AgeDemo {
    public static void main(String[] args) {
        Age validAge = new Age(25);
        System.out.println("Создан возраст: " + validAge);

        try {
            Age invalidAge = new Age(-5);
        } catch (IllegalArgumentException e) {
            System.out.println("Ошибка: " + e.getMessage());
        }

        try {
            Age tooOld = new Age(200);
        } catch (IllegalArgumentException e) {
            System.out.println("Ошибка: " + e.getMessage());
        }
    }
}

Вывод программы:

Создан возраст: Age[value=25]
Ошибка: Возраст должен быть от 0 до 150, получено: -5
Ошибка: Возраст должен быть от 0 до 150, получено: 200

Синтаксис Compact Constructor выглядит так:

public record Email(String value) {
    public Email {
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException(
                "Некорректный email: " + value
            );
        }
        value = value.toLowerCase();
    }
}

public class EmailDemo {
    public static void main(String[] args) {
        Email email = new Email("User@Mail.RU");
        System.out.println(email);
        System.out.println("Email: " + email.value());
    }
}

Вывод программы:

Email[value=user@mail.ru]
Email: user@mail.ru

Как видите, в compact constructor можно:

  • Валидировать входные данные
  • Нормализовать данные (привести к нижнему регистру)
  • Выбрасывать исключения при некорректных данных

Важно: В compact constructor нельзя обращаться к полям через this.name — они ещё не инициализированы! Используйте просто имена параметров.

Дополнительные методы

В record можно добавлять свои методы, статические методы и константы:

public record Point(double x, double y) {

    public static final Point ORIGIN = new Point(0, 0);

    public double distanceTo(Point other) {
        double dx = this.x - other.x;
        double dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }

    public Point translate(double dx, double dy) {
        return new Point(x + dx, y + dy);
    }

    public double distanceFromOrigin() {
        return distanceTo(ORIGIN);
    }
}

public class PointMethodsDemo {
    public static void main(String[] args) {
        Point p1 = new Point(3, 4);
        Point p2 = new Point(6, 8);

        System.out.println(p1);
        System.out.println(p2);
        System.out.println("Расстояние: " + p1.distanceTo(p2));
        System.out.println("От начала: " + p1.distanceFromOrigin());

        Point moved = p1.translate(10, 5);
        System.out.println("После сдвига: " + moved);
    }
}

Вывод программы:

Point[x=3.0, y=4.0]
Point[x=6.0, y=8.0]
Расстояние: 5.0
От начала: 5.0
После сдвига: Point[x=13.0, y=9.0]

Обратите внимание, что метод translate() возвращает новый объект, а не изменяет существующий — это принцип иммутабельности.

Переопределение методов

Вы можете переопределить автоматические методы toString(), equals() и hashCode():

public record Money(double amount, String currency) {

    @Override
    public String toString() {
        return String.format("%.2f %s", amount, currency);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money other)) return false;
        return Double.compare(amount, other.amount) == 0
            && currency.equals(other.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                "Нельзя складывать разные валюты"
            );
        }
        return new Money(amount + other.amount, currency);
    }
}

public class MoneyDemo {
    public static void main(String[] args) {
        Money price1 = new Money(100.50, "RUB");
        Money price2 = new Money(50.25, "RUB");
        Money price3 = new Money(10.00, "USD");

        System.out.println(price1);
        System.out.println(price2);
        System.out.println("Сумма: " + price1.add(price2));

        System.out.println("Равны? " + price1.equals(price2));

        try {
            price1.add(price3);
        } catch (IllegalArgumentException e) {
            System.out.println("Ошибка: " + e.getMessage());
        }
    }
}

Вывод программы:

100.50 RUB
50.25 RUB
Сумма: 150.75 RUB
Равны? false
Ошибка: Нельзя складывать разные валюты

Record и интерфейсы

Record может реализовывать интерфейсы:

public interface Named {
    String name();
}

public interface Describable {
    String description();
}

public record Product(String name, double price, String category)
    implements Named, Describable {

    @Override
    public String description() {
        return name + " (" + category + ")";
    }
}

public class ProductDemo {
    public static void printNamed(Named n) {
        System.out.println("Название: " + n.name());
    }

    public static void printDescription(Describable d) {
        System.out.println("Описание: " + d.description());
    }

    public static void main(String[] args) {
        Product laptop = new Product("Ноутбук", 75000, "Электроника");

        printNamed(laptop);
        printDescription(laptop);
        System.out.println(laptop);
    }
}

Вывод программы:

Название: Ноутбук
Описание: Ноутбук (Электроника)
Product[name=Ноутбук, price=75000.0, category=Электроника]

Record и наследование

Record не может наследоваться от другого класса (он неявно наследует java.lang.Record). Однако record может:

  • Реализовывать интерфейсы
  • Быть наследником (другой класс не может наследовать от record)
  • Быть泛型ным (с параметрами типов)
public record Pair<A, B>(A first, B second) {
}

public class GenericRecordDemo {
    public static void main(String[] args) {
        Pair<String, Integer> nameAge = new Pair<>("Алиса", 25);
        Pair<Integer, Boolean> idActive = new Pair<>(42, true);
        Pair<String, String> keyVal = new Pair<>("город", "Москва");

        System.out.println(nameAge);
        System.out.println(idActive);
        System.out.println(keyVal);
    }
}

Вывод программы:

Pair[first=Алиса, second=25]
Pair[first=42, second=true]
Pair[first=город, second=Москва]

Аналогия: Record — это как шаблон рецепта. Вы заполняете ингредиенты (компоненты), и повар (компилятор) автоматически готовит блюдо с правильными пропорциями, помечает его и кладёт в меню. Но менять рецепт после приготовления нельзя.

Зачем нужны Records?

Records идеально подходят для:

  • Передачи данных между методами (DTO — Data Transfer Objects)
  • Хранения результатов вычислений
  • Кортежей (пар, троек и т.д.)
  • Ключей в Map и элементов Set (благодаря автоматическому equals/hashCode)
  • Возвращаемых значений из методов
  • Записей из базы данных

Практический пример: координаты

Давайте создадим систему координат с полезными методами:

public record GeoPoint(double latitude, double longitude) {
    private static final double EARTH_RADIUS = 6371.0;

    public GeoPoint {
        if (latitude < -90 || latitude > 90) {
            throw new IllegalArgumentException(
                "Широта должна быть от -90 до 90"
            );
        }
        if (longitude < -180 || longitude > 180) {
            throw new IllegalArgumentException(
                "Долгота должна быть от -180 до 180"
            );
        }
    }

    public double distanceTo(GeoPoint other) {
        double lat1 = Math.toRadians(latitude);
        double lat2 = Math.toRadians(other.latitude);
        double dLat = Math.toRadians(other.latitude - latitude);
        double dLon = Math.toRadians(other.longitude - longitude);

        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                  Math.cos(lat1) * Math.cos(lat2) *
                  Math.sin(dLon / 2) * Math.sin(dLon / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

        return EARTH_RADIUS * c;
    }

    public String toDMSString() {
        return String.format("%.4f°, %.4f°", latitude, longitude);
    }
}

public class GeoPointDemo {
    public static void main(String[] args) {
        GeoPoint moscow = new GeoPoint(55.7558, 37.6173);
        GeoPoint spb = new GeoPoint(59.9343, 30.3351);
        GeoPoint paris = new GeoPoint(48.8566, 2.3522);

        System.out.println("Москва: " + moscow.toDMSString());
        System.out.println("СПб: " + spb.toDMSString());
        System.out.println("Париж: " + paris.toDMSString());

        System.out.printf("Расстояние Москва-СПб: %.1f км%n",
            moscow.distanceTo(spb));
        System.out.printf("Расстояние Москва-Париж: %.1f км%n",
            moscow.distanceTo(paris));
    }
}

Вывод программы:

Москва: 55.7558°, 37.6173°
СПб: 59.9343°, 30.3351°
Париж: 48.8566°, 2.3522°
Расстояние Москва-СПб: 668.8 км
Расстояние Москва-Париж: 2491.3 км

Record vs Class

Когда использовать record, а когда обычный класс?

Характеристика Record Class
Изменяемость Неизменяемый Любая
Наследование Нет Да
equals/hashCode/toString Автоматически Вручную
Количество строк 1-5 30-100+
Когда использовать Простые структуры данных Сложная логика, состояние

Практический пример: результат вычислений

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

public record CalculationResult(
    double value,
    boolean success,
    String message
) {
    public static CalculationResult ok(double value) {
        return new CalculationResult(value, true, "Успешно");
    }

    public static CalculationResult error(String message) {
        return new CalculationResult(0, false, message);
    }
}

public class CalculationDemo {
    public static CalculationResult divide(double a, double b) {
        if (b == 0) {
            return CalculationResult.error("Деление на ноль");
        }
        return CalculationResult.ok(a / b);
    }

    public static void main(String[] args) {
        CalculationResult r1 = divide(10, 3);
        CalculationResult r2 = divide(10, 0);
        CalculationResult r3 = divide(100, 5);

        System.out.println("10/3: " + r1);
        System.out.println("10/0: " + r2);
        System.out.println("100/5: " + r3);

        if (r1.success()) {
            System.out.println("Результат: " + r1.value());
        }
    }
}

Вывод программы:

10/3: CalculationResult[value=3.3333333333333335, success=true, message=Успешно]
10/0: CalculationResult[value=0.0, success=false, message=Деление на ноль]
100/5: CalculationResult[value=20.0, success=true, message=Успешно]
Результат: 3.3333333333333335

Record в коллекциях

Благодаря автоматическому equals() и hashCode(), record отлично работает в коллекциях:

import java.util.HashSet;
import java.util.Set;
import java.util.HashMap;
import java.util.Map;

public record PersonId(String firstName, String lastName) {
}

public class RecordInCollections {
    public static void main(String[] args) {
        Set<PersonId> students = new HashSet<>();
        students.add(new PersonId("Иван", "Петров"));
        students.add(new PersonId("Мария", "Сидорова"));
        students.add(new PersonId("Иван", "Петров"));  // дубликат!

        System.out.println("Студентов: " + students.size());

        Map<PersonId, Double> grades = new HashMap<>();
        grades.put(new PersonId("Иван", "Петров"), 5.0);
        grades.put(new PersonId("Мария", "Сидорова"), 4.5);

        for (Map.Entry<PersonId, Double> entry : grades.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

Вывод программы:

Студентов: 2
PersonId[firstName=Мария, lastName=Сидорова]: 4.5
PersonId[firstName=Иван, lastName=Петров]: 5.0

Обратите внимание: несмотря на то что мы дважды добавили одного и того же студента, в Set он попал только один раз — благодаря автоматическому equals().

Заключение

Итоги урока

  • Record — компактный способ создания классов для хранения данных
  • Автоматически создаёт: конструктор, геттеры, equals, hashCode, toString
  • Поля всегда private final — объект неизменяемый
  • Compact Constructor позволяет валидировать и нормализовать данные
  • Record может реализовывать интерфейсы, но не наследовать классы
  • Record может быть泛型ным (параметризованным типом)
  • Идеально подходит для DTO, кортежей, результатов вычислений
  • Отлично работает в коллекциях благодаря автоматическому equals/hashCode
  • С Java 16+

Следующий урок: Sealed Classes (Java 17+) →

Тест: Records

5 вопросов

Record Person

Premium