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

반응형
728x90
반응형

equals() 메서드 재정의

equals 메서드 재정의는 간단해 보여도 함정이 많은 행위다. 문제를 회피하는 가장 좋은 방법은 아예 재정의를 하지 않는 것인데, 재정의를 하지 않으면 Object 클래스의 equals() 메서드를 호출하므로 그 클래스의 인스턴스는 오직 자기 자신과만 같게된다. 

 

▶ 다음에서 열거한 상황 중 하나에 해당한다면 equals()를 재정의하지 말자.

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

Object의 equals() 메서드가 이에 속한다.

 

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

논리적 동치성

5만원 지폐가 2개가 있을때, 각 지폐는 다른 지폐다. 하지만 금액은 같다. 

어떤것을 비교하느냐에 따라 달라진다.

기본적으로 Object 클래스의 equals()는 같은 지폐인지를 체크하기 때문에 두 지폐는 동일하지 않다. 그러나 equals()를 재정의하여 금액이 같은지를 판단한다면 같은 것으로 판단한다.

 

예를 들어서 Pattern 클래스의 재정의된 equals()는 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사한다. 즉, 논리적 동치성을 검사하는 방법으로 재정의했다. 만약 애초에 이런 검사가 필요하지 않다면 Object 클래스의 기본 equals()만으로도 해결된다.

 

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

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

 

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

public의 경우는 어디서든지 호출이 가능하기 때문에 어떻게 쓰일지 예상할 수 없다. 대신 private나 package-private는 우리가 직접 equals()를 호출할 일이 없다면 equals() 메서드를 재정의할 필요가 없다.

equals() 메서드 호출의 위험을 처리하고자 한다면, equals() 메서드를 재정의하여 오류를 뱉어내면 된다.

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

 

 

equals() 메서드 재정의가 필요한 상황

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

 

 

equals() 메서드를 재정의할때의 규약

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

 

동치관계란?

집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다. 이 부분집합을 동치류(equivalence class; 동치 클래스)라고 한다. equals() 메서드가 쓸모있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야한다.

 

1) 반사성(reflexivity)

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

 

2) 대칭성(symmetry)

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

 

3) 추이성(transitivity)

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

 

4) 일관성(consistency)

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

 

5) null-아님

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

 

 

여기서부터 예제는 인프런의 이펙티브자바 1부 강의를 참고했다.

반사성 예제

객체는 자기 자신과 같아야 한다는 뜻인데, 이 요건은 일부러 어기는 경우가 아니라면 만족시키지 못하기가 더 어렵다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 false가 나오게된다.

 

Point.java
public class Point {

    private final int x; // final 로 해서 immutable하게 만든다.
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public static void main(String[] args) {
        Point point = new Point(1, 2);
        List<Point> points = new ArrayList<>();
        points.add(point);

        // equals()를 논리적 동치성을 판단하도록 재정의해야 true다.
        System.out.println(points.contains(new Point(1, 2)));
    }

    // 아이템 11 참조
    @Override public int hashCode()  {
        return 31 * x + y;
    }
}

1) 결과는 false다.

// equals()를 논리적 동치성을 판단하도록 재정의해야 true다.
System.out.println(points.contains(new Point(1, 2)));

 

문제해결
 @Override public boolean equals(Object o) {
    // 자기자신과 같은지 확인 (객체와 동일성) - 반사성
    if (this == o) {
        return true;
    }

    if (!(o instanceof Point)) {
        return false;
    }

    Point p = (Point) o;
    return p.x == x && p.y == y;
}

논리적 동치성을 판단하는 equals 메서드를 재정의했다. 

 

 

대칭성 예제

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(
                    // o를 CaseInsensitiveString 로 캐스팅하고 그 안의 s와 비교
                    ((CaseInsensitiveString) o).s);
        if (o instanceof String)  // 한 방향으로만 작동한다!
            return s.equalsIgnoreCase((String) o);
        return false;
    }

    // 문제 시연 (55쪽)
    public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
        CaseInsensitiveString cis2 = new CaseInsensitiveString("polish");
        String polish = "polish";
        System.out.println(cis.equals(polish)); // true
        System.out.println(polish.equals(cis)); // false (String 클래스의 equals)
        System.out.println(cis2.equals(cis)); // true

        List<CaseInsensitiveString> list = new ArrayList<>();
        list.add(cis);

        System.out.println(list.contains(polish)); // false
    }
}
문자열 비교 코드 결과
Polish(cis) vs polish(polish) System.out.println(cis.equals(polish)); // true true
polish(polish) vs Polish(cis) System.out.println(polish.equals(cis)); // false (String 클래스의 equals) false
polish(cis2) vs Polish(cis) System.out.println(cis2.equals(cis)); // true true

 

위 결과가 나온 이유
@Override public boolean equals(Object o) { // 권장하지 않는 코드
    if (o instanceof CaseInsensitiveString)
        return s.equalsIgnoreCase(
                // o를 CaseInsensitiveString 로 캐스팅하고 그 안의 s와 비교
                ((CaseInsensitiveString) o).s);
    if (o instanceof String)  // 한 방향으로만 작동한다!
        return s.equalsIgnoreCase((String) o);
    return false;
}
문자열 비교 설명
Polish(cis) vs polish(polish) System.out.println(cis.equals(polish)); // true
cis 변수는 CaseInsensitiveString 타입이므로 위 메서드가 수행된다.
그리고 polish 변수는 String 타입이므로 두번째 if문이 수행된다.
이때 대소문자를 무시하는 equalsIgnoreCase()로 비교하므로 true다.
polish(polish) vs Polish(cis) System.out.println(polish.equals(cis)); // false
polish 변수는 String 타입이므로 String 클래스의 equals가 수행되어 false가 나온다.
polish(cis2) vs Polish(cis) System.out.println(cis2.equals(cis)); // true
cis2 변수는 CaseInsensitiveString 타입이므로 위 메서드가 수행된다.
첫번째 if문의 return문을 실행하게되고, CaseInsensitiveString 타입인 o와 비교한다.

위 예제를 통해 x.equals(y)가 true면 y.equals(x)도 true인 대칭성에 위배됨을 알 수 있다.

 

문제해결

CaseInsensitiveString의 equals()를 String과도 연동하겠다는 생각을 버리자. 아래와 같이 다른 타입을 지원하지 않는 간단한 메서드로 대칭성 위배 문제를 해결할 수 있다.

@Override 
public boolean equals(Object o) {
    // 다른 타입을 지원하지말라
    return o instanceof CaseInsensitiveString &&
            ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

 

 

위 Point 클래스를 확장한 대칭성, 추이성 예제

대칭성 예제

위의 반사성 예제에 사용했던 Point 클래스를 확장해보자.

Point.java
public class Point {

    private final int x; // final 로 해서 immutable하게 만든다.
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    @Override public boolean equals(Object o) {
        // 자기자신과 같은지 확인 (객체와 동일성) - 반사성
        if (this == o) {
            return true;
        }

        if (!(o instanceof Point)) {
            return false;
        }

        Point p = (Point) o;
        return p.x == x && p.y == y;
    }

    public static void main(String[] args) {
        Point point = new Point(1, 2);
        List<Point> points = new ArrayList<>();
        points.add(point);

        // equals()를 논리적 동치성을 판단하도록 재정의해야 true다.
        System.out.println(points.contains(new Point(1, 2)));
    }

    // 아이템 11 참조
    @Override public int hashCode()  {
        return 31 * x + y;
    }
}

 

Color.java
public enum Color { RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET }

 

ColorPoint.java

Point 클래스를 확장해서 점에 색상을 더해보자.

public class ColorPoint extends Point {
    private final Color color;

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

    // 코드 10-2 잘못된 코드 - 대칭성 위배! (57쪽)
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
    
    public static void main(String[] args) {
        // 첫 번째 equals 메서드(코드 10-2)는 대칭성을 위배한다. (57쪽)
        Point p = new Point(1, 2);
        ColorPoint cp = new ColorPoint(1, 2, Color.RED);
        System.out.println(p.equals(cp) + " " + cp.equals(p)); // true + " " + false
    }
}

1) 객체 p 와 객체 cp의 equals()의 대칭성이 위배된다.

System.out.println(p.equals(cp) + " " + cp.equals(p)); // true + " " + false

 

대칭성은 지켜주지만, 추이성은 위배되는 예제

추이성 :  x.equals(y)가 true이고, y.equals(z)도 true면 x.equals(z)도 true다.

ColorPoint.java
public class ColorPoint extends Point {
    private final Color color;

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

    // 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)
    @Override public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;

        // o가 일반 Point면 색상을 무시하고 비교한다.
        // 위험한 코드다 : stackOverFlow
        if (!(o instanceof ColorPoint))
            // 만약 SmellPoint 라면? 또 SmellPoint 호출 
            return o.equals(this);

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

    public static void main(String[] args) {
        // 두 번째 equals 메서드(코드 10-3)는 추이성을 위배한다. (57쪽)
        ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
        Point p2 = new Point(1, 2);
        ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
        System.out.printf("%s %s %s%n",
                          p1.equals(p2), p2.equals(p3), p1.equals(p3));
    }
}

1) 추이성을 위배한다.

Point와 비교할때는 색상을 무시하도록 하였다. 이는 대칭성은 지켜주지만 추이성은 위배된다.

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

    // o가 일반 Point면 색상을 무시하고 비교한다.
    // 위험한 코드다 : stackOverFlow
    if (!(o instanceof ColorPoint))
        // 만약 SmellPoint 라면? 또 SmellPoint 호출 
        return o.equals(this);

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

 

위 결과의 이유
System.out.printf("%s %s %s%n",
                          p1.equals(p2), p2.equals(p3), p1.equals(p3));

 

코드 결과 설명
p1.equals(p2) true p1은 ColorPoint 타입이고 p2는 Point 타입이다.
ColorPoint 타입의 equals()가 수행되고, 넘어간 p2의 타입이 Point 타입이므로 두번째 if문을 타게된다.
여기서 Point 타입의 equals()문을 다시 수행하므로 결국 색상을 무시하고 비교하게된다.
p2.equals(p3) true p2는 Point 타입이고 p3는 ColorPoint 타입이다.
Point 타입의 equals()문이 수행되므로 색상을 무시하고 비교하게된다.
p1.equals(p3) false p1은 ColorPoint 타입이고 p3도 ColorPoint 타입이다.
ColorPoint의 equals()가 수행되고, 넘어온 p3의 타입이 ColorPoint므로 마지막 return 문의 비교문이 수행된다.
색상까지 비교하게 되므로 false다.

 

한가지 위험이 또 존재한다.

StackOverFlowError

무한 재귀에 빠질 위험이 있는 코드다. Point의 또다른 하위 클래스로 SmellPoint를 만들고, equals는 같은 방식으로 구현했다고 해보자. 그런 다음 myColorPoint.equals(mySmellPoint)를 호출하면 StackOverflowError가 발생한다.

 

SmellPoint.java
public class SmellPoint extends Point {

    private String smell;

    public SmellPoint(int x, int y, String smell) {
        super(x, y);
        this.smell = smell;
    }

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

        // o가 일반 Point면 색상을 무시하고 비교한다.
        if (!(o instanceof SmellPoint))
            // 만약에 o Col가rPoint type이라면?
            // ColorPoint의 equals() 호출
            return o.equals(this);

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

 

SmellPointTest.java
public class SmellPointTest {
    /**
     * TODO -Xss10M
     * @param args
     */
    public static void main(String[] args) {
        SmellPoint p1 = new SmellPoint(1, 0, "sweat");
        ColorPoint p2 = new ColorPoint(1, 0, Color.RED);
        p1.equals(p2);
    }
}

1) p1의 equals() 가 수행된다.

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

    // o가 일반 Point면 색상을 무시하고 비교한다.
    if (!(o instanceof SmellPoint))
        // 만약에 o Col가rPoint type이라면?
        // ColorPoint의 equals() 호출
        return o.equals(this);

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

 

2) p1의 equals()에서 p2는 ColorPoint 타입이므로 두번째 if문이 수행되어 ColorPoint의 equals()를 호출한다.

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

    // o가 일반 Point면 색상을 무시하고 비교한다.
    // 위험한 코드다 : stackOverFlow
    if (!(o instanceof ColorPoint))
        // 만약 SmellPoint 라면? 또 SmellPoint 호출 
        return o.equals(this);

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

 

3) 또 반복이다. 여기서도 또 SmellPoint의 equals()문이 수행된다.

이런식으로 무한 재귀에 빠져, 계속 stack이 쌓이게되면 StackOverflowError가 발생한다.

 

그렇다면 해법은 무엇일까?

모든 객체 지향 언어의 동치 관계에서 나타나는 근본적인 문제로, 구체 클래스를 확장해 새로운 값을 추가하면서 equals() 규약을 만족시킬 방법은 존재하지 않는다. 

 

 

리스코프 치환 원칙 위배 예제

만약 equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있을까?

 

Point.java
// 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)
// 상위타입일때, 하위타입일때 onUnitCircle() 메서드가 true, flase로 다르게 나온다.
@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;
}

 

CounterPoint.java

Point 클래스의 하위 클래스다.

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

 

CounterPointTest.java
public class CounterPointTest {
    // 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
    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);
    }

    /*
     // 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽) 의 equals()일 때

     리스코프 치환 원칙
     - 상위 클래스의 동작과 하위 클래스가 같아야한다.
     */
    public static void main(String[] args) {
        Point p1 = new Point(1,  0);
        Point p2 = new CounterPoint(1, 0); // Point 클래스의 자식클래스

        // true를 출력한다.
        System.out.println(onUnitCircle(p1));

        // true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
        // CounterPoint.getClass() != Point.getClass()
        System.out.println(onUnitCircle(p2));
    }
}
코드 결과 설명
onUnitCircle(p1); true p1은 Point 타입이다. getClass() 비교로 변경된 Point 클래스의 equals() 메서드에서 마지막 return 문이 실행되어 true다.
onUnitCircle(p2); false p2는 CounterPoint 타입이다.
따라서 getClass() 비교로 변경된 Point 클래스의 equals() 메서드에서 첫번째 if문으로 인해 false다.

위 결과를 보면, 상위클래스의 동작과 하위 클래스의 동작이 각각 true, false로 다르게된다. 이는 리스코프 치환 원칙에 위배된다.

 

문제해결

상속이 아닌 컴포지션을 사용하면 된다.

// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60쪽)
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();
    }
}

1) asPoint() 메서드를 통해 point 객체를 얻을 수 있다.

Point p2 = new ColorPoint(1, 0, Color.RED).asPoint();

 

 

마지막 주의사항

1) equals를 재정의할땐 hashCode도 반드시 재정의해야한다.

2) 너무 복잡하게 해결하려 들지 말자. 필드들의 동치성만 검사해도 충분하다.

3) Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자. 

 

 

 

반응형

Designed by JB FACTORY