Wybór języka

Jak nie korzystać z (pattern matching with) instanceof

Można, nie znaczy trzeba (a wręcz nie powinno się)

(Ten wpis został zaktualizowany. Dodatek jest na końcu.)

W pogoni za nietrywialnymi przykładami do zilustrowania zastosowań JEPa 305 (opisanego przeze mnie tutaj) niektóre osoby mogły zapędzić się za daleko. W szczególności w trudniejszy teren metody equals() obecnej w każdym obiekcie w Javie. Dokonajmy teraz aktu, który pewnie będzie budził zgorszenie wielu braci programistów i sióstr programistek: zajrzyjmy do dokumentacji metody Object.equals(). Można tam wyczytać jakie warunki musi spełniać implementacja equals(), żeby była poprawna. Jednym z nich jest zasada symetryczności: jeśli mamy dwa dowolne obiekty x i y, to wywołania x.equals(y) oraz y.equals(x) muszą dać taki sam wynik. Oba muszą dać albo true, albo false. Jeśli jedno daje true a drugie false, to metoda jest źle zaimplementowana. Handlujcie z tym.

Jednym z popularnych mitów krążących w światku programowania jest to, że “dziedziczenie to ZŁO”. (Jeśli jest dość czasu to mówię o tym w CONTEXTVS, STVLTE!)

Dziedziczenie i hierarchie typów nie są złe same z siebie. Nie wiem, jak ktoś może programować całkowicie bez dziedziczenia i nazywać to “programowaniem zorientowanym na obiekty”. Złe jest ich bezrozumne stosowanie w tych wypadkach, gdy nie powinny być stosowane, bo np. łamią zasadę podstawienia Barbary Liskov. Okraszone bezrefleksyjnym stosowaniem JEPa 305 daje ono iście wybuchową mieszankę.

Dobra, ale o co chodzi?

Popatrzmy na taki kod (którego całość dostępna jest tutaj):

 1class Point {
 2	public final int x;
 3	public final int y;
 4
 5	@Override
 6	public boolean equals(Object o) {
 7		if (this == o) return true;
 8		if (o instanceof Point that) {
 9			return this.x == that.x && this.y == that.y;
10		}
11		return false;
12	}
13	// ...	
14}

Wygląda niewinnie, prawda? Można wypychać? Po paru tygodniach wpada nieświadomy junior, który “poczebuje zaimplementować Punkt3D”:

 1class Point3D extends Point {
 2	public final int z;
 3
 4	@Override
 5	public boolean equals(Object o) {
 6		if (this == o) return true;
 7		if (o instanceof Point3D that) {
 8			if (!super.equals(o))
 9				return false;
10			return z == that.z;
11		}
12		return false;
13	}
14    //...
15}

Na pierwszy rzut oka kod wygląda niezgorzej. Jednak uruchomienie prostego sprawdzenia poprawności implementacji metod equals():

1var point = new Point (1,1);
2var point3D = new Point3D(1,1,1);
3System.out.println(point.equals(point3D));
4System.out.println(point3D.equals(point));

daje nam następujący wynik:

true
false

Wniosek jest prosty: implementacja metod equals() nie jest symetryczna. A dlaczegóż to, laboga, dlaczegóż to? Ano, z JEPem 305 jest jak z każdym innym napaleniem się na bajery jak szczerbaty na suchary: nie zadziała w tym wypadku. Błąd tkwi w linii 8. Ale jak to, przecież tam jest dopasowanie wzorca z instanceof, po to to zrobili, nie? Myślę, że wątpię. Punkt3D to nie jest “taki, rozumisz, zwykły Punkt, tylko z jeszcze jedną osią” oraz nie można w equals z punktem przyrównywać wszystkiego, co tylko jest punktem. Tak, instanceof zwraca true nie tylko dla dokładnie tego typu, ale także każdego typu, który dziedziczy/rozszerza. Tak, wiem, truzim. Tylko że przeglądy kodu świadczą o tym, że jakoś ów truizm nie przyjął się w powszechnej świadomości…

Bez zbędnej spiny, proszę

W życiu trzeba też być pragmatycznym programistą. Czasami zaimplementowanie equals() z wykorzystaniem instanceof razem dobrodziejstwem JEPa 305 nie jest złe, jeśli wszyscy jesteśmy świadomi konsekwencji, nie będziemy po typie dziedziczyć, a taki rodzaj sprawdzania identyczności jest potrzebny, bo jakieś frameworki tworzą proxy dziedziczące np. z encji.

Stąd taka moja mała prośba: jeśli widzisz gdzieś w kodzie (produkcyjnym czy na blogu) instanceof wewnątrz equals() to wiedz, że coś się dzieje. I nie kopiuj kodu z internetu na ślepo. Kontekst, głupcze!

Aktualizacja

Aby wszystko było jasne i by uniknąć nieporozumień, postanowiłem skorzystać z porady Nicolai’a Parloga i zaktualizować ten wpis w kwietniu 2022 roku.

Używanie instanceof w equals() samo w sobie nie jest problematyczne. Również używanie dziedziczenia w Javie samo w sobie nie jest problematyczne. Tak jak używanie młotka nie jest. Ale nadużycie już tak.

Kłopoty pojawiają się, gdy są one niewłaściwie używane, zwłaszcza razem: equals() polegająca na instanceof oraz klasy potomne.

Rozwiązanie tego problemu jest dość proste: najlepiej, aby klasa była final. Point3D to nie jest Point z jeszcze jednym dodatkowym wymiarem/właściwością. Przypuszczam, że klasa, która jest sealed wraz z dobrze napisanymi equals w całej rodzinie też by wystarczyła. Jeśli nie możesz sprawić, by klasa była final, to: public final boolean equals(Object o) też jest opcją. Wrzucając final do equals() przynajmniej upewniamy się, że nie będzie asymetrycznego zachowania equals.

  • Chwileczkę, ale wtedy mój Point3D(1, 1, 1) będzie równy Point(1, 1), nie o to mi chodzi, ponieważ ten point3D nie jest równy point!!11jeden"
  • Otóż to, czy nie mówiłem, że Point3D to nie jest Point z tylko jednym dodatkowym wymiarem/właściwością"? ;-)

TL;DR: finalizacja powoduje, że instanceof w equals() jest bezpieczne TL;DR2: Nie kopiuj i nie wklejaj losowych fragmentów z internetu bez zrozumienia kontekstu.

Wybór języka