Post

Effective Java - item 11

equals를 재정의하려거든 hashCode도 재정의하라

Effective Java - item 11

equals를 재정의하려거든 hashCode도 재정의하라

핵심 규약

equals를 재정의한 클래스는 반드시 hashCode도 재정의해야 한다. 그렇지 않으면 HashMap, HashSet 같은 컬렉션에서 제대로 동작하지 않는다.

hashCode 규약

  1. equals 비교에 사용되는 정보가 변경되지 않았다면, hashCode는 항상 같은 값을 반환해야 한다.
  2. equals(Object)가 두 객체를 같다고 판단했으면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
  3. equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

hashCode를 구현하지 않는다면?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class PhoneNumber {
    private final int areaCode;
    private final int prefix;
    private final int lineNum;
    
    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PhoneNumber)) return false;
        PhoneNumber pn = (PhoneNumber) o;
        return areaCode == pn.areaCode 
            && prefix == pn.prefix 
            && lineNum == pn.lineNum;
    }
    
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
    public static void main(String[] args) {
        Map<PhoneNumber, String> map = new HashMap<>();
        
        PhoneNumber number1 = new PhoneNumber(010, 1234, 5678);
        map.put(number1, "홍길동");
        
        PhoneNumber number2 = new PhoneNumber(010, 1234, 5678);
        
        System.out.println(number1.equals(number2)); // true
        
        System.out.println(map.get(number2)); // null (예상: "홍길동")
    }
}

왜 null이 나올까?

  1. number1과 number2는 equals로 비교하면 같다.
  2. 하지만 hashCode가 다르다. (기본 hashCode는 객체 주소 기반이다)
  3. HashMap은 hashCode로 버킷을 찾는다.
  4. number2의 hashCode로 찾은 버킷에는 number1이 없다.
  5. 결과적으로 null 반환된다.

hashCode의 구현

최악의 hashCode 구현법

1
2
3
4
@Override
public int hashCode() {
    return 42; // 모든 객체가 같은 해시코드
}

위의 코드는 모든 객체가 같은 버킷에 저장되어 해시 테이블이 연결리스트 처럼 동작해서 O(1)의 성능이 O(n)으로 저하된다.

방법 1: 전형적인 구현

1
2
3
4
5
6
7
@Override
public int hashCode() {
    int result = Integer.hashCode(areaCode);
    result = 31 * result + Integer.hashCode(prefix);
    result = 31 * result + Integer.hashCode(lineNum);
    return result;
}

31을 곱하는 이유

  • 31은 홀수이면서 소수(prime)
  • 31 * i는 (i « 5) - i로 최적화 가능
  • 전통적으로 사용되어 온 관례

방법 2: Objects.hash 사용 (간편하지만 느리다)

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

장점: 한 줄로 간단하게 구현 단점: 속도가 느림 (배열 생성 및 박싱/언박싱 비용)

방법 3: 캐싱 사용 (불변 객체일 때)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class PhoneNumber {
    private final int areaCode;
    private final int prefix;
    private final int lineNum;
    
    private int hashCode; // 0으로 자동 초기화
    
    @Override
    public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Integer.hashCode(areaCode);
            result = 31 * result + Integer.hashCode(prefix);
            result = 31 * result + Integer.hashCode(lineNum);
            hashCode = result;
        }
        return result;
    }
}

장점: 해시 코드를 한 번만 계산 (지연 초기화) 적합한 경우: 불변 객체이고 해시 계산 비용이 클 때

Ex 1) Person 클래스의 equals와 hashCode 재정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Person {
    private final String name;
    private final int age;
    private final String email;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age 
            && Objects.equals(name, person.name) 
            && Objects.equals(email, person.email);
    }
    
    @Override
    public int hashCode() {
        int result = name != null ? name.hashCode() : 0;
        result = 31 * result + age;
        result = 31 * result + (email != null ? email.hashCode() : 0);
        return result;
    }
}

결론

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

This post is licensed under CC BY 4.0 by the author.