Effective Java - Item 11

Updated:

Effective Java

Item 11. equals를 재정의하려면 hashCode도 재정의하라

hashCode를 재정의 해야하는 이유

모든 equals를 재정의한 클래스는 hashCode도 재정의 해야한다. 왜일까?
그렇지 않으면 hashCode 일반 규약을 어기게 되어 HashMap이나 HashSet같은 컬렉션의 원소로 사용할 때 문제를 일으킨다.
다음은 hashCode에 대한 Object 명세에서 발췌한 규약이다.

* App이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다.
* equals가 두 객체를 같다고 판단하면, 두 객체의 hashCode는 똑같은 값을 반환해야한다.  
* equals가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반납할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 hashTable의 성능이 좋아진다

hashCode를 재정의를 잘못했을 때 크게 문제가 되는 조항은 두번째다.
즉, 논리적으로 같은 객체는 같은 hashCode를 반환해야한다.

Map<ExClass, String> m = new HashMap<>();
m.put(new ExClass(100,200,300), "제니");
m.get(new ExClass(100,200,300)) // "제니"가 반환되지 않고 null이 나온다!

위 예는 hashCode를 재정의를 잘못했을때 나오는 결과이다.
두 ExClass의 인스턴스는 물리적으로 다른 두 객체를 논리적으로는 같다고 할 수는 있지만, 기본 hashCode 메서드는 이 둘이 전혀 다르다고 판단하여, 서로 다른 hashCode값을 반환하여 null이 출력된다.

좋은 hashCode를 작성하는 간단한 요령

그렇다면 hashCode를 재정의 하여 위와 같은 불상사가 일어나지 않도록 해야한다.

@Override
public int hashCode(){
	return 42;		// 최악의 hashCode 구현
}

이렇게 구현하면 동치인 모든 객체에서 똑같은 hashCode를 반환하니 적합해 보이지만, 모든 객체에게 똑같은 값만 내어주므로 객체가 다 같다고 인식되어 마치 연결리스트 처럼 동작한다.
따라서 평균 수행시간이 O(1)에서 O(n)으로 느려진다!

좋은 해시 함수는 서로 다른 인스턴스에 다른 hashCode를 반환한다.
이상적인 해시 함수는 주어진 서로 다른 인스턴스마다 각각 hashCode를 32비트 정수 범위에 균일하게 분배한다.

그렇다면 어떻게 좋은 hashCode를 만들까? 간단한 요령이 있다.

  • int 변수 result를 선언한 후 핵심 필드를 다음 방식으로 계산하여 c로 초기화한다.
    result = 31 * result + c
    
    1. 기본 타입 필드이면, Type.hashCode(f)를 수행하여 값 c로 초기화한다. 여기서 Type은 기본 타입의 Boxing Class이다.
    2. 참조 타입 필드이며 이 class의 equals 메서드를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
      필드의 값이 null이면 0을 사용한다.
    3. 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 위 계산법으로 계속 갱신한다.
      만약 배열에 핵심 원소가 없다면 0, 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
  • result를 반환한다.

곱하는 수가 31로 정한 이유는 31이 홀수이면서 소수(prime)이기 때문이다.

위 요령을 실제로 적용해 보자, 핵심 필드에 4개의 변수가 있다고 가정한다.

@Override
public int hashCode() {
    int c = 31;
    // 첫번째 변수에 대한 hashcode로 초기화 한다.
    int result = Integer.hashCode(firstNumber);

    // 두번째 변수이며 기본타입 필드라면 Type.hashCode()를 실행한다
    result = c * result + Integer.hashCode(secondNumber);

    // 참조타입이라면 참조타입에 대한 hashcode 함수를 호출 한다.
    result = c * result + thirdReference == null ? 0 : address.hashCode();

    // 필드가 배열이라면 핵심 원소를 각각 하나의 원소처럼 갱신한다.
    for (String elem : forthArray) {
      result = c * result + elem == null ? 0 : elem.hashCode();
    }

    // 배열의 모든 원소가 핵심필드이면 아래와 같은 방법도 가능하다.
    result = c * result + Arrays.hashCode(forthArray);

    return result;
}

또 다른 hashCode 생성 방법

  • Object.hash() 간단하게 한 줄로 작성할 수 있지만, 느리다.
  • Google의 AutoValue 언제나 권장된다.
  • 지연 초기화를 통한 hashCode 생성 클래스가 불변이고 hashCode 비용이 크다면 고려해볼만 하다.
    초기 값을 0으로 선언하고, 만약 hashCode이 0일 때만 생성한다.
    대신 위 방식을 위해서는 그 클래스를 thread safe 하게 만들어야 한다.

hashCode를 생성할 때의 주의사항

  • 성능을 높인다고 hashCode를 계산할 때 핵심 필드를 생략해서는 안된다.
  • hashCode 생성 규칙을 API 사용자에게 공표하지 않는다.
    클라이언트가 hashCode 값에 의지하지 않고 개선 할 수 있다.
    향후 릴리즈에서 hash 기능을 개선할 여지도 있다.

정리

equals를 재정의할 때는 반드시 hashCode도 재정의하자.
그렇지 않으면 프로그램이 제대로 동작하지 않는다.
hashCode를 재정의하기 위해 일반 규약을 따라야 하며, 서로 다른 인스턴스라면 되도록 hashCode도 서로 다르게 구현해야 한다.

출처

Joshua Bloch. Effective Java 3/E. n.p.: 인사이트, 2018년 11월 1일.

참고 페이지