개발바닥곰발바닥
728x90

equals는 일반 규약을 지켜 재정의하라

equals 메서드는 재정의하기 쉬워 보이지만 자칫하면 끔찍한 결과를 초래한다.
문제를 회피하는 가장 쉬운 길은 아예 재정의하지 않는 것이다.

그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다. 아래 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.

재정의 하지 않는 것이 좋은 경우

각 인스턴스가 본질적으로 고유하다.

값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다.
Thread가 좋은 예로, Object의 equals는 이러한 클래스에 맞게 구현되었다.

인스턴스의 논리적 동치성(logical equality)을 검사할 일이 없다.

예를 들어 java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규식을 나타내는지(논리적 동치성)
검사하는 방법도 있다. 하지만 설계자가 원하지 않거나 필요하지 않다고 판단했다면 Object의 기본 equals만으로 해결된다.

상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.

예를 들어 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고,
List 구현체들은 AbstractList, Map 구현체들은 AbstractMap으로부터 상속받아 그대로 쓴다.

//AbstractSet equals
public boolean equals(Object o) {
        if (o == this)
            return true;

        if (!(o instanceof Set))
            return false;
        Collection<?> c = (Collection<?>) o;
        if (c.size() != size())
            return false;
        try {
            return containsAll(c);
        } catch (ClassCastException | NullPointerException unused) {
            return false;
        }
    }

클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

equals가 실수로라도 호출되는 걸 방지하고 싶다면 아래처럼 구현한다.

class Something {
    @Override
    public boolean equals(Object o) {
        throw new AssertionError(); // 호출 방지
    }
}

그렇다면 equals를 재정의해야 할 때는 언제일까? 객체의 동일성이 아니라 논리적 동치성을 확인해야
하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다.

주로 값 클래스(Integer, String...)가 여기에 해당한다.
equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다. 다음은 Object 명세에 적힌 규약이다.

equals 메서드 재정의 규약

반사성(reflexivity)

반사성이란 null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true인 것이다.

단순히 말하자면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣고 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 할 것이다.

대칭성(symmetry)

대칭성이란 null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true면 y.equals(x)도 true인 것이다.

두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.

import java.util.Objects;

public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    //대칭성 위배
    @Override 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;
    }
}

CaseInsensitiveString의 equals는 일반 문자열과도 비교를 시도한다.

만약 CaseInsensitiveString 객체와 일반 String 객체를 비교한다면 대칭성이 위반된다.

public class Main {
    public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseinsensitiveString("Inwoo");
        String s = "inwoo";

        cis.equals(s); // true
        s.equals(cis); // false;
    }
}

원인은 CaseInsensitiveString의 equals는 일반 String을 알고 있지만 String의 equals는 CaseInsensitiveString의 존재를 모른다는 데 있다.

이 문제를 해결하려면 CaseInsensitiveString의 equals를 String과도 연동하려는 허황된 꿈을 버려야 한다.

추이성(transitivity)

추이성이란 null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면
x.equals(z)도 true인 것이다.

상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해 보자.

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 o) {
        if(!(o instanceof Point))
            return false;
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}

//확장 클래스 (색상 속성 추가)
public class ColorPoint extends Point {
    private final Color color;
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
}


이때 equals 메서드는 어떻게 해야 할까? 그대로 둔다면 Point의 구현이 상속되어 색상 값은 무시하고 비교를 수행한다.

//잘못된 코드 - 추이성 위배
import java.awt.*;

public class ColorPoint extends Point {
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;

        // o가 Point 객체면 색상을 무시하고 비교한다.
        if(!(o instanceof ColorPoint))
            return o.equals(this);

        // o가 ColorPoint면 색상까지 비교한다.
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}

    public static void main(String[] args) {
        ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
        Point p2 = new Point(1,2);
        ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
    }


위 방식은 대칭성은 지켜주지만 추이성을 깨버린다. p1.equals(p2)와 p2.equals(p3)는 true를 반환하는데 p1.equals(p3)가 false를 반환한다.

또한 이 방식은 무한 재귀에 빠질 위험도 존재한다.
구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법으로는 Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public으로 추가하는 방식이 있다.

일관성(consistency)

일관성이란 null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환하는 것이다.

즉, 두 객체가 같다면 수정되지 않는 한 앞으로도 영원히 같아야 한다는 뜻이다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들면 안 된다.

이 제약을 어기면 일관성 조건을 만족시키기 매우 어려워진다. java.net.URL의 equals는 주어진 URL과
매핑된 호스트의 IP 주소를 비교하는데, 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 해서 그 결과가
항상 같다고 보장할 수 없다.

이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 이용한 결정적 계산만 수행해야 한다.

null-아님

null-아님은 null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false인 것이다.

o.equals(null)이 true를 반환하는 상황은 거의 없겠지만 NullPointerException을 던지는 코드는 흔할 것이다.

이 일반 규약은 이런 경우도 허용하지 않는다.
수많은 클래스가 아래처럼 입력이 null인지를 확인해 자신을 보호한다.

// 명시적 null 검사 - 필요 없다!
@Override public boolean equals(Object o) {
    if (o == null)
        return false;
}


이러한 검사는 필요치 않다. 동치성을 검사하려면 equals는 객체를 적절히 형변환한 후 필드의 값을 알아내야 한다.

형변환에 앞서 instanceof 연산자로 올바른 타입인지 검사한다.
instanceof는 첫 번째 피연산자가 null이면 false를 반환한다. 따라서 입력이 null이면 false를 반환하기
때문에 null 검사를 명시적으로 하지 않아도 된다.

정리

지금까지의 내용을 종합해서 양질의 equals 메서드 구현 방법을 단계별로 정리해 보겠다.

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
    1. 자기 자신이면 true를 반환한다. 이는 성능 최적화를 도와준다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
    1. 이때의 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만 가끔은 그 클래스가 구현한 특정 인터페이스 일 수 있다.
  3. 입력을 올바른 타입으로 형변환한다.
    1. 앞서 2번에서 instanceof 검사를 했기 때문에 이 단계는 100% 성공한다.
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 검사한다.
    1. 모든 필드가 일치하면 true를, 하나라도 다르면 false를 반환한다.

어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 다를 가능성이 더 크거나
비교하는 비용이 싼 필드를 먼저 비교하자.

동기화용 락(lock) 필드 같이 객체의 논리적 상태와
관련 없는 필드는 비교하면 안 된다.

equals를 구현했다면 세 가지만 자문해 보자. 대칭적인가? 추이성이 있는가? 일관적인가?

equals를 재정의할 땐 hashCode도 반드시 재정의하자
equals와 hashCode

[Java] equals와 hashCode

equals와 hashCode Java의 모든 클래스는 Object 클래스를 암시적으로 상속받고 있다. 모든 클래스의 조상인 Object 클래스에서는 모든 클래스가 공통적으로 포함하고 있어야 하는 기능을 제공한다. 그

bestinu.tistory.com



꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 많은 경우 Object의 equals가 원하는 비교를 정확히 수행해 준다.

재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.

728x90

'JAVA' 카테고리의 다른 글

[Mockito] when().thenReturn vs doReturn().when  (0) 2023.01.15
[Java] equals와 hashCode  (0) 2022.12.22
[Java] Stream API 정리  (0) 2022.11.27
Java Optional(옵셔널)  (0) 2022.11.23
[JAVA] Reflection (리플렉션)이란?  (0) 2022.11.17
profile

개발바닥곰발바닥

@bestinu

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!