티스토리 뷰

728x90
반응형

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


  • equals를 재정의한 클래스에서는 반드시 hashCode도 재정의해야 합니다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet같은 컬렉션의 원소로 사용할 때 문제가 발생합니다.

 

hashCode의 일반 규약


  • 애플리케이션이 실행되는 동안 equals 비교에 사용되는 정보가 변경되지 않았다면, 그 객체의 hashCode를 몇 번이고 호출해도 일관되게 유지되어야 합니다.
  • equals를 통해 두 객체가 동일하다고 판단되면 두 객체의 hashCode는 동일한 값을 반환해야 합니다.
  • equals를 통해 두 객체가 동일하지 않다고 판단하더라도 hashCode가 서로 다른 값을 반환할 필요는 없습니다.(두 객체의 equals는 다를 수 있지만 hashCode는 같을 수 있음) 하지만 다른 값을 반환해야 해시 테이블의 성능이 좋아집니다.

 

문제가 발생하는 경우


  • 아래 예제를 보면 equals 메서드 중 모든 if에는 걸러지지 않고 마지막 return할 때 true가되어 결과는 true를 반환하게 됩니다. 
  • 두번째 if문에 걸리지 않은 이유는 hashCode를 재정의 하지 않았기 때문에 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하여 걸리지 않게 되었습니다.
  • HashMap을 사용하여 key값으로 phoneNumber1과 phoneNumber2를 사용하여 해당 값을 꺼내올 때 둘 다 "홍길동"을 반환할 거 같지만 phoneNumber2는 null을 반환하게 됩니다. 이유는 PhoneNumber 클래스에서 hashCode를 재정의 하지 않아 논리적으로는 같을지라도 해시코드는 서로 다르게 반환되기 때문입니다.
public class PhoneNumber {

    private final String first;
    private final String second;
    private final String third;

    public PhoneNumber(String first, String second, String third) {
        this.first = first;
        this.second = second;
        this.third = third;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof PhoneNumber)) return false;
        if (this == obj) return true;
        PhoneNumber phoneNumber = (PhoneNumber) obj;

        return Objects.equals(this.first, phoneNumber.first) &&
                Objects.equals(this.second, phoneNumber.second) &&
                Objects.equals(this.third, phoneNumber.third);
    }
}

public class EffectiveJavaApplication {

    public static void main(String[] args) {
        PhoneNumber phoneNumber1 = new PhoneNumber("010", "1234", "5678");
        PhoneNumber phoneNumber2 = new PhoneNumber("010", "1234", "5678");

        System.out.println(phoneNumber1.equals(phoneNumber2)); // true

        Map<PhoneNumber, String> map = new HashMap<>();
        map.put(phoneNumber1, "홍길동");

        System.out.println(map.get(phoneNumber1)); // 홍길동
        System.out.println(map.get(phoneNumber2)); // null
    }
}

 

대안책


🧨 문제는 없지만 사용해서는 안되는 방법

  • 아래와 같이 hashCode를 재정의하여 모든 객체가 동일한 해시 코드를 반환한다면 문제가 발생할 소지가 있습니다. 아래에서는 Map에서 phoneNumber2를 넘기더라도 해시코드가 해시코드가 같기 때문에 "홍길동"이 출력될 것입니다. 하지만 모든 객체가 동일한 해시코드를 반환하기 때문에 모든 객체가 해시테이블의 버킷 하나에 담겨 마치 연결 리스트처럼 동작하게 됩니다. 그 결과 평균 수행시간이 O(n)으로 느려져 객체가 많아지면 느려지게 됩니다.
public class PhoneNumber {

    private final String first;
    private final String second;
    private final String third;

    public PhoneNumber(String first, String second, String third) {
        this.first = first;
        this.second = second;
        this.third = third;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof PhoneNumber)) return false;
        if (this == obj) return true;
        PhoneNumber phoneNumber = (PhoneNumber) obj;

        System.out.println(this.hashCode()); // 42
        System.out.println(obj.hashCode()); // 42

        return Objects.equals(this.first, phoneNumber.first) &&
                Objects.equals(this.second, phoneNumber.second) &&
                Objects.equals(this.third, phoneNumber.third);
    }

    // !! 문제 발생 !!
    @Override
    public int hashCode() {
        return 42;
    }
}

public class EffectiveJavaApplication {

    public static void main(String[] args) {
        SpringApplication.run(EffectiveJavaApplication.class, args);

        PhoneNumber phoneNumber1 = new PhoneNumber("010", "1234", "5678");
        PhoneNumber phoneNumber2 = new PhoneNumber("010", "1234", "5678");
        PhoneNumber phoneNumber3 = new PhoneNumber("011", "1234", "5678");

        System.out.println(phoneNumber1.equals(phoneNumber2)); // true
        System.out.println(phoneNumber1.equals(phoneNumber3)); // false

        Map<PhoneNumber, String> map = new HashMap<>();
        map.put(phoneNumber1, "홍길동");

        System.out.println(map.get(phoneNumber1)); // 홍길동
        System.out.println(map.get(phoneNumber2)); // 홍길동
        System.out.println(map.get(phoneNumber3)); // null
    }
}

 

💡좋은 hashCode를 만드는 방법

  • 이상적인 해시 함수라면 서로 다은 객체에 다른 해시 코드를 반환해야 합니다.
  • 이상적인 해시 함수라면 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 합니다.
  1. 지역 변수 선언 후 핵심 필드 값 하나의 해시코드로 초기화
    • 기본 타입 필드라면 Type.hashCode(f)를 수행합니다. 여기서 Type은 기본 타입의 박싱 클래스입니다.
    • 참조 타입 필드라면 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교합니다. 계산이 복잡해질거 같으면 이 필드의 표쥰형을 만들어 해당 표준형의 hashCode를 호출합니다. 필드값이 null인 경우 0을 사용합니다.
    • 필드가 배열이라면 핵심 원소 각각을 별도 필드처럼 다룹니다. 만약 핵심적인 원소가 하나도 없다면 0을 사용하고, 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용합니다.
  2. 다른 핵심 필드들도 동일하게 해시코드화해서 지여 변수에 합칩니다. (지역변수 = 31 * 지역변수 + 핵심필드의 해시코드)
  3. 지역변수의 값을 반환합니다.
@Override
public int hashCode() {
    int result = first.hashCode();
    result = 31 * result + second.hashCode();
    result = 31 * result + third.hashCode();
    return result;
}

참고) 곱할 숫자가 31인 이유는 31이 홀수이면서 소수이기 때문입니다. 만약 짝수이고 오버플로가 발생한다면 정보를 잃게 됩니다. (2를 곱하는건 시프트 연산과 같기 때문입니다.) 그리고 소수를 곱하는 이유는 전통적으로 그래왔고 명확하지는 않습니다.
또한 31 * i는 (i << 5) -1과 동일합니다.

 

💡Objects 클래스의 hashCode 메서드 사용

  • Objects 클래스도 임의의 갯수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash 메서드를 제공합니다.
  • 장점은 한줄로 작성이 가능하고 단점으로는 속도가 느립니다. 입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본 타입이 있다면 박싱과 언박싱을 거쳐야하기 때문입니다. 그러니 hash 메서드는 성능에 민감하지 않은 상황에서만 사용하는게 좋습니다.
@Override
public int hashCode() {
    return Objects.hash(first, second, third);
}

 

고려사항


  • 클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야 합니다. 이 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 합니다.
  • 이때 핵심 필드는 모두 해시코드를 계산할 때 포함해야 합니다. 핵심 필드가 누락되면서 해시의 신뢰도가 떨어지면서 해시 테이블의 성능 역시 떨어질 수 있습니다.
  • 물론 AutoValue, Lombok과 같은 라이브러리를 사용한다면 어노테이션으로 자동으로 equals and hashCode를 제공해주고, 일부 IDE에서도 이런 기능을 제공해줍니다. 그렇기 때문에 사실 크게 equals and hashCode에 대한 생각 없이 사용할 수 있는데, 이런 규약들에 대해 기억하고 사용한다면 더 나은 성능 개선을 할 수 있습니다.
  • 가령 성능이 중요해서 Objects 클래스의 hash 메서드를 사용하는게 권장되지 않는 상황에서 인텔리제이의 자동생성 기능으로 hashCode를 사용하면 기본적으로 Objects의 hash 메서드로 hashCode를 구현하게 됩니다. 그렇기 때문에 이러한 부분에 대해서는 잘 알고 넘어가는게 중요한것 같습니다.
public class PhoneNumber {
    
    private int hashCode; // 자동으로 0으로 초기화

    private final String first;
    private final String second;
    private final String third;

    public PhoneNumber(String first, String second, String third) {
        this.first = first;
        this.second = second;
        this.third = third;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof PhoneNumber)) return false;
        if (this == obj) return true;
        PhoneNumber phoneNumber = (PhoneNumber) obj;

        return Objects.equals(this.first, phoneNumber.first) &&
                Objects.equals(this.second, phoneNumber.second) &&
                Objects.equals(this.third, phoneNumber.third);
    }

    @Override
    public int hashCode() {
        if (hashCode != 0) return hashCode;
        
        int result = first.hashCode();
        result = 31 * result + second.hashCode();
        result = 31 * result + third.hashCode();
        hashCode = result;
        return hashCode;
    }
}

 

 

 

참고자료)

https://catsbi.oopy.io/313829e4-e869-48fe-9fb4-adcca06de1c5

https://jaehun2841.github.io/2019/01/12/effective-java-item11/#%EC%A2%8B%EC%9D%80-%ED%95%B4%EC%8B%9C-%ED%95%A8%EC%88%98-%EB%A7%8C%EB%93%A4%EA%B8%B0

 

 

 

 

728x90
반응형