본문 바로가기
책/effective java

[effective java] item 11. equals를 재정의하려거든 hashCode도 재정의하라

by 2245 2023. 7. 31.

결론

equals를 재정의했다면, hashCode도 규약을 지켜 재정의해야 한다.
그렇지 않으면, 해당 클래스의 원소를 HashMap이나 HashSet과 같은 컬렉션의 원소로 사용할 때 문제가 발생한다.
이때, equals와 hashCode 메서드를 AutoValue 프레임워크로 자동으로 생성하는 방법도 있다. IDE들도 이런 기능을 일부 제공한다. 

 

PhoneNumber 클래스 예제: 전형적인 hashCode 메서드

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    ...

    @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;
    }

    // 코드 11-2 전형적인 hashCode 메서드 (70쪽)
    @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도 재정의해야 합니다.
그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킵니다. 

 

HashCode 일반 규약

  1. equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 합니다.
    단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관 없습니다.
  2. equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 합니다. ⭐ 
  3. equals(Object)가 두 객체를 다르게 판단했더라도, 두 객체의 hashCode가 반드시 서로 다른 값을 반환하지는 않아도 됩니다.
    단, 다른 객체라면 다른 값을 반환해야 해시 테이블의 성능이 좋아집니다. 

 

HashCode를 재정의해야 하는 이유 

equals를 재정의하고 hashCode를 재정의하지 않았을 때의 문제점은 두 번째 조항입니다. 

즉, 논리적으로 같은 객체라고 판단했으면, 같은 해시코드를 반환해야 합니다.

 

그렇다면, 논리적으로 같은 객체라고 판단했으나, 다른 해시코드를 반환할 경우 어떤 문제가 발생할까요?

아이템 10의 PhoneNumber 클래스의 인스턴스를 HashMap 원소로 사용한다고 해봅시다.

 

문제점 예시: HashMap

Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "제니");

//문제 발생
m.get(new PhoneNumber(707, 867, 5309));			//"제니"를 기대했으나, null 출력

이처럼 넣을 때의 PhoneNumber 객체와 꺼낼 때의 객체는 논리적으로 동치이지만, hashCode를 재정의하지 않았기 때문에 두 객체가 (무작위처럼 보이는) 서로 다른 해시코드를 반환하여 이 둘이 전혀 다르다고 판단하여 생기는 문제입니다. 

 

따라서 Object의 기본 hashCode를 재정의해야 합니다. 

 

참고 
get 메서드는 엉뚱한 해시 버킷에 가서 객체를 찾으려한 것입니다.
설사 두 인스턴스가 같은 버킷에 담았더라도 get 메서드는 여전히 null을 반환합니다.
HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어 있기 때문입니다. 

 

 

HashCode 작성법

최악의 hashCode 구현 - 사용 금지! (p. 68) 

@Override public int hashCode() {
	return 42;
}
  • 이 코드는 모든 객체에 똑같은 해시코드를 반환하므로 적법하긴 합니다.
  • 하지만, 끔찍하게도 모든 객체에게 똑같은 값만 내어줍니다. 
  • 그렇게 되면, 모든 객체가 해시테이블의 버킷 하나에 담겨 마치 연결 리스트(linked list)처럼 동작합니다.
  • 그 결과 평균 수행 시간이 O(1)인 해시 테이블이 O(n)으로 느려지고, 객체가 많아지면 도저히 쓸 수 없게 됩니다.

 

 

좋은 hashCode

좋은 해시 함수라면 서로 다른 인스턴스에 대해서는 다른 해시코드를 반환합니다.

이것이 hashCode의 세 번째 규약입니다. 

이상적인 해시 함수는 주어진 (서로 다른) 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 합니다. 

이상을 완벽히 실현하기는 어렵지만 비슷하게 만들기는 그다지 어렵지 않습니다.

다음은 좋은 hashCode를 작성하는 간단한 요령입니다.

 

 

hashCode 작성 요령

  1. int result = c;
    • int 변수 result를 선언 후 값 c로 초기화합니다. 
    • 이때 c는 해당 클래스의 첫 번째 핵심 필드를 단계 2-1 방식으로 계산한 해시 코드입니다.
    • 여기서 핵심 필드란 equals 비교에 사용되는 필드를 의미합니다. (아이템 10 참조)
  2. 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행합니다.
    1. 해당 필드의 해시코드 c를 계산합니다.
      1. 기본 타입 필드일 경우
        : Type.hashCode(f)를 수행합니다. 여기서 Type은 해당 기본 타입의 박싱 클래스입니다.
      2. 참조 타입 필드이면서, 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교할 경우
        : 이 필드의 hashCode를 재귀적으로 호출합니다.
        * 계산이 더 복잡해질 것 같으면, 이 필드의 표준형(canonical representation)을 만들어 그 표준형의 hashCode를 호출합니다. 
        * 필드의 값이 null이면 0을 사용합니다. (다른 상수도 괜찮지만, 전통적으로 0을 사용합니다.)
      3. 필드가 배열일 경우
        : 핵심 원소 각각을 별도 필드처럼 나눕니다. 위의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 2-2 방식으로 갱신합니다. 
        * 배열에 핵심 원소가 하나도 없다면 단순히 상수(0을 추천)를 사용합니다.
        * 모든 원소가 핵심 원소라면, Arrays.hashCode를 사용합니다.
    2. 단계 2-1에서 계산한 해시코드 c로 result를 갱신합니다. 
      result = 31 * result + c;
  3. result를 반환합니다. 

 

참고

  • 파생 필드는 해시코드 계산에서 제외해도 됩니다. 즉, 다른 필드로부터 계산해낼 수 있는 필드는 모두 무시해도 됩니다. 
  • equals 비교에 사용되지 않은 필드는 '반드시' 제외해야 합니다. 그렇지 않으면, hashCode 규약 두 번째를 어기게 될 위험이 있습니다. 
  • 단계 2-2의 곱셈 31 * result 는 필드를 곱하는 순서에 따라 result의 값이 달라지게 합니다. 그 결과 클래스에 비슷한 필드가 여러 개일때 해시 효과를 크게 높여줍니다. 
    • 예를 들어, String의 hashCode를 곱셈없이 구현한다면 모든 애너그램(anagram, 구성하는 철자가 같고 그 순서만 다른 문자열)의 해시코드가 같아집니다. 
    • 곱할 숫자를 31로 정한 이유는 31은 홀수이면서 소수(prime)이기 때문입니다. 
    • 만약 이 숫자가 짝수이고 오버플로가 발생한다면 정보를 읽게 됩니다. 왜냐하면 2를 곱하는 것은 시프트 연산과 같은 결과를 내기 때문입니다.
    • 소수를 곱하는 이유는 명확하지 않지만 전통적으로 그렇게 해왔습니다. 
    • 결과적으로, 31을 이용하면 이 곱셉을 시프트 연산과 뺄셈으로 대체해 최적화할 수 있습니다.
      (31 * i 는 (i <<5) - i 와 같습니다.)
    • 요즘 VM들은 이런 최적화를 자동으로 해줍니다.  

 

 

테스트

hashCode를 다 구현했다면 이 메서드가 동치인 인스턴스에 대해 똑같은 해시 코드를 반환할지 테스트해봅시다. 

직관을 검증할 단위 테스트를 작성해봅시다.

(만약, equals와 hashCode 메서드를 AutoValue로 생성했다면 건너뛰어도 좋습니다.) 

 

 

PhoneNumber 클래스: hashCode 적용

전형적인 hashCode 메서드

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    ...

    @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;
    }

    // 코드 11-2 전형적인 hashCode 메서드 (70쪽)
    @Override public int hashCode() {
        int result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        return result;
    }
}
  • PhoneNumber 인스턴스의 핵심 필드 3개만을 사용해 간단한 계산만을 수행합니다. 
  • 과정에 비결정적(undeterministic) 요소는 전혀 없으므로, 동치인 PhoneNumber 인스턴스들은 같은 해시코드를 가질 것이 확실합니다. 
  • 해당 코드는 PhoneNumber에 딱 맞게 구현한 hashCode입니다. 
  • 자바 플랫폼 라이브러리의 클래스들이 제공하는 hashCode 메서드와 비교해도 손색이 없습니다. 
  • 단순하고, 충분히 빠르고, 서로 다른 전화번호들은 다른 해시 버킷들로 제법 훌륭히 분배합니다.

 

참고 구아바의 com.google.common.hash.hashing
이번 아이템에서 소개한 해시 함수 제작 요령은 최첨단은 아니지만 충분히 훌륭합니다. 
품질 면에서나 해싱 기능 면에서나 자바 플랫폼 라이브러리가 사용한 방시과 견줄만하며 대부분의 쓰임에도 문제가 없습니다. 
단, 해시 충돌이 더욱 적은 방법을 써야 한다면 구아바의 com.google.common.hash.hashing을 참고합시다.

 

 

한 줄 짜리 hashCode 메서드 - 간단하지만, 속도가 조금 느리다. 

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    ...

    @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;
    }

    // 코드 11-3 한 줄짜리 hashCode 메서드 - 성능이 살짝 아쉽다. (71쪽)
    @Override public int hashCode() {
        return Objects.hash(lineNum, prefix, areaCode);
    }
}
  • Object 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공합니다. 
  • 이 메서드를 활용하면 앞서의 요령대로 구현한 코드와 비슷한 수준의 hashCode 함수를 한 줄로 작성할 수 있습니다.
  • 하지만, 아쉽게도 속도는 더 느립니다.
  • 입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본 타입이 있다면 박싱과 언박싱도 거쳐야 하기 때문입니다.
  • 그러니 hash 메서드는 성능이 민감하지 않은 상황에서만 사용합시다. 

 

 

해시코드를 지연 초기화하는 hashCode 메서드  - 스레드 안정성까지 고려해야 한다.

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    ...

    @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;
    }

    // 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽)
    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;
    }
}
  • 클래스가 불변이고, 해시 코드를 계산하는 비용이 클 경우, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야 합니다.
  • 또한, 이 클래스가 주로 해시의 키로 사용될 것 같다면, 인스턴스가 만들어질 때 해시 코드를 계산해두는 것이 좋습니다. 
  • 그러나 해시의 키로 사용되지 않는 경우라면, hashCode가 처음 불릴 때 계산하는 지연 초기화(lazy initialization) 전략을 사용할 수 있습니다. 
  • 필드를 지연 초기화하려면 그 클래스를 스레드 안전하게 만들도록 신경 써야 합니다. (아이템 89)
  • PhoneNumber 클래스는 굳이  이렇게까지 할 필요는 없지만, 예시를 위해 한번 해보겠습니다.
  • 한 가지, hashCode 필드의 초깃값은 흔히 생성되는 객체의 해시코드와 달라야 함에 유념합시다. 

 

 

PhoneNumber 클래스 전체 코드 및 실행

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;
    }

    // 코드 11-2 전형적인 hashCode 메서드 (70쪽)
    @Override public int hashCode() {
        int result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        return result;
    }

    public static void main(String[] args) {
        Map<PhoneNumber, String> m = new HashMap<>();
        m.put(new PhoneNumber(707, 867, 5309), "제니");
        System.out.println(m.get(new PhoneNumber(707, 867, 5309)));		//"제니" 출력
    }
}

 

 

 

hashCode 작성 시 주의사항

  • 성능을 높인답시고 해시 코드를 계산할 때 핵심 필드를 생략해서는 안 됩니다.
    • 속도야 빨라지겠지만, 해시 품질이 나빠져 해시 테이블의 성능을 심각하게 떨어뜨릴 수도 있습니다.
    • 특히, 어떤 필드는 특정 영역에 몰린 인스턴스들의 해시코드를 넓은 범위로 고르게 퍼트려주는 효과가 있을 지도 모릅니다. 
    • 하필 이런 필드를 생략한다면, 해당 영역의 수 많은 인스턴스가 단 몇 개의 해시코드로 집중되어 해시테이블의 속도가 선형으로 느려질 것입니다.
    • 예를 들어 URL처럼 계층적인 이름을 대량으로 사용한다면 이런 필드를 사용한 해시 함수는 좁은 범위에 몰려 앞서 이야기한 심각한 문제를 고스란히 드러냅니다. 
  • hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말아야 합니다. 
    그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있습니다. 
    • String과 Integer를 포함해, 자바 라이브러리의 많은 클래스에서 hashCode 메서드가 반환하는 정확한 값을 알려줍니다. 
    • 바람직하지 않지만 바로 잡기엔 이미 늦었습니다. 
    • 향후 릴리스에서 해시 기능을 개선할 여지도 없애버렸습니다.
    • 자세한 규칙을 공표하지 않는다면, 해시 기능에서 결함을 발견하거나 더 나은 해시 방법을 알아낸 경우 다음 릴리스에서 수정할 수 있습니다. 

 

 

 


출처

https://www.yes24.com/Product/Goods/65551284

 

이펙티브 자바 Effective Java 3/E - 예스24

자바 플랫폼 모범 사례 완벽 가이드 - Java 7, 8, 9 대응자바 6 출시 직후 출간된 『이펙티브 자바 2판』 이후로 자바는 커다란 변화를 겪었다. 그래서 졸트상에 빛나는 이 책도 자바 언어와 라이브

www.yes24.com