Records — Компактные классы данных
Начиная с Java 16, records позволяют создавать компактные классы для хранения данных. Автоматические конструкторы, методы, валидация — всё из коробки
Проблема: шаблонный код
Представьте, что вы создаёте класс для хранения данных о человеке: имя, возраст, 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 вопросов