[이펙티브 자바.아이템 10] equals 는 일반 규약을 지켜 재정의하라

박상준·2024년 5월 25일
0

이펙티브 자바

목록 보기
8/19
post-custom-banner
  • Object 의 equals 는 각 인스턴스가 오직 자기 자신과만 같게 구현되어 있음.
  • 일반적으로 equals 의 경우 제대로 알고 사용하지 않는 경우 함정이 곳곳에 위치하기에 주의해야한다.
public boolean equals(Object obj) {
    return (this == obj);
}

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

  1. 각 인스턴스가 본질적으로 고유한 경우
    • 값을 표현하는 것이 아니라, 동작하는 개체를 표현하는 클래스가 해당됨.
    • Thread 의 경우, 각 인스턴스가 고유하기에 Object.equals 사용례에 맞음.
  2. 인스턴스의 논리적 동치성(Logical equality) 을 검사할 일이 없는 경우
    • java.util.regex.Pattern 에서
              Pattern a = Pattern.compile("a");
              Pattern b = Pattern.compile("a");
      
              System.out.println(a.equals(b)); // false
      • 내부적으로 compile(”a”) 부분이 동일하니, 두 인스턴스가 같아야 한다는 판단이 있게 된다면, 이는 ⇒ 논리적 동치성(Logical equality) 를 검사할 필요가 있다는 의미가 된다.
  3. 상위 클래스에서 재정의한 equals 가 하위 클래스에도 딱 들어맞는 경우
    • Set 구현체에서 AbstractSet , List 구현체에서 AbstractList , Map 구현체들은 AbstractMap 으로부터 상속받아 equals 를 사용하는 경우
  4. 클래스가 private 이거나 package-private 이고 equals 메서드를 호출할 일이 없는 경우
    • 실수로라도 equals 가 호출되지 않도록 하기 위해 다음과 같이 구현할 수 있음.
      @Override
      public boolean equals(Object o) {
          throw new AssertionError(); // 호출 금지!
      }

언제 equals 를 재정의해야 할까?

  • 객체 식별성 이 아닌 논리적 동치성 을 확인해야 할 때 재정의한다.
  • 상위 클래스의 equals 가 논리적 동치성을 비교하도록 재정의되지 않았을 때 재정의한다.
  • 주로 값 클래스 들이 해당됨.
    • Integer 이나 String 처럼 값을 표현하는 클래스이다.

      예외 상황

    • 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스

      라면 equals 를 재정의하지 않아도 됨.

      예 : Enum 클래스

      해당 클래스에서 논리적으로 같은 인스턴스가 1개 이상 만들어지지 않는다.

      논리적 동치성객체 식별성 이 사실상 동일한 의미가 된다.

      따라서 Objectequals 가 논리적 동치성까지 확인해준다고 볼 수 있음.

equals 메서드 재정의 시 일반 규약

  • equals 메서드를 재정의시에 반드시 다음의 규약을 따라야함
    1. 반사성(reflexity)

      • 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)truex.equals(z)true 이다.
    4. 일관성(consistency)

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

      대칭성 위배 케이스

      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 - equals - String 은 true 반환

    • String - equals - CaseInsensitiveString 는 false 반환

      컬렉션에서의 문제

    • CaseInsensitiveString 을 컬렉션에 넣은 후 contains 메서드를 호출하면 예측할 수 없는 결과가 나온다.

      
      CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
      String s = "polish";
      
      List<CaseInsensitiveString> list = new ArrayList<>();
      list.add(cis);
      list.contains(s); // 결과는 불확실

      해결방안

    • CaseInsensitiveStringequalsString 과 연동하려는 시도를 포기해야함.

      @Override
      public boolean equals(Object o) {
          if (o instanceof CaseInsensitiveString)
              return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
          return false;
      }
    • 로 수정하면 된다.

      추이성의 문제 : ColorPoint 와 Point 클래스 예시

    • 상속을 사용하여 클래스를 확장할 떄 발생하는 문제이다.

    • Point 클래스와 이를 확장한 ColorPoint 클래스 예시 (아래)

      기본 Point 클래스

    • 2차원 공간에서 점을 나타내는 Point 클래스를 살펴보자.

    • 해당 클래스는 xy 좌표를 가진다, equals 메서드를 재정의하여 두 점이 같은지 비교한다.

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

ColorPoint 클래스의 확장

  • Point 클래스를 확장하여 색상 정보를 추가한 ColorPoint 클래스 작성
    public class ColorPoint extends Point {
        private final Color color;
    
        public ColorPoint(int x, int y, Color color) {
            super(x, y);
            this.color = color;
        }
    }

잘못된 equals 메서드 : 대칭성 위배

  • ColorPoint 클래스에서 equals 메서드 재정의 시, 색상 정보까지 비교한다면?
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
        return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}
  • 대칭성 위배함.
  • Point 클래스 equals 메서드는 색상을 무시하지만,
  • ColorPoint 클래스의 equals 메서드는 색상을 고려하기에,
  • 포인트.equals(컬러포인트) ⇒ 는 true 를 반환해도 컬러포인트.equals(포인트) 는 false 를 반환한다.

잘못된 equals 메서드 : 추이성 위배

  • 대칭성 문제 해결을 위해 아래 같이 수정이 가능함
@Override
public boolean equals(Object o) {
    if (!(o instanceof Point))
        return false;
    if (!(o instanceof ColorPoint))
        return o.equals(this);
    return super.equals(o) && ((ColorPoint) o).color == color;
}
  • 대칭성은 지키지만, 추이성을 깬다.
  • 객체 A 가 객체 B 와 같고, 객체 B 가 객체 C 와 같다면 ⇒ 객체 A는 객체 C와 같아야하지만, 해당 예시에서는 같지 않다
    • 그러나

      ColorPoint c1 = new ColorPoint(1, 2, Color.RED);
      Point c2 = new Point(1, 2);
      ColorPoint c3 = new ColorPoint(1, 2, Color.BLUE);
    1. 1 케이스 c1 == c2 ⇒ TRUE
      • ColorPoint 의 동등성 메서드를 탄다
      • Object o 안에는 Point클래스의 c2 인스턴스가 들어오며, ColorPoint 의 형이 아니기에, return o.equals(this); 로 비교한다.
      • 즉, Point 클래스의 동등성 비교를 위해 x, y 값으로 비교하게 되며
      • true 를 반환한다
    2. 2 케이스 c2 == c3 ⇒ TRUE
      • Point 클래스의 동등성 메서드를 탄다.
      • 단순 x , y 의 값만 비교하기에, true 가 반환된다.
    3. 3 케이스 c3 == c1 ⇒ FALSE
      • 둘다 같은 ColorPoint 클래스이다.
      • ColorPointequals 메서드가 호출되는데, x,y 좌표가 같아도, 색상이 다르기에 false 를 반환하게 된다.
    • 결론
      • 분명히 x y 값만 서로 비교하다가, 갑자기 3 케이스에서는 색상을 비교하기 시작했다.. 이처럼 equals 에서 메서드를 잘못 구현하는 경우 추이성을 위배할 수 있다는 문제점이 있다.

instanceof 대신 getClass 로 동치성 문제를 해결하려고 해볼까?

  • 결론부터 말하면 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;
}
  • Point 클래스의 서브 클래스인 ColorPoint 클래스간의 비교를 하는 경우, 둘의 논리적 동치성을 비교하지 않고, 단순 구현 클래스가 다르다는 이유로 같지 않음을 반환해 버린다.
  • 즉, 상위 클래스와 하위 클래스 간의 호환성을 꺠뜨리게 되는 것이다.

예시 : 단위 원 내의 점 확인 메서드

  • Set 을 사용하여 단위 원 내의 점을 포함하도록 초기화
    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 클래스는 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();
        }
    }
    • CounterPoint 인스턴스를 onUnitCircle 메서드에 넘기면, Point 클래스의 equalsgetClass 를 사용해 작성한 경우 onUnitCirclefalse 를 반환한다.
      • 리스코프 치환 원칙을 위배

우회 방안: 상속 대신 컴포지션을 사용한다.

  • Point 를 상속하는 대신 PointColorPointprivate 필드로 설정,
  • ColorPoint 와 같은 위치의 Point 를 반환하는 뷰(view) 메서드를 public 에 추가한다
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);
    }

    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 의 기능을 그대로 사용하면서
  • ColorPoint 만의 기능을 추가할 수 있는 장점이 있다고 한다
  • 상속을 사용할 떄이 equals 메서드의 추이성을 위배하지 않으면서, ColorPoint 객체를 Point 객체처럼 사용할 수 있다.

구체 클래스를 확장하여 값을 추가한 클래스의 문제점

  • 예시 : java.sql.Timestamp
    • 타임스탬프 클래스는 [java.util.Date](http://java.util.Date) 를 확장하여 nanosecond 필드를 추가한 클래스이다.
    • 이 해당 사실 떄문에, TimeStampequals 메서드는 대칭성을 위배한다.
    • Date 객체와 TimeStamp 객체를 같은 컬렉션에 넣거나 섞어 사용하면 예기치 않은 동작이 가능하다.
  • 추상 클래스의 하위 클래스에서의 해결책
    • 추상 클래스의 하위 클래스는 equals 규약을 지키면서 값을 추가가 가능하다.
    • 태그 달린 클래스보다는 클래스 계층구조를 활용하라 (아이템 23) 에 따라
      • 아무런 값을 갖지 않는 추상 클래스인 Shape 를 위에 두고,
        - 이를 확장해 radius 필드를 추가한 Circle 클래스,
        - lengthwidth 필드를 추가한 Rectengle 클래스를 만들 수 있다.

        상위 클래스를 직접 인스턴스로 만드는 게 불가능하면, equals 관련 동치성, 추이성에 대한 문제는 발생하지 않음.

일관성

  • 일관성은 일반적으로 두 객체가 같고, 수정이 되지 않는다는 전제하에서는 앞으로도 영원히 같아야 한다는 의미이다.

  • 가변 객체를 비교 시점에 따라, 서로 다를 수도 혹은 같은 수도 있는 반면에, 불변 객체는 한번 다르면, 끝까지 달라야 한다.

  • 클래스를 작성시에, 불변 클래스로 만드는 것이 나을지 심사숙고 해야함.

  • 불변 클래스로 만들기로 하였다면, equals 의 판단에 신뢰할 수 없는 자원이 끼어들게 하면 안됨.

  • ex

    • java.net.URLequals 의 경우 주어진 URL매핑된 호스트 IP 를 이용하여 비교한다.
      - 호스트는 이름을 IP 주소로 변경하려면, 네트워크를 통하여야 하는데, 항상 결과가 같다는 보장이 불가능하다.
      - URL 의 equals 가 일반 규약을 어기게 하고, 실무에서 문제가 된다고 한다.

      즉, equals 는 항시 메모리에 존재하는 객체만을 사용하여 결정적 계산만 수행해야하는 것임. ( 외부 의존 X )

Null 아님 규약

  • equals 메서드의 동등성 비교에서 null 검사는 필요없음.
    // 묵시적 null 검사 - 이쪽이 낫다.
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof MyType))
            return false;
        MyType mt = (MyType) o;
        ...
    }
    • instanceof 에서 이미 피연산자가 null 이면 false 를 반환하여 코드가 더 짧아짐.

양질의 equals 메서드의 구현 방안

  1. 자기 자신을 참조하는지 확인한다

    • == 연산자를 통해 입력이 자기 자신의 참조인지 체크한다
    • 자기 자신이면 true 를 반환한다.
    • 성능 최적화용
    if (this == o) return true;
  2. 타입 확인

    • instanceof 연산자로 입력이 올바른 타입인지 확인한다
    • 그렇지 않다면 false 반환한다
    • 올바른 타입은 보통 equals 가 정의된 클래스지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있음.
    if (!(o instanceof MyType)) return false;
  3. 형변환

    • 입력을 올바른 타입으로 형변환함.
    • instanceof 로 검사했기에, 이 단계는 무조건 성공
    MyType mt = (MyType) o;
  4. 핵심 필드 비교

    • 논리적 동치성을 위해 필드 비교시도
    return this.field1.equals(mt.field1) && this.field2 == mt.field2;

equals 메서드 구현 시 추가 고려사항

  • 기본 타입 필드 비교법
    • float 혹은 double 을 제외한 기본 타입 필드는 == 연산자로 비교한다
    • floatdouble 필드는 각각 Float.compare(float, float)Double.compare(double, double) 로 비교한다.
      • 왜냐?
        • Float.NaN 이나 -0.0f 등 특수한 부동소수 값을 다루기 위함임.

          Float . compare ( float , float ) 의 장점

          1. Nan 비교

            • NaN 은 수학적으로 정의되지 않은 값으로서
            float a = Float.NaN;
            System.out.println(a == a); // false 가 된다.
            • [Float.compare](http://Float.compare) 에서는 NaN 값을 같게 처리한다
          2. -0.0 과 0.0

            • 두 같은 수학적으로는 같은 값이지만, 부동소수점 표현에서는 다르다.
            float b = -0.0f;
            float c = 0.0f;
            System.out.println(b == c); // true
            • [Float.compare](http://Float.compare) 에서는 -0.00.0 을 다르게 처리한다.
            • 시각적으로는 같아보이지만, 부동소수점상에서는 다르게 처리되어야 하낟.
  • 참조 타입 필드 비교법
    Objects.equals(Object, Object) 로 비교하여
    
    인자에서 null 이 들어가도 NPE 가 터지지 않도록 처리함
  • 배열 필드 비교법
    배열필드는 원소 각각을 앞의 지침대로 비교
    배열 모든 원소가 핵심 필드라면 `Arrays.equals` 메서드를 사용
  • 필드 비교 순서
    • 다를 가능성이 크거나 비교 비용이 싼 필드를 먼저 비교한다.
    • 동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하지 않는다.
profile
이전 블로그 : https://oth3410.tistory.com/
post-custom-banner

0개의 댓글