W poprzednim wpisie było o tym, jak utworzyć rekord od Javy 14 i po co w ogóle rekordy są. W tym wpisie na tapet bierzemy ograniczenia i możliwości rekordów.
Ten wpis został zaktualizowany pod wpływem (nadchodzącej) Javy 15 i JEPa 384.
TL;DR:
Z rekordami nie można zrobić nic, to naraża kontrolę ich stanu na wyciek. (Z wyjątkiem oczywiście niezbyt mądrej koncepcji przechowywania w składowych obiektów “mutowalnych”). Poza tym można z nimi robić wszystko inne. Można, nie znaczy trzeba ;-)
Czego nie wolno rekordom?
Jak napisano w JEPie 359, rekordy są “ograniczoną formą klasy, podobnie jak enum”. Oczywiście typy wyliczeniowe i rekordy nie mają dokładnie takich samych ograniczeń, niektóre są podobne, inne zbliżone w swej naturze.
Rekordy nie mogą dziedziczyć po innych klasach, inne klasy nie mogą dziedziczyć klas rekordów. Dlatego poniższy kod nie może zadziałać, każda linia generuje błąd kompilacji:
Mimo iż wszystkie rekordy są obiektami i dziedziczą po java.lang.Record, to w definicji rekordu nie możemy w ogóle wykorzystać klauzuli extends. I oczywiście po utworzeniu klasy rekordów są implicite final, dlatego rekord nie może być “abstrakcyjny” i wykorzystywać abstract. Wszystkie pola też są final.
Poza tym rekordy nie mogą deklarować pól, które nie są podawane przez deklarację / konstruktor kanoniczny:
Tworzenie pola field2 w ten sposób nie zadziała.
Rekordy nie mogą też zmieniać wartości pól tworzonych, dlatego nie można stworzyć czegoś na kształt “setterów”:
Oczywiście, niektóre nazwy komponentów są zastrzeżone. Np. nie można komponentu nazwać hashCode, bo wówczas doszłoby do kolizji metody hashCode() z akcesorem dla takiego komponentu.
Nie można mieć też w rekordach metod native. To jest logiczne, bo taka metoda mogłaby zmieniać stan rekordu. A rekordy w swoim założeniu mają być niezmienialnymi nośnikami danych. Takie kapsuły na dane. Coś jak enum albo String. Jak już masz gotową instancję w ręku, to nie można (przynajmniej teoretycznie) grzebać w środku i podmieniać zawartości. Można stworzyć nową instancję, która jest dokładną kopią lub zbliżona (np. łącząc napisy).
Jeśli w ogóle świerzbią kogoś palce, żeby wykorzystać rekordy do czegoś, co nie jest związane tylko i wyłącznie z przenoszeniem lub agregacją danych, to chyba trzeba odradzić stosowanie rekordów…
Poza tym wygląda na to, iż wolno im wszystko inne.
Co wolno rekordom?
Rekordy mogą implementować interfejsy:
Z powyższego przykłady wynika od razu kolejna możliwość: rekordy mogą mieć dodatkowe metody poza akcesorami, toString(), equals() i hashCode(). I to nie tylko takie, które wynikają z implementacji interfejsu, np.:
Tylko trzeba zadać sobie pytanie, czy rekordy na pewno służą do tego, żeby dodawać do nich takie metody niezwiązane z danymi, które te rekordy przechowują? Myślę, iż wątpię. Ale móc - to można.
Dodatkowo rekordy mogą też mieć pola i metody statyczne:
Mogą mieć tylko jeden komponent:
Ba! Mogą nie mieć żadnego komponentu:
I znów - technicznie: można, praktycznie… czy to ma sens? Może lepiej zastosować enum…?
Nadpisywanie wygenerowanych metod
Bardzo sympatyczną cechą rekordów jest to, iż mają one od razu zaimplementowane parę metod. jeżeli potrzebujemy jednak je nadpisać, to można to zrobić. Przy czym zalecałbym to robić z rozsądkiem i przestrzegając kontraktów dla tych metod. W szczególności metody equals() i hashCode() powinny być ze sobą zgodne. Można je nadpisać przykładowo tak:
Można też oczywiście nadpisywać akcesory do komponentów. Nie bardzo wiem, po co ktoś miałby to robić (poza tworzeniem kopii dla składowych “mutowalnych”), ale da się. Proszę:
Przy nadpisywaniu akcesorów trzeba pamiętać, iż typ zwracany musi się dokładnie zgadzać. Przykładowo: akcesor do składowej typu String też musi zwracać typ String, nie Object, Serializable lub CharacterSequence.
Na deser: konstruktory
Jeśli chodzi o konstruktory w rekordach, to mogą one występować w trzech smakach. Zawsze występuje (bo jest tworzony przez kompilator na podstawie definicji rekordu) tzw. konstruktor kanoniczny. jeżeli rekord R w swojej definicji ma komponenty A a, B b, C c, to konstruktor kanoniczny ma parametry w dokładnie takiej samej kolejności i jest uruchamiany przy każdym wywołaniu new R(a, b, c);
Podobnie jak wszystkie wygenerowane metody, konstruktor kanoniczny można nadpisać. Przydaje się to szczególnie wtedy, gdy chcemy w konstruktorze sprawdzić, czy rekord z danymi komponentami ma w ogóle sens, bo może nie chcemy takiego rekordu w ogóle tworzyć. Np. mając cztery pola, możemy wymagać, żeby pierwsze było zawsze różne od drugiego, a trzecie od czwartego:
(Ciekawe, jak spece od “performęsu” chcą to zrobić z konstruktorem tablicy int[]… ;-) )
Jedną z sympatycznych cech rekordów jest to, iż pozwalają pozbyć się wielu linii kodu ceremonialnego, choć to nie jest oficjalny cel z JEPa. Niestety, nadpisanie konstruktora kanonicznego nie wygląda ciekawie. Żeby dodać dwa sprawdzenia, trzeba było powtórzyć cztery parametry i cztery przypisania. Nie wygląda to najlepiej… Na szczęście, żeby tak nie było, to jeżeli chcemy do konstruktora w rekordzie dodać tylko trochę logiki, możemy wykorzystać do tego celu tzw. konstruktor kompaktowy. Różnica polega na tym, iż nie trzeba powtarzać rzeczy oczywistych, czyli parametrów i przypisań:
Poza tym jeszcze jeden rodzaj konstruktorów jest możliwy w rekordach, przydaje się w sytuacjach raczej nietypowych, gdy chcemy mieć listę parametrów inną niż lista komponentów. Możemy chcieć inicjalizować rekord jakimś innym obiektem i wyłuskać z niego jakieś dane. Np. możemy chcieć mieć rekord (z jakiegoś mrocznego powodu), który przechowuje długość i hashCode napisu. I żeby nie trzeba było przy każdym tworzeniu instancji takiego rekordu wywoływać metod length() i hashCode() “ręcznie” i wysyłać wyniku argumentami do konstruktora, możemy schować to wyłuskiwanie w konstruktorze niestandardowym:
Pamiętajmy, iż konstruktor kanoniczny istnieje zawsze i iż przy wywołaniu konstruktora z konstruktora obowiązują pewne zasady, w szczególności this(...) musi być pierwszym wywołaniem.