Metody hashCode i equals to jedne z podstawowych metod wykorzystywanych w Javie. Ich deklaracja znajduje się już w klasie Object. Mimo iż obie metody posiadają domyślną implementację, to wykorzystanie jej lub próba napisania własnej, może przysporzyć programistom nie lada problemów.
public boolean equals(Object obj) public int hashCode()
Spis treści
Porównywanie typów prostych w Javie
Do porównywania typów prostych, takich jak liczby całkowite, liczby zmiennoprzecinkowe, czy wartości boolean przeznaczony jest standardowy operator porównania ==.
1 == 1 1.5 == 2.5 true == false
Porównywanie obiektów w Javie [equals]
W przypadku obiektów sprawa nie jest już tak prosta, jak w przypadku typów prostych. Operator porównania może okazać się zwodniczy.
Operator porównania ==
Java udostępnia standardowy operator porównania ==, który sprawdza, czy obiekty są „takie same – identyczne”. Co w praktyce oznacza, że muszą znajdować się dokładnie w tym samym miejscu w pamięci komputera. Jednak nawet jeżeli obiekty będą miały taką samą wartość, np. liczba 1 (typ Integer), to mogą znajdować się w innym miejscu w pamięci.
Metoda equals
Kolejny sposób porównywania obiektów to metoda equals, która w zamyśle wykorzystana jest do porównania obiektów wg ich wartości. Jednak jej ostateczne zachowanie zależne jest od implementacji. Przykładowo, domyślna implementacja to standardowy operator porównania, sprawdzający identyczność obiektów.
public boolean equals(Object obj) { return (this == obj); }
Klasy typów opakowujących (np. Integer, Long itp.) oraz String nadpisują implementację metody equals, w celu porównywania obiektów po wartości.
Dla własnych klas taką implementację należy napisać samemu.
Metoda hashCode
Metoda hashCode powstała w celach optymalizacyjnych. Jej zadaniem jest wygenerowanie dla obiektu szybkiego skrótu (hashu) w formie liczby całkowitej.
Hash generowany jest na podstawie stanu obiektu – wartości pól obiektu, które wyróżniają go od innych obiektów tej samej klasy. Powstały kod powinien zapewniać jak największą unikatowość – tak by możliwie niewielka liczba obiektów mogła wygenerować identyczny kod.
HashCode jest wykorzystywana między innymi przez wbudowane w Javie struktury danych (np. HashMap). Najpierw obiekty są dzielone na tak zwane kubełki, wg wartości kodu hashCode. A dopiero następnie są między sobą porównywane z wykorzystaniem metody equals. Dzięki temu nie ma potrzeby porównywania ze sobą wszystkich obiektów.
Kontrakt między metodami hashCode() i equals()
O tych metodach zazwyczaj mówi się jednocześnie, ponieważ został zdefiniowany między nimi kontrakt. Jego wypełnienie chroni programistę przed trudnymi do zdiagnozowania błędami.
- Każde wywołanie metody hashCode na tym samym obiekcie musi kończyć się zwróceniem tej samej liczy całkowitej.
- Jeżeli dwa obiekty są sobie równe (wg metody equals), to ich hashCode również musi być równy.
- Jeżeli obiekty są różne (wg metody equals), to ich hashCode może być równy, jednak ze względów wydajnościowych powinno to być unikane.
- Relacja wyznaczona metodą equals musi być zwrotna, czyli dla każdej zmiennej x różnej od null wyrażenie x.equals(x) musi zwracać wartość = true.
- Relacja wyznaczona metodą equals musi być symetryczna, czyli dla każdej pary zmiennych x i y, wyrażenie x.equals(y) ma wartość true i wtedy i tylko wtedy gdy y.equals(x) = true.
- Relacja wyznaczona metodą equals musi być przechodnia, czyli dla dowolnych zmiennych z, y i z, jeżeli x.equals(y) = true oraz y.equals(z) = true, to x.equals(z) musi również = true.
- Relacja wyznaczona metodą equals musi być spójna, czyli każdorazowe wywołanie x.equals(y) (przy założeniu, że między wywołaniami obiekty nie były modyfikowane) zawsze musi zwracać tę samą wartość – zawsze true albo zawsze false.
- Każdy obiekt jest różny od null, czyli wywołanie x.equals(null) dla obiektu x różnego od null, zawsze musi zwrócić false.
Potencjalne konsekwencje niezachowania kontraktu między equals i hashCode
Brak zachowania kontraktu niestety nie powoduje błędów kompilacji. Może jednak przyczynić się do powstania trudnych do wyśledzenia błędów podczas działania aplikacji.
Jednym z częściej spotykanych problemów jest możliwość wielokrotnego dodania takich samych obiektów do zbioru danych (Set) lub „gubienie” się tych obiektów w secie. Może to przyczynić się do utraty wielu godzin na debugowaniu takiego błędu.
Implementacja metod hashCode i equals
Bardzo często wystarczy automatyczna implementacja wygenerowana przez IDE, Lombok, AutoValue lub inne podobne narzędzia. Tylko w bardzo specyficznych sytuacjach zachodzi potrzeba implementowania tych metod ręcznie.
We własnej implementacji warto zwrócić uwagę między innymi, by zmienne oznaczane jako transient lub których stan jest wyliczany wlocie, nie były porównywane. Tego typu pola, np. po deserializacji obiektu mogą mieć nieaktualne wartości.
Więcej na temat problemów z implementacją i utrzymaniem tych metod można przeczytać w artykule o boilerplate code.
public class User { private String name; private String username; private Integer age; @Override public int hashCode() { int hash = 5; hash = 37 * hash + Objects.hashCode(this.name); hash = 37 * hash + Objects.hashCode(this.username); hash = 37 * hash + Objects.hashCode(this.age); return hash; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } final User other = (User) obj; if (!Objects.equals(this.name, other.name)) { return false; } if (!Objects.equals(this.username, other.username)) { return false; } if (!Objects.equals(this.age, other.age)) { return false; } return true; } }
Jak testować hashCode i equals?
Bardzo często pojawia się pytanie o sensowność testowania implementacji hashCode i equals. Prawda jest jednak taka, że te metody również zawierają logikę. Rezygnacja z ich testowania może przyczynić się do powstania różnego rodzaju błędów, tak samo, jak w przypadku innych metod.
Sytuacja nie jest jednak taka zła, na jaką wygląda. Ponieważ większość implementacji tych metod jest do siebie zbliżona, można z testów wyciągnąć część wspólną i je również, przynajmniej częściowo, zautomatyzować.
Poniżej przedstawiam listę przykładowych możliwości testowania metod hashCode oraz equals.
1. Własna klasa testująca
W ramach testów przygotowałem klasę testującą kontrakt hashCode i equals: ManualEqualsTester oraz jej przykładowe wykorzystanie w teście UserManualTest.
public class ManualEqualsTester { private static final int ITERATIONS = 10; private final Object reference, equalReference, secondEqualReference, notEqualReference; public ManualEqualsTester(Object reference, Object equalReference, Object secondEqualReference, Object notEqualReference) { this.reference = reference; this.equalReference = equalReference; this.notEqualReference = notEqualReference; this.secondEqualReference = secondEqualReference; } public void verify() { testConstancyHashCode(); testEqualityOfHashCode(); testIsReflexive(); testIsSymmetric(); testIsTransitive(); testConstancyEquals(); testNullIsNotEquals(); } private void testConstancyHashCode() { int hashCode = reference.hashCode(); for (int i = 0; i < ITERATIONS; i++) { Assert.assertEquals(hashCode, reference.hashCode()); } } private void testEqualityOfHashCode() { Assert.assertEquals(reference.hashCode(), equalReference.hashCode()); } private void testIsReflexive() { Assert.assertTrue(reference.equals(reference)); Assert.assertTrue(equalReference.equals(equalReference)); Assert.assertTrue(notEqualReference.equals(notEqualReference)); } private void testIsSymmetric() { Assert.assertTrue(reference.equals(equalReference)); Assert.assertTrue(equalReference.equals(reference)); Assert.assertFalse(reference.equals(notEqualReference)); Assert.assertFalse(notEqualReference.equals(reference)); } private void testIsTransitive() { Assert.assertTrue(reference.equals(equalReference)); Assert.assertTrue(equalReference.equals(secondEqualReference)); Assert.assertTrue(reference.equals(secondEqualReference)); } private void testConstancyEquals() { for (int i = 0; i < ITERATIONS; i++) { Assert.assertTrue(reference.equals(equalReference)); Assert.assertTrue(reference.equals(secondEqualReference)); Assert.assertFalse(reference.equals(notEqualReference)); } } private void testNullIsNotEquals() { Assert.assertFalse(reference.equals(null)); Assert.assertFalse(equalReference.equals(null)); Assert.assertFalse(secondEqualReference.equals(null)); Assert.assertFalse(notEqualReference.equals(null)); } }
public class UserManualTest { private static ManualEqualsTester manualEqualsTester; @BeforeClass public static void setUp() throws Exception { User user = new User("Tomasz", "Woliński", 100); User userEqual = new User("Tomasz", "Woliński", 100); User userSecondEqual = new User("Tomasz", "Woliński", 100); User userNotEqual = new User("", "", 1); manualEqualsTester = new ManualEqualsTester(user, userEqual, userSecondEqual, userNotEqual); } @Test public void testEquality() { manualEqualsTester.verify(); } }
Warto zrobić coś takiego w celu poznania warunków, jakie muszą spełniać te metody. Jednak w praktyce spokojnie swoje testy można opierać o gotowe rozwiązania, takie jak EqualsVerifier czy Guava, które są opisane w kolejnych punktach.
2. EqualsVerifier
EqualsVerifier to niewielka, ale bardzo pomocna przy testowaniu implementacji equals i hashCode biblioteka. Kod źródłowy oraz szczegółową dokumentację można znaleźć na stronie projektu.
public class UserEqualsVerifierTest { @Test public void testEquality() { EqualsVerifier.forClass(User.class).usingGetClass().verify(); } }
Jeżeli metoda equals jest napisana z wykorzystaniem getClass() zamiast instanceof, należy w wywołaniu dodać usingGetClass(), w przeciwnym wypadku jest to zbędne.
3. Guava Testing Library
Guava w swoim arsenale również posiada klasę EqualsTester, która może być bardzo pomocna przy automatycznym testowaniu poprawności metod equals i hashCode.
Biblioteka guava-testlib dostępna jest na repozytorium maven.
package pl.stormit.hashcodeequals; import com.google.common.testing.EqualsTester; import org.junit.BeforeClass; import org.junit.Test; public class UserGuavaTest { private static EqualsTester equalsTester; private static User user1, user2; @BeforeClass public static void setUp() throws Exception { equalsTester = new EqualsTester(); user1 = new User("Tomasz", "Woliński", 1000); user2 = new User("Tomasz", "Woliński", 1000); } @Test public void testEquality() { equalsTester.addEqualityGroup(user1, user2); equalsTester.testEquals(); } }
Podsumowanie – hashCode i equals
Metody hashCode i equals są na tyle powszechne w Javie, że prędzej czy później każdy programista Java będzie musiał się z nimi zmierzyć. Warto oczywiście znać wszystkie warunki, jakie te metody muszą spełniać, jednak o ile to możliwe, warto unikać pisania ich ręcznie. Ich implementację lepiej powierzyć gotowym bibliotekom lub generatorom kodu. Dzięki czemu zaoszczędzimy nie tylko na samym czasie implementacji, ale również unikniemy konieczności ich testowania i ewentualnych błędów.
20+ BONUSOWYCH materiałów z programowania
e-book – „8 rzeczy, które musisz wiedzieć, żeby dostać pracę jako programista”,
e-book – „Java Cheat Sheet”,
checklista – „Pytania rekrutacyjne”
i wiele, wiele wiecej!