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ównyPoint(1, 1), nie o to mi chodzi, ponieważ ten point3D nie jest równy point!!11jeden" - Otóż to, czy nie mówiłem, że
Point3Dto nie jestPointz 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.