CodeGym /Kursy /JAVA 25 SELF /equals, hashCode, toString: autogenerowanie

equals, hashCode, toString: autogenerowanie

JAVA 25 SELF
Poziom 22 , Lekcja 2
Dostępny

1. Automatyczne generowanie equals, hashCode, toString

Po co są te metody?

Pracując z obiektami w Javie, dość szybko trafiasz na te same zadania. Czasem trzeba sprawdzić, czy dwa obiekty są równe. Na przykład ustalić, czy obiekt już jest w kolekcji takiej jak Set lub Map. W innych przypadkach obiekt jest używany jako klucz w HashMap i wtedy bez specjalnych zasad porównywania ani rusz. A jeszcze niemal zawsze chcesz wydrukować obiekt w logu lub na ekranie tak, aby wynik nie był bełkotem w rodzaju MyClass@7b23ec81, tylko czymś sensownym.

Właśnie na takie sytuacje każda klasa w Javie ma trzy szczególne metody:

  • equals(Object o) odpowiada za sprawdzanie równości.
  • hashCode() daje obiektowi numeryczny „odcisk”, potrzebny kolekcjom pokroju tablic haszujących.
  • toString() zwraca wygodną tekstową reprezentację obiektu, co bardzo ułatwia debugowanie i drukowanie.

Dlaczego to bywa bolesne w zwykłych klasach?

W zwykłych klasach te metody trzeba pisać ręcznie. I tu zaczyna się nuda i ból głowy. Powstaje masa szablonowego kodu, który tylko zaśmieca klasę. Bardzo łatwo się gdzieś pomylić: zapomnieć porównać pole, źle policzyć hashCode, a potem łapać zagadkowe bugi. A jeśli do klasy dodasz nowe pole — trzeba będzie znów zajrzeć do tych metod i wszystko poprawić.

Przykład zwykłej klasy


public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return 31 * x + y;
    }

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

Brzmi znajomo? Tak, i to tylko dla dwóch pól! A co, jeśli jest ich dwadzieścia?

Jak robi to record

Klasa record robi to wszystko za ciebie. Wystarczy zadeklarować:


public record Point(int x, int y) { }

I Java sama wygeneruje:

  • Konstruktor
  • Gettery (x(), y())
  • equals, hashCode, toString

Automatycznie wygenerowane metody

  • equals porównuje wszystkie komponenty rekordu po wartości.
  • hashCode jest obliczany na podstawie wszystkich komponentów.
  • toString zwraca ciąg w postaci Point[x=1, y=2].

Zobaczmy to w praktyce!


public record Point(int x, int y) {}

public class Demo {
    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);

        System.out.println(p1.equals(p2)); // true
        System.out.println(p1.hashCode() == p2.hashCode()); // true
        System.out.println(p1); // Point[x=1, y=2]
    }
}

Wynik:

true
true
Point[x=1, y=2]

Wszystko działa zgodnie z oczekiwaniami — bez ani jednej zbędnej linijki kodu!

2. Dlaczego to jest ważne: kolekcje, debugowanie i bezpieczeństwo

Poprawne działanie w kolekcjach

Wyobraź sobie, że używasz obiektów jako kluczy w HashMap lub elementów w HashSet. Jeśli equals i hashCode są zaimplementowane błędnie — kolekcje będą zachowywać się dziwnie: nie znajdą elementu, który właśnie dodałeś, albo przeciwnie — uznają dwa różne obiekty za identyczne.

Z klasami record możesz mieć pewność: porównanie i hash zawsze uwzględniają wszystkie komponenty rekordu (w kolejności, w której zostały zadeklarowane).

Przykład: użycie rekordu jako klucza


import java.util.HashMap;
import java.util.Map;

public class Demo {
    public static void main(String[] args) {
        record Point(int x, int y) {}

        Map<Point, String> map = new HashMap<>();
        Point p1 = new Point(3, 4);
        map.put(p1, "Hello!");

        Point p2 = new Point(3, 4);
        System.out.println(map.get(p2)); // "Hello!" — działa!
    }
}

Zwróć uwagę: p1 i p2 to różne obiekty (różne referencje), ale zawierają takie same wartości pól, więc są uznawane za równe. A więcej o Map i HashMap poznasz na poziomie 26 :P

Wygoda debugowania i logowania

Zamiast smutnego Point@1a2b3c4d (jak bywa domyślnie w zwykłych klasach) rekord wypisuje się ładnie i informacyjnie:

Point[x=3, y=4]

To świetnie oszczędza czas podczas debugowania i logowania.

3. Jak działają equals, hashCode, toString wewnątrz rekordu

Metoda equals

Rekord implementuje equals tak, że dwa obiekty są uznawane za równe, jeśli:

  • Są tego samego typu (tej samej klasy rekordu)
  • Wszystkie ich komponenty są równe (== dla prymitywów, equals() dla obiektów)

Przykład porównania


Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point p3 = new Point(1, 3);

System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false

Metoda hashCode

Kod skrótu jest obliczany na podstawie wszystkich komponentów rekordu, zwykle za pomocą standardowej metody Objects.hash(...).


System.out.println(p1.hashCode()); // Na przykład 994
System.out.println(p2.hashCode()); // Też 994
System.out.println(p3.hashCode()); // Inna liczba

Metoda toString

Reprezentacja tekstowa ma zawsze format:

ClassName[field1=value1, field2=value2, ...]

System.out.println(p1); // Point[x=1, y=2]

4. Nadpisywanie equals, hashCode, toString: kiedy i jak?

Czasem (rzadko, ale bywa) trzeba zmienić standardowe zachowanie tych metod. Na przykład chcesz, aby toString zwracał ciąg w innym formacie, albo żeby porównanie odbywało się tylko po części pól.

Uwaga: jeśli nadpisujesz equals/hashCode, rób to bardzo świadomie! Naruszenie ich „kontraktu” może prowadzić do błędów, które trudno wychwycić.

Jak nadpisać metodę

Po prostu zdefiniuj własną metodę wewnątrz ciała rekordu:


public record Point(int x, int y) {
    @Override
    public String toString() {
        return "(" + x + "; " + y + ")";
    }
}

Point p = new Point(3, 5);
System.out.println(p); // (3; 5)

Czy można nadpisać equals/hashCode?

Tak, ale jest to zdecydowanie odradzane, jeśli nie masz pewności, co robisz. Na przykład gdy chcesz porównywać tylko po polu x (co już jest dziwne):


public record Point(int x, int y) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point other)) return false;
        return x == other.x;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(x);
    }
}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 999);

System.out.println(p1.equals(p2)); // true (!)

Ale bądź ostrożny: jeśli nadpisujesz equals, zawsze nadpisz też hashCode — w przeciwnym razie kolekcje będą działać niepoprawnie.

Dobre praktyki

  • Jeśli nie wiesz dokładnie, po co nadpisywać — nie nadpisuj!
  • Dla toString — możesz śmiało zrobić własny format, jeśli chcesz.
  • Dla equals/hashCode — tylko jeśli masz ważny powód i rozumiesz konsekwencje.

5. Praktyka: porównywanie obiektów i użycie rekordów w kolekcjach

Przykład: porównanie dwóch obiektów record


public record User(String name, int age) {}

public class Demo {
    public static void main(String[] args) {
        User u1 = new User("Alice", 20);
        User u2 = new User("Alice", 20);
        User u3 = new User("Bob", 25);

        System.out.println(u1.equals(u2)); // true
        System.out.println(u1.equals(u3)); // false

        System.out.println(u1.hashCode() == u2.hashCode()); // true
        System.out.println(u1); // User[name=Alice, age=20]
    }
}

Przykład: użycie rekordu jako klucza w HashMap

Wyobraźmy sobie, że mamy aplikację, w której przechowujemy liczbę odwiedzin użytkowników według ich imienia i wieku (kto wie — może w klubie są dwaj „Ivan, 20 lat”).


import java.util.HashMap;
import java.util.Map;

public class Demo {
    public static void main(String[] args) {
        record User(String name, int age) {}

        Map<User, Integer> visits = new HashMap<>();
        User ivan20 = new User("Ivan", 20);
        User ivan22 = new User("Ivan", 22);

        visits.put(ivan20, 5);
        visits.put(ivan22, 2);

        // Sprawdźmy, że wyszukiwanie po wartości działa poprawnie
        System.out.println(visits.get(new User("Ivan", 20))); // 5
        System.out.println(visits.get(new User("Ivan", 22))); // 2
    }
}

Gdyby equals i hashCode nie były zaimplementowane poprawnie, wyszukiwanie by nie zadziałało. A więcej o Map i HashMap dowiesz się z wykładów poziomu 26 :P

6. Typowe błędy przy pracy z equals, hashCode, toString w klasach record

Błąd nr 1: Oczekiwanie, że pola można zmieniać po utworzeniu.
Pola rekordu są zawsze final, a porównanie odbywa się na podstawie ich wartości nadanych w konstruktorze. Jeśli jakimś podstępnym sposobem „zmieniasz” stan wewnętrzny (np. przez modyfikowalny obiekt wewnątrz pola), porównanie i hash mogą stać się niepoprawne.

Błąd nr 2: Nadpisano equals, ale zapomniano o hashCode.
Jeśli nadpisujesz jedną z tych metod — zawsze nadpisuj też drugą! W przeciwnym razie kolekcje (HashSet, HashMap) będą zachowywać się nieprzewidywalnie.

Błąd nr 3: Oczekiwanie, że toString będzie w innym formacie.
Jeśli potrzebujesz szczególnego formatu ciągu — po prostu nadpisz toString. Domyślnie format to zawsze ClassName[field1=value1, field2=value2].

Błąd nr 4: Używanie rekordu dla złożonych klas z polami modyfikowalnymi.
Pola rekordu powinny być niezmienialne. Jeśli jako pole wykorzystujesz na przykład ArrayList i ktoś zmienia jego zawartość — porównanie i kod skrótu mogą się „rozsypać”. Do rekordów najlepiej używać tylko typów niezmienialnych.

Błąd nr 5: Używanie rekordu dla klas, które nie są obiektami wartości (value object).
Record to nie „mała klasa z krótką składnią”. To właśnie value object, przeznaczony do przechowywania zestawu wartości. Jeśli masz złożoną logikę, stan modyfikowalny lub potrzebę dziedziczenia — użyj zwykłej klasy.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION