Wybór języka

Rekordy w Javie - co na to Lombok?

Aktualizacja kwiecień 2021: Proszę mieć na względzie, że poniższy wpis dotyczy Javy 15, gdzie rekordy były preview feature. Od tego czasu parę rzeczy się zmieniło, aktualizacja jest w innym wpisie.

Rekordy: konfrontacja

W czasie moich aktywności online dotyczących tłumaczenia rekordów parę osób rzuciło podkręconą piłkę “no a Lombok?”

W szczególności pytanie przybiera formę “skoro rekordy są immutable, to czym się różnią od @Value z Lomboka?”

Przede wszystkim trzeba pamiętać, że “rekordy nie są Java Beans”, @Value generuje nam niezmienialny (“niemutowalny”) JavaBean.

Częściowo efekty zastosowania record i @Value pokrywają się.

  • Pojawią się metody equals(), hashCode(), toString().
  • Wszystkie pola zostaną oznaczone jako private final.
  • Pojawią się “konstruktory do wszystkich pól” (jeśli w ziarenku od @Value któreś z pól jest zainicjalizowane przy deklaracji, to oczywiście zostanie pominięte).
  • record i @Value tworzą klasy, które są final, więc nie można po nich dziedziczyć.

Dwie fundamentalne różnice są takie:

  • W rekordach deklarujemy składowe w nagłówku rekordu (pięknie opisuje to gramatyka z JEPa 384) i na tej podstawie generowane są pola i parametry konstruktora kanonicznego, zaś do JavaBean poddanych @Value trzeba wpisać wszystkie pola i to na ich podstawie jest tworzony konstruktor “kanoniczny”.
  • Rekordy posiadają “akcesory”, @Value da nam “gettery”.

Różnic oczywiście jest więcej. Jeśli chcecie je dokładnie poznać, to sposobem totalnym można porównywać kod bajtowy (bytecode), lub chociaż dokonać “delombokizacji” @Value w IDE. Przykładowo, jeśli do konfiguracji Lomboka w lombok.config dopisać lombok.anyConstructor.addConstructorProperties=true, to konstruktor “kanoniczny” dzięki @Value posiada także adnotację @java.beans.ConstructorProperties. “E tam, jakiś detal”. Tak, ale ten detal powoduje, że elegancko można zdeserializować takim konstruktorem JSONa wykorzystując Jacksona. Jako “elegancko” rozumiem bez pisania @JsonCreator przed konstruktorem i @JsonProperty("name) przed każdym parametrem konstruktora. (Pamiętajmy, że “normalna deserializacja”, czyli z formatu binarnego, w rekordach też jest robiona przez konstruktor, więc koncepcja deserializacji konstruktorem zyskuje na popularności na wielu frontach. Wreszcie.)

W przyszłości tych różnic może być jeszcze więcej. Jednym ze sposobów na wykorzystanie rekordów ma być dopasowanie wzorców (pattern matching) z wyłuskiwaniem danych. A takie wyłuskiwanie wymaga dekonstrukcji obiektu (jak unapply w Scali). Kiedyś może się okazać, że record może tworzyć takie dekonstruktory dla rekordów. Na razie brak mi wiedzy, czy i jak będzie można rozkładać obiekty samemu i/lub czy Lombok jakoś to umożliwi.

A teraz: pocałujcie się!

Now kiss
Uważny czytelnik dokumentacji @Value (bo czytasz dokumentację, prawda?) zauważy, że w jakimś sensie @Value jest skrótem kilku innych adnotacji. Lista wygląda tak: final @ToString @EqualsAndHashCode @AllArgsConstructor @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @Getter

Rekordy mają toString(), to nie potrzebują @ToString itd. W zasadzie poza @Getter to mają wszystko.

Co się stanie, jeśli do definicji rekordu dopisać @Getter

@Getter
public record RecordWithGetters(int f1, String f2) {}
i skompilować wszystko z Lombokiem? Wyleci plik .class, który po dekompilacji wygląda mniej więcej tak:
 1  // IntelliJ API Decompiler stub source generated from a class file
 2  // Implementation of methods is not available
 3
 4public final class RecordWithGetters extends java.lang.Record {
 5    private final int f1;
 6    private final java.lang.String f2;
 7
 8    public RecordWithGetters(int f1, java.lang.String f2) { /* compiled code */ }
 9
10    public int getF1() { /* compiled code */ }
11
12    public java.lang.String getF2() { /* compiled code */ }
13
14    public java.lang.String toString() { /* compiled code */ }
15
16    public final int hashCode() { /* compiled code */ }
17
18    public final boolean equals(java.lang.Object o) { /* compiled code */ }
19
20    public int f1() { /* compiled code */ }
21
22    public java.lang.String f2() { /* compiled code */ }
23}

Czyli poza akcesorami do składowych, w rekordzie pojawiły mi się również gettery. Nie wiem, czy mi się to podoba. Ale wiem, że dzięki temu pojawia się kolejna opcja, którą można dołączyć do listy z wpisu o refleksji rekordów i mieć gettery wygenerowane, zamiast dopisywać je ręcznie, bo “niestety, ta biblioteka wymaga getterów”.

No ale coś tam pisałem o działaniu @AllArgsConstructor… Jak pomęczyć rekordy jeszcze bardziej i nad klasą dodać tę właśnie adnotację (i gdy mamy odpowiedni wpis w json.config):

@AllArgsConstructor
public record RecordWithAllArgsConstructor(int f1, String f2) {}

to po całej kompilacji konstruktor wygląda następująco:

@java.beans.ConstructorProperties({"f1", "f2"})
public RecordWithAllArgsConstructor(int f1, java.lang.String f2) { /* compiled code */ }

Co nam to daje? “Kompatybilność” z nieszczęsnym Jacksonem przy przerabianiu JSONa na rekordy (czyli deserializacji).

Podsumowując: dopisanie do definicji rekordu dwóch adnotacji @Getter @AllArgsConstructor przy posiadaniu odpowiednio skonfigurowanego Lomboka w projekcie, już teraz umożliwi nam wykorzystanie rekordów w dość popularnym obszarze, czyli przerabianiu obiektów/rekordów Javy na odpowiedzi RESTowe i wczytywanie tych obiektów/rekordów z żądań RESTowych.

Czy warto?

Szczerze? Nie wiem.

Z jednej strony umożliwi to już teraz (jeśli ktoś się nie boi --enable-preview i lubi życie na krawędzi) wykorzystanie rekordów w bardzo popularnym zakresie. A gdy już wszystkie biblioteki wykorzystywane przez nas będą w stanie obsługiwać rekordy, to będzie można z tych adnotacji zrezygnować.

Z drugiej strony Lombok nie jest za darmo. Ciężko się żyje bez wtyczki do IDE, wtyczka nie zawsze jest aktualna, Lombok (z racji bycia “wtyczką do kompilatora”) nie zadziała np. przy mieszanej kompilacji ze Scalą itd. Na duży minus zasługuje to, że w moich obecnych ustawieniach (Intellij IDEA 2020.1.1, wtyczka Lombokowa do IDEI: 0.30-2020.1) nie da się wywołać konstruktora rekordu, jeśli ten konstruktor jest z Lomboka ;-) Kompilowanie i uruchamianie Mavenem na OpenJDK Runtime Environment (build 15-ea+23-1098) jak najbardziej działa.

Lombok-generated constructor in IDEA's plugin

Czyli móc, to można. Ale czy warto? Każdy niech odpowie sobie sam.

A co z @Value

“No dobrze, ale skoro @Getter @AllArgsConstructor są w jakimś sensie podzbiorem @Value, to może korzystać z @Value przy rekordach?”

Tego bym raczej nie robił w żadnych okolicznościach.

  • Wykorzystanie @Value generuje w IDE ostrzeżenie, że metody equals() i hashCode() nie uwzględniają klas, z których nasza klasa dziedziczy. Na pierwszy rzut oka @Value record ARecord(){} z niczego nie dziedziczy, ale trzeba pamiętać o java.lang.Record. Lombok, zdaje się, ma tu (jeszcze?) problemy.
  • Równoczesne wykorzystanie Lomboka i rekordów powoduje, że jeśli jakaś metoda jest generowana przez Lombok i przez rekord “z definicji”, to w pliku .class znajdzie się metoda wygenerowana przez Lombok. A tak sobie myślę, że chyba lepiej zostawić by było tę “oryginalną od rekordu”. Kwestia smaku i kompatybilności w przód.

Lombok jest potężnym narzędziem. I jak każde potężne narzędzie wymaga zrozumienia i odpowiedzialności za każde (złe) użycie.

Jeśli świerzbią kogoś palce, żeby własnoręcznie popastwić się nieco nad rekordami, to kod leży sobie na Githubie.

Wybór języka