W języku Java wszystkie klasy dziedziczą po klasie "java.lang.Object". Pośród dziedziczonych metod znajduje się metoda "equals". Poprawna implementacja tej metody jest kluczowa dla poprawności programu i - wbrew pozorom - niekoniecznie trywialna. Wiele struktur danych polega na jej właściwej implementacji. W związku z tym błędna jej implementacja skutkuje ich niewłaściwym zachowaniem.
Metoda java.lang.Object.equals
Metoda java equals
umożliwia stwierdzenie, czy dwa obiekty są sobie równe. Jej domyślna definicja dostarczana przez klasę java Object
bazuje na referencjach obiektów. Taka implementacja w wielu przypadkach jest wystarczająca. Generalnie, dla klas, których celem jest dostarczanie jakiejś funkcjonalności, raczej nie implementuje się metody java equals
. Przykładem może tu być klient HTTP. Ciężko w ogóle wyobrazić sobie jak mogłoby wyglądać porównywanie takich obiektów inaczej, niż przez identyczność. W takich właśnie przypadkach powinno się polegać na domyślnej implementacji.
Sytuacja wygląda inaczej w przypadku obiektów reprezentujących byty z modelowanego świata, np. obiekty reprezentujące książki, notatki, itd. To właśnie dla tego typu obiektów z reguły dostarcza się metodę java equals
.
Poprawność implementacji tytułowej metody można rozpatrywać dwojako:
- Obiekty powinny być sobie równe, gdy w modelowanym świecie byłyby równe. Przykładowo, dwie książki możemy uznać za równe, jeżeli mają taki sam numer ISBN.
- Metoda
java equals
musi spełniać tzw. kontrakty, kt óre są wymagane przez standard języka Java i których przestrzeganie jest konieczne dla poprawnego zachowania się niektórych struktur danych.
Zanim przejdziemy do analizy wyżej wspomnianych kontraktów, spójrzmy na prostą hierarchię klas, do której będziemy się dalej odwoływać.
1class Book { 2 String isbn; 3} 4 5class Ebook extends Book { 6 String format; 7}
Kontrakty względem equals
Standard języka wymaga od implementacji equals zachowania następujących niezmienników:
- zwrotność, czyli obiekt jest sam sobie równy. Inaczej mówiąc, dla każdego obiektu to prawdą jest, że
java o.equals(o) == true
, - symetryczność, czyli jeżeli pierwszy obiekt jest równy drugiemu, to drugi też jest równy pierwszemu. Oznacza to tyle, że jeżeli
java o1.equals(o2)
zwraca prawdę (fałsz), tojava o2.equals(o1)
też musi zwrócić prawdę (fałsz), - spójność, czyli dla każdych dwóch obiektów metoda
java o1.equals(o2)
powinna zawsze zwracać tę samą wartość, o ile nie zaszły żadne zmiany w obiektach, - przechodniość to warunek, który zapewnia o tym, że wynik operacji
equals
jest przechodni, tj. jeżeli mamy trzy obiektyjava o1
,java o2
,java o3
, i jeżeli o1 jest równyjava o2
, ajava o2
jest równyjava o3
, to wtedyjava o1
jest równyjava o3
, - porównanie obiektu i wartości
java null
zawsze zwracajava false
.
Poprawna implementacja metody equals
Skupmy się teraz na poprawnej implementacji metody java equals
, tj. takiej, która zachowuje wszystkie obwarowania wprowadzone przez standard języka. Generalnie, jeżeli rozważamy porównywanie obiektów dokładnie takiego samego typu, to sytuacja jest prosta i standardowe implementacje, bazujące na porównywaniu pól obiektów, są poprawne i wystarczające. Sytuacja jednak nie jest tak prosta, ponieważ java equals
przyjmuje w argumentach parametr typu java Object
:
1public boolean equals(Object o)
W konsekwencji, do instancji naszej klasy może zostać porównany obiekt każdego innego typu. I, o ile oczywistym jest, że obiekty z różnych hierarchii klas są po prostu różne, to, równość obiektów pozostających w jednej hierarchii może być już przedmiotem rozważań.
Skupmy się teraz na klasach przedstawionych powyżej - książki i ebooka. Ustalmy sobie, że chcielibyśmy takiej sytuacji, w której ebook i książka może być równa. Rozważmy prostą implementację:
1class Book { 2 public boolean equals(Object o){ 3 if(!(o instanceof Book)) { 4 return false; 5 } 6 return this.isbn == ((Book)o).isbn; 7 } 8}
Ten sposób implementacji uznaje, że dwie książki (oraz ich pochodne - ebooki) są równe, gdy ich numery ISBN są równe. Rozsądna implementacja dla klasy Ebook
mogłaby wyglądać tak:
1class Ebook { 2 public boolean equals(Object o) { 3 if ((o instanceof Ebook)) { 4 return format.equals(((Ebook) o).format) && super.equals(o); 5 } else if ((o instanceof Book)) { 6 return super.equals(o); 7 } 8 return false; 9 } 10}
Implementacja java Ebook.equals
rozważa dwa przypadki:
- Porównywany obiekt ma typ
java Ebook
. Sytuacja jest prosta - porównujemy obiekty tego samego typu. - Instancję Ebook porównujemy z instancją
java Book
. W tym celu wywołujemy metodę z nadklasy, żeby porównać tę część, którą da się porównać - tylko kod ISBN.
Nie trudno zaobserwować, że obydwie metody dostarczają zwrotność, symetryczność i spójność. Spójrzmy jednak, jak sytuacja wygląda z przechodniością. Otóż tak zaimplementowany java equals
nie jest przechodni. Żeby się o tym przekonać, wystarczy przeanalizować następujący przypadek:
1Book b1 = new Book("1"); 2Ebook e1 = new Ebook("1", "mobi"), e2 = new Ebook("1", "epub"); 3e1.equals(b1) -> true (1) 4b1.equals(e2) -> true (2) 5e1.equals(e2) -> false (3)
Jak widzimy, operacja (3) zwraca java false
, wbrew temu, co byłoby oczekiwane od przechodniego operatora.
Co z tą przechodniością?
Zauważmy, że z przedstawionego przykładu można wysnuć natychmiastowy, bardziej ogólny wniosek. Mianowicie, że nie da się zaimplementować metody java equals
, która obejmowałaby porównywanie obiektów w relacji rodzic-dziecko i jednocześnie będącej przechodnią. Taki stan rzeczy nie wynika z ograniczeń języka, a jest po prostu bezpośrednią konsekwencją dziedziczenia. Otóż klasa będąca wyżej w hierarchii klas nie ma pojęcia o polach, które znajdują się w klasie będącej niżej w hierarchii.
W naszym przykładzie książka wie jedynie o numerze ISBN i może co najwyżej tego numeru się spodziewać w klasach pochodnych. Innymi słowy, podczas porównywania książki i ebooka musi dojść do tzw. logicznego object sliceing’u, tj. potraktowania ebooka tak jakby był zwykłą książką. Jest to - niech wybrzmi to raz jeszcze - naturalna konsekwencja porównywania bytów różnego typu, zarówno w sensie świata rzeczywistego jak i obiektowego.
Jak w takim razie można rozwiązać taki problem? W takim przypadku można zrobić dwie rzeczy:
- Uniemożliwić dziedziczenie klas, które są tzw. value classes (klasy reprezentujące wartości czy też obiekty świata rzeczywistego). W gruncie rzeczy jest to całkiem rozsądne zarówno z punktu widzenia modelowania świata rzeczywistego jak i technicznego - taki zabieg bardzo upraszcza implementację
java equals
. - Jeżeli z jakichś powodów nasza klasa musi być otwarta na ewentualne dziedziczenie, to możemy uznać, że obiekty różnych typów nigdy nie są sobie równe. Wtedy implementacja jest również bardzo prosta. Szablon metody przy takim podejściu mógłby wyglądać tak:
1public boolean equals(Object o) { 2 if(o == null) return false; 3 if(o.getClass() != this.getClass()) return false; 4 ... // just compare fields 5}
W takim podejściu należy pamiętać, że taka metoda nie zachowuje się poprawnie dla klas pochodnych.
Wróćmy jeszcze na moment do źródłowego problemu. Czy naprawdę nie da się zaimplementować metody java equals
tak, żeby spełniała wszystkie wymagania i jednocześnie była w stanie porównywać obiekty z różnych poziomów w hierarchii? Generalnie, istnieją techniki, które umożliwiają poprawną implementację. Pozostaje jednak wtedy nadal pytanie, czy rzeczywiście rozsądne jest, aby obiekty różnych typów mogły być równe? Po drugie, takie rozwiązania są najczęściej skomplikowane i dużo trudniejsze w implementacji niż można by się spodziewać po java equals
. W związku z tym najbardziej sensownym wydaje się jednak uznanie, że klasy przenoszące wartości powinny być klasami zamkniętymi na rozszerzanie.
Podsumowanie
Implementacja metody java equals
wydaje się być bardzo prosta. Należy jednak zwrócić szczególną uwagę na jej właściwą implementację, gdyż niespełnienie jej wymogów może powodować błędy, które niekoniecznie będą widoczne na pierwszy rzut oka. Programista dostarczający implementację java equals
powinien dokładnie przeanalizować czy jego funkcja przestrzega wszystkich wymaganych obostrzeń.