Metoda equals w Javie. Jak poprawnie ją zaimplementować?

Radosław Kondziołka
Ikona kalendarza
25 marca 2021

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:

  1. 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.
  2. 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), to java 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 obiekty java o1, java o2, java o3, i jeżeli o1 jest równy java o2, a java o2 jest równy java o3, to wtedy java o1 jest równy java o3,
  • porównanie obiektu i wartości java null zawsze zwraca java 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:

  1. Porównywany obiekt ma typ java Ebook. Sytuacja jest prosta - porównujemy obiekty tego samego typu.
  2. 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:

  1. 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.
  2. 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ń.

Przeczytaj także

Ikona kalendarza

14 luty

Co to jest Docker i dlaczego warto go używać?

Co to jest Docker i jak działa? Jakie korzyści niesie Docker dla programistów, testerów, administratorów i architektów IT? Przeczytaj...

Ikona kalendarza

18 styczeń

Jak się przygotować do zdania ISTQB® Foundation Level?

Jak przygotować się do zdania egzaminu ISTQB® Foundation Level? Sprawdź cenne wskazówki przed przystąpieniem do egzaminu.

Ikona kalendarza

11 styczeń

Dofinansowanie KFS na szkolenie w 2024 roku

Z artykułu dowiesz się, jak wygląda budżet KFS w 2024 roku, poznasz priorytety wydatkowania oraz dowiesz się, jak wnioskować o dofina...