본문 바로가기

reviews/Effective JAVA

014. Comparable을 구현할지 고려하라

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

public interface Comparable<T> {
    int compareTo(T t);
}

compareTo는 Comparable 인터페이스의 유일무이한 메서드인데 equals와 유사하지만 Object의 메서드가 아니다.

단순 동치성 비교에 더해 순서까지 비교할 수 있으며 제네릭하다.

compareTo 규약을 지키지 못하면 비교를 활용하는 클래스와 어울리지 못한다. : TreeSet TreeMap Collections Arrays

Comparable 구현의 이점

  • Comparable을 구현한 클래스의 인스턴스들은 자연적인 순서가 있어 다음과 같이 간단히 정렬이 가능하다.
Arrays.sort(a);
  • 제네릭 알고리즘과 Collections의 힘을 누릴 수 있다.

equals와의 차이

  • 모든 객체에 대해 전역 동치관계를 부여하는 equals와 달리 compareTo는 타입이 다른 객체를 신경쓰지 않아도 된다.
    • 타입이 다른 객체 끼리의 비교도 허용하지만 대부분 간단히 ClassCaseException을 던진다.
  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일 타임에 정해지기 때문에 입력 인수의 타입을 확인하거나 형변환할 필요가 없다.
  • Null을 인수로 넣어 호출하면 NullPointerException을 던져야 한다.

compareTo 메서드의 일반 규약

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

  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))여야 한다(따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야 한다).
  • ⇒ 두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다. 반사성
  • Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면x.compareTo(z) > 0이다.
  • ⇒ 첫 번째가 두 번째보다 크고 두 번재가 세 번째보다 크면, 첫 번째는 세 번째보다 커야 한다. 대칭성
  • Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))다.
  • ⇒ 크기가 같은 객체끼리는 어떤 객체와 비교하더라도 항상 같아야 한다. 추이성
  • 이번 권고가 필수는 아니지만 꼭 지키는 게 좋다. (x.compareTo(y) == 0) == (x.equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 다음과 같이 명시하면 적당할 것이다.
    • compareTo 메서드로 수행한 동치성 테스트의 결과가 equals와 같아야 한다.
    • 지키지 않은 경우 이 컬렉션이 구현한 인터페이스(Collection, Set, Map)에 정의된 동작과 엇박자를 낼 것이다.
      /** BigDecimal.java : equals와 compareTo가 일관되지 않는 예 */
      BigDecimal bd1 = new BigDecimal("1.0");
      BigDecimal bd2 = new BigDecimal("1.00");
      
      bd1.equals(bd2); // false
      HashSet<BigDecimal> hs = new Set<BigDecimal>();
      hs.add(bd1);
      hs.add(bd2);
      // bd1의 bd2의 hashCode가 서로 다름 => hs에 원소 2개
      
      bd1.compareTo(b2); // 0
      TreeSet<BigDecimal> ts = new Set<BigDecimal>();
      ts.add(bd1);
      ts.add(bd2);
      // compareTo로 비교하면 두 인스턴스가 동일하기 때문에 => ts에 원소 1개
      
    • *이 인터페이스들은 equals 메서드의 규약을 따른다고 되어 있지만 실제로는 동치성을 비교할 때 compareTo를 사용한다.

*sgn(표현식) 표기는 수학에서 말하는 부호 함수(signum function)를 뜻 하며, 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1을 반환하도록 정의했다.

작성 요령

  • 각 필드가 동치인지를 비교하는 게 아니라 그 순서를 비교해야 한다.
  • 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해 야 한다면 비교자(Comparator)를 대신 사용한다.
    • 비교자는 직접 만들거나 자바가 제공하는 것 중에 골라 쓰면 된다.
    ▼ 자바가 제공하는 비교자를 사용한 경우
  • public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> { public int compareTo(CaseInsensitiveString cis) { return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s); } ... }
  • compareTo 메서드에서 관계 연산자 <, >를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니 박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 이용해라.
  • 핵심 필드가 여러개라면 비교 순서가 중요하다. 가장 핵심적인 필드부터 비교 해 비교 결과가 0이 아니라면 바로 반환한다.
  • public int compareTo(PhoneNumber pn) { int result = Short.compare(areaCode, pn.areaCode); // 가장 중요한 필드 if (result == 0) { result = Short.compare(lineNum, pn.lineNum); // 두 번째로 중요한 필드 if (result == 0) { result = Short.compare(prefix, pn.prefix); // 세 번째로 중요한 필드 } } return result; }
  • 자바 8 부터는 Comparable을 사용해 구현할 수 있다.

주의점

값의 차를 기준으로 비교하지 않아야 한다.

: 정수 오버플로를 일으키기나 IEEE 754 부동소수점 계산 방식에 따른 오류를 낼 수 있다.

static Comparator<Object> hashCodeOrder = new Comparator<>() { 
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode(); 
    }
};

▼ 권장하는 방법

# 정적 compare 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = new Comparator<>() { 
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCode()); 
    }
};

# 비교자 생성 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

Comparable

Comparator 인터페이스가 메서드 연쇄 방식으로 비교자를 생성할 수 있어 이 비교자들을 이용해 구현할 수 있으나 약간의 성능 저하가 있다.

private static final Comparator<PhoneNumber> COMPARATOR = 
    comparingInt((PhoneNumber pn) -> pn.areaCode) 
						// 람다를 인수로 받으며 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로 전화번호의 순서를 정하는 Comparator<PhoneNumber>를 반환
							// 입력 인수에 타입 PhoneNumber을 명시해 자바의 타입 추론 능력으로 타입을 알아낼 수 없는 부분을 명시함
        .thenComparingInt(pn -> pn.prefix) // 두 전화번호의 지역 코드가 같을 수 있으니 추가 비교함 - prefix
						// 자바의 타입 추론 능력으로 추론할 수 있어 타입을 명시하지 않았음
        .thenComparingInt(pn -> pn.lineNum); // 추가비교 - 가입자 번호

public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn); 
}
  • 자바의 숫자용 기본 타입을 모두 커버하는 보조 생성 메서드들이 있다. comparingInt 등
  • comparing 객체 참조용 비교자 생성 메서드
    • 키 추출자를 받아서 그 키의 자연적 순서를 사용한다.
    • 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.
  • thenComparing 인스턴스 메서드
    • 비교자 하나만 인수로 받아 그 비교자로 부차 순서를 정한다.
    • 키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정한다.
    • 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.

Comparable을 구현한 클래스를 확장해 값 컴포넌트를 추가하고 싶은 경우

(equals와 동일함 : 아이템 10)

기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가 했다면 compareTo 규약을 지킬 방법이 없어 다음과 같은 방법으로 우회할 수 있다.

  • 확장하는 대신 독립된 클래스를 만든다.
  • 이 클래스에 원래 클래스의 인스턴스를 가리키는 필드를 두고 내부 인스턴스를 반환하는 뷰 메서드를 제공한다.
  • 바깥 클래스에 우리가 원하는 compareTo 메서드를 구현한다.

순서를 고려하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하자. compareTo 메서드에서 필드의 값을 비교할 때는 <, > 연산자를 쓰지 않고 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

반응형