Przesłonięcie metod equals i hashCode

0

Cześć

Używałem wcześniej kolekcji w javie ale nie przykładałem wagi do nadpisywania metod equals i hashCode. Doczytałem o kontrakcie między tymi metodami ale nie wiem jak miał by wyglądać przypadek gdzie brak tych metod wywołał by błąd w założeniach, np. przy mapach.

public class T2 
{
	public String zmienna3;
	public String zmienna4;
	public static void main(String[] args) 
	{
		T2 t2 = new T2();
		t2.zmienna3 = "test";
		t2.zmienna4 = "test";
		if(t2.zmienna3.hashCode() == t2.zmienna4.hashCode())
		{
			System.out.println("równe");
		}else
		{
			System.out.println("Nierówne");
		}
	}
}

W tym przykładzie przy obu metodach dostaję wartość true. Wiem, że lepiej było by to zobrazować na kolekcjach ale kiedy i używałem to wszystko mi działało tak jak chciałem.
Jak wyglądał by przykład gdzie brak przesłonięcia tych metod spowodował by błąd (wiem, że nie chodziu tu o błąd kompilacji tylko o to, że np. dostanę dwa rekordy w kolekcji gdzie nie powinno być powtórzeń)?

0

Stwórz drugi obiekt typu T2 i nadaj zmiennym te same wartości. Nie porównuj hashcode zmiennych tylko obiektów typu T2. Potem dodaj metodę hashcode i zobacz różnicę :)

3

Żeby zobrazować jaki co ma wpływ zrobimy tak - weżmiemy HashSet i będziemy wrzucać do niego 100.000 (100k) różnych elementów T2. Ale żeby było zabawniej każdy po 10 razy.
Ponieważ to HashSet (czyli Set) to na koniec powinniśmy mieć 100000 elementów (bo powtorki wypadną). I jeszcze zobaczmy ile to trwa.
Pomiary czasu sa bardzo nieprofesjonalne i robione na VMce, gdzie w tle działa dużo aplikacji (nie chce mi się wyłacząć) - więc tylko pi razy oko oddają co wychodzi... ale coś będzie widać.

Na początek wersja bez equals i hashCode

public class T2 {

    public final String zmienna3;
    public final String zmienna4;

    public T2(String zmienna3, String zmienna4) {
        this.zmienna3 = zmienna3;
        this.zmienna4 = zmienna4;
    }

    public static void main(String[] args) {
        putAndCount(10_000);//to rozgrzewka
        long startTime = System.currentTimeMillis();
        System.out.println("Elementów=" + putAndCount(100_000));
        final long endTime = System.currentTimeMillis();
        System.out.println("czas="+ (endTime-startTime));


    }


    public static int putAndCount(int size) {
        final HashSet<T2> mySet = new HashSet<>();
        for ( int j = 0; j < 10; j++) {
            for ( int  i = 0; i < size; i++) {
                final T2 value  = new T2("x="+i, "y"+i);
                mySet.add(value);
            }
        }
        return mySet.size();
    }

    
}

Wynik:

Elementów=1000000
czas=1509

Od razu widać kiszkę, bo elementów wypadło nam milion, a powinno 100 000. No ale skad ten biedny HashSet mial wiedzieć, że wrzucamy powtórki, jak nie było equals()?
Dorzucamy equals:

@Override
   public boolean equals(Object o) {
       if (this == o) return true;
       if (o == null || getClass() != o.getClass()) return false;
       T2 t2 = (T2) o;
       return Objects.equals(zmienna3, t2.zmienna3) &&
               Objects.equals(zmienna4, t2.zmienna4);
   }

(to z intellij wygenerowane. ale brzydactwo).
Wynik:

Elementów=1000000
czas=1346

Czyli czas nieco krótszy (dziwne), ale elementów nadal milion. Sam equals nie pomógł.
Dodajmy zatem hashCode(). Wygenerowany róznież z automatu.

@Override
    public int hashCode() {
        return Objects.hash(zmienna3, zmienna4);
    }

Wynik:

Elementów=100000
czas=270

No i wreszcie. jest wynik taki jak trzeba. I całkiem krótki czas.

Dlaczego sam equals nie działa? Bo HashSet najpierw porównuje tylko hashCody, i jak sa różne (a jeśli nie pokryjemy hashCode to mamy duże szanse) to po prostu od razu uznaje obiekty za różne. (Pech)
Dopiero gdy dwa obiekty mają równy hashCode to wtedy jest sprawdzanie equlsem, które o równości przesądza. Można powiedzieć, że hashCode to taki "bardzo zgrubny equals". I zasada jest taka, jeśli obiekty są equals to powinny mieć równy hashCode. Inaczej kiełbasa. (patrz przykład z samym equals).

No dobra. Ale z tego też wynika, że jeśli obiekty są różne wg. equals to wcale nie muszą mieć różnego hashCode! Nie, nie muszą! Różne obiekty nawet często mają ten sam hashCode. No bo to w końcu jeden int. Wiec jak klasa ma dwa pola int... to coś się musi powtórzyć.

Co więc jeśli zrobimy extremalnie zabawny hashCode?:

 @Override
    public int hashCode() {
        return 42;
    }

Czy wyjdzie dobry wynik?
Wyjdzie.
Jaki bedzie czas?
Nie wiem, to się nadal liczy.....
EDIT: właśnie się policzyło.

Elementów=100000
czas=3005740

Żeby zrozumieć skąd jest taka katastrofa, to trzeba by wiedzieć się jak jest wewnętrznie zorganizowany HashSet (zachęcam do samodzielnego doczytania (w zasadzie o HashMap bo Haset javowy działa w oparciu o HashMap, wtedy będzie jasne).

Widać możliwy skutek nie do końca dobrego hashCode. HashCode powinien się liczyć szybko i dla różnych elementów powinien starać się dawać różne wyniki. Starać się, bo różnych zawsze dać nie może.
Przy okazji "pośredni hashCode" (tylko na jednym polu)

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

Daje całkiem ok wyniki.

Elementów=100000
czas=151

Dobry wynik i całkiem dobry czas. (bo liczenie hashCodu szybsze).

Akurat u nas tak jak te elementy wsadzaliśmy do hashSet ( jeśli zmienna3 była różna to od razu zmienna4 też była różna).

Tu też widać ważną cechę hashCode. HashCode powinien być dopasowany do tego co i jak wrzucamy (czyli do scenariusza/ biznesu). Automatycznie wygenerowany hashCode często jest nieoptymalny, a nawet dramatycznie zły. (Trzeba mieć pecha, ale są notowane takie przypadki).

1

Ja jeszcze dodam bardzo ważną sprawę. Poniższa metoda zawsze zwróci true:

boolean metoda() {
  String x = "aaa";
  String y = "aaa";
  return x == y;
}

Dzieje się tak dlatego, że wszystkie literały (wartości wprost) z klasy lądują w puli stałych (czyli w sekcji pliku .class) i te na etapie kompilacji są deduplikowane. Kompilator sprawdzi sobie, że "aaa" i "aaa" to te same Stringi, więc zapisze tylko jeden i tylko jeden potem będzie używany w czasie wykonywania. Jeśli chcesz ominąć tą deduplikację w czasie kompilacji to niezawodnym rozwiązaniem jest zrobienie łączenia stringów na etapie wykonania, np:

boolean metoda(String param) {
  String a = "a" + param;
  String b = "a" + param;
  return a == b;
}

Przykład: https://www.ideone.com/9yPLOd

Możesz sobie poguglać "java constant pool". Przykładowa strona z wyjaśnieniami: https://stackoverflow.com/q/10209952

Kolejna sprawa:
Przesłonięcie, a nadpisanie metody to dwie zupełnie różne rzeczy.

0

Bardzo dziękuję za obszerną odpowiedź.
Dodałem napisany przez Ciebie kod i zwraca mi:

Elementów=1
czas=92

Mój kod:

public class T2 
{
	public final String zmienna3;
	public final String zmienna4;
	
	public T2(String zmienna3, String zmienna4)
	{
		this.zmienna3 = zmienna3;
		this.zmienna4 = zmienna4;
	}
	public static void main(String[] args) 
	{

		putAndCount(10_000); //to rozgrzewka
        long startTime = System.currentTimeMillis();
        System.out.println("Elementów=" + putAndCount(100_000));
        final long endTime = System.currentTimeMillis();
        System.out.println("czas="+ (endTime-startTime));
        
	}
	
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        T2 t2 = (T2) o;
        return Objects.equals(zmienna3, t2.zmienna3) &&
                Objects.equals(zmienna4, t2.zmienna4);
    }
	
    public int hashCode() {
        return Objects.hash(zmienna3, zmienna4);
    }
    
	public static int putAndCount(int size)
	{
		final HashSet<T2> mySet = new HashSet<>();
		for (int j = 0; j < 10; j++)
		{
			for(int i = 0; i < size; i++)
			{
				final T2 value = new T2("x= " + 1, "y= " + 1);
				mySet.add(value);
			}
		}
		return mySet.size();
	}
}

korzystam z Eclipsa i kiedy chcę wygenerować te metody to wyglądają zupełnie inaczej niż te od Ciebie:

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((zmienna3 == null) ? 0 : zmienna3.hashCode());
		result = prime * result + ((zmienna4 == null) ? 0 : zmienna4.hashCode());
		return result;
	}
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		T2 other = (T2) obj;
		if (zmienna3 == null) {
			if (other.zmienna3 != null)
				return false;
		} else if (!zmienna3.equals(other.zmienna3))
			return false;
		if (zmienna4 == null) {
			if (other.zmienna4 != null)
				return false;
		} else if (!zmienna4.equals(other.zmienna4))
			return false;
		return true;
	}

Czy to kwestia narzędzia, którego używam?

1

To mi zabiłeś ćwieka... ale na szczęście cuda tak często się nie zdarzają:
Masz taką linijkę:

 final T2 value = new T2("x= " + 1, "y= " + 1);

zamiast

 final T2 value = new T2("x= " + i, "y= " + i);

Wrzucałeś zawsze jeden i ten sam T2 (x=1, y=1).

Co do różnych implementacji hashCode i equals przez rózne IDE - nie ma to większego znaczenia. Co do działania są takie same.

1 użytkowników online, w tym zalogowanych: 0, gości: 1