본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

[아이템 11] equals를 재정의하려거든 hashCode도 재정의하라

[Why]

  • equals를 재정의한 클래스에서 hashCode를 재정의하지 않으면 hashCode의 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap, HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다.

 

[When]

  • Object 명세에서 발췌한 규약
    • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. 단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다.
    • equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
    • equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

 

[How]

(given)

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;
    
    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");
        this.prefix = rangeCheck(prefix, 999, "프리픽스");
        this.lineNum = rangeCheck(lineNum, 999, "가입자 번호");
    }
    
    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max) {
            throw new IllegalArgumentException(arg + ": " + val);
        }
        return (short) val;
    }
    
    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof PhoneNumber)) {
            return false;
        }
        PhoneNumber pn = (PhoneNumber) o;
        return pn.lineNum == lineNum 
            && pn.prefix == prefix
            && pn.areaCode == areaCode;
    }
    
    ... // 나머지 코드는 생략
}

 

(then)

@Override
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

[포함 필드]

  • 성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다. 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴 수도 있다. 파생 필드는 해시코드 계산에서 제외해도 된다. 즉, 다른 필드로부터 계산해낼 수 있는 필드는 모두 무시해도 된다.
  • equals 비교에 사용되지 않은 필드는 '반드시' 제외해야 한다. 그렇지 않으면 hashCode 규약 두 번째를 어기게 될 위험이 있다.

[작동 원리]

  • 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 한다.
    • 클래스에 비슷한 필드가 여러 개일 때 해시 효과를 크게 높여준다.
    • 예컨대 String의 hashCode를 곱셈 없이 구현한다면 모든 아나그램(anagram, 구성하는 철자가 같고 그 순서만 다른 문자열)의 해시코드가 같아진다.
    • 곱할 숫자를 31로 정한 이유는 31이 홀수이면서 소수(prime)이기 때문이다. 만약 이 숫자가 짝수이고 오버플로가 발생한다면 정보를 잃게 된다. 2를 곱하는 것은 시프트 연산과 같은 결과를 내기 때문이다.
    • 31을 이용하면 이 곱셈을 시프트 연산과 뺄셈으로 대체해 최적화할 수 있다. 요즘 VM들은 이런 최적화를 자동으로 해준다.
      31 * i   →  (i  << 5) - i

[다른 방법]

  1. 해시 충돌이 더욱 적은 방법을 꼭 써야 한다면 구아바의 com.google.commom.hash.Hashing을 참고하자.
  2. Object 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다.
    • @Override
      public int hashCode() {
          return Objects.hash(lineNum, prefix, areaCode);
      }
    • 아쉽게도 속도는 더 느리다.
    • 입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본 타입이 있다면 박싱과 언박싱도 거친다.
    • hash 메서드는 성능이 민감하지 않은 상황에서만 사용하자.
  3. 클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기보다는 캐싱하는 방식을 고려해야 한다.
    • 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다.
  4. 해시의 키로 사용되지 않는 경우라면 hashCode가 처음 불릴 때 계산하는 지연 초기화(lazy initialization) 전략도 괜찮다.
    • private int hashCode;   // 자동으로 0으로 초기화된다.
      
      @Override
      public int hashCode() {
          int result = hashCode;
          if (result == 0) {
              result = Short.hashCode(areaCode);
              result = 31 * result + Short.hashCode(prefix);
              result = 31 * result + Short.hashCode(lineNum);
              hashCode = result;
          }
          return result;
      }
    • 필드를 지연 초기화하려면 그 클래스를 스레드 안전하게 만들도록 신경 써야 한다아이템83.

[주의 사항]

  • hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자.
    • 그래야 클라이언트가 이 값에 의지하지 않게 된다.
    • 또한 추후에 계산 방식을 바꿀 수도 있다.