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

Jimin Lim·2022년 3월 13일
0

Effective Java

목록 보기
11/38
post-thumbnail

아이템 11

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

equals를 재정의한 클래스는 모두 hashCode도 재정의해야 한다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다.

HashCode란?

  • 객체를 식별하는 하나의 정수 값
  • 객체의 메모리 번지를 이용해 객체를 식별하기에 객체마다 다른 값을 가지고 있다.
  • 객체가 같은지 동일성을 비교한다.
  • equals 결과가 true라면 hashCode는 같아야 한다. 반대로 hashCode가 같다고 해서 equals 결과가 true일 필요는 없다.

참고 자료

HashCode 관련 규약

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 일관된 값을 반환해야 한다.
  • equals가 두 객체가 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환한다. 즉 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.
  • equals가 두 객체를 다르다고 판단했더라도, hashCode는 꼭 다를 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

논리적으로 같은 경우

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

// m.get(new PhoneNumber(707, 867, 5309)) 실행 시 null 반환

위의 코드에서 PhoneNumber 클래스는 해시코드를 재정의하지 않았기에 논리적 동치인 두 객체가 서로 다른 해시코드를 반환해 두 번째 규약을 지키지 못하고 있다. 두 개의 PhoneNumber 인스턴스를 같은 버킷에 담았더라도 get 메서드는 여전히 null을 반환하는데, HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어 있기 때문이다.

HashCode 구현

다음과 같이 hashCode를 재정의할 수도 있다.

@Override 
	public int hashCode() {
		return 42;
	}

하지만 위의 경우모든 객체가 해시테이블의 버킷 하나에 담겨 연결 리스트처럼 동작하게 된다. 그 결과 평균 수행 시간이 O(1)인 해시테이블이 O(n)으로 느려져서 객체가 많아지면 쓸 수 없게 된다.

좋은 해시코드 작성 요령

  1. int 변수인 result를 선언한 후 값을 c로 초기화한다.
    이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2.a 방식으로 계산한 해시코드이다. (여기서 핵심 필드는 equals 비교에 사용되는 필드를 말한다.)
  2. 해당 객체의 나머지 핵심 필드인 f 각각에 대해 다음 작업을 수행한다.
  • a. 해당 필드의 해시코드 c 를 계산한다.

    • 기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본 타입의 박싱 클래스다.
    • 참조 타입 필드면서, 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출하여 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
    • 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다.
      모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
  • b. 단계 2.a에서 계산한 해시코드 c로 result를 갱신한다.
    result = 31 * result + c;

  1. result를 반환한다.

전형적인 hashCode 메서드

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

위 메서드는 핵심 필드 3개를 사용해 간단한 계산을 수행한다. 비결정적 요소는 없어, 동치인 PhoneNumber 인스턴스들은 같은 해시코드를 가질 것이다.

Objects 클래스가 제공하는 hashCode

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

앞의 코드와 비슷한 수준의 hashcode를 한 줄로 작성할 수는 있지만, 속도는 더 느리다.

클래스가 불변이고 해시코드가 계산하는 비용이 크다면, 매번 새로 계산하기보다는 캐싱하는 방식을 고려해야 한다.
이 타입의 객체가 주로 해시의 키로 사용된다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다. 해시의 키로 사용되지 않는 경우라면 hashcode가 처음 불릴 때 계산하는 지연 초기화전략이 좋을 것이다.

//캐싱하는 방법, 동기화 신경써야 한다.
private int hashCode; //자동으로 0으로 초기화

@Override
public int hashCode() {
      	int result = hashCode; 
        if(result == 0) {
        int result = Integer.hashCode(areaCode);
        result = 31 * result + Integer.hashCode(areaCode);
        result = 31 * result + Integer.hashCode(areaCode);
        hashCode = result;
        }
        return result;
}

주의할 점

  • 성능을 높인다고 해시코드를 계산할 때 핵심 필드를 생략해서는 안된다.
    • 속도는 빨라질 수 있겠지만, 어떤 필드는 특정 영역에 몰린 인스턴스들의 해시코드를 분산시켜주는 효과가 있을지도 모른다.
  • hashcode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자
    • 클라이언트가 이 값에 의지하지 않게 되고 추후 계산 방식을 바꿀 수 있다.

결론

equals를 재정의할 땐 hashCode도 반드시 재정의해야 한다. 또한 AutoValue 프레임워크에서 equals, hashCode를 만들어주니 참고하자.

profile
💻 ☕️ 🏝 🍑 🍹 🏊‍♀️

0개의 댓글