ABOUT ME

Developer를 위한 Reference 블로그 입니다.

Today
Yesterday
Total
  • Item 10. equals는 일반 규약을 지켜 재정의하라
    Book/Effective Java 3E 2022. 10. 17. 06:28
    반응형

      equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 있어 자칫하면 끔찍한 결과를 초래합니다. 문제를 회피하는 가장 쉬운 길은 아예 재정의하지 않는 것입니다. 다음에 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선입니다.

    • 각 인스턴스가 본질적으로 고유하다. 값을 표현하는(Integer, String 등) 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. (예: Thread)
    • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
    • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
    • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다. 위험을 철저히 회피하는 스타일이라 equals가 실수로라도 호출되는 걸 막고 싶다면 다음처럼 구현해두자.
      @Override public boolean equals(Object o) {
        throw new AsserionError(); // 호출 금지!
      }

     

      객체 식별성(object identity; 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때 equals를 재정의합니다. 주로 값 클래스들(Integer, String 등)이 해당합니다. 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스(Item 1)라면 equals를 재정의하지 않아도 됩니다. Enum(Item 34)도 여기에 해당합니다.

     

      다음은 Object 명세에 적힌 규약입니다.


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

     

    반사성(reflexivity)

      null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다. 객체는 자기 자신과 같아야 한다는 뜻입니다.

     

    대칭성(symmetry)

      null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다. 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻입니다. (예: 대소문자를 구별하지 않는 문자열을 구현한 클래스)

    package effectivejava.chapter3.item10;
    
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Objects;
    
    // 코드 10-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;
        }
    
        // 문제 시연
        public static void main(String[] args) {
            CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
            String s = "polish";
    
            List<CaseInsensitiveString> list = new ArrayList<>();
            list.add(cis);
    
            System.out.println(list.contains(s));
        }
    
    //    // 수정한 equals 메서드 (56쪽)
    //    @Override public boolean equals(Object o) {
    //        return o instanceof CaseInsensitiveString &&
    //                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    //    }
    }

     

     

    추이성(transitivity)

      null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다. 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻입니다.

      구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않습니다. 자바 라이브러리에 구체 클래스를 확장해 값을 추가한 클래스가 종종 있습니다. 한 가지 예로 java.sql.Timestamp는 java.util.Date를 확장한 후 nanoseconds 필드를 추가했습니다. 그 결과로 Timestamp의 equals는 대칭성을 위배하며, Date객체와 한 컬렉션에 넣거나 서로 섞어 사용하면 엉뚱하게 동작할 수 있습니다. 그래서 Timestamp의 API 설명에는 Date와 섞어 쓸 때의 주의사항을 언급하고 있습니다.

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

     

    일관성(consistency)

      null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다. 두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻입니다.

      클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 됩니다. 예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교합니다. 호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없습니다. 이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킵니다. 이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 합니다.

     

    null-아님

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

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

      // 명시적 null 검사 - 필요 없다!
      @Override public boolean equals(Object o) {
        if(o == null) return false;
        ...
      }

      동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야 합니다. 그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 합니다. instanceof는 (두 번째 피연산자와 무관하게) 첫 번째 피연산자가 null이면 false를 반환합니다.[JLS, 15.20.2] 따라서 입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null 검사를 명시적으로 하지 않아도 됩니다.

      // 묵시적 null 검사 - 이쪽이 낫다.
      @Override public boolean equals(Object o) {
        if(!(o instanceof MyType)) return false;
        
        MyType mt = (MyType) o;
        ...
      }

     

      지금까지의 내용을 종합해서 양질의 equals 메서드 구현 방법을 단계별로 정리하면 다음과 같습니다.

    1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. (성능 최적화용)
    2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
    3. 입력을 올바른 타입으로 형변환한다.
    4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.

     

      다음은 이상의 비법에 따라 작성해본 PhoneNumber 클래스용 equals 메서드입니다.

    ackage effectivejava.chapter3.item10;
    
    // 코드 10-6 전형적인 equals 메서드의 예
    public final class PhoneNumber {
        private final short areaCode, prefix, lineNum;
    
        public PhoneNumber(int areaCode, int prefix, int lineNum) {
            this.areaCode = rangeCheck(areaCode, 999, "지역코드");
            this.prefix   = rangeCheck(prefix,   999, "프리픽스");
            this.lineNum  = rangeCheck(lineNum, 9999, "가입자 번호");
        }
    
        private static short rangeCheck(int val, int max, String arg) {
            if (val < 0 || val > max)
                throw new IllegalArgumentException(arg + ": " + val);
            return (short) val;
        }
    
        @Override public boolean equals(Object o) {
            if (o == this)
                return true;
            if (!(o instanceof PhoneNumber))
                return false;
            PhoneNumber pn = (PhoneNumber)o;
            return pn.lineNum == lineNum && pn.prefix == prefix
                    && pn.areaCode == areaCode;
        }
    
        // 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다(아이템 11)!
    }

     

      float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입 필드는 각각의 equals 메서드로, float와 double 필드는 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교합니다. float와 double을 특별 취급하는 이유는 Float.NaN, -0.0f, 특수한 부동소수 값 등을 다뤄야 하기 때문입니다. 자세한 설명은 [JLS 15.21.1]이나 Float.equals의 API 문서를 참고하시면 됩니다.

     

    주의사항

    • equals를 재정의할 땐 hashCode도 반드시 재정의하자(Item 11).
    • 너무 복잡하게 해결하려 들지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다. 일반적으로 별칭(alias)은 비교하지 않는 게 좋다.
    • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
      // 잘못된 예 - 입력 타입은 반드시 Object여야 한다!
      public boolean equals(MyClass o) {
        ...
      }

      이 메서드는 Object.equals를 재정의한 게 아닙니다. (다중정의) @Override 애너테이션을 사용하면 이러한 실수를 예방할 수 있습니다.

      // 어전히 잘못된 예 - 컴파일되지 않음
      @Override public boolean equals(MyClass o) {
        ...
      }

     

      equals(hashCode도 마찬가지)를 작성하고 테스트하는 일은 지루하고 이를 테스트하는 코드도 항상 뻔합니다. 구글이 만든 AutoValue 프레임워크는 해당 작업을 대신해 줍니다. 클래스에 애너테이션 하나만 추가하면 AutoValue가 직접 작성하는 것과 근본적으로 똑같은 코드를 만들어줄 것입니다.

     

    꼭 필요한 경우가 아니라면 equals를 재정의하지 말자.

    많은 경우에 Object의 equals가 여러분이 원하는 비교를 정확히 수행해준다.

    재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.

     

    [참고 정보]

    이펙티브 자바 Effective Java 3/E 도서 [조슈아 블로크 ]

    이펙티브 자바 깃허브 저장소

    <이펙티브 자바, 3판> 번역 용어 해설

    반응형

    댓글

Designed by Tistory.