Effective Java - 모든 객체의 공통 메서드(2) : equals를 재정의하려거든 hashCode도 재정의하라

목포·2022년 4월 17일
0

이펙티브 자바

목록 보기
12/13

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

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

Object 명세에서의 규약

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

두 번째 항목은 hashCode 재정의를 잘못했을 때 문제가 될 수 있는 조항이다. 논리적으로 같은 객체는 같은 해시코드를 반환해야한다.

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

// 아래 코드를 실행해도 "제니"는 나오지 않는다. (null 반환)
// 논리적 동치인 이 객체는 hashCode를 정의하지 않았기 때문에 두 객체가 서로 다른 해시코드를 반환하여 두 번째 규약을 지키지 못한다.
m.get(new PhoneNumber(707, 867, 5309));
.
.
.

@Override
public int hashCode() {return 42;}
// 이 코드처럼 hashCode를 정의하면 모든 객체가 해시테이블의 버킷 하나에 담겨 마치 연결리스트처럼 동작한다.
// 그러면 평균 수행 시간이 O(1)인 해시테이블이 O(n)으로 느려져 쓸 수 없게 된다.
// hashCode 함수는 서로 다른 인스턴스에 대해 다른 해시코드를 반환해야한다. 

hash 값을 사용하는 Collection(HashSet, HashMap, HashTable)은 객체가 논리적으로 같은지 비교할 때 다음과 같은 과정을 거친다.

hashCode 메서드의 리턴 값이 우선 일치하고 equals 메서드의 리턴 값이 true여야 논리적으로 같은 객체라고 판단한다.
첫 번째 예제의 경우 hashCode 메서드를 정의하지 않아 Object 클래스의 hashCode 메서드가 사용되었다.
Object 클래스의 hashCode 메서드는 객체의 고유한 주소 값을 int값으로 반환하기 때문에 객체마다 다른 값을 리턴한다.
때문에 만약 두 객체로 equals 비교를 했다면 다른 객체로 판단될 것이다.

HashCode 메서드 작성하기

이상적인 함수는 주어진 서로 다른 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다. 요령을 알아보자.

  1. int 변수 result를 선언한 후 값 c로 초기화한다. 이 때 c는 해당 객체의 첫 번째 핵심필드를 단계 2-1 뱡식으로 계산한 해시코드이다. (여기서 핵심필드란 equals 비교에 사용되는 필드를 말한다.)
  2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.
    1. 해당 필드의 해시코드 c를 계산한다.
      1. 기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본 타입의 박싱클래스이다. (Integer.hashCode(int))
      2. 참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다. 계산이 더 복잡해질 것 같으면 이 필드의 표준형을 만들어 그 표준형의 hashCode를 호출한다. 필드의 값이 null이면 0을 사용한다.(다른 상수도 괜찮지만 전통적으로 0을 사용한다.)
      3. 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 단계 2-2 방식으로 갱신한다. 배열에 핵심 원소가 하나도 없다면 단순히 상수(0 추천)를 사용한다. 모든 원소가 핵심라면 Arrays.hashCode를 사용한다.
    2. 단계 2-1에서 계산한 해시코드 c로 result를 갱신한다. 코드로는 다음과 같다.
      ex) result = 30 * result + c;
    3. result를 반환한다.

위와 같이 작업했다면 이제 단위테스틀 통해 검증을 하면 된다.
파생 필드는 해시코드 계산에서 제외해도 된다. 즉, 다른 필드로부터 계산해낼 수 있는 필드는 모두 무시해도 된다. 또한 equals 비교에 사용되지 않은 필드는 ‘반드시’ 제외해야 한다. 그렇지 않으면 hashCode 두 번째 규약을 어기게 된다.

PhoneNumber 클래스에 적용하기

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

2-2의 곱셈 31 result는 필드를 곱하는 순서에 따라 result값이 달라지게한다. 그 결과 클래스에 비슷한 필드가 여러 개일 때 해시효과를 크게 높여준다. 예컨대 String의 hashCode를 곱셈없이 구현한다면 모든 아나그램(구성하는 철자가 같고 그 순서만 다른 문자열)의 해시코드가 같아진다. 곱할 숫자를 31로 정한 이유는 31이 홀수이면서 소수이기 때문이다. 만약 이 숫자가 짝수이고 오버플로가 발생한다면 정보를 잃게된다. 2를 곱하는 것은 시프트 연산과 같은 결과를 내기 때문이다. 소수를 곱하는 이유는 명확하지 않지만 전통적으로 그리 해왔다. 결과적으로 31을 곱하면 이 곱셈을 시프트 연산과 뺄셈으로 대체해 최적화할 수 있다.(31 i는 (i << 5) - i와 같다.) 요즘 VM들은 이런 최적화를 자동으로 해준다.

PhoneNumber 인스턴스의 핵심 필드 3개만을 이용해 간단한 계산만 수행한다. 그 과정에 비결정적 요소는 전혀 없으므로 동치인 PhoneNumber 인스턴스들은 같은 해시코드를 가질 것이 확실하다.

Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다.
만약 hash 메서드를 이용해 hashCode를 한 줄로 작성할 수도 있겠지만 속도는 느리다. 입력 중 기본타입이 있다면 박싱과 언박싱도 거쳐야 하기 때문이다.

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

클래스가 final이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야한다. 이 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다.

profile
mokpo devlog

0개의 댓글