Java Class Design #6

Override the hashCode, equals, and toString methods from the Object class to improve the functionality of your class.

Wszystkie klasy w Javie niejawnie dziedziczą po klasie Object. Oznacza to, że w każdej klasie mamy do dyspozycji takie metody jak toString(), equals() oraz hashCode(). W tym artykule opiszę jak i w jakim celu je nadpisywać.


Na skróty:

toString() [Opis]
equals() [Opis]
hashCode() [Opis]
Object [API]

Repozytorium z przykładami [GitHub]


toString()

Na początek prosta metoda, która z założenia zwraca ciąg znaków reprezentujący dany obiekt. Metoda ta jest domyślnie wywoływana na przykład, kiedy dany obiekt chcemy wypisać na konsoli. W większości przypadków, jeśli jej nie nadpiszemy, zwróci ona adres, pod którym w pamięci zapisany jest dany obiekt.

public class ToStringExample {
  public static void main(String[] args){	
    Car car = new Car();
    System.out.println(car.toString());	
  }
}

class Car {
  String model = "Porshe";
  int year = 2016;
}

W tym przykładzie mamy klasę Car z ustawionymi na sztywno atrybutami model oraz year. Tworzymy jeden obiekt jej klasy i przekazujemy do wypisania go na konsoli. Wynik będzie podobny do tego:

com.javathehat.classdesign6.tostring.Car@6bc7c054

Możemy jednak sprawić, aby zamiast tego mało znaczącego ciągu znaków uzyskać całkiem zgrabny opis, który będzie o wiele bardziej przyjazny (i użyteczny) dla człowieka.

class Car {
  String model = "Porshe";
  int year = 2016;
  
  public String toString(){
    String result = "Car, model: " + model + ", production year: " + year;
    return result;
  }
}

Tak wygląda zmodyfikowana klasa Car, w której nadpisana została metoda toString(). Tym razem po wykonaniu kodu, na konsoli uzyskamy:

Car, model: Porshe, production year: 2016

Oczywiście toString() musi być nadpisana zgodnie z opisanymi wcześniej regułami nadpisywania metod w Javie.

Nadpisywanie toString() jest proste i nie ma tu za wiele pułapek, na które można by wpaść. Musimy tylko uważać, jeżeli w metodzie korzystamy z globalnych zmiennych, które są statyczne.

Zmienne statyczne są „wiązane” w trakcie kompilacji, a nie wykonania kodu.

Weźmy na przykład klasę Computer ze statyczną zmienną isLaptop ustawioną na false i nadpisaną metodą toString() korzystającą z tej zmiennej. Po klasie Computer dziedziczy klasa Laptop, która również ma zmienną isLaptop, tym razem ustawioną na true, ale za to, klasa ta nie nadpisuje toString(), tym samym korzystając z metody dostępnej z klasy rodzica.

class Computer {
  static boolean isLaptop = false;
  public String toString() {
    return "Laptop? " + isLaptop;
  }
}

class Laptop extends Computer{
  static boolean isLaptop = true;
}

W trakcie kompilacji metoda toSting() w klasie Computer zostanie powiązana z będącą „w zasięgu” zmienną isLaptop z tej właśnie klasy. Dlatego wywołanie kodu:

Computer computer1 = new Laptop();
Laptop computer2 = new Laptop();

System.out.println(computer1);
System.out.println(computer2);

zwróci dwa razy false:

Laptop? false
Laptop? false

equals()

O ile porównanie dwóch liczb czy nawet liter jest w miarę oczywiste, to porównywanie obiektów wymaga już jednak zastanowienia się. Kiedy dwa samochody są identyczne? Gdy mają taki sam kolor? Markę? Model? VIN? A może gdy stoją w tym samym garażu? Nadpisanie metody equals() jest bardzo ważne nie tylko, jeśli mamy zamiar porównywać ze sobą obiekty danej klasy, ale również jeśli np. zamierzamy te obiekty przechowywać w listach, mapach czy kolejkach. Metoda ta jest używana na przykład do sprawdzenia, czy taki obiekt już istnieje w zbiorze klasy Set, który nie przyjmuje duplikatów, jest używana w liście ArrayList, kiedy chcemy sprawdzić, czy lista zawiera już taki obiekt… takich zastosowań jest wiele.

Z nadpisywaniem equals() wiąże się trochę więcej potencjalnych pułapek niż z toString(). Pierwszą z nich jest sygnatura metody.

public boolean equals(Object obj) – argument jest typu Object, a nie konkretnej klasy, którą chcemy porównywać!

To NIE JEST prawidłowo nadpisana metoda equals():

public boolean equals(Car car){
  return this.year == car.year
}

Po drugie, jeśli już wiemy, że do equals() możemy przekazać dowolny obiekt, to w pierwszej kolejności musimy się upewnić, czy jest to faktycznie obiekt tej samej klasy, w której nadpisujemy metodę. Dopiero jeśli się upewnimy, że tak jest, to możemy przekazany obiekt rzutować na naszą klasę i zacząć porównywać odpowiednie atrybuty.

Tutaj widzimy klasę Book z takimi atrybutami jak tytuł, autor, rok wydania i ilość stron. Przyjąłem, że dwie książki są sobie równe, jeśli mają ten sam tytuł, autora i rok wydania. Przy porównaniu ignoruję ilość stron. Tak wygląda obiekt, wraz z konstruktorem i metodą equals() wygenerowaną przez środowisko Eclipse:

class Book {
  String title;
  String author;
  int pages;
  int year;
  
  public Book(String title, String author, int pages, int year) {
    super();
    this.title = title;
    this.author = author;
    this.pages = pages;
    this.year = year;
  }

  public boolean equals(Object obj) {
    if (this == obj)
      return true;
    if (obj == null)
      return false;
    if (getClass() != obj.getClass())
      return false;
    Book other = (Book) obj;
    if (author == null) {
      if (other.author != null)
        return false;
    } else if (!author.equals(other.author))
      return false;
    if (title == null) {
      if (other.title != null)
        return false;
    } else if (!title.equals(other.title))
      return false;
    if (year != other.year)
      return false;
    return true;
  }	
}

To nie jest jedyna słuszna wersja. Tak na prawdę to my decydujemy jakie obiekty mają być uważane za równe sobie, jeśli zakodujemy, że kwiatek i samochód to to samo, to tak będzie w naszym programie. Grunt, żebyśmy byli świadomi tego w trakcie pracy z aplikacją 🙂

W tym przypadku, w pierwszej kolejności sprawdzamy, czy porównywane obiekty mają ten sam adres w pamięci. Oznaczałoby, że jest to dokładnie ten sam obiekt, więc na pewno jest sobie równy. Następnie sprawdzamy czy nie porównujemy się z nullem i czy mamy do czynienia z obiektem tej samej klasy. Dopiero na końcu rzutujemy podany obiekt na typ Book i sprawdzamy, czy kluczowe dla nas zmienne są takiej samej wartości.

Musimy też mieć świadomość, że to, że nasza wersja metody equals() się skompiluje, nie oznacza, że działa ona poprawnie.

Prawidłowo nadpisana equals() musi spełniać 5 warunków:

  1. x.equals(x) zawsze zwraca true.
  2. Jeśli x.equals(y) zwraca true, to y.equals(x) również.
  3. Jeśli x.equals(y) zwraca true oraz y.equals(z) zwarca true, to x.equals(z) musi zwracać true.
  4. Jeśli x.equals(y) zwraca true, to ZAWSZE będzie zwracało true (dopóki nie zmienimy implementacji metody).
  5. x.equals(null) zwraca false, chyba x też jest nullem.

hashCode()

Ta metoda jest często niedoceniania bądź źle rozumiana przez programistów, a jest ona równie ważna co equals() i jej nie nadpisanie, lub nadpisanie błędnie może przysporzyć sporo trudnych do znalezienia błędów.

Zawsze, kiedy w swoim obiekcie nadpisujemy metodę equals(), powinniśmy również nadpisać metodę hashCode().

Jest to bardzo ważne, natomiast wbrew pozorom:

Te dwie metody nie korzystają z siebie nawzajem!

Metoda hashCode() zwraca int zawierający hash danego obiektu. Prawidłowo nadpisana, spełnia dwa podstawowe warunki:

  1. Jeśli dwa obiekty są sobie równe według metody equals(), to dla każdego z nich metoda hashCode() zwróci taką samą wartość.
  2. Jeśli obiekty są różne od siebie, wartość zwrócona przez hashCode() dla tych dwóch obiektów NIE MUSI być różna.

Po co właściwie jest ten cały hash? Jest on wykorzystywany w algorytmach haszujących, na przykład w takich klasach jak HashMap.

W trakcie dodawania nowego obiektu do mapy typu HashMap, najpierw wyliczany jest (za pomocą metody hashCode() ) hash tego obiektu. Następnie na podstawie tego hasha obiekt wrzucany jest do jednej z „szuflad” . Kiedy chcemy odnaleźć w mapie konkretny obiekt, najpierw za pomocą jego hasha określana jest szuflada, w której jest ten obiekt, a później wszystkie obiekty z szuflady sprawdzane są za pomocą metody equals() w celu znalezienia obiektu, który nas interesuje. W ten sposób nie ma potrzeby przeszukania wszystkich obiektów w mapie, obszar poszukiwań jest od razu zawężony. Jak bardzo? To już zależy od tego jak dobrze zostanie nadpisana metoda hashCode(). Do jednej szuflady trafiają tylko obiekty o takim samym hashu, więc hashCode() powinien być zaprojektowany tak, aby w miarę równomiernie rozłożyć wszystkie obiekty po szufladach. Jeżeli szuflad będzie za mało, equals() będzie wywoływany na zbyt dużej ilości obiektów, co spowolni przetwarzanie. Jeżeli szuflad będzie za dużo, ucierpi na tym pamięć. Niestety znalezienie złotego środka jest sporym wyzwaniem i przedmiotem całych książek.

Tak wygląda hashCode() wygenerowany przez Eclipse dla omawianej wcześniej klasy Book:

public int hashCode() {
  final int prime = 31;
  int result = 1;
  result = prime * result + ((author == null) ? 0 : author.hashCode());
  result = prime * result + ((title == null) ? 0 : title.hashCode());
  result = prime * result + year;
  return result;
}

Na szczęście egzamin nie będzie sprawdzał czy potrafimy optymalnie nadpisywać tą metodę. Powinniśmy się skupić na tym, aby robić to prawidłowo, czyli przede wszystkim zgodnie z wymienionymi wcześniej dwoma zasadami. Dobrą praktyką jest wykorzystanie do wyliczenia hasha pól których wartość nie będzie zmieniana, zdecydowanie powinniśmy też unikać wartości, które mogą się różnić w zależności od systemu, na którym uruchomimy aplikację.

Przejdźmy sobie przez przykład jak NIE robić, dlaczego źle nadpisana metoda może skutkować sporymi problemami.

class Card{
  int value;
  public boolean equals(Object obj){
    if(obj.getClass().equals(this.getClass())){
      Card other = (Card) obj;
      return this.value == other.value;
    }
    return false;
  }
  public int hashCode(){
    return Integer.valueOf((int) (Math.random() * 500));
  }
}

Klasa Card ma jedną zmienną typu int. Dwa obiekty tej klasy są sobie równie, jeśli zmienna ma taką samą wartość. Natomiast w metodzie hashCode() za każdym razem zwracamy losową wartość.

Utwórzmy zatem obiekt tej klasy, nadajmy wartość polu value i włóżmy ten obiekt do mapy typu HashMap:

Card c1 = new Card();
c1.value = 10;

HashMap<Card, String> map = new HashMap<Card, String>();
map.put(c1, "karata c1");

System.out.println(map.get(c1));

Kiedy spróbujemy wyjąć ten obiekt, jest wysoce prawdopodobne, że dostaniemy null, ponieważ hashCode() zwróci inną wartość, przez co obiekt będzie szukany w zupełnie innej, pustej szufladzie. Reasumując, mimo, że obiekt jak najbardziej jest przechowywany w mapie, to my dostajemy informację, że wcale go tam nie ma.

To przedostatni artykuł z tego działu, zostało jeszcze krótkie omówienie paczek i słowa kluczowego import.