Implementacja hashCode i equals dla encji

Jak poprawnie zaimplementować hashCode() i equals() dla encji? Dokumentacja do Hibernate, daje prostą wskazówkę: użyj klucza biznesowego o ile taki istnieje. Co jednak jeśli takiego klucza nie ma? Czy istnieje dobra dobre rozwiązanie lub chociaż dostatecznie dobre 🙂

By sprawdzić jakie problemy mogą wystąpić z różnymi implementacjami, zbuduję proste narzędzie weryfikujące(source code). Metody equals() i hashCode() będę testował dla klasy zawierającej pole odpowiadające PK oraz inna właściwość:

public class SomeClass {

  private Integer id;
  private String name;

  public SomeClass() {
    this(null, null);
  }

  public SomeClass(Integer id, String name) {
    this.id = id;
    this.name = name;
  }

  // ... dla uproszczenia dalej geterry i settery
}

Oto testy sprawdzające podstawowe warunki nakładane na equals() przez Javadoc:

	@Test
	public void equalsIsReflective() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = new SomeClass(null, "other");

		// then
		assertEqualsIsRefelective(one);
		assertEqualsIsRefelective(three);
	}

	private void assertEqualsIsRefelective(Object one) {
		assertTrue(one.equals(one));
	}

	@Test
	public void equalsIsSymetric() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = new SomeClass(null, "other");

		// then
		assertEqualsIsSymetric(one, three);
	}

	private void assertEqualsIsSymetric(Object one, Object three) {
		boolean isEquals = one.equals(three);
		assertEquals(three.equals(one), isEquals);
	}

	@Test
	public void equalsIsTransitive() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass two = new SomeClass(1, "two");
		SomeClass three = new SomeClass(1, "other");

		// then
		assertEqualsIsTransitive(one, two, three);
	}

	private void assertEqualsIsTransitive(Object one, Object two, Object three) {
		if(one.equals(two) && two.equals(three)) assertTrue(one.equals(three));
	}

	@Test
	public void equalsIsFalseForNull() {
		SomeClass one = new SomeClass(1, "one");
		assertEqualsIsFalseForNulls(one);
	}

	private void assertEqualsIsFalseForNulls(Object one) {
		assertFalse(one.equals(null));
	}

Podobnie dla metody hashCode():

	@Test
	public void hashCodeForeEqualObjectIsEqual() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass two = new SomeClass(1, "two");

		// then
		assertHashCodeTheSameWhenEquals(one, two);
	}

	private void assertHashCodeTheSameWhenEquals(Object one, Object two) {
		if (one.equals(two))
			assertTrue(one.hashCode() == two.hashCode());
	}

	@Test
	public void hashCodeDontChangeWhenNotEqualDependentFieldChanges() {
		// given
		SomeClass one = new SomeClass(1, "one");
		int hashCode = one.hashCode();

		// when
		one.setName("other");

		// then
		assertEquals(one.hashCode(), hashCode);
	}

Oto kilka dodatkowych warunków, pozwalających na efektywne korzystanie ze standardowych kolekcji Java:

	@Test
	public void shouldFindInHashSet_IdNullAndNotNull() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = new SomeClass(null, "three");

		// when
		Set set = new HashSet();
		set.add(one);
		set.add(three);

		// then
		assertTrue(set.contains(one));
		assertTrue(set.contains(three));
		assertEquals(set.size(), 2);
	}

	@Test
	public void shouldFindInHashMap_IdNullAndNotNull() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = new SomeClass(null, "three");

		// when
		Map set = new HashMap();
		set.put(one, one.getName());
		set.put(three, three.getName());

		// then
		assertTrue(set.containsKey(one));
		assertTrue(set.containsKey(three));
		assertEquals(set.size(), 2);
	}

	@Test
	public void shouldDistinguish_IdNullAndNotNull() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = new SomeClass(null, "three");

		assertNotEquals(three, one);
	}

	@Test
	public void shouldNotDistinguish_SameId() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass other = new SomeClass(1, "other");

		// then
		assertTrue(one.equals(other));
		assertTrue(other.equals(one));
	}

	@Test
	public void shoulDistinguish_twoNullId() {
		// given
		SomeClass one = new SomeClass(null, "one");
		SomeClass two = new SomeClass(null, "two");

		// then
		assertFalse(one.equals(two));
	}

	@Test
	public void shouldFindInHashSet_twoNullId() {
		// given
		SomeClass one = new SomeClass(null, "one");
		SomeClass three = new SomeClass(null, "three");

		// when
		Set set = new HashSet();
		set.add(one);
		set.add(three);

		// then
		assertTrue(set.contains(one));
		assertTrue(set.contains(three));
		assertEquals(set.size(), 2);
	}

	@Test
	public void shouldFindInHashMap_twoNullId() {
		// given
		SomeClass one = new SomeClass(null, "one");
		SomeClass three = new SomeClass(null, "three");

		// when
		Map set = new HashMap();
		set.put(one, one.getName());
		set.put(three, three.getName());

		// then
		assertTrue(set.containsKey(one));
		assertTrue(set.containsKey(three));
		assertEquals(set.size(), 2);
	}

Poniższe testy pozwolą na wykrycie zachowań obiektów przy zmianie ich wewnętrznego stanu:

	@Test
	public void shouldFindInHashSet_afterIdChanged() {
		// given
		SomeClass one = new SomeClass(null, "one");

		// when
		Set set = new HashSet();
		set.add(one);

		one.setId(42); // e.g. persist this object

		// then
		assertTrue(set.contains(one));
		assertEquals(set.size(), 1);
	}

	@Test
	public void shouldFindInHashMap_afterIdChanged() {
		// given
		SomeClass one = new SomeClass(null, "one");

		// when
		Map set = new HashMap();
		set.put(one, one.getName());

		one.setId(42); // e.g. persist this object

		// then
		assertTrue(set.containsKey(one));
		assertEquals(set.size(), 1);
	}

	@Test
	public void shouldFindInHashSet_afterNameChanged() {
		// given
		SomeClass one = new SomeClass(null, "one");

		// when
		Set set = new HashSet();
		set.add(one);

		one.setName("other"); // some business logic operation

		// then
		assertTrue(set.contains(one));
		assertEquals(set.size(), 1);
	}

	@Test
	public void shouldFindInHashMap_afterNameChanged() {
		// given
		SomeClass one = new SomeClass(null, "one");

		// when
		Map set = new HashMap();
		set.put(one, one.getName());

		one.setName("other"); // some business logic operation

		// then
		assertTrue(set.containsKey(one));
		assertEquals(set.size(), 1);
	}

Brak implementacji

Mając taki zestaw testów, zobaczmy jaka zachowa się klasa SomeClass, gdy pozostawimy domyślną implementacją hashCode() i equals() z Object().

Jedyny problem jaki został wykryty to brak rozpoznawania dwóch obiektów o takich samych identyfikatorach bazodanowych jako takich samych. Czy to duży problem? Jeśli chcemy tworzyć algorytmy przetwarzające obiekty pobrane z różnych sesji, lub też wykorzystywać jako kolekcje zbiory to tak to bardzo duży problem. Ostrzega przed tym dokumentacja Hibernate.

Implementacja generowana przez Eclipse operująca na PK

Zobaczmy jak zachowa się wyegenerowanie hashCode() i equals() z Eclipse:

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((id == null) ? 0 : id.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;
		SomeClass other = (SomeClass) obj;
		if (id == null) {
			if (other.id != null)
				return false;
		} else if (!id.equals(other.id))
			return false;
		return true;
	}

Uruchomienie testów pokazuje dwa poważne problemy:

  • obiektu nie da się pobrać z HashSet czy użyć jako klucza w HashMap
  •  dwa obiekty o identyfikatorze null są traktowane jak takie same
  • po zmianie identyfikatora nie da się obiektu pobrać z HashSet lub HashMap

Lekka modyfikacja metody equals() rozwiązuje dwa pierwsze problemy:


	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		SomeClass other = (SomeClass) obj;
		if (id == null) {
			// if (other.id != null)
				return false;
		} else if (!id.equals(other.id))
			return false;
		return true;
	}

W ten sposób dochodzimy do dwóch rozwiązań, z których każde wiąże się z ograniczeniami. Tyle jeśli chodzi o podstawy. Drugie rozwiązane oparte o equals() i hashCode() bazujące na kluczu podstawowym można dodatkowo poprawić.

Dynamiczne proxy

Co się stanie gdy framework ORM będzie posługiwał się proxy naszego obiektu a nie istancją naszej klasy? Aby zrozumiec problem, wykorzystamy Javasist do utworzenia proxy dla instancji SomeClass, które będzie po prostu delegowało wywołania do klasy na podstawie którego zostało utworzone:


	public static SomeClass buildSomeClass(final SomeClass baseObject) {
		ProxyFactory f = new ProxyFactory();
		f.setSuperclass(SomeClass.class);
		f.setFilter(new MethodFilter() {
			public boolean isHandled(Method m) {
				return !m.getName().equals("finalize");
			}
		});
		@SuppressWarnings("rawtypes")
		Class c = f.createClass();
		MethodHandler mi = new MethodHandler() {
			public Object invoke(Object self, Method m, Method proceed, Object[] args) throws Throwable {
				return m.invoke(baseObject, args);
			}
		};
		SomeClass foo = null;
		try {
			foo = (SomeClass) c.newInstance();
		} catch (InstantiationException | IllegalAccessException e) {
			e.printStackTrace();
			return null;
		}
		((ProxyObject) foo).setHandler(mi);
		return foo;
	}

Dodajemy dodatkowe sprawdzenia:

@Test
	public void shouldRecognizeEqualsWhenSameId() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = SimpleDelegatingProxyMaker.buildSomeClass(new SomeClass(1, "three"));

		// then
		assertTrue(one.equals(three));
		assertTrue(three.equals(one));
	}

	@Test
	public void shouldRecognizeDifferWhenDifferentId() {
		// given
		SomeClass one = new SomeClass(1, "one");
		SomeClass three = SimpleDelegatingProxyMaker.buildSomeClass(new SomeClass(2, "three"));

		// then
		assertFalse(one.equals(three));
		assertFalse(three.equals(one));
	}

	@Test
	public void shouldRecognizeSameWhenBothNullId() {
		// given
		SomeClass one = new SomeClass(null, "one");
		SomeClass three = SimpleDelegatingProxyMaker.buildSomeClass(new SomeClass(null, "three"));

		// then
		assertFalse(one.equals(three));
		assertFalse(three.equals(one));
	}

Oba poprzednie rozwiązania są obarczone tym samym problemem:, metoda equals() sprawdza czy klasy obu obiektów są identyczne, dlatego metoda equals() zwraca false. Dodatkowy problem to odwoływanie się do pól w metodzie equals() a nie do metod, co szerzej opisane jest na blogu Xebia.

Rozwiązanie uwzględniające oba problemy mogło by wyglądać tak:

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((getId() == null) ? 0 : getId().hashCode());
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (! (obj instanceof SomeClass) )
			return false;
		SomeClass other = (SomeClass) obj;
		if (getId() == null) {
//			if (other.id != null)
				return false;
		} else if (!id.equals(other.getId()))
			return false;
		return true;
	}

Trzeba być jednak świadomym, iż tracimy w ten sposób zabezpieczenie przed porównywaniem obiektów typu bazowego i typu go rozszerzającego o identycznych identyfikatorach.

Nieefektywny hashCode()

Problemy z odnajdywaniem obiektu w kolekcjach po zmianie id są spowodowane zmianą wartości hashcode. Jeśli dodamy obiekt do kolekcji wykorzystującej haszowanie, trafi ona do pódełka na podstawie wartości zwracanej przez hashCode(). Jeśli zmieni się id obiektu np w wyniku jego zapisu w bazie danych i przyznajiu mu automatycznego id, obiekt będzie miał inny hashCode(), a zatem metody próbujące go pobrać będą szukały go w niewaściwym pudełku.

Słyszałem o 2 rozwiązaniach. Pierwsze polega na wykorzstaniu jednej z najbardziej nieefektywnych i jednocześnie najprostszych metod implementacji hashCode():

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

Proste 🙂

Rozwiązanie oparte o sztuczne id biznesowe

Kolejne rozwiązanie pojawia się w różnych wariantach w dyskusji na forum Hibernate:


//...

private volatile Object identyfiObject;

//...

	@Override
    public boolean equals(Object obj) {
        final boolean result;
        if (obj instanceof SomeClass) {
            return getObject().equals(((SomeClass)obj).getObject());
        } else {
            result = false;
        }
        return result;
    }

	@Override
    public int hashCode() {
        return getObject().hashCode();
    }
	private Object getObject() {
        if (getIdentyfiObject() != null || getIdentyfiObject() == null && getId() == null) {
            if (getIdentyfiObject() == null) {
                synchronized(this) {
                    if (getIdentyfiObject() == null)
                    	setIdentyfiObject(new Object());
                }
            }
            return getIdentyfiObject();
        }
        return getId();
    }
	private Object getIdentyfiObject() {
		return identyfiObject;
	}
	private void setIdentyfiObject(Object identyfiObject) {
		this.identyfiObject = identyfiObject;
	}

Pomijając dostęp za pomocą metod a nie odwołania do pól, sprowadza się ona do utworzeniu dodatkowego pola, w którym przechowywane jest identyfikator. Jest ono równe PK jeśli wcześniej nikt nie użył metody hashCode() lub equals(). Jeśli jednak ktoś ich użyje, jest tworzony nowy obiekt i do niego delegowane są odwołania do hashCode() i equals().

Rozwiązanie to nie jest zbyt przejrzyste, spotkało się z bużliwą dyskusją na wspomnianym uprzednio forum.

Myślę, iż warto samemu przejść wszystkie te przypadki by zrozumieć jakie są problemy z różnymi podejściami. Warto też poeksperymentować z błędnymi w oczywisty sposób pomysłami jak equals() bez hashCode().

Ciekawe też, mogą być długoterminowe skutki zastosowania niektórych implementacji 🙂

Advertisements
This entry was posted in Uncategorized and tagged , . Bookmark the permalink.

2 Responses to Implementacja hashCode i equals dla encji

  1. Faktycznie, rozwiązanie oparte o uuid.toString() jest rozwiązuje problemy, niemniej zastanawianie się “Czy fv o identyfikatorze bcd12345sdfs3sf3se33rd3 to ta sama fv co bcd12345sbfs3sf3se33rd3?” lub “Jaki był identyfikator tego komunikatu, który ostatnio dodałem?”
    potrafi człowiekowi zepsuć delektowanie się poranną kawą.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s