1. Проблема "классов-DTO": зачем нам record-классы?
Давайте честно: сколько раз вы писали вот такой класс?
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point{" + "x=" + x + ", y=" + y + '}';
}
}
Вроде бы ничего сложного, но посчитайте строки! А теперь представьте, что у вас 10 таких классов, и в каждом по 5–6 полей. Даже IDE устает генерировать этот шаблонный код. А если вы решите добавить новое поле — придётся править конструктор, геттер, equals, hashCode, toString... Скука, рутина и источник ошибок.
Такие классы называют DTO (Data Transfer Object) или Value Object. Они просто хранят данные — и всё. Но из-за шаблонного кода поддерживать их тяжело.
Если вам кажется, что это не проблема, просто подождите, когда вам придётся менять 50 таких классов сразу. Тогда вы вспомните про record-классы с особой теплотой!
2. Введение в record: синтаксис и магия Java 16+
С Java 16 всё изменилось. Появился новый вид классов — record. Они специально созданы для случаев, когда нужно просто хранить набор данных. Синтаксис — почти как у кортежа в других языках.
Как объявить record?
public record Point(int x, int y) { }
И... всё! Вы только что создали неизменяемый класс с двумя полями, конструктором, геттерами, equals, hashCode и toString. Без лишней писанины.
Что делает Java "под капотом"?
- Поля x и y становятся private final.
- Геттеры создаются автоматически: int x() и int y().
- Конструктор: public Point(int x, int y).
- equals/hashCode: сравнивают все поля по значению.
- toString: возвращает строку вида "Point[x=1, y=2]".
Можно сказать, что record — это «DTO на стероидах»: меньше кода, больше гарантий, меньше багов.
Неизменяемость (immutability)
Все поля record-класса автоматически final. После создания объекта изменить его нельзя — это гарантирует компилятор.
Если вы попытаетесь добавить сеттер или сделать поле не final — компилятор вас остановит. Такая забота о вашем спокойствии встречается нечасто!
3. Пример использования record-класса
Обычный класс (много кода):
public class Client {
private final String name;
private final int id;
public Client(String name, int id) {
this.name = name;
this.id = id;
}
public String getName() { return name; }
public int getId() { return id; }
// equals, hashCode, toString ...
}
Record-класс (одна строка!):
public record Client(String name, int id) { }
Использование:
public class Main {
public static void main(String[] args) {
Client client = new Client("Иван", 123);
System.out.println(client.name()); // Иван
System.out.println(client.id()); // 123
System.out.println(client); // Client[name=Иван, id=123]
}
}
Обратите внимание:
- Методы доступа к полям называются так же, как поля: name(), id().
- Нет setName() или setId() — объект нельзя изменить после создания.
4. Преимущества record-классов: меньше кода, меньше ошибок
Меньше кода — больше счастья
Зачем писать 40 строк, если можно обойтись одной? Record-классы экономят время и нервы, особенно в больших проектах, где много DTO и value-объектов.
Неизменяемость "по контракту"
- Record-классы всегда final и неизменяемы.
- Объект нельзя подделать или случайно изменить.
- Нет «странных» багов из-за изменения состояния объекта в неожиданном месте.
- Можно безопасно использовать в многопоточных программах (если все поля тоже immutable).
Автоматическая генерация equals/hashCode/toString
Не нужно ручками писать методы сравнения, вычисления хэша и красивого вывода. Всё делается автоматически и правильно.
Client c1 = new Client("Анна", 42);
Client c2 = new Client("Анна", 42);
System.out.println(c1.equals(c2)); // true
System.out.println(c1.hashCode() == c2.hashCode()); // true
System.out.println(c1); // Client[name=Анна, id=42]
Идеально для коллекций и ключей
Record-объекты можно использовать как ключи в HashMap, элементы в HashSet и т.д. — всё будет работать корректно, потому что equals и hashCode учитывают все поля.
import java.util.HashMap;
import java.util.Map;
Map<Client, String> clients = new HashMap<>();
clients.put(new Client("Анна", 42), "VIP");
System.out.println(clients.get(new Client("Анна", 42))); // VIP
Явное описание данных
Синтаксис record-класса сразу показывает, какие данные хранятся и что объект неизменяемый. Это делает код понятнее для других разработчиков (и для вас через полгода).
5. Таблица: сравнение обычного класса и record-класса
| Обычный класс | Record-класс | |
|---|---|---|
| Синтаксис | Много кода | Одна строка |
| Неизменяемость | Нужно явно писать | Гарантирована компилятором |
| Автогенерация методов | Нет | Да (equals, hashCode, toString) |
| Можно добавить поля | Да | Только компоненты record |
| Наследование | Можно наследовать | Всегда final, наследовать нельзя |
| Использование в коллекциях | Нужно корректно реализовать методы | Работает «из коробки» |
6. Развиваем учебное приложение: пример с record-классом
Допустим, в вашем учебном банковском приложении нужно хранить операции по счету: дата, сумма, тип операции (например, «пополнение» или «снятие»).
До Java 16:
public class Transaction {
private final LocalDate date;
private final double amount;
private final String type;
public Transaction(LocalDate date, double amount, String type) {
this.date = date;
this.amount = amount;
this.type = type;
}
public LocalDate getDate() { return date; }
public double getAmount() { return amount; }
public String getType() { return type; }
// equals, hashCode, toString ...
}
С Java 16 и record:
import java.time.LocalDate;
public record Transaction(LocalDate date, double amount, String type) { }
Использование:
Transaction t = new Transaction(LocalDate.now(), 100.0, "deposit");
System.out.println(t); // Transaction[date=2024-06-01, amount=100.0, type=deposit]
System.out.println(t.amount()); // 100.0
7. Визуализация: что генерирует record
Посмотрим на «развёрнутый» record-класс (примерно, что сгенерирует компилятор):
public final class Point extends java.lang.Record {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) { /* сравнение по полям */ }
@Override
public int hashCode() { /* вычисление по полям */ }
@Override
public String toString() { /* красивый вывод */ }
}
Кратко: когда стоит использовать record
- Когда нужен неизменяемый объект с набором данных.
- Когда нужен «честный» equals/hashCode/toString без ручной писанины.
- Когда вы делаете DTO, Value Object, пары, тройки, цвет, точку, диапазон, ключ для коллекции и т.п.
8. Типичные ошибки при работе с record-классами
Ошибка №1: попытка добавить сеттер или изменить поле после создания.
Record-класс не позволяет изменять свои поля. Если вы попробуете добавить метод вроде setX(int x), компилятор сразу скажет «нельзя». То же самое — если попытаться изменить поле напрямую.
Ошибка №2: попытка добавить нестатическое поле.
В record-классе можно объявлять только компоненты (поля, указанные в скобках после имени record) и статические поля. Обычные нестатические поля добавлять нельзя — компилятор не даст это сделать.
Ошибка №3: использование record для mutable-логики.
Record-классы не предназначены для объектов с изменяемым состоянием. Если вам нужно что-то менять после создания — используйте обычный класс.
Ошибка №4: забыли, что record всегда final.
Record-класс нельзя наследовать и нельзя сделать его суперклассом. Попытка нарушить это ограничение приведёт к ошибке компиляции. Ключевой признак — не пытаемся «расширять» record: он задуман как законченный неизменяемый тип.
Ошибка №5: игнорирование автогенерируемых методов.
Если вы переопределяете equals, hashCode или toString, будьте осторожны — не нарушайте их контракт, иначе коллекции и сравнения будут работать некорректно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ