equals를 재정의하려거든 hashCode도 재정의하라.
hashCode
일반 규약
equals()
를 재정의한 클래스 모두에서hashCode()
도 재정의해야 한다.- 그렇지 않으면,
HashMap
혹은HashSet
의 원소가 되었을 때 문제가 발생할 수 있다.
- 그렇지 않으면,
equals()
비교에 사용되는 필드가 변하지 않았다면,hashCode()
메서드는 몇번을 호출하든, 항상 같은 값을 반환해야 한다.- 단, 애플리케이션을 재시작한 경우에는 달라질 수 있다.
equals(Object)
가 두 값을 같다고 판단했다면,hashCode()
의 반환 값도 같아야 한다.equals(Object)
가 두 객체를 다르다고 판단했더라도,hashCode()
가 달라질 필요는 없다.- 단, 해시테이블 성능 최적화를 위해서 다르게 나오는 것이 좋다.
equals()
만 오버라이드 하였을 때
public class Item11Test {
static class PhoneNumber {
public final String areaCode;
public final String prefix;
public final String lineNum;
public PhoneNumber(String areaCode, String prefix, String lineNum) {
this.areaCode = areaCode;
this.prefix = prefix;
this.lineNum = lineNum;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PhoneNumber that = (PhoneNumber) o;
return Objects.equals(areaCode, that.areaCode) && Objects.equals(prefix, that.prefix) && Objects.equals(lineNum, that.lineNum);
}
}
@Test
public void hashMapTest1() {
HashMap<PhoneNumber, String> hashMap = new HashMap<>();
PhoneNumber jennyPhoneNumber1 = new PhoneNumber("010", "0001", "0000");
hashMap.put(jennyPhoneNumber1, "제니");
String s1 = hashMap.get(jennyPhoneNumber1);
System.out.println("s1 = " + s1); // 제니
PhoneNumber jennyPhoneNumber2 = new PhoneNumber("010", "0001", "0000");
String s2 = hashMap.get(jennyPhoneNumber2);
System.out.println("s2 = " + s2); // null
boolean equals = jennyPhoneNumber1.equals(jennyPhoneNumber2);
System.out.println("equals = " + equals); // true
int hashCode1 = jennyPhoneNumber1.hashCode();
int hashCode2 = jennyPhoneNumber2.hashCode();
System.out.println("hashCode1 = " + hashCode1); // hashCode1 = 611563982
System.out.println("hashCode2 = " + hashCode2); // hashCode2 = 336484883
}
}
equals()
만 오버라이드 하는 경우equals()
값은 올바르게 판명된다.HashMap
에서는 내용이 같음에도 같은 키라고 인정받지 못한다.
hashCode()
메서드를 구현하였을 때
@Override
public int hashCode() {
return 40;
}
hashCode
를 구현했다. 비록40
만 반환하지만, 나름 동작은 한다.
@Test
public void hashMapTest1() {
HashMap<PhoneNumber, String> hashMap = new HashMap<>();
PhoneNumber jennyPhoneNumber1 = new PhoneNumber("010", "0001", "0000");
hashMap.put(jennyPhoneNumber1, "제니");
String s1 = hashMap.get(jennyPhoneNumber1);
System.out.println("s1 = " + s1); // 제니
PhoneNumber jennyPhoneNumber2 = new PhoneNumber("010", "0001", "0000");
String s2 = hashMap.get(jennyPhoneNumber2);
System.out.println("s2 = " + s2); // 제니
boolean equals = jennyPhoneNumber1.equals(jennyPhoneNumber2);
System.out.println("equals = " + equals);
int hashCode1 = jennyPhoneNumber1.hashCode();
int hashCode2 = jennyPhoneNumber2.hashCode();
System.out.println("hashCode1 = " + hashCode1); // 40
System.out.println("hashCode2 = " + hashCode2); // 40
PhoneNumber paulPhoneNumber = new PhoneNumber("010", "0001", "0001");
hashMap.put(paulPhoneNumber, "폴");
String paul = hashMap.get(paulPhoneNumber);
System.out.println("paul = " + paul); // 폴
}
- 위의 코드가 모두 정상적으로 동작한다.
- 그 이유는
hashMap
이 동일한key
인지 판단할 때,equals()
와hashCode()
두 메서드 모두에 의존하기 때문이다.- 하나라도 다르면, 다른
key
로 간주한다.
- 하나라도 다르면, 다른
- 그 이유는
hashCode()
에서 계속 똑같은 숫자를 반환해도 동작은 하지만, 원소가 늘어날수록 해시 테이블의 성능이 매우 떨어진다. O(1)의 시간복잡도가 점차 링크드리스트처럼 O(n)이 되어갈 것이다.
좋은 해시함수는 32비트 정수 범위에 인스턴스들을 고루 분배해야 한다.
hashCode()
메서드를 구현하는 요령
핵심필드
란equals()
비교에 사용되는 필드이다.
- 핵심 필드에 아래 작업을 수행한다.
- 기본 타입 필드면,
Type.hashCode(f)
를 수행한다.Type
이란, 기본 타입의 박싱 클래스를 말한다.
- 참조 타입 필드면서, 이 클래스의
equals()
메서드가 이 필드의equals()
메서드를 재귀적으로 호출한다면, 필드의hashCode()
를 재귀적으로 호출하면 된다. (보통equals()
가 있다는 건hashCode()
도 올바른 방식으로 구현했음을 말한다.)- 계산이 더 복잡해질 것 같으면, 표준형을 만들어 표준형의
hashCode()
를 호출한다. - 필드의 값이
null
이면0
을 사용한다.
- 계산이 더 복잡해질 것 같으면, 표준형을 만들어 표준형의
- 배열이라면, 핵심원소 각각을 별도 필드처럼 다룬다.
- 배열에 핵심원소가 하나도 없다면
0
(권장) 혹은 다른 상수를 사용한다. - 모든 원소가 핵심 원소라면
Arrays.hashCode()
를 사용한다.
- 배열에 핵심원소가 하나도 없다면
- 기본 타입 필드면,
- 위의 작업에서 계산한 해시코드로
result
를 갱신한다.- 첫 필드 값이라면 할당
int result = c
- 첫 필드 값이 아니라면 갱신
result = 31 * result + c
- 첫 필드 값이라면 할당
result
를 반환한다.
구현된 해시코드
@Override
public int hashCode() {
int result = areaCode.hashCode();
result = 31 * result + prefix.hashCode();
result = 31 * result + lineNum.hashCode();
return result;
}
- 32비트 정수 범위에 균일하게 배치하려고 약간의 연산을 추가한 것 말고는 딱히 어려울 건 없다.
31 * result
는 필드를 곱하는 순서에 따라result
값이 달라지게 만들어준다. 비슷한 필드가 여러개일 때 효과가 크다.
- 필드의 타입에 따라 구분되는 해시 값을 잘 사용하면 되고, 대부분은 클래스에 이미 구현되어 있다.
- 이전의
URL
클래스처럼 비결정적 요소가 존재하면 매번 결과가 달라지므로, 규약을 위반하게 되니 주의하자.
equals()
에 사용되지 않은 필드는 '반드시' 제거해야 한다. 안그러면 규약을 어기게 된다.
한 줄로 hashCode()
구현하기
Objects.hash(lineNum, prefix, areaCode);
Objects
에서는 이미 훌륭한 정적 메서드를 제공한다.- 성능은 약간 떨어지지만 사용하는데 전혀 문제가 없다.
- 입력 인수를 넣을 배열을 만들고, 기본 타입이 있다면 박싱하는 정도의 비효율만 있다.
- 성능은 약간 떨어지지만 사용하는데 전혀 문제가 없다.
해시코드를 생성하는 비용이 크다면 LazyLoad
와 Cache
를 적용할 수 있다.
private int hashCode;
@Override
public int hashCode() {
int result = hashCode;
if(result == 0) {
result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
hashCode = result;
}
return result;
}
- 해시코드 생성 비용이 크다면, 한번 필드에 값을 저장해놓고 재활용해도 된다.
LazyLoad
도 되고 있어 불리지 않는 한 쓸모 없는 연산은 하지 않을 것이다.
위와 같이 구현하는 경우 반드시 스레드 안전하게 만들도록 신경써주자. 초기 생성 시에는 싱크를 맞춰주어야 한다.
성능을 높이기 위해 hashCode()
메서드에 들어가는 필드를 빼지 마라
- 당장
hashCode()
메서드의 성능은 좋아질 수 있으나, 많은 필드를 사용하는 것은 해시 값을 32 비트 정수로 고루 퍼지게 만드는데 도움을 주기 때문에 무작정 필드를 빼지 말자. 해시 테이블에서 심각한 비효율이 날 수 있다.
hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자
그래야 클라이언트는 이 값에 의지하지 않고, 계산방식을 바꿀 수도 있다.
핵심 정리
equals()
를 재정의할 때는 반드시hashCode()
도 재정의하자.hashCode()
를 정의할 때도 반드시 일반 규약을 따르자.- 다른 인스턴스의
hashCode()
는 최대한 32비트 정수 안에서 고르게 나오게 하도록 노력하자.
사실 요즘은 직접 만들기보다 IDE의 도움을 받아 만드는 것이 좋다.
'Java > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바, 쉽게 정리하기 - item 13. clone 재정의는 주의해서 진행하라 (0) | 2021.12.30 |
---|---|
이펙티브 자바, 쉽게 정리하기 - item 12. toString을 항상 재정의하라 (0) | 2021.12.30 |
이펙티브 자바, 쉽게 정리하기 - item 10. equals는 일반 규약을 지켜 재정의하라 (0) | 2021.12.29 |
이펙티브 자바, 쉽게 정리하기 - item9. try-finally보다는 try-with-resources를 사용하라 (0) | 2021.12.28 |
이펙티브 자바, 쉽게 정리하기 - item8. finalizer와 cleaner 사용을 피하라 (0) | 2021.12.27 |