ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 11: Always override hashCode when you override equals
    독서/Effective Java 2021. 12. 12. 17:47

    "equals"를 재정의하는 모든 클래스에서 hashCode를 재정의해야 한다.

    이유: 재정의하지 않을 경우 hashCode에 대한 일반 규약을 위반하여 HashMap 및 HashSet과 같은 컬렉션에서 제대로 작동하지 않는다.

     

    Object 명세에서 발췌한 hashCode 일반 규약

    • 응용 프로그램 실행 중에 객체에 대해 hashCode 메서드가 반복적으로 호출되면 equals 비교에 사용된 정보가 수정되지 않는 한 일관되게 동일한 값을 반환해야 한다. 이 값은 애플리케이션을 다시 실행한다면 일관성을 유지할 필요가 없다.
    • equals(Object) 메서드에 따라 두 객체가 동일한 경우 두 객체에 대해 hashCode는 동일한 결과여야 한다.
    • 두 객체가 equals(Object) 메서드에 따라 같지 않은 경우 각 객체에 대해 hashCode를 호출할 때 고유한 결과(서로 다른 결과)가 생성되어야 하는 것은 아니다. 그러나 프로그래머는 같지 않은 개체에 대해 고유한 결과(다른 결과)를 생성하면 해시 테이블의 성능이 향상될 수 있음을 알고 있어야 한다.

     

    hashCode 재정의에 실패했을 때 위반되는 핵심 조항은 두 번째 조항이다.

    동일한 Object는 동일한 해시 코드를 가져야 한다.

    두 인스턴스는 클래스의 equals 메소드에 따라 논리적으로 동일 => But, Object의 hashCode 메소드에 따르면 두 별개의 Object일 수 있다.

    따라서 Object의 hashCode 메서드는 두 인스턴스에 대해 다른 HashCode를 Return한다.
    예를 들어 Item 10의 PhoneNumber 클래스 인스턴스를 HashMap의 키로 사용하려고 한다고 가정해 보자

    package com.tistory.povia.effectivejava.item11;
    
    public final class PhoneNumber {
      private final short areaCode, prefix, lineNum;
    
      public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "area code");
        this.prefix = rangeCheck(prefix, 999, "prefix");
        this.lineNum = rangeCheck(lineNum, 9999, "line num");
      }
    
      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;
      }
      // ... // Remainder omitted
    }
    
    
    // TestCode
      @Test
      void hashCodeTest(){
        PhoneNumber number = new PhoneNumber(707, 867, 5309);
        Map<PhoneNumber, String> m = new HashMap<>();
        m.put(number, "Tove");
    
        Assertions.assertThat(m.get(new PhoneNumber(707, 867, 5309))).isEqualTo("Tove");
      }
      
    // 결과 로그
    org.opentest4j.AssertionFailedError: 
    expected: "Tove"
     but was: null

     

    예상 -> 당연히 기존에 넣었던 값을 뱉어주겠지?

    결과 -> 물리적으로 동일한 키의 데이터가 존재하지 않는다.

    원인 -> hashCode가 재정의되어 있지 않기 때문에, 같은 내용을 가지고 있는 두 다른 인스턴스가 결국 다른 해시코드를 갖게 됐다.

    두 내용이 같은 인스턴스에 대해 동일함을 보장하는 방법에는 무엇이 있을까?

     

    HashCode를 재정의하는 방법

     

    가장 무식한 방법

    // The worst possible legal hashCode implementation - never use!
    @Override public int hashCode() { return 42; }

     

    해당 클래스를 통해 생성되는 모든 인스턴스가 동일한 해시 코드를 가질 수 밖에 없음(합법).

    문제: 모든 Object는 동일한 버킷에 해시되고 해시 테이블은 Linked List처럼 연결된다.

    해시코드를 기반으로 빠르게 찾던 것을 벗어나 모든 Object를 다 판별해서 결과를 낼 수 밖에 없기에 성능이 더 떨어지게 된다.

     

    해결책

     

    좋은 해시 함수는 같지 않은 인스턴스에 대해 같지 않은 해시 코드를 생성해야 한다.

    이상적으로 해시 함수는 모든 int 값에 균일하지 않은 인스턴스의 합당한 컬렉션을 배포해야 한다. 

    다음은 해시코드를 생성하는 방법 중 하나이다.

    1. result라는 int 변수를 선언하고 2.1단계에서 계산된 대로 개체의 첫 번째 중요 필드에 대한 해시 코드 c로 초기화합니다. (Item 10에서 중요한 필드는 동등 비교에 영향을 미치는 필드임을 상기하십시오.)
    2. 개체에 남아 있는 모든 중요 필드 f에 대해 다음을 수행합니다.
      1. 필드에 대한 int 해시 코드 c를 계산하자.
        1. 필드가 primitive type인 경우 Type.hashCode(f)를 계산한다. Type은 Wrapper Class이다(long -> Long)
        2. 필드가 Object Reference이고 이 클래스의 equals 메서드가 equals를 재귀적으로 호출하여 필드를 비교하는 경우 필드에서 hashCode를 재귀적으로 호출한다. 더 복잡한 비교가 필요한 경우 이 필드에 대한 "표준형"을 계산하고 표준형에 대해 hashCode를 호출한다. 필드 값이 null이면 0으로 사용한다.
        3. 필드가 배열인 경우 각 중요 요소가 별도의 필드인 것처럼 처리하자. 모든 요소가 중요 요소인 경우 Arrays.hashCode를 사용한다.
      2. 2.1단계에서 계산된 HashCode c를 다음과 같이 결과에 계산한다.
        result = 31 * result + c;​
    3. 결과를 반환한다.

     


    해시 코드 계산에서 파생 필드를 제외할 수 있다. 즉, 다른 필드로부터 계산해낼 수 있는 필드는 무시해도 좋다.

    Equals에서 사용되지 않는 필드는 빼자(규약의 두 번째 조항과 충돌함). 
    단계 2.2의 곱셈 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 한다. 이 연산은 시프트 연산과 뺄셈으로 대체해 최적화 가능하다(31 * i = (i << 5) - i).
    위의 PhoneNumber 클래스에 해결책을 적용해보자.

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

    테스트 코드를 다시 돌려보면 다음과 같은 결과가 나온다.

    @Test
      void hashCodeTest(){
        PhoneNumber number = new PhoneNumber(707, 867, 5309);
        PhoneNumber numberB = new PhoneNumber(707, 867, 5309);
        Map<PhoneNumber, String> m = new HashMap<>();
        m.put(number, "Tove");
    
        log.info("numberA = {}, numberB = {}", number.hashCode(), numberB.hashCode());
        Assertions.assertThat(m.get(numberB)).isEqualTo("Tove");
      }
      
    // 로그
    numberA = 711613, numberB = 711613


    Objects 클래스의 hash 메서드 사용

     

    Objects 클래스에는 임의의 수의 개체를 가져와서 해당 개체에 대한 해시 코드를 반환하는 "hash"라는 정적 메서드가 존재한다.

    이를 사용했을 경우 한 줄로도 hashCode 메서드를 작성할 수 있다.

    하지만, primitive type인 경우 boxing과 unboxing이 수행되고, 가변 개수(variable number)의 인수(arguments)를 전달하기 위한 배열 생성이 필요해 성능이 떨어지게 된다.

    @Override public int hashCode() {
       return Objects.hash(lineNum, prefix, areaCode);
    }


    클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 hashCode를 저장해두자. 이 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해야 한다. 이를 위해 지연 초기화(lazy initialization) 전략을 사용할 수 있지만, 필드를 지연 초기화하기 위해서는 Thread-Safe 하도록 신경써야 한다.

    // hashCode method with lazily initialized cached hash code
    private int hashCode; // Automatically initialized to 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;
    }

     

    정리

     

    equals를 재정의할 때마다 hashCode도 재정의하자. 그렇지 않으면 프로그램이 내가 원하는 대로 실행되지 않는다.

    hashCode 메서드는 Object에 지정된 일반 규약을 준수해야 하며 같지 않은 인스턴스에 같지 않은 해시 코드를 할당하는 합리적인 작업을 수행해야 한다.

    이것은 위의 해결책을 사용해 쉽게 달성할 수 있다.

    Item 10에서 나왔듯 AutoValue 프레임워크는 equals 및 hashCode 메서드를 수동으로 작성하는 것에 대한 훌륭한 대안을 제공하며 IDE도 이 기능 중 일부를 제공한다. (추가로 Lombok도 지원하기는 한다)

    댓글

Designed by Tistory.