Effective Java : 아이템10. equals는 일반 규약을 지켜 재정의하라 (핵심 정리)

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다. 


    이 글의 요약

    • equals()는 가급적이면 재정의하지 않는 것이 최선임.
    • 논리적 동치성은 가지고 있는 값이 같은지로 판단, 객체의 동일성은 같은 주소를 가지는지 판단.
    • equals() 구현시 반사성, 대칭성, 추이성, 일관성, null 아님을 지켜서 구현해야 한다. 
    • 상속 구조에서는 equals() 규약을 만족하기 어렵다. 
      • 상속 구조에 필드가 추가된 구조라면 equals() 규약을 만족하는 방법이 없음.
      • 상속 구조에 필드가 추가되지 않은 구조라면, 상위 클래스의 equals()를 이용해야함. 
      • 상속 구조에 필드가 추가된 경우라면, Composition을 이용해서 equals()를 손쉽게 구현할 수 있음. 

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

    Object 객체는 equals 메서드를 제공한다. 만약 우리가 생성하는 클래스가 있고 equals를 재정의 해야할 때는 여기 있는 내용을 참고해서 재정의하자. 아래에 자세한 내용을 작성한다.


    핵심 정리1. equals를 재정의 하지 않는 것이 최선임

    • 다음의 경우에 해당한다면 equals를 재정의 할 필요가 없다. 
      • 각 인스턴스가 본질적으로 고유한 경우. (싱글톤 객체, enum)
      • 인스턴스의 논리적 동치성을 검사할 필요가 없는 경우. (객체의 동일성으로 충분한 경우)
      • 상위 클래스에서 재정의한 equals가 하위 클래스에도 적절한 경우.
      • 클래스가 private이거나 package-private인 경우. equals 메서드를 호출할 일이 거의 없기 때문.

    기본적으로 equals() 메서드는 재정의하지 않는 것이 좋다. 따라서 필요한 경우에만 equals를 선택적으로 재정의하도록 한다. 

     

    equals를 재정의 하지 않아도 되는 경우

    1. 각 인스턴스가 고유한 경우

    각 인스턴스가 고유할 때는 equals가 필요없다. 근본적으로 단 하나의 객체이기 때문에 다른 객체와 비교할 필요가 없다. 고유한 객체의 예시는 싱글톤 인스턴스, enum 같은 녀석들이 있다.

    2. 인스턴스의 논리적 동치성을 검사할 필요가 없을 때 (같은 값을 가지는지 검사할 필요가 없을 때) 

    '객체가 가진 값이 같을 때, 논리적 동치성이 있다' 라고 표현한다. 예를 들어 50달러짜리 지폐 두 개가 존재한다면, 논리적 동치성은 있지만, 객체의 동일성은 없다. 50달러로 값은 같은데, 일련번호가 다르기 때문이다. 이걸 인스턴스에 적용한다면 값은 같은데, 메모리 주소(객체의 주소)가 다르다. 

    Object 클래스가 기본적으로 제공하는 equals는 객체의 동일성을 검사한다. 논리적 동치성을 검사하지 않는 대표적인 예시는 "hello" 같은 문자열은 equals를 구현했다. "hello"를 선언한 객체는 모두 다른 객체처럼 보이지만, 실제로는 문자열 풀에서 같은 리터럴을 가진 문자열은 같은 객체를 가리키기 때문이다. 이처럼 인스턴스의 논리적 동치성을 보지 않고, 객체의 동치성만 봐도 충분한 경우에는 equals를 재정의 할 필요가 없다. 

    3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 적절하다.

    상위 클래스에 이미 equals가 정의되어 있고, 자식 클래스에도 적절히 사용할 수 있다면 자식 클래스에서 equals를 구현할 필요가 없다. 예를 들면 List, Set을 상속해서 하위 클래스를 구현하려고 할 때 equals를 구현할 필요가 없다. 이미 AbstractList, AbstractMap 같은 곳에 equals가 적절히 정의되어 있으므로 하위 클래스에서는 재정의 할 필요는 없다. 

    4. 클래스가 private이거나 package-private인 클래스인 경우(제한된 클래스)

    위의 경우처럼 제한된 클래스는 노출된 곳이 적기 때문에 equals 메서드를 호출할 일이 적다. 반면 public 클래스는 equals가 외부에서 사용될 가능성이 높다. 따라서 제한적으로 관리할 수 있는 private, package-private에서는 equals가 호출되지 않을 것을 보장할 수 있다면, equals를 구현할 필요 없다. (YAGNI) 


    핵심 정리2. equals 규약

    • 반사성
      • A.equals(A) == true
      • A와 A는 같아야 함. (거울에 비친 50달러)
      • 'this == object'로 구현할 수 있다. 
    • 대칭성
      • A.equals(B) == B.equals(A)
      • CaseInsensitiveString 예시 살펴보기
    • 추이성
      • A.equals(B) && B.equals(C)  → A.equals(C)
      • A와 B가 같고, B와 C가 같으면 A와 C도 같아야 한다. (추이성)
      • Point, ColorPoint(inherit), CounterPointer, ColorPoint(comp) 예시 살펴보기.
    • 일관성
      • A.equals(B) == A.equals(B)
      • 여러 번 equals()를 호출해도 같은 결과가 나와야 함. 
    • null 아님
      • A.Equals(null) == false
    • equals를 정의 할 때, 위 규약을 모두 따라야한다. 단, 일관성 관점에서 너무 높은 수준의 일관성을 구현하려고 하면 안됨. 구현이 너무 어려워지기 때문임. 

    어쩔 수 없이 equals를 구현해야 하는 경우라면, 위의 equals 규약을 만족하는 equals 메서드를 정의해야한다. 

     


    대칭성이 깨진 경우

    대칭성은 다음 성질을 만족하는 것을 의미한다. 

    • A.equals(B) == B.equals(A)

    equals를 구현하다보면 대칭성이 깨지는 경우도 제법 있다. 아래 코드의 equals()를 살펴보자. 

    // 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
    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;
        }
    }
    • equals()로 전달된 값이 CaseInsensitiveString 인스턴스인 경우, CaseInsensitiveString이 가진 필드 s값을 비교한다.
    • equals()로 전달된 값이 String인 경우, CaseInsensitiveString이 가진 필드 s값과 비교한다. (이게 문제임)

    A.equals(B) 라는 것을 실행했을 때, A와 B 둘다 CaseInsensitiveString 인스턴스라면 equals() 메서드는 대칭성을 만족한다. 반면 A는 CaseInsensitiveString이고 B는 String인 경우라면 대칭성을 만족하지 않는다. B.equals(A)를 했을 때, String 타입인 B는 CaseInsensitiveString 인스턴스에 대한 equals 결과를 false로 반환해주기 때문이다. 따라서 아래 코드같은 경우라면 대칭성을 만족시키지 못한다. 

        public static void main(String[] args) {
            CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
            String polish = "polish";
    
            System.out.println(cis.equals(polish));
            System.out.println(polish.equals(cis));
        }

    대칭성 위배를 해결하기 위해서 equals는 아래와 같이 구현하면 된다.  이렇게 구현하면 CaseInsensitiveString - String 인스턴스 사이에서 발생하는 equals()의 대칭성 문제가 해결된다. 

    • CaseInsensitiveString은 더이상 String 인스턴스가 들어왔을 때, equals()를 지원하지 않는다. 즉, 이 경우에는 항상 False를 반환함. 
    // 대칭성 위배한 equals()
    @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;
    }
    
    // 대칭성 해결한 equals()
    @Override 
    public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    }

     


    추이성이 깨지는 경우

    추이성은 다음 조건을 만족하는 속성을 의미한다.

    A.equals(B) && B.equals(C) → A.equals(C). A = B, B=C이면 A=C를 만족함. 

    아래에서 추이성이 깨지는 코드에 대해서 살펴보고자 한다. Point 클래스를 상속한 ColorPoint를 만들고, Color 속성을 추가했다. 그렇다면 ColorPoint의 equals()는 어떻게 구현해야할까? 가장 쉽게 떠올릴 수 있는 방법은 다음과 같다.

    • 부모 클래스(Point)의 equals()를 만족 +  Color 필드의 equals()를 만족.

    일견 괜찮아 보이는 이 생각은 equals()의 '대칭성'을 만족시키지 못한다. 아래 main() 메서드의 코드를 실행하면 ColorPoint 클래스의 equals()는 대칭성을 만족하지 못한다. ColorPoint → Point는 만족하지만, Point → ColorPoint는 만족하지 못하기 때문인데 그 이유는 Point의 equals()는 ColorPoint를 고려하지 않았기 때문이다. 

    // Point에 값 컴포넌트(color)를 추가 (56쪽)
    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));
        }
    }

    그렇다면 타입까지 고려한 equals()를 어떻게 구현해야할까? 아래 코드는 타입을 고려해서 Point, ColorPoint 인스턴스에 대해서 각각 비교하도록 작성되어있다. 이렇게 작성되면, 대칭성 문제는 해결하지만 추이성이 깨진다. 아래 main() 메서드를 살펴보면 추이성이 깨지는 것을 바로 알아챌 수 있다. 아무튼 이 방법도 틀렸다는 것을 알 수 있다. 

    p1 = p2 , p2 = p3를 만족하지만 p1 = p3를 만족하지 않는다. 왜냐하면 색깔이 다르기 때문이다. 

    equals()의 규약을 깨는 것뿐만 아니라 이 equals()는 굉장히 위험한 코드다. 만약 ColorPoint와 같은 레벨의 서브 클래스를 구현하고 equals를 똑같이 구현하면, equals()가 호출되었을 때 무한히 순환해서 OOM을 일으키게 될 것이다.

    @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) {
        // 두 번째 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));
    }

    또한 다른 equals() 구현의 예시를 한번 살펴보자. CountPoint는 Point를 상속받은 클래스고, 부모 클래스인 Point의 equals() 메서드를 그대로 사용한다. 만약 아래의 CounerPointTest 코드를 실행하면 어떤 결과가 나오고, 어떤 결과가 나와야 객체 지향적으로 올바른 코드일까? onUnitCircle()을 호출했을 때, p1과 p2 모두 True라는 값이 나와야 객체 지향적으로 올바른 코드이고, 이것은 '리스코프 치환 원칙'을 따르기 때문이다.

    // CounterPoint를 Point로 사용하는 테스트 프로그램
    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);
        }
    
        public static void main(String[] args) {
            Point p1 = new Point(1,  0);
            Point p2 = new CounterPoint(1, 0);
    
            // true를 출력한다.
            System.out.println(onUnitCircle(p1));
    
            // true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
            System.out.println(onUnitCircle(p2));
        }
    }

    리스코프 치환 원칙은 상위 클래스를 사용하고 있는 곳에 슈퍼 클래스를 사용하더라도 전체적인 컨텍스트가 바뀌지 않아야 한다는 것이다. 만약 위 코드에서 각각 True / False가 나온다면 상위 클래스와 하위 클래스는 서로 다른 문맥을 보여주기 때문에 리스코프 치환 원칙을 만족하지 못한다. 아래 코드는 Point 클래스의 equals()를 구현한 예시들이다. 

    • 첫번째 equals()는 정상적으로 구현된 equals()다. 이 방식으로 구현된 equals()는 리스코프 치환 원칙을 만족한다.
    • 두번째 equals()는 리스코프 치환 원칙을 만족하지 않는 equals()다. Point / CounterPoint 클래스는 서로 다르기 때문에 x,y가 같은 값을 가지더라도 False를 반환하게 된다. 
    @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;
    }
    
    
        // 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)
        @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()를 구현하기 굉장히 어려워진다. 상속을 사용하는 경우 다음 두 가지 경우로 나누어 정리해 볼 수 있따.

    • 서브 클래스에 필드가 추가되지 않음. → 상위 클래스의 equals()를 그대로 사용하면 됨. 
    • 서브 클래스에 필드가 추가됨 → equals 규약을 만족하는 equals()를 추가하는 방법이 없음. 

    따라서 만약 equals()를 구현해야하는 경우라면 상속 대신 Composition을 이용하는 것이 권장된다. 특히나 서브 클래스에서 필드가 추가되어야 하며, equals()가 필요한 경우라면 무조건 Composition을 이용해야 한다. Composition으로 구현한 ColorPoint를 아래에서 확인할 수 있다.

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

    Composition으로 특정 클래스를 구현한 경우, equals()를 구현하기 매우 쉽다. 

    A 필드의 equals() && B 필드의 equals()

    자신의 타입이 아닌 경우면 false를 리턴한 후, 주요 필드의 equals()를 모두 확인하는 것이다. 뿐만 아니라 Composition으로 구현했을 때, asPoint()같은 메서드를 이용해 View를 제공해 줄 수 있다는 장점도 있다. asPoint()의 의미는 '나는 ColorPoint지만, 필요하다면 Point로도 볼 수 있어'라는 것이다. 

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

    이처럼 컴포지션으로 사용하면, 추이성 + 반사성 + 대칭성을 손쉽게 만족하는 equals()를 구현할 수 있다.


    equals 규약 - 일관성

    일관성을 만족한다는 것은 다음 조건을 만족하는 것이다.

    A.equals(B) == A.equals(B)

    일관성은 A, B 객체에 들어있는 값이 바뀌면 깨질 수 있다. 만약 객체가 불변 객체라면(필드 값들도 final로 선언되었다면), '일관성'이 항상 보장된다. 불변 객체가 아니라면 일관성을 보장할 수 없다. 그런데 만약 어떤 객체든 상관없이 일관성을 지키도록 equals를 복잡하게 구현하려 한다면, 오히려 equals는 일관성을 깨뜨릴 수도 있다. 이런 경우는 바람직하지 않기 때문에 적당한 선에서 일관성을 타협해야 한다. 

    // URL은 도메인이 아닌, 도메인이 가진 IP로 equals를 확인한다.
    // 따라서 일관성 위배 될 수 있음. 
    public class EqualsInJava extends Object {
    
        public static void main(String[] args) throws MalformedURLException {
            long time = System.currentTimeMillis();
            Timestamp timestamp = new Timestamp(time);
            Date date = new Date(time);
    
            // 대칭성 위배! P60
            System.out.println(date.equals(timestamp));
            System.out.println(timestamp.equals(date));
    
            // 일관성 위배 가능성 있음. P61
            URL google1 = new URL("https", "about.google", "/products/");
            URL google2 = new URL("https", "about.google", "/products/");
            System.out.println(google1.equals(google2));
        }
    }

    일관성이 깨질 수 있는 경우는 자바의 URL 클래스다. 자바의 URL 클래스는 equals()를 확인할 때 다음을 확인한다.

    도메인이 가진 IP를 기준으로 equals()를 확인함. 

    host의 값은 동일하지만, 순간적으로 각 URL 객체의 domain이 가리키는 IP 값은 언제든지 바뀔 수 있다. 따라서 일관성이 깨질 수 있다. 이처럼 일관성을 고려해서 equals()를 너무 복잡하게 구현한다면, 오히려 일관성을 깨뜨릴 수도 있다. 그러므로 일관성은 적당한 기준까지 고려해서 구현하는 것이 좋다. 


    equals 규약 - null

    마지막으로 지켜야하는 equals()의 규약은 null이 왔을 때, false를 반환해야 한다는 것이다. 코드 구현은 간단하며, 아래에서 확인할 수 있다. 

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

    댓글

    Designed by JB FACTORY