Metoda equals w Javie. Jak poprawnie ją zaimplementować?
Author
Radosław Kondziołka
blog_date_icon
25 marca

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.<!--more-->

Metoda java.lang.Object.equals

Metoda equals umożliwia stwierdzenie, czy dwa obiekty są sobie równe. Jej domyślna definicja dostarczana przez klasę 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 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ę 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 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ć.

class Book {
    String isbn;
}

class Ebook extends Book {
    String format;
}

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 o prawdą jest, że 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 o1.equals(o2) zwraca prawdę (fałsz) to o2.equals(o1) też musi zwrócić prawdę (fałsz),
  • spójność, czyli dla każdych dwóch obiektów metoda 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 o1, o2, o3, i jeżeli o1 jest równy o2, a o2 jest równy o3 to wtedy o1 jest równy o3,
  • porównanie obiektu i wartości null zawsze zwraca false.

Poprawna implementacja metody equals

Skupmy się teraz na poprawnej implementacji metody 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ż equals przyjmuje w argumentach parametr typu Object:

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

class Book {
    public boolean equals(Object o){
        if(!(o instanceof Book)) {
            return false;
        }
        return this.isbn == ((Book)o).isbn;
    }
}

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:


class Ebook {
    public boolean equals(Object o) {
        if ((o instanceof Ebook)) {
            return format.equals(((Ebook) o).format) && super.equals(o);
        } else if ((o instanceof Book)) {
            return super.equals(o);
        }
        return false;
    }
}

Implementacja Ebook.equals rozważa dwa przypadki:

  1. Porównywany obiekt ma typ Ebook. Sytuacja jest prosta - porównujemy obiekty tego samego typu.
  2. Instancję Ebook porównujemy z instancją 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 equals nie jest przechodni. Żeby się o tym przekonać, wystarczy przeanalizować następujący przypadek:

Book b1 = new Book("1");
Ebook e1 = new Ebook("1", "mobi"), e2 = new Ebook("1", "epub");
e1.equals(b1) -> true   (1)
b1.equals(e2) -> true   (2)
e1.equals(e2) -> false  (3)

Jak widzimy, operacja (3) zwraca 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 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ę 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:
public boolean equals(Object o) {
    if(o == null) return false;
    if(o.getClass() != this.getClass()) return false;
    ... // just compare fields
}

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 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żnaby się spodziewać po 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 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ę equals powinien dokładnie przeanalizować czy jego funkcja przestrzega wszystkich wymaganych obostrzeń.

Przeczytaj także