본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

[아이템 10] equals는 일반 규약을 지켜 재정의하라

[Why]

  • equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.

 

Object 명세에 적힌 규약

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

  • 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
  • 대칭성(symmetry): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
  • 추이성(transitivity): null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다.
  • 일관성(consistency): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
  • null-아님: null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.

 

[When]

※ equals 메서드는 곳곳에 함정이 있으므로 웬만하면 재정의하지 마라.

equals 메서드 재정의 하면 안 될 때

  • 각 인스턴스가 본질적으로 고유할 때. 값 표현이 아니라 동작하는 개체를 표현하는 클래스. 예) Thread
  • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없을 때.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞을 때.
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없을 때.
  • 값 클래스이지만, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스

equals 메서드 재정의 해도 될 때

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

 

[How]

1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 성능 최적화 목적

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

3. 입력을 올바른 타입으로 형변환한다. 2번에서 instanceof 검사를 했기 때문에 이 단계는 100% 성공한다.

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

5. equals를 재정의할 땐 hashCode도 반드시 재정의하자아이템11

6. 입력 타입은 반드시 Object여야 한다.

 

※ equals, hashCode를 작성하고 테스트하는 일은 지루하고 테스트 코드도 항상 뻔하다. 구글이 만든 AutoValue 프레임워크는 이 작업을 대신해준다.

 

예시1) 대칭성 위배 

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은 String 클래스를 알지만, String 클래스는 CaseInsensitiveString을 모른다.
// 옳은 예
@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
  • String과 CaseInsensitiveString 간 equals 비교를 true로 만들 생각하지 말고, CaseInsensitiveString 간의 비교만 구현한다.

 

예시2) 대칭성, 추이성 위배

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의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다.
// 대칭성 위배!
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) {
        return false;
    }
    return super.equals(o) && ((ColorPoint) o).color == color;
}
  • Point와 ColorPoint를 비교하면, Point의 equals는 색상을 무시하고 true를 반환하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환할 것이다. 
// 추이성 위배!
@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;
}
  • 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의 또 다른 하위 클래스로 SmellPoint를 만들고, equals를 같은 방식으로 구현한 후, myColorPoint.equals(mySmellPoint)를 호출하면 StackOverflowError를 일으킨다.

 

구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

  • 이 말은 얼핏, equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있는 것처럼 들린다. 하지만...
// 리스코프 치환 원칙 위배!
@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;
}
  • 위 equals는 구현 클래스끼리 비교할 때만 true를 반환한다.
  • Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다. 그런데 이 방식에서는 그렇지 못하다.

 

구체 클래스의 하위 클래스에서 값을 추가하는 괜찮은 우회 방법

  • "상속 대신 컴포지션을 사용하라"는 아이템18의 조언을 따르면 된다.
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.requiredNonNull(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);
    }
 
    ... // 나머지 코드는 생략
}
  • Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드를 public으로 추가하는 식이다.

 

equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.

  • java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다.
  • 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다.
  • 이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다.
  • URL의 equals를 이렇게 구현한 것은 커다란 실수였으니 절대 따라해서는 안 된다.