티스토리 뷰

반응형

동일성과 동등성

 

 '동일하다'와 '동등하다'는 거의 같은 단어처럼 들리겠지만 프로그래밍에서는 큰 차이를 가지고 있다. int나 double 같은 기본 타입의 경우 우리는 '==' 연산자를 통해 같음을 확인한다. 그러나 객체의 경우 우리가 같다고 취급하고 싶어도 equals 메서드 혹은 '==' 연산자로 동일함이 확인되지 않는다.

 

System.out.println(1 == 1); // true

// 리터럴로 생성된 string은 string pool에 담겨 같은 객체가 재사용된다.
String string1 = "string";
String string2 = "string";
System.out.println(string1 == string2); // true
System.out.println(string1.equals(string2)); // true

// 일반적인 객체는 별도의 재정의가 없다면 내부의 값이 같아도 같다고 인식되지 않는다.
Score score1 = new Score(100);
Score score2 = new Score(100);
System.out.println(score1 == score2); // false
System.out.println(score1.equals(score2)); // false

 

'아, 같은 점수라면 동일하다는 결과가 나오게 하고 싶은데!'

 안타깝게도 '=='를 통한 연산은 참조하는 메모리의 주소값이 일치하는지 확인하는 동일성 검사이므로 false를 반환, equals()는 객체의 해시값을 기반으로 동등한지 판별하므로 모든 클래스가 상속하는 Object의 equals()를 재정의하지 않으면 같다고 인식하지 않는다.

 

Score score1 = new Score(100);
Score score2 = new Score(100);
System.out.println(score1); // Score@251a69d7
System.out.println(score2); // Score@7344699f 가지고 있는 해시값이 다르다.

 

위의 두 객체는 점수가 100이라는 같은 값을 가지지만 그 값을 다른 메모리에 각각 저장한 별개의 객체이므로 동일하지 않다.

따라서 점수만 같다면 논리적으로 같도록 취급하는 동등성을 부여하기 위해서는 equals() 재정의가 추가적으로 필요하다.

 

 

Equals() 재정의

 

그렇다면 이제 Object.equals()를 재정의하여 논리적 동등함을 확인하도록 해보자!

 

class Score {
    . . .
    @Override
    public boolean equals(Object o) {
        Score score1 = (Score)o // 인수가 Score의 객체가 아니였다면 캐스팅에서 오류가 발생할 수 있다.
        return this.score == score1.score;
    }
}

System.out.println(score1.equals(score2)); // true

 

여전히 각각은 다른 객체이므로 '==' 연산자를 통한 동일함 비교까지는 불가하지만, equals()를 통해 논리적 동치성(동등성)을 확인할 순 있다. 하지만 사용자가 잘못 사용하여 equals()에 들어오는 인수가 Score의 객체가 아니라면 equals() 내부에서 캐스팅을 하며 오류가 발생할 수 있으므로 이에 대해서 조금 보완해 보자.

 

@Override
public boolean equals(final Object o) {
    if (this == o)
        return true;
    if (o == null || getClass() != o.getClass())
    return false;

    Score score1 = (Score)o;
    return score == score1.score;
}

 

모두가 통용하는 방법으로 equals를 사용하기 위해 일반적으로 지켜야 하는 규칙에 대해서는 아래를 참고하기 바란다.

 

더보기

기능 상 문제가 없을 정도로만 equals를 재정의할 수도 있긴 하지만, equals 메서드는 일반 규약을 따라야 한다는 관습이 있다.

 

다음은 Object.java의 equals 메서드에 대한 명세의 일부다.

Object.java의 equals에 대한 권고

null이 아닌 세 참조 값 x, y, z에 대해 equals 메서드는 아래의 성질을 지녀야 한다.

  • 반사성: x.equals(x)는 항상 true다.
  • 대칭성: x.equals(y)가 true면 y.equals(x)도 true다.
  • 추이성: x.equals(y)가 true고 y.equals(z)가 true라면 x.equals(z)도 true다.
  • 일관성: x.equals(y)는 항상 true를 반환하거나 false를 반환해야 한다.
  • null 아님: x.equals(null)은 false다.

위의 예시에서 재정의한 equals는 이 다섯 가지의 성질을 모두 만족한다.

 

이것으로 equals()의 인수가 같은 클래스의 객체인지까지 판단이 가능해졌다.

만약 나의 프로그램에서 equals를 통한 객체의 비교만을 사용한다면 이것만으로도 기능적인 문제는 없다.

 

 

HashCode() 재정의

 

여전히 아쉬운 부분은, equals 메서드를 통한 비교에서는 우리가 생각한 논리적 동치성을 적용해 주지만 아래와 같이 해시값을 사용하는 Collection Framework를 적용할 때 문제가 발생한다는 것이다.

 

Score score1 = new Score(100);
Score score2 = new Score(100);
System.out.println(score1.equals(score2)); // true

Set<Score> scoreSet = new HashSet<>();
scoreSet.add(score1);
scoreSet.add(score2);
System.out.println(scoreSet.size()); // 1이 아닌 2가 나온다.

 

equals를 재정의하긴 했지만 여전히 score1과 score2는 다른 객체이고 다른 해시값을 가진다. 따라서 HashSet, HashMap과 같이 객체의 해시값을 사용하는 경우 이들을 다르게 해석하게 된다. 

 

이를 방지하려면 hashCode 메서드 또한 재정의가 필요한데, 어떻게 hashCode를 재정의하면 좋을까? Object.java에서는 hashCode의 일반 규약 또한 명시하고 있다.

 

Object.java의 hashCode 관련 명세

 

간단히 요약하면 다음과 같다.

  1. 변경되지 않은 객체의 hashCode 결과는 항상 똑같은 integer여야 한다. 변경되었더라도 equals() 메서드가 확인하는 정보가 그대로라면 hashCode값은 그대로 여야 한다.
  2. equals 메서드가 같다고 판별한 객체들의 hashCode 결과는 동일한 integer 값이어야 한다.
  3. equals 메서드가 다르다고 판별했다고 hashCode의 결과가 항상 달라져야 하는 것은 아니다. 그러나 hashCode의 결과가 다를 때 해시 테이블의 성능이 향상된다.

즉 hashCode는 equals와 긴밀히 연계되어 있으며, equals가 true라면 hashCode의 값은 같아야 하고 false라면 최대한 hashCode값은 달라야 한다는 말이다.

 

 

hashCode 메서드를 재정의하는 방법은 여럿 있으며 관습적으로 소수이며 홀수인 31을 필드에 곱하고 더해나가며 해시 값을 만들어내도록 한다. 여기서는 아래와 같이 간단하게 Objects 클래스의 hash를 사용하는 방법을 예시로 사용하겠다.

 

class Score {
    . . .
    @Override
    public int hashCode() {
       return Objects.hash(score);
    }
}

Score score1 = new Score(100);  
Score score2 = new Score(100);

Set<Score> scoreSet = new HashSet<>();
scoreSet.add(score1);
scoreSet.add(score2);
System.out.println(scoreSet.size()); // 1

 

비로소 HashSet에서 같은 객체로 취급하여 원활한 사용이 가능해진다.

 

참고로 Objects의 hash를 사용하는 방법은 가변 인수로 인한 배열 생성 및 오토박싱 과정 등이 포함되어 성능 상 매우 약간의 손해를 볼 수 있다. hashCode 메서드의 재정의 방법과 일반적으로 왜 그러한 방법이 사용되는지에 대해서는 좋은 글을 맨 아래에 첨부한다.

 

 

언제 재정의할까?

 

이제 다른 객체에 대해서 논리적으로 같게(동등하게) 취급하기 위해서는 equals와 hashCode를 재정의해야 함을 알았다.

남과 협업하고 예측 가능한 방식으로 코드를 관리하기 위해서는 두 개의 메서드는 동시에 재정의되거나 재정의되지 않아야 한다.

 

언제나 재정의할 필요는 없지만

  1. 위와 같이 객체 내부의 값을 기반으로 논리적 동치성에 대한 검사가 필요할 때
  2. 상속하는 상위 클래스의 equals(보통은 Object의 equals)와 다르게 기능해야 할 때

equals와 hashCode를 모두 재정의하여 사용하자.

 


 

참고자료

Baeldung, hashCode() 재정의 가이드

https://www.baeldung.com/java-hashcode

 

이종립 님의 hashCode() 메서드에 관하여 (간편한 해시 함수, 예제, 왜 31을 사용하는지 등)

https://johngrib.github.io/wiki/java/object-hashcode/

 

반응형