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

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

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

equals를 재정의한 모든 클래스에서는 hashCode도 함께 재정의해야한다.

Object 명세에서 발췌한 규약

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야한다. ( 단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다. )

  • equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야한다.

  • equals(Object)가 두 객체가 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다.

Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(010, ****,1993), "배태호");

String name = m.get(new PhoneNumber(010, ****,,1993));

HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어있다. 따라서 위의 코드는 hashCode 함수를 재정의 하지 않으면 null을 반환한다.

hashCode를 작성하는 요령

  1. int 변수인 result를 선언한 후 값을 c로 초기화한다.
    • c는 해당 객체의 첫번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드이다.
    • 핵심 필드는 equals 비교에 사용되는 필드를 말한다.
  2. 해당 객체의 나머지 핵심 필드인 f 각각에 대해 다음 작업을 수행한다.
    1. 해당 필드의 해시코드 c 를 계산한다.
      1. 기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본타입의 박싱 클래스다. ex) Short.hashCode(f)
      2. 참조 타입 필드면서, 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출하여 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
        • 계산이 복잡해질 것 같으면, 이 필드의 표준형을 만들어 그 표준형의 hashCode를 호출한다.
        • 필드의 값이 null이면 전통적으로 0을 사용한다.
      3. 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
    2. 단계 2.1에서 계산한 해시코드 c로 result를 갱신한다.
      • result = 31 * result + c;
  3. result를 반환한다.
  • 검증할 단위 테스트를 작성하자.
  • 파생 필드는 계산에서 제외해도 된다.
  • equals 비교에 사용되지 않은 필드는 반드시 제외하자.
  • 곱하는31을 곱하는 이유는 31은 홀수이여서 소수이기 때문이다.
    • 짝수를 사용하면 뒤에 0이 채워지면서 숫자가 조금 날아갈 수 있다.
    • 사전에 있는 모든 단어를 hashing해서 테스트 해본 결과 hash 충돌이 제일 적게 나는 숫자가 31이었다고 한다.
@Override
public int hashCode() {
		int result = 31 * result * Short(areaCode);
		result = 31 * result * Short(prefix);
		result = 31 * result * Short(lineNum);
		return result;
}

위의 요령을 따라 hashCode()메소드를 재정의하면 다음과 같다.

한줄 짜리 hashCode

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

Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다. 하지만 입력 인수를 담기 위한 배열이 만들어지고, 박싱과 언박싱을 거쳐야 할 수 있어 성능이 아쉽다는 단점이 있어 성능에 민감하지 않은 사황에만 사용하자.

intellij 자동생성(alt+insert) 해시가 위의 코드와 같다.

캐싱을 사용한 hashCode

클래스가 불변이고 해시코드를 계산하는 비용이 크다면 캐싱하는 방식을 고려하자

private int hashCode; // 자동으로 0으로 초기화 된다.

@Override
public int hashCode() {
		int result = hashCode;
		if (result == 0) {
				int result = 31 * result * Short(areaCode);
				result = 31 * result * Short(prefix);
				result = 31 * result * Short(lineNum);
				hashCode = result;
		}
		return result;
}

위와 같이 cashing을 사용해 hashcode를 구현할 수 있다. 이와 같은 경우 스레드 안정성을 고려할 필요가 있는데 아래와 같이 더블체킹, synchronized를 사용해 감싸줄 수 있다.

private volatile int hashCode; // 자동으로 0으로 초기화 된다.

@Override
public int hashCode() {
   if( this.hashcode(!=0)){
      return hashCode;
   }
   synchronized(this){
      int result = hashCode;
		if (result == 0) {
				int result = 31 * result * Short(areaCode);
				result = 31 * result * Short(prefix);
				result = 31 * result * Short(lineNum);
				hashCode = result;
		}
		return result;
   }
}

위와 같은 방법 말고 ThreadLocal을 사용해도 무방하다.

주의

  • hashCode가 반환하는 값의 생성 규칠을 API 사용자에게 자세히 공표하지 않기.
    • 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있게 된다.
  • 성능을 높이기 위해 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.
    • 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴 수도 있다.
  • 두 객체의 값이 달라도 해시코드는 같을 수 있다(해시충돌).
    • 하지만 이는 모두 같은 hashCode의 bucket에 들어가기 때문에 hash를 쓰는 이점이 없다 (LinkedList를 쓰는 것과 동일, O(1)->O(n))
    • 자바 8부터는 같은 bucket에 8개 이상의 값이 들어가면 LinkedList 대신 red-black트리를 사용하도록 최적화되었다.(O(n)->O(logN))

Spring의 @EqualsAndHashCode

Lombok에서 제공하는 @EqualsAndHashCode 어노테이션은 기본적으로 클래스의 모든 필드를 기반으로 비교해서, 자동으로 생성된다.

하지만 개발자가 논리적 동치를 판단할 때, 모든 필드를 비교하지 않고 특정한 자신만의 기준을 통해 객체의 논리적 동치를 판단해야 할 떄는 어떻게 해야할까?

만약 특정 필드만을 기준으로 객체의 논리적 동치를 판단하고자 한다면, 해당 필드에 대한 equals() 및 hashCode() 메서드를 직접 구현하거나, @EqualsAndHashCode 어노테이션의 매개변수를 사용하여 이를 지정할 수 있다.

@EqualsAndHashCode(exclude = "age")
public class Person {
    private String name;
    private int age;
}

예를들어 어노테이션에 exclude나 of 매개변수를 사용하여 논리적 동등성 비교에 포함시키고자 하는 필드를 개발자가 직접 지정할 수 있습니다.

위와 같이 @EqualsAndHashCode를 사용하면 equals()나 hashcode()구현 후 따로 테스트를 해줄 필요가 없어 편리하다.

정리

equals를 재정의 할 때는 hashCode도 반드시 재정의하자.

재정의한 hashCode는 Object의 API 문서에 기술된 일반 규약을 따라야하며, 서로 다른 인스턴스라면 되도록 해시코드도 서로 다르게 구현하자.