1. Problem „klas DTO”: po co nam rekordy?
Bądźmy szczerzy: ile razy pisaliście taką klasę?
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 + '}';
}
}
Niby nic trudnego, ale policzcie linie! A teraz wyobraźcie sobie, że macie 10 takich klas, a w każdej po 5–6 pól. Nawet IDE męczy się generowaniem tego szablonowego kodu. A jeśli postanowicie dodać nowe pole — trzeba będzie poprawić konstruktor, getter, equals, hashCode, toString... Nuda, rutyna i źródło błędów.
Takie klasy nazywa się DTO (Data Transfer Object) lub Value Object. One po prostu przechowują dane — i tyle. Jednak przez szablonowy kod trudno je utrzymywać.
Jeśli wydaje wam się, że to nie problem, poczekajcie, aż będziecie musieli zmienić 50 takich klas naraz. Wtedy z wyjątkowym ciepłem przypomnicie sobie o rekordach!
2. Wprowadzenie do record: składnia i magia Java 16+
Od Javy 16 wszystko się zmieniło. Pojawił się nowy rodzaj klas — record. Zostały stworzone specjalnie na przypadki, gdy trzeba po prostu przechowywać zestaw danych. Składnia jest niemal jak krotka w innych językach.
Jak zadeklarować record?
public record Point(int x, int y) { }
I... to wszystko! Właśnie utworzyliście niezmienną klasę z dwoma polami, konstruktorem, getterami, equals, hashCode i toString. Bez zbędnego klepania.
Co Java robi „pod maską”?
- Pola x i y stają się private final.
- Gettery tworzą się automatycznie: int x() i int y().
- Konstruktor: public Point(int x, int y).
- equals/hashCode: porównują wszystkie pola po wartości.
- toString: zwraca łańcuch w postaci "Point[x=1, y=2]".
Można powiedzieć, że record to „DTO na sterydach”: mniej kodu, więcej gwarancji, mniej błędów.
Niezmienność (immutability)
Wszystkie pola rekordu są automatycznie final. Po utworzeniu obiektu nie można go zmienić — gwarantuje to kompilator.
Jeśli spróbujecie dodać setter lub zrobić pole nie-final — kompilator was zatrzyma. Taka troska o wasz spokój rzadko się zdarza!
3. Przykład użycia klasy record
Zwykła klasa (dużo kodu):
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 ...
}
Klasa record (jedna linijka!):
public record Client(String name, int id) { }
Użycie:
public class Main {
public static void main(String[] args) {
Client client = new Client("Ivan", 123);
System.out.println(client.name()); // Ivan
System.out.println(client.id()); // 123
System.out.println(client); // Client[name=Ivan, id=123]
}
}
Zwróć uwagę:
- Metody dostępu do pól nazywają się tak samo jak pola: name(), id().
- Nie ma setName() ani setId() — obiektu nie da się zmienić po utworzeniu.
4. Zalety rekordów: mniej kodu, mniej błędów
Mniej kodu — więcej szczęścia
Po co pisać 40 linii, skoro można jedną? Rekordy oszczędzają czas i nerwy, zwłaszcza w dużych projektach, gdzie jest dużo DTO i obiektów wartości.
Niezmienność „z kontraktu”
- Rekordy są zawsze final i niezmienne.
- Obiektu nie da się podrobić ani przypadkowo zmienić.
- Brak „dziwnych” błędów wynikających ze zmiany stanu obiektu w nieoczekiwanym miejscu.
- Można bezpiecznie używać w programach wielowątkowych (jeśli wszystkie pola też są immutable).
Automatyczna generacja equals/hashCode/toString
Nie trzeba ręcznie pisać metod porównania, obliczania hash i ładnego wypisu. Wszystko dzieje się automatycznie i poprawnie.
Client c1 = new Client("Anna", 42);
Client c2 = new Client("Anna", 42);
System.out.println(c1.equals(c2)); // true
System.out.println(c1.hashCode() == c2.hashCode()); // true
System.out.println(c1); // Client[name=Anna, id=42]
Idealne do kolekcji i kluczy
Obiekty record można używać jako klucze w HashMap, elementy w HashSet itd. — wszystko będzie działać poprawnie, ponieważ equals i hashCode uwzględniają wszystkie pola.
import java.util.HashMap;
import java.util.Map;
Map<Client, String> clients = new HashMap<>();
clients.put(new Client("Anna", 42), "VIP");
System.out.println(clients.get(new Client("Anna", 42))); // VIP
Jawny opis danych
Składnia rekordu od razu pokazuje, jakie dane są przechowywane i że obiekt jest niezmienny. To sprawia, że kod jest bardziej zrozumiały dla innych programistów (i dla was za pół roku).
5. Tabela: porównanie zwykłej klasy i rekordu
| Zwykła klasa | Klasa record | |
|---|---|---|
| Składnia | Dużo kodu | Jedna linijka |
| Niezmienność | Trzeba napisać jawnie | Gwarantowana przez kompilator |
| Autogeneracja metod | Nie | Tak (equals, hashCode, toString) |
| Można dodać pola | Tak | Tylko komponenty rekordu |
| Dziedziczenie | Można dziedziczyć | Zawsze final, dziedziczenie niedozwolone |
| Użycie w kolekcjach | Trzeba poprawnie zaimplementować metody | Działa „od razu z pudełka” |
6. Rozwijamy aplikację edukacyjną: przykład z rekordem
Załóżmy, że w waszej ćwiczebnej aplikacji bankowej trzeba przechowywać operacje na koncie: datę, kwotę, typ operacji (np. „wpłata” lub „wypłata”).
Przed Javą 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 ...
}
Od Javy 16 i record:
import java.time.LocalDate;
public record Transaction(LocalDate date, double amount, String type) { }
Użycie:
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. Wizualizacja: co generuje record
Spójrzmy na „rozłożony” rekord (mniej więcej to, co wygeneruje kompilator):
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) { /* porównanie po polach */ }
@Override
public int hashCode() { /* obliczanie na podstawie pól */ }
@Override
public String toString() { /* ładny wydruk */ }
}
W skrócie: kiedy warto używać record
- Gdy potrzebny jest niezmienny obiekt z zestawem danych.
- Gdy potrzebne są „uczciwe” equals/hashCode/toString bez ręcznego pisania.
- Gdy tworzycie DTO, Value Object, pary, trójki, kolor, punkt, zakres, klucz do kolekcji itp.
8. Typowe błędy przy pracy z rekordami
Błąd nr 1: próba dodania settera lub zmiany pola po utworzeniu.
Rekord nie pozwala zmieniać swoich pól. Jeśli spróbujecie dodać metodę w stylu setX(int x), kompilator od razu powie „niedozwolone”. Tak samo — jeśli spróbujecie zmienić pole bezpośrednio.
Błąd nr 2: próba dodania niestatycznego pola.
W rekordzie można deklarować tylko komponenty (pola wskazane w nawiasach po nazwie rekordu) oraz pola statyczne. Zwykłych niestatycznych pól dodawać nie wolno — kompilator na to nie pozwoli.
Błąd nr 3: używanie rekordu do logiki mutable.
Rekordy nie są przeznaczone do obiektów ze zmiennym stanem. Jeśli trzeba coś zmieniać po utworzeniu — użyjcie zwykłej klasy.
Błąd nr 4: zapomnienie, że rekord zawsze jest final.
Rekordu nie można dziedziczyć i nie można uczynić go nadklasą. Próba naruszenia tego ograniczenia zakończy się błędem kompilacji. Kluczowa wskazówka — nie próbujemy „rozszerzać” rekordu: został zaprojektowany jako kompletny, niezmienny typ.
Błąd nr 5: ignorowanie autogenerowanych metod.
Jeśli nadpisujecie equals, hashCode lub toString, bądźcie ostrożni — nie łamcie ich kontraktu, w przeciwnym razie kolekcje i porównania będą działać niepoprawnie.
GO TO FULL VERSION