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

eastshine-high·2021년 10월 13일
0

equals는 잘못 재정의하는 경우 끔찍한 결과를 초래할 수 있다. 따라서 아래의 상황 중 하나에 해당한다면 재정의를 하지 않는 것이 최선이다.

  • 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당한다. Thread가 좋은 예다.
  • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다. 예를들어 List 구현체들은 AbstractList로부터 상속받아 그대로 쓴다.
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

실수로라도 equals가 호출되는 걸 막고 싶다면 다음처럼 구현해두자.

@Override
public boolean equals(Object a){
    throw new AssertionError(); 호출 금지
}

그렇다면 equals를 재정의해야 할 때는 언제일까?

객체 식별성(objectidentity : 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다. 주로 값 클래스들이 여기 해당한다. 값 클래스란 Integer와 String처럼 값을 표현하는 클래스를 말한다.

equals 메서드를 재정의 할 때는 반드시 일반 규약을 따라야 한다.

equals 메서드는 동치 관계(equivalence relation)를 구현하며, 다음을 만족한다.

→ 동치관계란 무엇일까? 쉽게 말해, 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다. 이 동치관계를 만족시키기 위한 다음 다섯 요건을 하나씩 살펴보자.

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

→ 객체는 자기 자신과 같아야 한다는 뜻이다. 이 요건은 일부러 어기는 경우가 아니라면 만족시키지 못하기가 더 어려워 보인다.

  1. 대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)는 true면 y.equals(x)도 true다.

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

import java.util.Objects;

public class CaseInsesitiveString {
    private final String s;

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

    @Override
    public boolean equals(Object o) {
        if(o instanceof CaseInsesitiveString)
            return s.equalsIgnoreCase(
                    ((CaseInsesitiveString) o).s);
        if(o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;
    }
}
public class Main {
    public static void main(String[] args) {
        CaseInsesitiveString cis = new CaseInsesitiveString("Hello");
        String s = "hello";

        System.out.println(s.equals(cis));
        System.out.println(cis.equals(s));
    }
}

실행결과
false
true

이 코드의 문제는 CaseInsensitiveString의 equals는 일반 String을 알고 있지만 String의 equals는 CaseInsensitiveString의 존재를 모른다는 데 있다. 따라서 s.equals(cis)는 false를 반환하여, 대칭성을 명백히 위반한다.

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

        if(o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;

따라서 CaseInsesitiveString클래스의 equals 메소드에서 위의 부분은 제거해야 한다.

  1. 추이성(transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(y)도 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 = (Square)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 메서드를 그대로 둔다면 Square의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다.

다음 코드처럼 비교 대상이 또 다른 ColorPoint이고 위치와 색상이 같을 때만 true를 반환하는 equals를 생각해보자.

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

이 메서드는 Square의 equals는 색상을 무시하고, ColorSquare의 equals는 Square 객체는 받지 않기 때문에 매번 false를 반환할 것이다.

그러면 다음처럼 ColorSquare.equals가 Square와 비교할 때는 색상을 무시하도록 하면 해결될까?

@Override
public boolean equals(Object o){
    if(!(o instanceof ColorSquare))
        return false;
    if(!(o instanceof ColorSquare))
        return o.equals(this);
    return super.equals(o) && ((ColorSquare) 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);

    System.out.println(p1.equals(p2));
    System.out.println(p2.equals(p3));
    System.out.println(p1.equals(p3));
}

실행결과
true
true
false

그럼 해법은 무엇일까? 이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근복적인 문제다. 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다. 객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다.

이 말은 얼핏, equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들린다.

ColorSquare.equals 메서드를 다음과 같이 변경

@Override
public boolean equals(Object o){
    if(o == null || o.getClass() != this.getClass())
        return false;
    Point p = (Point) o;
    return p.getX() == this.getX() && p.getY() == this.getY();
}

실행

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

    System.out.println(p1.equals(p2));
    System.out.println(p2.equals(p3));
    System.out.println(p1.equals(p3));
}
실행 결과
false
true
true

이번에는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다.

getClass를 사용할 경우 리스코프 치환 원칙(=어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다.)을 위배할 수 있다. 즉 Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다는 말이다. (그렇기에 상속 관계에서는 instanceof를 사용해야 한다.)

예를 들어 주어진 점이 (반지름이 1인) 단위 원 안에 있는 지를 판별하는 메서드가 필요하다고 해보자.

	
public class Point {
    ...
    ...
 	  private static final Set<Point> unitCircle = Set.of(
		        new Point(1, 0), new Point(0, 1),
		        new Point(-1, 0), new Point(0, -1)
		);
		
		public static boolean onUnitCircle(Point p) {
		    return unitCircle.contains(p);
		}
}

CounterPoint

public class CounterPoint extends Point {
    private static final AtomicInteger counter = new AtomicInteger();

    public CounterPoint(int x, int y) {
        super(x, y);
        counter.incrementAndGet();
    }

    public static int numberCreated() {
        return counter.get();
    }
}
public class Main {
    public static void main(String[] args) {
        Point p = new Point(1, 0);
        CounterPoint.onUnitCircle(p);
        System.out.println(CounterPoint.numberCreated());

        Point cp = new CounterPoint(1, 0);
        CounterPoint.onUnitCircle(cp);
        System.out.println(CounterPoint.numberCreated());
    }
}
실행결과
0
1

구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 있다. "상속 대신 컴포지션을 사용하라"는 아이템 18의 조언을 따르면 된다. Point를 상속하는 대신 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드(아이템 6)를 public으로 추가하는 식이다.

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

→ 일관성은 두 객체가 같다면(어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야 한다.

클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다. 예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다. 이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다.

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

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

→ null-아님은 이름처럼 모든 객체가 null과 같지 않아야 한다는 뜻이다.

의도하지 않았음에도 o.equals(null)이 true를 반환하는 상황은 상상하기 어렵지만, 실수로 NullPointerException을 던지는 코드는 흔할 것이다. 이 일반 규칙은 이런 경우도 허용하지 않는다. 수 많은 클래스가 입력이 null인지를 호가인해 자신을 보호한다.

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

이러한 검사는 필요치 않다. instanceof는 (두 번째 피 연산자와 무고나하게) 첫 번째 피연산자가 null이면 false를 반환한다.

0개의 댓글