Equals i hashCode – co może pójść nie tak?

Equals i hashCode

Wpisując w google zapytanie w stylu:

java interview questions

Bardzo prawdopodobne, że natrafimy na pytanie np.:

Na czym polega kontrakt między equals i hashCode?

I nie ma co się dziwić, takie pytanie pada na większości rekrutacji na Juniora i potrafi od razu rozpoznać czy kandydat na stanowisko ma pojęcie o podstawach Javy.

Niestety zdarza się tak, że kandydaci wręcz śpiewająco klepią formułkę wspomnianego wyżej kontraktu, ale czy aby na pewno wiedzą jakie dokładnie konsekwencje niesie ze sobą użycie lub nie użycie tych dwóch metod?

Abyś uniknął opisanego wyżej zjawiska, pokażę Ci w tym artykule kilka przypadków użycia metod equals i hashCode w praktyce.

Mam nadzieję, że podane przykłady pozwolą Ci już w 100% zrozumieć działanie tych metod.

Kontrakt

Na początek, na szybko przypomnę Ci jak wygląda kontrakt między metodą equalsh i hashCode. W skrócie prezentuje się to tak:

  1. Metoda hashCode dla tych samych wartości pól zawsze powinna zwracać tą samą wartość.
  2. Jeżeli dwa obiekty są sobie równe to ich hashe powinny być takie same.
  3. Dwa różne obiekty mogą mieć ten sam hash.

Nie będę teraz szczegółowo omawiał każdy z nich – jeśli nie masz jeszcze pewności na czym polega ten kontrakt to zajrzyj do tego artykułu, który jest m.in. o porównywaniu obiektów w Javie.

Warsztat

Nie chcę już „rzucać” w tym artykule więcej teorii, przejdźmy jak najszybciej do przykładów.

Wszystkie przykłady są oparte o HashMap, ponieważ jak już prawdopodobniesz wiesz, metody takie jak equals i hashCode są wykorzystywane w implementacjach niektórych struktur danych np. Map, Set.

Na warsztat wejdą dokładnie trzy przypadki testowe, wszystkie z nich będą oparte o prostą klasę User, w której pole ID i login są stałe, zaś wiek może się zmieniać w czasie działania aplikacji.

public class User {
    final int id;
    final String login;
    int age;

    public User(int id, String login, int age) {
        this.id = id;
        this.login = login;
        this.age = age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Wspomniane przypadki bedą takie:

  1. Klasa User bez przysłoniętych metod equals i hashCode;
  2. Klasa User z wygenerowanymi w IntelliJ Idea metodami equals i hashCode;
  3. Klasa User z punktu drugiego ze szczyptą naszej modyfikacji.

Nie ma co zwlekać, przejdźmy do przetestowania wyżej opisanych przypadków.

W skrócie, w testach będę umieszczał obiekty typu User w Mapie, wykonywał na nich pewne operacje i na koniec próbował je stamtąd wykaraskać. Zobaczymy z jakim efektem.

Do testowania wykorzystam bibliotekę JUnit 5 w połączeniu z AssertJ – struktura testów nie powinna Ci przeszkadzać w zrozumieniu kodu. Jeśli tak nie jest to daj znać w komentarzu, a postaram się to uprościć jeszcze bardziej lub coś doprecyzować w odpowiedzi. 😉

1. Brak metod equals i hashCode

Zacznę po kolei – czyli na pierwszy ogień idzie czysta klasa.

public class User {
    final int id;
    final String login;
    int age;

    public User(int id, String login, int age) {
        this.id = id;
        this.login = login;
        this.age = age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Pierwszy test sprawdza, czy po wsadzeniu użytkownika jako klucz do mapy uda nam się wyciągnąć wsadzaną tam wartość.

    @Test
    void shouldReturnUserFromMap() {
        //given
        User user = new User(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        List<String> values = map.get(user);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }

Zauważ, że wartość wyciągamy używając oryginalnego obiektu, który jest kluczem. Jak można się domyśleć test przeszedł pomyślnie.

Przejdźmy do przypadku, gdy do wyciągnięcia wartości z mapy użyjemy klucza, ale tym razem jego kopii – oba obiekty będą miały te same wartości.

    @Test
    void shouldReturnUserFromMapWhenGetValueUsingCopyOfKey() {
        //given
        User user = new User(1, "Pablo", 10);
        User pablo = new User(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        List<String> values = map.get(pablo);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }

Jak może już się domyśliłeś, test nie przeszedł z sukcesem. Wytłumaczenie tego jest proste, dla mapy obiekty pablo oraz user są dwoma różnymi obiektami pomimo tego, że mają te same wartości pól.

Wniosek – do porównywania obiektów na podstawie pól potrzebujemy zaimplementować metodę equals i hashCode, w przeciwnym wypadku obiekty są porównywane na podstawie referencji.

2. User z wygenerowanym equals i hashCode

Skoro mamy już pierwsze wnioski, to weźmy tym razem klasę, która ma przysłonięte metody equals i hashCode. Z powodu, że jestem trochę leniwy to w tym przypadku metody są wygenerowane przez moje IDE tj. IntelliJ Idea.

Klasa przedstawia się tym razem tak:

import java.util.Objects;

public class UserWithGeneratedEqualsAndHashCode extends User {
    public UserWithGeneratedEqualsAndHashCode(int id, String login, int age) {
        super(id, login, age);
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserWithGeneratedEqualsAndHashCode that = (UserWithGeneratedEqualsAndHashCode) o;
        return this.id == that.id &&
                age == that.age &&
                Objects.equals(login, that.login);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, login, age);
    }
}

Skorzystałem z dziedziczenia po klasie User, aby uniknąć niepotrzebnej duplikacji kodu.

Przejdźmy, więc ponownie do napisania testów – na razie takich jak w przypadku pierwszym, aby upewnić się, że takie podejście jest lepsze.

class UserWithGeneratedEqualsAndHashCodeTest implements WithAssertions {
    @Test
    void shouldReturnUserFromMap() {
        //given
        User user = new UserWithGeneratedEqualsAndHashCode(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        List<String> values = map.get(user);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }

    @Test
    void shouldReturnUserFromMapWhenGetValueUsingCopyOfKey() {
        //given
        User user = new UserWithGeneratedEqualsAndHashCode(1, "Pablo", 10);
        User pablo = new UserWithGeneratedEqualsAndHashCode(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        List<String> values = map.get(pablo);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }
}

Za dużo w testach się nie zmieniło, zmieniłem tylko tworzenie obiektów UserWithGeneratedEqualsAndHashCode zamiast User.

I tym razem testy przechodzą, zanim jednak przejdziemy do wyciągania wniosków z tego przykładu, napiszę jeszcze jeden test.

    @Test
    void shouldReturnUserFromMapEvenWhenUserStateHasChanged() {
        //given
        User user = new UserWithGeneratedEqualsAndHashCode(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        user.setAge(15);
        List<String> values = map.get(user);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }

Tym razem w teście sprawdziłem czy uda mi się wyciągnąć odpowiednią wartość z mapy, pomimo tego, że zmieniłem wewnętrzny stan obiektu user.

Test oczywiście nie przeszedł, z prostego względu – implementacja equals i hashCode wygląda jak wygląda. Przypomnę ją, abyś wiedział o czym mówię.

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserWithGeneratedEqualsAndHashCode that = (UserWithGeneratedEqualsAndHashCode) o;
        return this.id == that.id &&
                age == that.age &&
                Objects.equals(login, that.login);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, login, age);
    }

Jak możesz zauważyć, obie metody w swoich implementacjach wykorzystują wszystkie pola klasy User. Dlatego po zmianie wieku użytkownika, wszystko nam się sypie – i obie metody dają już inne wartości niż na początku.

Czy to dobrze? Odpowiedź brzmi – to zależy.

W przypadku, który Ci przedstawiłem, jest to błędne zachowanie, ponieważ na początku zaznaczyłem, że wiek użytkownika może się zmienić (na co też wskazuje pole niefinalne), zaś jego zmiana w aktualnym przypadku daje efekt niezamierzony. Krótko mówiąc, po zmianie wieku użytkownika dla mapy jest to całkiem inny użytkownik.

Wniosek z tego przykładu jest jeden, nigdy nie bazuj tylko na gotowych metodach equals i hashCode i miej świadomość, które pola powinny być uwzględnione w ich implementacji, dzięki czemu unikniesz zdziwienia, gdy z mapy zostanie zwrócony null zamiast poprawnej wartości.

3. User z przykładu drugiego ze szczyptą poprawek

Skoro już wiesz, że poprzednia implementacja equals i hashCode nie była zbyt poprawna w naszym przypadku, to postarajmy się teraz ją zmienić – minimalnie.

Z poprzednich testów wynika, że zmiana wieku użytkownika nie powinna być równoznaczna z zapomnieniem o danym użytkowniku. Dlatego też, z metod equals i hashCode usuńmy wszystko co związane z polem age.

public class UserWithOwnImplementationOfEqualsAndHashCode extends User {
    public UserWithOwnImplementationOfEqualsAndHashCode(int id, String login, int age) {
        super(id, login, age);
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, login);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserWithOwnImplementationOfEqualsAndHashCode that = (UserWithOwnImplementationOfEqualsAndHashCode) o;
        return id == that.id &&
                login.equals(that.login);
    }
}

Tym razem, po świadomej implementacji metod hashCode i equals mamy pewność, że identyfikacja obiektu jest oparta tylko na pola finalne: login oraz id.

Czy aby na pewno? Sprawdźmy to kilkoma testami jak poprzednio.

package pl.blog.spring;

import org.assertj.core.api.WithAssertions;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class UserWithOwnImplementationOfEqualsAndHashCodeTest implements WithAssertions {
    @Test
    void shouldReturnUserFromMap() {
        //given
        User user = new UserWithOwnImplementationOfEqualsAndHashCode(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        List<String> values = map.get(user);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }

    @Test
    void shouldReturnUserFromMapWhenGetValueUsingCopyOfKey() {
        //given
        User user = new UserWithOwnImplementationOfEqualsAndHashCode(1, "Pablo", 10);
        User pablo = new UserWithOwnImplementationOfEqualsAndHashCode(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        List<String> values = map.get(pablo);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }

    @Test
    void shouldReturnUserFromMapEvenWhenUserStateHasChanged() {
        //given
        User user = new UserWithOwnImplementationOfEqualsAndHashCode(1, "Pablo", 10);
        Map<User, List<String>> map = new HashMap<>();
        map.put(user, Collections.singletonList("Value1"));

        //when
        user.setAge(15);
        List<String> values = map.get(user);

        //then
        assertThat(values)
                .hasSize(1)
                .contains("Value1");
    }
}

Testy przekopiowałem z poprzedniego przykładu, zmieniłem tylko tworzenie obiektu typu UserWithGeneratedEqualsAndHashCode na UserWithOwnImplementationOfEqualsAndHashCode i okazuje się, że wszystkie testy przechodzą.

Cel osiągnięty, w trzech prostych przypadkach sprawdziliśmy jak działa metoda equals i hashCode.

Wniosek tym razem jest krótki – implementację equals i hashCode należy pisać generować z głową.

Podsumowanie

Mam nadzieję, że po przejściu przez powyższe trzy przypadki implementacji metod equals i hashCode – lub jej braku – masz już 100% pewności jak one działają i żaden bug w projekcie już Cię nie zaskoczy. Oczywiście ten związany m.in. z umieszczaniem obiektów w kolekcjach.

Kod, który został pokazany w tym artykule możesz przejrzeć ściągając go z tego repozytorium.