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

equals 재정의 시 고려사항

  1. 각 인스턴스가 본질적으로 고유한 경우
    값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. ex) Thread.class

  2. 인스턴스의 논리적 동치성을 검사할 필요 없는 경우
    예컨대 java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사하는 방법도 있다. 하지만 설계자는 클라이언트가 이 방식을 원하지 않거나 필요없다고 판단한다면 Object의 기본 equals만으로 해결된다.

  3. 상위 클래스에서 재정의한 equals를 하위 클래스에서 사용해도 되는 경우
    대부분의 Set, List, Map 구현체들은 추상 클래스로부터 상속받아 equals를 그대로 쓴다.

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

equals 재정의해야 할 경우

객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때. 주로 값 클래스.
값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다. ex) Enum

equals 재정의 일반 규약

컬렉션 클래스들을 포함해 수 많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다.

반사성

null이 아닌 모든 참조 값 x에 대해 x.equals(x)는 true

대칭성

null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true면 y.equals(x)도 true

  • 대칭성 위배 메서드
@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;
}

toString 메서드는 원본 문자열의 대소문자를 그대로 돌려주지만 equals에서는 대소문자를 무시한다.

  • 수정 후
@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

CaseInsensitiveString의 equals와 String과의 연동을 포기해야 한다.

추이성

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;
    }

//    // 잘못된 코드 - 리스코프 치환 원칙 위배!
//    @Override public boolean equals(Object o) {
//        if (o == null || o.getClass() != getClass())
//            return false;
//        Point p = (Point) o;
//        return p.x == x && p.y == y;
//    }

    @Override public int hashCode()  {
        return 31 * x + y;
    }
}

상속

public class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    // 잘못된 코드 - 대칭성 위배
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        return super.equals(o) && ((ColorPoint) o).color == color;
    }

//    // 잘못된 코드 - 추이성 위배
//    @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;
//    }
}

상위클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 경우. 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 상속 대신 컴포지션을 사용하여 우회하는 방법이 있다.

컴포지션

public class ColorPoint {
    private final Point point;
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        point = new Point(x, y);
        this.color = Objects.requireNonNull(color);
    }

    /**
     * 이 ColorPoint의 Point 뷰를 반환한다.
     */
    public Point asPoint() {
        return point;
    }

    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }

    @Override public int hashCode() {
        return 31 * point.hashCode() + color.hashCode();
    }
}

추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다. 태그 달린 클래스보다는 클래스 계층구조를 활용하는게 클래스 계층구조에서 아주 중요한 사실이다. 예컨대 아무런 값을 갖지 않는 추상 클래스인 Shape를 위에 두고, 이를 확장하여 radius 필드를 추가한 Circle 클래스와, length와 width 필드를 추가한 Rectangle 클래스를 만들 수 있다.
상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 문제는 일어나지 않는다.

일관성

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

가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있는 반면, 불변객체는 한번 다르면 끝까지 달라야한다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다.

null-아님

null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false 모든 객체가 null과 같지 않아야 한다.

@Override
public boolean equals(Object o) {
    if(o == null)
        return false;
    ...
}

수많은 클래스가 위 코드처럼 입력이 null인지를 확인하지만 명시적 null 검사는 필요하지 않다. 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야 한다. 그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다.

@Override
public boolean equals(Object o) {
    if(!(o instanceof MyType))
        return false;
    MyType myType = (MyType)o;
    ...
}

equals가 타입을 확인하지 않으면 잘못된 타입이 인수로 주어졌을 때 ClassCastException을 던져서 일반 규약을 위배하게 된다. 그런데 instanceof는 첫 번째 피연산자가 null이면 false를 반환한다. 따라서 입력이 null이면 타입 확인 단계에서 false를 반환하여 null 검사가 필요하지 않다.

equals 메서드 구현방법 정리

1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인

자기 자신이면 true를 반환한다. 이는 단순한 성능 최적화용이다.

2. instanceof 연산자로 입력이 올바른 타입인지 확인

이 때 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있다. 어떤 인터페이스는 자신을 구현한 클래스끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다. 이런 경우 equals에서 클래스가 아닌 해당 인터페이스를 사용해야 한다. Set, List, Map, Map.Entry 컬렉션 인터페이스가 여기에 해당.

3. 입력을 올바른 타입으로 형변환

4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 검사

모든 필드가 일치하면 true, 하나라도 다르면 false 반환. 2단계에서 인터페이스를 사용했다면 필드 값을 가져올 때도 인터페이스의 메서드를 사용해야 한다.

기본 타입 필드

float와 double은 Float.compare(float, float)Double.compare(double, double) 로 비교하고, 나머지 기본 타입 필드는 == 연산자로 비교한다. Float.equals와 Double.equals 메서드는 오토박싱을 수반할 수 있으니 성능상 좋지 않다.

참조 타입 필드

참조 타입 필드는 각각의 equals 메서드로 비교한다. null도 정상 값으로 취급하는 참조 타입 필드의 경우, Objects.equals(Object, Object)로 비교해 NullPointException 발생을 예방하자.

배열 필드

원소 각각을 앞서의 지침대로 비교한다. 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드를 사용하자.

비교하기 복잡한 필드

필드의 표준형을 저장해둔 후 표준형끼리 비교하면 훨씬 경제적이다. 특히 불변 클래스에 제격이다. 가변 객체라면 값이 바뀔 때마다 표준형을 최신상태로 갱신해줘야 한다.

어떤 필드를 먼저 비교하느냐에 따라 equals의 성능을 좌우하기도 한다. 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자. 동기화 락 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안 된다. 핵심 필드로부터 계산해낼 수 있는 파생필드 역시 굳이 비교할 필요는 없지만, 파생 필드를 비교하는 쪽이 더 빠른경우는 예외다.

equals 구현시 주의사항

  • equals를 재정의할 때는 hashcode도 반드시 정의하자.
  • 너무 복잡하게 해결하려 들지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자. 이 경우는 Object.equals를 재정의한 게 아니라 다중정의 한 것이다.(@Override 애너테이션을 일관되게 사용하여 실수를 예방)

꼭 필요한 경우가 아니라면 equals를 재정의하지 말자. 많은 경우에 Object의 equals가 원하는 비교를 정확히 수행해준다. 재정의해야 할 때는 그 클래스의 핵심 필드를 빠짐없이, 다섯 가지 규약을 지키며 비교해야 한다.

Comments