ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 11. equals를 재정의하려거든 hashCode도 재정의하라
    백수의 개발/이펙티브 자바 2019. 8. 20. 17:43

     

    equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.

    이는 HashMap이나 HashSet같은 컬렉션의 원소로 사용할 때 문제를 일으킬 수 있기 때문이다.

     

    hashCode의 규약

    1. equals에 비교되는 정보가 변경되지 않았다면, hashCode 메서드는 일관되게 항상 같은 값을 반환해야 한다.(단, 애플리케이션 재실행 시 값이 달라져도 상관없다.)
    2. equals가 두 객체를 같다고 판단했다면, 두 객체의 hashCode도 동일해야 한다.
    3. equals가 두 객체를 다르다고 판단하더라도, 두 객체의 hashCode가 다른 값을 반환할 필요는 없다.(단, 다른 값을 반환해주어야 해시테이블의 성능이 향상된다.)

     

    hashCode 재정의가 안되거나, 잘 못된 경우

    대부분 hashCode 재정의 시 hashCode의 두번째 규약을 어기기 쉽다. 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

     

    만약 hashCode를 재정의 하지 않을 때 어떤 문제가 발생하는지 아래 코드를 통해 살펴보자.

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

    위의 코드를 보면 get(PhoneNumber)을 통해 "제니"가 나올 것으로 기대했지만, 실제로는 hashCode가 재정의되지 않아 원하는 데이터를 찾지 못하고 null을 반환한다. 이는 hashCode를 재정의 해주면 된다.

    public class PhoneNumber{
    	...
    	@Override
    	public int hashCode(){
    		return 42;
    	}
    	...
    ]

    위와 같이 hashCode를 재정의하면 "제니"를 가져올 수 있다. 하지만 hashCode가 동일하여 모든 객체에 대해 동일한 해시값을 반환한다.

    이때문에 해시테이블의 버킷 하나에 담겨 마치 Linked List처럼 동작하게 되고, 그 결과 평균 수행 시간이 O(1)인 해시테이블이 O(n)으로 느려져, 객체가 많아지면 도저히 쓸 수가 없게 된다.

     

    hashCode 작성하는 법

    아래 코드는 기본적으로 hashCode를 작성하는 방법이다.

    class HashCode {
        int firstValue;
        long secondValue;
        String thirdValue;
        String[] forthValue;
    
        @Override
        public int hashCode() {
            // 1. int변수 result를 선언한 후 첫번째 핵심 필드에 대한 hashcode로 초기화 한다.
            // 초기화 방법은 아래와 같이 데이터 타입에 맞게 해주면 된다.
            int result = Integer.hashCode(firstValue);
    
            // 31 * result + hashCode(C)으로 다음 result를 계산한다.
            
            // 2. 기본타입 필드라면 Type.hashCode(f)를 실행한다
            // Type은 기본타입의 Boxing 클래스이다.
            result = 31 * result + Long.hashCode(secondValue);
    
            // 3. 참조타입이라면 참조타입에 대한 hashcode 함수를 호출 한다.
            // 값이 null이면 0을 더해 준다.
            result = 31 * result + (thirdValue == null ? 0 : thirdValue.hashCode());
    
            // 4.1 필드가 배열이라면 핵심 원소를 각각 필드처럼 다룬다.
            for (String elem : forthValue) {
                result = 31 * result + (elem == null ? 0 : elem.hashCode());
            }
    
            // 4.2 배열의 모든 원소가 핵심필드이면 Arrays.hashCode를 이용한다.
            result = 31 * result + Arrays.hashCode(forthValue);
            
            //result를 리턴한다.
            return result;
        }
    }

     

    만약, 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱을 고려해야할 것 이다.

     

    해당 타입의 객체가 주로 해시의 키로 사용 될 것 같다면 아래와 같이 인스턴스가 만들어질 때 해시코드를 계산해두면 효율적이다.

    public class HashCode{
        private int hashCode;
        
        ...
        
        public HashCode(...){
        	hashCode = 31 * result + c;
        }
        
        ...
        
        @Override
        public int hashCode(){
        	return hashCode;
        }
    }

     

    해시의 키로 사용되지 않는 경우라면 아래와 같이 hashCode가 처음 불릴 때 계산하는 지연 초기화도 가능하다.

    public class HashCode{
        private int hashCode;
        
        ...
        
        @Override
        public int hashCode(){
        	int result = hashCode;
        	if(result == 0){
            	result = 31 * result + c;
                hashCode = result;
            }
        	return result;
        }
    }

    만약, 위와 같이 지연 초기화를 하게 된다면 스레드 안정성도 고려해주어야한다.

     

    성능을 높이기 위해 해시코드를 계산할 때 핵심 필드를 생략해서는 안된다. 이는 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴 수도 있다.

     

    마무리

    equals를 재정의할 때는 hashCode도 반드시 재정의해야 한다. 그렇지 않으면 프로그램이 제대로 동작하지 않을 수 있다.

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

    * AutoValue 프레임워크를 사용하면 equals와 hashCode를 자동으로 만들어준다.

    댓글

Designed by Tistory.