Wybór języka

Rekordy w Javie - po co i dlaczego

Java idzie na rekord

Java 14 przyniosła nam rekordy (ang. records) jako preview feature. Było (jest?) przy tym sporo zamieszania, deklaracji i ciężkich obelg miotanych w kierunku wrażych bibliotek i wtyczek do IDE. Pozwólcie mi dorzucić moje trzy grosze.

Przede wszystkim można chyba powiedzieć: wreszcie. Po case classes w Scali i data classes w Kotlinie, “w końcu robią to w Javie”. Fakt, tylko do pełnego obrazu tej układanki trzeba dołożyć jeszcze kilka puzzli:

  • Java stara się utrzymywać zgodność wsteczną, jak tylko się da (czy to dobrze, czy źle, temat na osobną dyskusję),
  • Java w maju 2020 obchodzi 25 urodziny i jest w pełnym rozkwicie (nie jest nowym językiem bez ekosystemu), więc trzeba zmiany jakoś sensownie wpasować, jak w każdy dojrzały system/technologię,
  • trzeba jakoś przygotować rzeszę programistów do nadchodzących zmian (raczej kapać nowościami niż urządzać topienie).

Jeśli masz ochotę zacząć od czegoś formalnego, to polecam przeczytać i zrozumieć JEP 359.

Moje postrzeganie rekordów

Tłumaczenie komuś czym są rekordy zacząłbym chyba od tego, żeby zapomnieć wszystko, co się wie o Javie do tej pory. I skupiłbym się na zdaniu z JEPa Records can be considered a nominal form of tuples. Lub, jak zdaje się stwierdził p. Brajan Goetz, named tuples. Czyli że chodzi o nazwane krotki albo “krotki, które mają nazwę”.

W porządku, co to te krotki? To skoczmy może na grunt zaprzyjaźnionego SQLa. Gdy napiszesz

SELECT (gross_weight, gross_volume) FROM packages;

to w wyniku otrzymasz zbiór krotek (ang. tuples), każda krotka ma dwa elementy. (Tak, pewnie w programie do podglądu danych z bazy pokazuje to w formie tabelarycznej, stąd krotki wyglądają jak wiersze.) W Javie można napisać metodę zwracającą masę i pojemność jednej paczki np. tak

GrossWeightAndVolume getGrossWeightAndVolume(PackageID id) {...}

Tylko żeby takie coś zadziałało, to trzeba stworzyć kolejną klasę GrossWeightAndVolume, najprawdopodobniej jako JavaBean, razem z konstruktorem, getterami, najprawdopodobniej nie zaszkodzi dodać też equals() i hashCode(), może nawet toString(), żeby ładnie w logach wypisywało. Ewentualnie można do pracy zaprządz Project Lombok i wykorzystać adnotację @Value. (Wykorzystanie Lomboka ma swoje plusy i minusy, o czym innym razem).

W tym momencie pojawiają się tzw. “cwane gapy”, które w imię “śrubowania performęsu” zamiast kolejnego typu (bo rozmiar JARów, classloader,…) namiętnie zalecają stosować typy podstawowe do obłędu i sygnaturę metody zapisują dokładnie tak, jak de facto wychodzi ona z zapytania SQL i stosują tablicę do reprezentowania krotek:

double[] getGrossWeightAndVolume(long id) {...}

Kłopot w tym, że mając takie zapytanie w SQLu, możemy też mieć kolejne (pomijając jego sens “biznesowy”):

SELECT (total_mileage, trunk_volume) FROM cars;

Można znowu albo tworzyć JavaBean (i utrzymywać!), albo radośnie wykorzystywać double[].

Zaczyna się robić wesoło, gdy uświadomimy sobie, że te krotki z SQLa i te z double[] trzeba trzymać twardą ręką i w żadnym wypadku nie wolno ich ze sobą nigdy pomieszać. Bo nagle może dojść do takich absurdów:

SELECT (gross_weight, gross_volume) FROM packages
UNION
SELECT (total_mileage, trunk_volume) FROM cars;

Tak samo można pomieszać dwie różne tablice double[]. Czy może być jeszcze weselej? Oczywiście! Nie po to olewamy typy i kompilator, żeby było sztampowo… A co jeśli z takiej metody trzeba zwrócić równocześnie liczbę całkowitą i rzeczywistą? Wtedy trzeba zrobić tablicę Number[], pojawia się autoboxing,… A może dodajmy jeszcze napis… co wtedy? Serializable[]? “A wcale że nie, możesz mieć klasę Pair albo Triple z generykami!” Fakt, tylko skąd wtedy wiadomo, że jedna para Pair<Double, Double> nie może być mieszana z inną parą Pair<Double, Double>, która logicznie i semantycznie przechowuje coś zupełnie innego? Widząc Set<Pair<Double, Double>>, czego spodziewać się w środku? Rozmiarów paczek? Liczb zespolonych?

Za każdym razem, gdy w Javie pozbywasz się kontroli typów przez kompilator i zaczynasz traktować takie same typy inaczej w zależności od kontekstu, to do nazwy Java dopisujesz Script.

Z tych właśnie powodów, żeby nie mieszać śliwek z Camaro, Java posiada krotki nazwane, które nazywamy rekordami.

Jak stworzyć rekord?

Bardzo łatwo. JEP określa rekordy jako “ograniczoną formą klasy, podobnie jak enum”. Jeśli masz ochotę zdefiniować np. rekord dla liczb zespolonych, można zapisać to tak:

record Complex(double real, double imaginary) {}

A jeśli masz potrzebę przechowywać masę i rozmiar paczki, można zdefiniować inny rekord:

record PackageSize(double grossWeight, double grossVolume) {}

Co to daje? Kompilator od razu stworzy na dla nas:

  • konstruktor kanoniczny, czyli możemy napisać: Complex aComplex = new Complex(3.0, 1.7);
  • akcesory dla każdego komponentu, czyli możemy pobrać część rzeczywistą: double x = aComplex.real();
  • metody equals() i hashCode() wykorzystujące wszystkie komponenty rekordu,
  • metodę toString(), która wypisze nam nazwę klasy rekordu oraz wartości wszystkich komponentów, np. Complex[real=3.0, imaginary=1.7]

Z rzeczy dostępnych na dzień dobry to w zasadzie tyle. Tylko boli mnie trochę, że zapominamy o rzeczach nie mniej ważnych: rekord daje nam nazwę i typ! Czyli mając:

Set<Complex> aSet = new Set<>();

nie można do tego zbioru dodać innego rekordu (nawet jeśli ma dokładnie takie same komponenty):

// to se ne da
aSet.add(new PackageSize(15.0, 10.0));

To pomaga pamiętać, że kompilator to nie stary zrzędliwy architekt, tylko sympatyczny kolo, który zawsze programuje z Tobą w parze.

Tylko do odczytu

Jeśli Twym oczom przypadkiem umknęło, to warto podkreślić, że rekordy nie mają niczego na kształt “setterów”. Dla każdego komponentu jest tworzona tylko metoda dostępu, nie ma żadnej metody zmiany. W oryginalne figurują one jako accessors, stąd przyjąłem tłumaczenie (może nie najlepsze): akcesory. Oczywiście pod spodem każdy komponent trafia do pola, które jest private final, dlatego oczywiste jest, że nie można referencji podmienić czy nadpisać. Co można zrobić, to jako komponent podać coś, czego wartość teoretycznie można zmieniać, np. ArrayList lub AtomicInteger. Jednakże z wielu powodów nie wydaje się to rozsądne.

W kolejnym wpisie jest o tym, co wolno zrobić z rekordami, a czego nie.

Wybór języka