본문 바로가기

Language & Framework/Java

이펙티브 자바 (8) equals를 재정의하려면 규약을 지켜라, equals 재정의 시 hashCode도 재정의하라.

 @EqualsAndHash를 사용하라 

 

오늘 다루는 내용은 Item 10. equals는 일반 규약을 지켜 재정의하라, Item 11. equals를 재정의하려거든 hashCode도 재정의하라 부분이다.

농담처럼 적어놨지만 책에서도 Lombok이 아닐 뿐 라이브러리를 통해 재정의하는 게 편하다는 게 결론이니 지금까지 하던 것처럼 라이브러리를 통해 재정의하면 문제 생길 일이 (거의) 없을 것이다.

이번 건 정말로 따로 정리하지 않으려다가 그래도 한 번쯤 알고 넘어가면 좋을 것 같아 정리하게 되었다.

다음 파트인 Cloneable은 정말 넘어갈 예정이다..

 

우선 equals에 대한 내용부터 다루도록 하겠다. 책에서는 equals로 인한 문제를 겪지 않기 위해서는 그냥 재정의하지 않는 것이 최선이며, 오직 인스턴스의 논리적 동치성을 검사할 필요가 있을 때에만 equals를 재정의할 것을 권장하고 있다.

논리적 동치성이 무엇이냐면, 두 개의 객체가 있을 때 분명 둘은 따로 존재하는 객체지만 실질적(논리적)으로 같은지에 대한 것이다.

만약 똑같은 브랜드에서 출시한 같은 모델명의 의자 두 개가 있다면 둘은 같은가?

이것이 논리적 동치성이다. 더 쉽게 얘기하자면 해당 객체의 특색이 되는 중요한 필드들을 비교한다고 생각하면 되겠다.

 

다만 equals를 재정의할 경우 아래 항목을 모두 만족해야 한다.

이는 저자가 혼자 생각해서 적은 내용이 아니라 소스코드에도 동일하게 적혀있는 내용이니 숙지하도록 하자.

 

 

1. 반사성 

- 모든 Non-null 참조 객체의 A.eq

uals(A)는 true를 반환해야 한다.

- 자기 자신과의 비교가 반드시 참이여야 한다는 뜻으로, 이것을 굳이 어기게하는 것이 더 어려울 것이다.

 

2. 대칭성

- 어떤 Non-null 참조 객체 A, B가 있을 때, A.equals(B)가 참이라면 B.equals(A)도 참이여야 한다.

- 상식적으로 당연한 말이라고 생각하겠지만, 정말 멍청한 잘못된 방법으로 equals를 구현한다면 이런 문제가 발생할 수 있다. 아래 예시를 확인하자.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CaseInsensitiveString {
    private final String s;
 
    public CaseInsensitiveString(String s) {
        this.s = s;
    }
 
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(
                    ((CaseInsensitiveString)o).s);
        if (o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;
    }
 
    public static void main(String[] args) {
        CaseInsensitiveString stupidEquals = new CaseInsensitiveString("바보");
        String stupid = "바보";
        System.out.println(stupidEquals.equals(stupid));
 
        List<CaseInsensitiveString> list = new ArrayList<>();
        list.add(stupidEquals);
        System.out.println(list.contains(stupid));
    }
}
cs

 

CaseInsensitiveString이라는 클래스의 equals 메서드를 살펴보자.

무슨 의도인지는 모르겠으나, 만약 equals의 인자로 String이 들어온다면 객체의 필드 변수와 비교해서 값을 반환하고 있다.

 

그렇다면 new CaseInsenstiveString("바보").equals(new String("바보"));는 참을 반환할 것이다.

반대는 어떨까? String의 equals는 아래와 같이 구현되어 있다.

 

 

해시코드가 같거나, 혹은 같은 String 타입일 경우 문자열 압축 타입을 먼저 비교하고, 이후 value를 비교하여 boolean 값을 반환한다.

당연히 CaseInsentiveString같은 클래스는 입구에서 바로 거절당한다.

이런 경우 대칭성을 지키지 못한다고 할 수 있다. 이런 상황에 처하지 않기 위해서는 타입이 다를 경우 equals로 비교하지 않는 것이 최선이다.

 

3. 추이성

- 어떤 Non-null인 참조 객체 A,B,C가 있을 때, A.equals(B), A.equals(C)라면 B.equals(C)여야 한다.

- 위의 경우보다는 그나마 생길 수 있을 법한 문제다. 아래 예시를 보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Point {
    private final int x;
    private final int y;
 
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
 
    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Point)) {
            return false;
        }
        Point point = (Point) obj;
        return point.x == x && point.y == y;
    }
}
 
public class ColorPoint extends Point {
    private final String color;
 
    public ColorPoint(int x, int y, String color) {
        super(x, y);
        this.color=color;
    }
 
 
    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof ColorPoint)) {
            return false;
        }
        return ((ColorPoint) obj).color == color && super.equals(obj);
        // colorPoint면 정상적으로 비교, Point면 false
        // Point에서는 모두 비교.
    }
 
    public boolean equals2(Object obj) {
        if (!(obj instanceof Point)) {
            return false;
        }
 
        if (!(obj instanceof ColorPoint)) {
            return obj.equals(this);
        }
        // ColorPoint a = new ColorPoint(2,2,"Red");
        // Point b = new Point(2,2);
        // ColorPoint c = new ColorPoint(2,2,"Blue");
 
        return ((ColorPoint) obj).color == color && super.equals(obj);
        // colorPoint면 정상적으로 비교, Point면 false
        // Point에서는 모두 비교.
    }
}
 
cs

 

나름 용써봤지만 어떻게 해도 문제라는 걸 알 수 있다.

ColorPoint는 Point를 상속 받고 있기 때문에, Point 객체는 ColorPoint를 equals의 인자로 받아 비교할 수 있다. (대칭성 X)

반대로 ColorPoint는 같은 방식을 사용해서는 Point로 비교할 수 없고, 그렇다고 어떻게든 다르게 구현해서 Point를 구현할 수 있게 만들자니 대칭성은 만족하지만 추이성이 망가진다.

 

그렇다면 아래처럼 Point 객체는 Point끼리만 비교할 수 있게 하면 어떨까?

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    public boolean equals2(Object obj) {
        if ( obj == null || obj.getClass() != getClass() ) {
            return false;
        }
        Point p = (Point)obj;
        return p.x == x && p.y == y;
    }
// 이렇게 구현한다면?
 
 
 
public class CounterPointTest {
    private static final Set<Point> unitCircle = Set.of(
            new Point(10), new Point(01),
            new Point(-10), new Point(1-1)
    );
 
    public static boolean onUnitCircle(Point p) {
        return unitCircle.contains(p);
    }
 
    public static void main(String[] args) {
        Point point = new Point(10);
        CounterPoint counterPoint = new CounterPoint(10);
 
        System.out.println(onUnitCircle(point));
        System.out.println(onUnitCircle(counterPoint));
    }
}
 
// ColorPoint와 달리 CounterPoint는 누가 봐도 동일한 값을 에도 비교할 수 없다.
 
 
 
 
cs

 

Point는 오직 Point 객체와만 비교하도록 해봤다.

이것이 과연 올바른 선택일까?

 

리스코프 치환 원칙(Liskov Subsitution Principle)을 떠올려보자.

자식 객체는 부모 객체의 기능을 완전히 동일하게 사용할 수 있어야 한다.

그런데 같은 멤버 변수를 가진 자식 객체와 비교할 수 없다면 이것은 명백한 리스코프 치환 원칙 위반이다.

 

그러면 어떡하라고? 이런 경우(상속 받는 객체에 멤버 변수가 추가됨)에는 추이성을 지켜낼 방법이 없다.

실제로 자바 소스코드에서도 추이성이 깨진 사례를 볼 수 있는데, 바로 Date와 Timestamp이다.

 

만약 정 equals를 재정의하고 싶거든 Composition을 사용해보면 어떨까?

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ColorPoint2 {
    private final Point point;
    private final String color;
 
    public ColorPoint2(Point point, String color) {
        this.point = point;
        this.color = color;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ColorPoint2 that = (ColorPoint2) o;
        return Objects.equals(point, that.point) && Objects.equals(color, that.color);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(point, color);
    }
}
cs

 

반드시 상속 기능이 필요한 상황이 아니라면, 위와 같은 방법이 훌륭한 대안이 될 수 있을 것이다.

 

 

4. 일관성 

- 불변 객체일 경우 A.equals(B)의 반환값이 항상 같아야 한다.

- 이는 깨지기 어려운 원칙 같지만 equals()를 쓸데없이 복잡하게 만들 경우 문제가 생길 수 있다.

 

 

이는 URL 클래스의 equals 구현인데, 보다시피 아주 까다롭게 구현해놓은 덕분에 ip가 바뀐다거나 port가 바뀌면 일관성이 깨져버리는 문제를 가지고 있다.

잘못된 것을 알지만 하위 호환성 문제로 고치지도 못하고 계속 사용하고 있는 중이다.

 

지금 이걸 읽으면서 "equals 메서드를 하나 더 만들면 안되나?"라고 생각했다면 반성해야 한다.

equals 메서드는 해당 객체에서만 사용하는 것이 아니라 각종 자료구조에서 동등성 비교 시 사용되기 때문에 그런 식으로 만들어놓은 equals는 아무런 의미가 없다.

 

 

 

5. Not-null

- 일부분 null을 허용하는 것은 괜찮지만, 단순히 null인 것들끼리 비교해서 결과를 반환해서는 안된다.

- 지극히 상식적인 부분이니 설명은 생략하겠다.

 

 

 

 

 

후해시코드 이야기도 해야 하는데 생각보다 너무 길어졌다.

지금까지 문제가 되는 경우에 대해 알아봤으니 안전하게 equals를 재정의하는 방법을 빠르게 알아보자.

 

1. 자기 자신과의 비교는 반드시 true를 반환하도록 구현한다.

2. 타입이 다르다면 false를 반환하도록 구현한다.

3. 핵심적인 필드만 구현한다. (Lock을 위한 부수적인 필드 등은 제외한다.)

4. 부동소수점에 영향을 받는 자료형 (float, double)은 자체적인 compare로 비교한다. (Float.compare(f1,f2))

5. Primitive 타입이 아니라면 equals를 사용해서 비교한다.

6. 특정 값이 Null이여도 된다면 Objects.equals를 사용하면 된다.

7. Lombok을 쓰자.

 

너무나도 간단하고, 그리고 가장 좋은 방법이 있기 때문에 굳이 자세한 설명은 하지 않겠다.

내 개인 의견이 아니라 저자 또한 라이브러리 사용을 추천하고 있다.

최소한의 기본 원칙을 아는 것이 중요할 뿐, 실질적인 구현은 어차피 라이브러리가 원칙에 맞춰 만들어주기 때문에 일일히 만들 필요는 없다고 생각한다.

 

 

 

 

다음은 해시코드 이야기로 넘어가겠다, 이 쪽은 다행히도 equals처럼 할 말이 많지는 않다.

만약 equals를 재정의할 경우에는 hashCode도 항상 재정의해야 한다.

이에 대한 내용도 Java의 Object 소스 코드에 모두 작성되어 있다.

 

 

1. equals의 기준이 변경된 것이 아니라면, 같은 객체는 항상 같은 값을 반환해야 한다.

2. 두 객체의 equals가 같다면, hashCode도 항상 같아야 한다.

3. 두 객체의 equals가 false이더라도 hashCode의 값은 같을 수 있다.

다만, 성능을 위해서라면 되도록 해시값이 다르도록 구현하는 것이 좋다.

(해시 충돌 문제로 인해 해시 값이 같다면 내부에서 LinkedList를 사용한다. LinkedList의 탐색 시간 복잡도는 O(N)으로 HashMap의 시간 복잡도인 O(1)보다 굉장히 느려지기 때문에 주의할 필요가 있다.)

 

3번의 원칙이 중요한데, 우리가 만드는 모든 객체의 해시코드가 모두 1이여도 상관은 없다는 것이다.

그러나 Hash Collision으로 인해 성능이 저하되기 때문에 그런 짓은 하지 말도록 하자..

 

https://ko.wikipedia.org/wiki/%ED%95%B4%EC%8B%9C_%EC%B6%A9%EB%8F%8C

 

해시 충돌 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 해시 충돌이란 해시 함수가 서로 다른 두 개의 입력값에 대해 동일한 출력값을 내는 상황을 의미한다. 해시 함수가 무한한 가짓수의 입력값을 받아 유한한 가

ko.wikipedia.org

 

 

만약 hashCode를 굳이 직접 정의하고 싶다면 이런 식으로 만들 수 있다.

 

1
2
3
4
5
6
7
    @Override
    public int hashCode(){
        int result = Integer.hashCode(areaCode);
        result = 31 * result + Integer.hashCode(prefix);
        result = 31 * result + Integer.hashCode(prefix);
        return result;
    }
cs

 

우선 멤버 변수 하나의 해시값을 구한 뒤, 해당 값에 31을 곱하고 다른 멤버변수의 해시코드를 더하는 과정을 반복하는 것이다.

굳이 31을 더하는 이유는 짝수를 곱했다가 Overflow가 발생하는 경우 Shift 연산과 같은 효과가 일어나서 값의 일부를 잃어버리게 되기 때문이고, 이외에도 소수를 사용하는 것이 해시 충돌이 덜 일어난다고 한다.

 

추가적으로, 만약 hashCode를 사용할 일이 정말정말 많다면, hashCode를 처음 사용 시에만 메서드로 반환하고 이후에는 멤버 변수에 저장해서 반환하는 방법이 있다. 지연 초기화 방식과 같다.

 

1
2
3
4
5
6
7
8
9
10
    private int hashCode;
 
    @Override
    public int hashCode() {
        if ( hashCode != 0 ) return hashCode;
        else {
            hashCode = Objects.hash(areaCode, prefix, lineNum);
            return hashCode
        }
    }
cs

 

정말 성능이 극도로 중요한 어플리케이션이라면 약간이나마 도움이 될 수 있을지도 모르겠다..

 

중요한 것은, hashCode를 불러오는데 필요한 리소스가 아까워서 hashCode 생성에 사용하는 멤버 수를 줄인다거나 하는 행동은 해서는 안된다.

그런 행동이 더 성능을 저하시키기 때문이다.

 

그리고.. 이것도 직접 구현하지 말고 라이브러리나 IDE 자동 완성 기능을 사용하는 것이 오히려 더 안전하다..

테스트를 할 필요가 없어진다는 것도 아주 큰 장점이다.

 

따라서 원리만 알아두고 실제 구현은 IDE와 어노테이션에 맡기도록 하자.

(다시 한 번 말하지만 나만의 의견이 아니라 저자인 조슈아 블로크님께서도 같은 의견이다 ㅎㅎ)

 

오늘은 여기서 끝이다.