코딩하는 털보

이펙티브 자바, 아이템 11. equals를 재정의 하려거든 hashCode도 재정의하라 본문

Book/이펙티브 자바

이펙티브 자바, 아이템 11. equals를 재정의 하려거든 hashCode도 재정의하라

이정인 2021. 8. 26. 16:49

이펙티브 자바, 아이템 11. equals를 재정의 하려거든 hashCode도 재정의하라

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.
그렇지 않으면 hashCode 일반 규약을 어기게 되어 HashMap 또는 HashSet 같은 컬렉션의 원소로 사용할 때 문제가 발생한다.

hashCode 규약

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

HashMap 같은 경우 hashCode가 다른 객체에 대해서 동치성 비교를 하지 않고 다른 객체라고 판단한다.
아래와 같이 세 개의 번호로 논리적 동치성을 확인하는 PhoneNumber가 있다. PhoneNumber는 equals만 재정의했고 hashCode는 재정의하지 않았다.

public class PhoneNumber {

    private int ariaCode, prefix, lineNum;

    public PhoneNumber(int ariaCode, int prefix, int lineNum) {
        this.ariaCode = ariaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof PhoneNumber)) {
            return false;
        }
        PhoneNumber that = (PhoneNumber) o;
        return ariaCode == that.ariaCode && prefix == that.prefix && lineNum == that.lineNum;
    }
}

hashCode를 재정의하지 않은 PhoneNumber는 규약을 위반했으며 HashMap의 원소로 사용하면 논리적 문제가 발생한다.

    public static void main(String[] args) {
        PhoneNumber pn1 = new PhoneNumber(123,456,7890);
        PhoneNumber pn2 = new PhoneNumber(123,456,7890);

        //true, 번호가 같으므로 논리적으로 동치
        System.out.println(pn1.equals(pn2));

        Map<PhoneNumber, String> map = new HashMap<>();
        map.put(pn1, "정인");

        //논리적으로 동치이니 "정인"을 반환활 것 같지만 null을 반환한다.
        System.out.println(map.get(pn2));

    }

이상적인 hashCode 메서드 작성 요령

//c1는 첫번째 핵심 필드의 해시코드
//필드 f가 기본 타입이면 Type.hashCode(f)
//필드 f가 참조 타입이면 필드의 f.hashCode(), 필드 값이 null이면 0
//필드 f가 배열이면 배열안있는 핵심 원소를 별도의 필드처럼 계산. 핵심 원소가 없으면 0, 모든 원소가 핵심 원소이면 Arrays.hashCode()
int result = c1; 

//c2는 두번째 핵심 필드의 해시코드
//31을 곱하는 것은 해시 효과를 높여주기 위함이다.
result = 31*result+c2;

...

return result;

hashCode를 구현했다면 동치인 인스턴스에 대해 같은 hashCode를 반환하는지 자문하고 단위 테스트를 작성한다.
파생 필드나 equals 비교에 사용되지 않은 필드는 해시코드 계산에서 제외한다.

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

다시 테스트

    public static void main(String[] args) {
        PhoneNumber pn1 = new PhoneNumber(123,456,7890);
        PhoneNumber pn2 = new PhoneNumber(123,456,7890);
        System.out.println(pn1.equals(pn2));

        Map<PhoneNumber, String> map = new HashMap<>();
        map.put(pn1, "정인");

        //재정의한 hashCode에 의해 "정인"을 반환한다.
        System.out.println(map.get(pn2));

    }

인텔리제이에서 자동으로 만들어주는 hashCode는 Objects.hash()를 사용하는데, 이 메서드는 조금 느릴 수 있기 때문에 성능에 민감하지 않은 상황에서만 사용한다.

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

만약 어떤 불변클래스 타입의 객체가 주로 해시의 키로 사용되며 해시코드 계산 비용이 클 땐,
매번 계산하기 보다는 인스턴스가 만들어질 때 해시코드를 계산해두어 캐싱하는 방식을 고려한다. (대신 객체 생성 속도가 느려지겠네용...)

성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.
핵심 필드를 생략하면 해시코드 계산을 빨라질 수도 있지만 해시 테이블 성능이 심각하게 떨어질 수 있다.

hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.
API 사용자가 내가 만든 hashCode에 의존하게 된다면? hashCode를 수정하기 어렵겠네요!

Comments