이펙티브 자바 - item 11. equals를 재정의하려거든 hashCode도 재정의하라
on JAVA
이펙티브 자바 - 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를 작성하는 요령
- int 변수인
result
를 선언한 후 값을c
로 초기화한다.- c는 해당 객체의 첫번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드이다.
- 핵심 필드는
equals
비교에 사용되는 필드를 말한다.
- 해당 객체의 나머지 핵심 필드인
f
각각에 대해 다음 작업을 수행한다.- 해당 필드의 해시코드
c
를 계산한다.- 기본 타입 필드라면,
Type.hashCode(f)
를 수행한다. 여기서 Type은 해당 기본타입의 박싱 클래스다. ex)Short.hashCode(f)
- 참조 타입 필드면서, 이 클래스의
equals
메소드가 이 필드의equals
를 재귀적으로 호출하여 비교한다면, 이 필드의hashCode
를 재귀적으로 호출한다.- 계산이 복잡해질 것 같으면, 이 필드의 표준형을 만들어 그 표준형의 hashCode를 호출한다.
- 필드의 값이 null이면 전통적으로 0을 사용한다.
- 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 모든 원소가 핵심 원소라면
Arrays.hashCode
를 사용한다.
- 기본 타입 필드라면,
- 단계 2.1에서 계산한 해시코드
c
로result
를 갱신한다.result
= 31 *result
+c
;
- 해당 필드의 해시코드
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 문서에 기술된 일반 규약을 따라야하며, 서로 다른 인스턴스라면 되도록 해시코드도 서로 다르게 구현하자.