Wybór języka

Rekordy w Javie - co wolno i czego nie wolno

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.

Jeśli wolisz oglądać i słuchać niż czytać, to bezwstydnie polecam mój wykład Java Records for the Intrigued.

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:

record CannotExtend(int a) extends java.util.Date {}
class ExtendingRecordDoesNotWork extends CannotExtend {}

Mimo że 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:

record NoExtraFields(int field1) {
	private final int field2;
}

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”:

record NoSetters(int field) {
	public void field(int newValue) {
		this.field = newValue;
	}
}

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, że wolno im wszystko inne.

Co wolno rekordom?

Rekordy mogą implementować interfejsy:

record CanImplementInterfaces(int a, int b) implements Comparable<CanImplementInterfaces> {
	@Override
	public int compareTo(CanImplementInterfaces that) {
		return Objects.compare(this, that,
			Comparator.comparing(CanImplementInterfaces::a)
			    .thenComparing(CanImplementInterfaces::b));
	}
}

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.:

record CustomMethods(int a, int b) {
	public void printHello(String to) {
		System.out.println("Hello " + to);
	}
}

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ę, że wątpię. Ale móc - to można.

Dodatkowo rekordy mogą też mieć pola i metody statyczne:

record StaticMembers(int a) {
	private static String FOO = "bar";

	public static String bar() {
		return "foo";
	}
}

Mogą mieć tylko jeden komponent:

record OneField(int a) {}

Ba! Mogą nie mieć żadnego komponentu:

record NoField() {}

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, że mają one od razu zaimplementowane parę metod. Jeśli 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:

record CustomEqualsAndHashCode(String string, int a) {
	//Please keep in mind that equals and hashCode need to obey their contracts!
	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		CustomEqualsAndHashCode that = (CustomEqualsAndHashCode) o;
		return a == that.a &&
			Objects.equals(string, that.string);
	}

	@Override
	public int hashCode() {
		return Objects.hash(string, a);
	}
}

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ę:

record CustomAccessor(int secret) {
	public int secret() {
		System.out.println("Here's my public secret. Please don't tell anybody...");
		return secret;
	}
}

Przy nadpisywaniu akcesorów trzeba pamiętać, że 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śli 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:

record CustomCanonicalConstructor(int a, int b, int c, int d) {
	public CustomCanonicalConstructor(int a, int b, int c, int d) {
		assert (a != b);
		assert (c != d);
		this.a = a;
		this.b = b;
		this.c = c;
		this.d = d;
	}
}

(Ciekawe, jak spece od “performęsu” chcą to zrobić z konstruktorem tablicy int[]… ;-) )

Jedną z sympatycznych cech rekordów jest to, że 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śli chcemy do konstruktora w rekordzie dodać tylko trochę logiki, możemy wykorzystać do tego celu tzw. konstruktor kompaktowy. Różnica polega na tym, że nie trzeba powtarzać rzeczy oczywistych, czyli parametrów i przypisań:

record CustomCompactConstructor(int a, int b, int c, int d) {
	public CustomCompactConstructor {
		assert (a != b);
		assert (c != d);
	}
}

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:

record CustomNonCanonicalConstructor(int length, int hash) {
	public CustomNonCanonicalConstructor(String string) {
		this(string.length(), string.hashCode());
	}
}

Pamiętajmy, że konstruktor kanoniczny istnieje zawsze i że przy wywołaniu konstruktora z konstruktora obowiązują pewne zasady, w szczególności this(...) musi być pierwszym wywołaniem.

Wybór języka