본문 바로가기

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

[아이템 14] Comparable을 구현할지 고려하라

※  compareTo는 Object의 메서드가 아니다. (equals, hashCode, toString, clone은 Object의 메서드)

※ compareTo 메서드 : 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
                                    객체와 비교할 수 없는 객체가 주어지면 ClassCastException을 던진다.

 

[Why]

  • compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.
  • Comparable을 구현한 객체들의 배열은 손쉽게 정렬할 수 있다. e.g.) Arrays.sort(a);

 

[When]

  • 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

 

[How]

equals 규약과 똑같이 반사성, 대칭성, 추이성을 충족해야 한다.

  • equals와 주의사항도 똑같고, 우회법도 같다.
  • 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가했다면 compareTo 규약을 지킬 방법이 없다아이템10
public class Point implements Comparable<Point> {
    private final int x;
    private final int y;

    public Point(int x, int y) {
      this.x = x;
      this.y = y;
    }
  
    @Override
    public int compareTo(Point o) {
      if(o.x > this.x && o.y > this.y) {
        return -1;
      } else if(o.x < this.x && o.y < this.y) {
        return 1;
      }
      return 0;
    }
}



public class ColorPoint extends Point {
    private final Color color;	// String 값
    
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
    
    @Override
    public int compareTo(Point o) {
        ?????
    }
    
    
    ... // 나머지 코드는 생략
}

 

  • 기존 클래스를 확장한 구체 클래스에서 compareTo를 구현했을 때의 문제점
    1. 상위 클래스의 좌표값 x, y만 비교하면 color값을 비교할 수 없다.
    2. 하위 클래스의 color값까지 넣어서 비교하면 상위 클래스의 인스턴스가 비교대상으로 들어왔을 때 color값이 없어서 에러난다.
    3. 구현체의 compareTo에서 인자로 ColorPoint만 받으면 리스코프 치환 원칙에 위배된다.
  • Comparable을 구현한 클래스를 확장해 값 컴포넌트를 추가하고 싶다면, 독립된 클래스를 만들고 이 클래스에 원래 클래스의 인스턴스를 가리키는 필드를 두자.
public class CompositePoint implements Comparable<CompositePoint> {
    private final Point point;
    private final Color color;

    public CompositePoint(Point point, Color color) {
      this.point = point;
      this.color = color;
    }

    public Point getPoint() {
      return point;
    }

    public Color getColor() {
      return color;
    }

    @Override
    public int compareTo(CompositePoint o) {
        // 비교 로직
    }
}
  • 그 다음 내부 인스턴스를 반환하는 '뷰' 메서드를 제공하면 된다.
  • 이렇게 하면 바깥 클래스(CompositePoint)에 우리가 원하는 compareTo 메서드를 구현해넣을 수 있다.
  • 클라이언트는 필요에 따라 바깥 클래스의 인스턴스를 필드 안에 담긴 원래 클래스의 인스턴스로 다룰 수도 있다.(??)

 

마지막 규약은 이왕이면 지키도록 한다. (x.compareTo(y) == 0) == (x.equals(y))

  • 이를 잘 지키면 compareTo로 줄지은 순서와 equals의 결과가 일관되게 된다.
  • 만약 일관되지 않으면, 이 클래스의 객체를 정렬된 컬렉션에 넣었을 때 해당 컬렉션이 구현한 인터페이스(Collection, Set, Map)에 정의된 동작과 엇박자를 낼 것이다.
  • 이 인터페이스들은 equals 메서드의 규약을 따른다고 되어 있지만, 정렬된 컬렉션들은 동치성을 비교할 때 compareTo를 사용한다.
    • new BigDecimal("1.0"), new BigDecimal("1.00")
    • HashSet에 넣으면 equals 메서드로 비교해 원소가 2개가 된다.
    • TreeSet에 넣으면 compareTo 메서드로 비교해 원소가 1개가 된다.

 

Comparable은 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.

  • 입력 인수의 타입을 확인하거나 형변환할 필요가 없다는 뜻이다.

 

Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 Comparator를 사용한다.

  • compareTo 메서드에서 필드의 값을 비교할 때 '<',  '>' 연산자는 쓰지 말아야 한다.
  • 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

 

클래스에 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교해나가자.

public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode);   // 가장 중요한 필드
    if (result == 0) {
        result = Short.compare(prefix, pn.prefix);       // 두번째로 중요한 필드
        if (result == 0) {
            result = Short.compare(lineNum, pn.lineNum); // 세번째로 중요한 필드
    }
    return result;
}

 

'값의 차'를 기준으로 비교하지 말자.

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode();
    }
}
  • 정수 오버플로를 일으키거나, 부동소수점 계산 방식에 따른 오류를 낼 수 있다. 그리고 월등히 빠르지도 않다.
  • 정적 compare 메서드비교자 생성 메서드를 활용하자.

 

TIP

자바 8에서의 비교자 생성 메서드(comparator construction method)

  • Comparator 인터페이스가 메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었다.
  • 이 비교자들을 Comparable 인터페이스의 compareTo 메서드를 구현하는 데 사용할 수 있다.
  • 하지만 약간의 성능 저하가 뒤따른다. (저자 기준 10% 느려짐)
  • 자바의 정적 임포트 기능을 이용하면 정적 비교자 생성 메서드들을 그 이름만으로 사용할 수 있어 코드가 훨씬 깔끔해진다.
private static final Comparator<PhoneNumber> COMPARATOR = 
        comparingInt((PhoneNumber pn) -> pn.areaCode)
            .thenComparingInt(pn -> pn.prefix)
            .thenComparingInt(pn -> pn.lineNum);
            
public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}
  • 이 코드는 클래스를 초기화할 때 비교자 생성 메서드 2개(comparingInt, thenComparingInt)를 이용해 비교자를 생성한다.
    • comparingInt는 객체 참조를 int 타입 키에 매핑하는 키 추출 함수(key extractor function)를 인수로 받아, 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드다.
      • 위의 예에서 comparingInt는 람다(lambda)를 인수로 받으며, 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로 전화번호의 순서를 정하는 Comparator<PhoneNumber>를 반환한다.
    • thenComparingInt는 Comparator의 인스턴스 메서드로, int 키 추출자 함수를 입력 받아 다시 비교자를 반환한다.

 

Comparator의 객체 참조용 비교자 생성 메서드

  • comparing 정적 메서드가 2개, thenComparing이란 인스턴스 메서드가 3개 다중정의되어 있다.
    1. public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
                  Function<? super T, ? extends U> keyExtractor)
          {
              ... // 생략
      }
      키 추출자를 받아서 그 키의 자연적 순서를 사용한다.
    2. public static <T, U> Comparator<T> comparing(
                  Function<? super T, ? extends U> keyExtractor,
                  Comparator<? super U> keyComparator)
          {
              ... // 생략
      }
      키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.
    3. default Comparator<T> thenComparing(Comparator<? super T> other) {
          ... // 생략
      }
      비교자 하나만 인수로 받아 그 비교자로 부차() 순서를 정한다.
    4. default <U extends Comparable<? super U>> Comparator<T> thenComparing(
              Function<? super T, ? extends U> keyExtractor)
      {
          ... // 생략
      }
      키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정한다.
    5. default <U> Comparator<T> thenComparing(
              Function<? super T, ? extends U> keyExtractor,
              Comparator<? super U> keyComparator)
      {
          ... // 생략
      }
      키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.