Rekordy (Records)

W Javie często tworzymy klasy, które są prostymi nośnikami informacji między różnymi warstwami aplikacji. Klasy takie zgodnie z różnymi konwencjami najczęściej są bardzo powtarzalne, mają prywatne pola, metody dostępowe, konstruktory oraz nadpisują metody takie jak equals() , hashCode() itoString() z klasy Object. Od Javy 14 dostępne są także tzw. rekordy (records), ktróre ułatwiają tworzenie takich typów.

Problem do rozwiązania

Każdy programista Javy zauważa w pewnym momencie, że w ramach aplikacji często tworzone są klasy, które są prostym nośnikiem informacji między warstwami aplikacji. Mam tu na myśli np. obiekty DTO, które pozwalają "przepchnąć" dane np. z serwisu do klasy z punktem krańcowym. Klasy takie najczęściej budowane są według schematu:

  1. tworzymy klasę,
  2. definiujemy pola,
  3. generujemy konstruktor,
  4. generujemy gettery i settery,
  5. nadpisujemy metody z klasy Object takie jak equals() , hashCode() i toString().

Co prawda większość z tych kroków możemy zautomatyzować i np. wygenerować przy pomocy IntelliJ IDEA:

intellij_generate setters

albo posłużyć się projektem Lombok, ale sam fakt konieczności wykonywania tak powtarzalnych czynności rodzi pytanie, czy na pewno nie da się tego zrobić jakoś lepiej? Tym bardziej, że w sytuacji, gdy np. zapomnimy o wygenerowaniu metody equals() , albo co gorsze nie zachowamy kontraktu między metodami equals() i hashCode() , to możemy doprowadzić do trudnych do wykrycia błędów, związanych np. ze sposobem działania struktur danych takich jak zbiór.

Jako punkt wyjścia do dalszej części artykułu, posłużmy się klasą, która reprezentuje kurs. Kurs ma identyfikator, nazwę, opis i cenę.

import java.math.BigDecimal;
import java.util.Objects;

class Course {
    private Long id;
    private String title;
    private String description;
    private BigDecimal price;

    public Course(Long id, String title, String description, BigDecimal price) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Course course = (Course) o;
        return Objects.equals(id, course.id) &&
                Objects.equals(title, course.title) &&
                Objects.equals(description, course.description) &&
                Objects.equals(price, course.price);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, title, description, price);
    }

    @Override
    public String toString() {
        return "Course{" +
                "id=" + id +
                ", title='" + title + '\'' +
                ", description='" + description + '\'' +
                ", price=" + price +
                '}';
    }
}

Jak widać kod takiej klasy jest bardzo długi, jak na fakt, że jedyne co faktycznie zapisujemy ręcznie to ten wycinek:

class Course {
    private Long id;
    private String title;
    private String description;
    private BigDecimal price;
//...
}

a całą resztę generujemy z pomocą środowiska.

Kurs Java

Deklarowanie rekordów

Od Javy 14 do języka dodano rekordy . Do Java 16 były to tzw. preview feature, więc funkcjonalność ta może jeszcze ulec zmianom w kolejnych wersjach Javy. W celu jej włączenia, przy kompilacji programu należało dodać dodatkową flagę

javac --release 14 --enable-preview NazwaKlasy.java

lub przestawić poziom języka projektu w IntelliJ IDEA. Od Javy 16 jest to pełnoprawna część języka i dodawanie flag nie jest już konieczne.

Co ciekawe w opisie rekordów czytamy, że głównym celem nie jest walka z powtarzającym się kodem (który może być łatwo wygenerowany), ale raczej wprowadzenie dodatkowego typu, który pozwoli odróżnić klasy posiadające stan i mogące zmieniać ten stan, od nośników informacji, które z definicji mają być niemodyfikowalne. Rekordy najprawdopodobniej będą się także rozwijały wraz z kolejnymi wersjami Javy i będzie można je stosować np. z dekonstrukcją obiektów i ulepszoną instrukcją switch.

W celu utworzenia rekordu w IntelliJ IDEA wybierz opcję New > Class , a po wpisaniu nazwy, wybierz z listy Record:

dodawanie rekordów intellij

Rekordy, podobnie jak klasy najwyższego rzędu, mogą mieć domyślny lub publiczny specyfikator dostępu, możemy też w nich dodawać importy i deklarację pakietów. W inny sposób definiujemy tu jednak pola. Wymieniamy je w dodatkowych okrągłych nawiasach, a nie w ciele wyznaczanym przez nawiasy klamrowe.

import java.math.BigDecimal;

record Course(Long id,
              String title,
              String description,
              BigDecimal price) { }

Powyżej zdefiniowany rekord będzie posiadał:

  • cztery prywatne pola finalne,
  • konstruktor z czterema parametrami,
  • metody dostępowe do pól,
  • nadpisaną metodę toString(),
  • nadpisane metody equals() i hashCode().

Rekordy reprezentują obiekty niemodyfikowalne, co oznacza, że raz ustawione, nie będą mogły być zmienione. Z tego powodu nie znajdziesz w nich setterów. Metody dostępowe to nie typowe gettery. Jeżeli chcesz odwołać się do jakiegoś pola, to robimy to przez metodę o nazwie identycznej z nazwą tego pola.

Przykładowo:

import java.math.BigDecimal;

class Test {
    public static void main(String[] args) {
        Course course = new Course(1L, "Java", "Super kurs java", BigDecimal.valueOf(999));
        BigDecimal coursePrice = course.price();
        System.out.println("Cena kursu: " + coursePrice);
    }
}

Na etapie kompilacji, rekord zamieniany jest na klasę finalną. Oznacza to, że nie można po nim dziedziczyć. Możesz się o tym przekonać korzystając z polecenia javap.

Rekord po kompilacji:

final class Course extends java.lang.Record {
  public Course(java.lang.Long, java.lang.String, java.lang.String, java.math.BigDecimal);
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.Long id();
  public java.lang.String title();
  public java.lang.String description();
  public java.math.BigDecimal price();
  static {};
}

Pola w rekordach

W ramach rekordu pola instancji musisz zadeklarować w okrągłych nawiasach. Próba dodania dodatkowego pola pomiędzy nawiasami klamrowymi spowoduje błąd kompilacji. Do rekordów możesz jednak dodawać stałe w standardowy sposób:

import java.math.BigDecimal;

record Course(Long id,
              String title,
              String description,
              BigDecimal price) {
    public static final BigDecimal DEFAULT_PRICE = BigDecimal.valueOf(100);
    //public int y = 5; //nie można dodawać pól instancji w ciele rekordu
}

Konstruktory w rekordach

W rekordzie będzie wygenerowany tzw. kompaktowy konstruktor , który pozwala ustawić wszystkie jego pola. Autorzy pomyśleli jednak o tym, że przecież w konstruktorze chcemy czasami przeprowadzić np. jakąś walidację. Dodatkowo w ramach rekordu możesz definiować własne konstruktory, ale pierwszą instrukcją, która się w nich pojawi, musi być wywołanie konstruktora kompaktowego. Działa to podobnie jak w przypadku tradycyjnych klas, gdzie pierwszą instrukcją w konstruktorze musi być wywołanie super(), czyli konstruktora z klasy nadrzędnej.

import java.math.BigDecimal;

record Course(Long id,
              String title,
              String description,
              BigDecimal price) {
    public static final BigDecimal DEFAULT_PRICE = BigDecimal.valueOf(100);

    //nadpisujemy konstruktor kompaktowy
    public Course {
        if (price.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Price have to be greater than 0");
    }

    //dodatkowy konstruktor bez ceny
    Course(Long id, String title, String description) {
        this(id, title, description, BigDecimal.ZERO);
    }
}

Zwróć uwagę na to jak wygląda kompaktowy konstruktor. Nie pojawiają się przy nim okrągłe nawiasy z parametrami i dodatkowo musi on być publiczny. Co prawda możesz samodzielnie dopisać okrągłe nawiasy z parametrami o nazwach identycznych z nazwami wcześniej zdefiniowanych pól, jednak nie jest to zalecane i dodatkowo kompilator zweryfikuje, czy na pewno każde z pól rekordu zostanie zainicjowane.

import java.math.BigDecimal;

record Course(Long id,
              String title,
              String description,
              BigDecimal price) {
    public static final BigDecimal DEFAULT_PRICE = BigDecimal.valueOf(100);

    //też ok
    public Course(Long id, String title, String description, BigDecimal price) {
        if (price.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Price have to be greater than 0");
        this.id = id;
        this.title = title;
        this.description = description;
        this.price = price;
    }
}

Własne metody w rekordach

Jeżeli z jakiegoś powodu nie będzie ci odpowiadała implementacja metod equals() , hashCode() , czy toString(), to również możesz je nadpisać. Dodatkowo w ramach rekordu możesz definiować dowolne metody statyczne lub instancji, dokładnie tak samo jak w tradycyjnych klasach.

import java.math.BigDecimal;

record Course(Long id,
              String title,
              String description,
              BigDecimal price) {
    public static final BigDecimal DEFAULT_PRICE = BigDecimal.valueOf(100);

    //nadpisujemy konstruktor kompaktowy
    public Course {
        if (price.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Price have to be greater than 0");
    }

    //dodatkowy konstruktor bez ceny
    Course(Long id, String title, String description) {
        this(id, title, description, BigDecimal.ZERO);
    }

    //dodatkowa metoda
    boolean isExpensive() {
        return price.compareTo(BigDecimal.valueOf(100)) > 0;
    }

    //własny toString()
    @Override
    public String toString() {
        return "Kurs: " + title + ", " + price + "zł";
    }
}

Korzystanie z rekordów nie różni się specjalnie od zwykłych klas niemodyfikowalnych, przykład:

import java.math.BigDecimal;

class Test {
    public static void main(String[] args) {
        Course course = new Course(1L, "Java", "Super kurs java", BigDecimal.valueOf(999));
        BigDecimal coursePrice = course.price();
        System.out.println(course);
        System.out.println("Cena kursu: " + coursePrice);
        if (course.isExpensive()) {
            System.out.println("Drogi kurs");
        } else {
            System.out.println("Tani kurs");
        }
    }
}
intellij console output

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.