아이템 14. Comparable을 구현할지 고려하라. - ksw6169/effective-java GitHub Wiki

Comparable을 구현했을 때의 장점

  • compareTo는 Object.equals와 비슷하나 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.
  • Comparable을 구현했다는 것은 그 클래스의 인스턴스들 사이에는 자연적인 순서(natural order)가 있음을 뜻한다.
public interface Comparable<T> {
    public int compareTo(T o);
}
  • Comparable을 구현한 객체들의 배열은 다음처럼 손쉽게 정렬할 수 있다.
Arrays.sort(a);
  • Comparable을 구현하면 검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 쉽게 할 수 있다.
  • 예컨대 다음 코드는 명령줄 인수들을 알파벳순으로 출력한다. String이 Comparable을 구현한 덕분이다.
public class WordList {
    public static void main(String[] args) {
        // TreeSet은 순서가 존재하는 정렬된 컬렉션으로 compareTo를 통해 비교, 정렬을 수행한다.
        Set<String> s = new TreeSet<>();
        Collections.addAll(s, args);  // Collection s에 args의 원소를 하나씩 추가한다.
        System.out.println(s);
    }
}
  • Comparable을 구현하면 이 인터페이스를 활용하는 수많은 제네릭 알고리즘과 컬렉션의 힘을 누릴 수 있다.
  • 사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입이 Comparable을 구현했다.
  • 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

compareTo의 일반 규약

  • 아래의 규약은 compareTo 메소드로 수행하는 동치성 검사도 equals 규약과 마찬가지로 반사성, 대칭성, 추이성을 충족해야함을 의미한다.
이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를,
같으면 0을, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면
ClassCastException을 던진다.

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

1. Comparable을 구현한 클래스는 모든 x, y에 대해 
   sgn(x.compareTo(y)) == -sgn(y.compareTo(x)) 여야 한다.
   (따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야 한다.)

2. Comparable을 구현한 클래스는 추이성을 보장해야 한다. 
   즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0이다.

3. Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 
   sgn(x.compareTo(z)) == sgn(y.compareTo(z))다. 

4. 이번 권고가 필수는 아니지만 꼭 지키는 게 좋다. 
   (x.compareTo(y) == 0) == (x.equals(y)) 여야 한다.
   Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 
   다음과 같이 명시하면 적당할 것이다.
   "주의: 이 클래스의 순서는 equals 메소드와 일관되지 않다."
  • 타입이 다른 객체가 주어지면 ClassCastException을 던져도 된다. 물론 이 규약에서는 다른 타입 사이의 비교도 허용한다. (보통은 비교할 객체들이 구현한 공통 인터페이스를 매개로 비교가 이루어진다.)
  • 비교를 활용하는 클래스의 예로는 정렬된 컬렉션인 TreeSet과 TreeMap, 검색과 정렬 알고리즘을 활용하는 유틸리티 클래스인 Collections와 Arrays가 있다.

확장 클래스에서 compareTo 규약을 지키는 방법

  • 기존 클래스를 확장한 구체 클래스에서 새로운 값 필드를 추가했다면 compareTo 규약을 지킬 방법이 없다. 이 때는 equals 때의 해결 방법과 마찬가지로 확장하는 대신 독립된 클래스를 만들고 이 클래스에 기존 클래스의 인스턴스를 가리키는 필드를 두는 식으로 구성하여 compareTo 규약을 지킬 수 있다. (독립된 클래스에서 Comparable을 구현)

compareTo와 equals의 동치성 검사 결과는 같아야 한다.

  • compareTo의 순서와 equals의 결과가 일관되지 않은 클래스는 동작은 하지만, 이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, 혹은 Map)에 정의된 동작과 엇박자를 낼 것이다.
  • 이 인터페이스들은 equals 메소드의 규약을 따른다고 되어 있지만 놀랍게도 정렬된 컬렉션들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문이다. 따라서 이 문제를 주의해야 한다.
/**
 * 예제: compareTo와 equals의 동치성 검사가 일관되지 않은 BigDecimal 클래스
 */
Set<BigDecimal> set;

BigDecimal decimal1 = new BigDecimal("1.0");
BigDecimal decimal2 = new BigDecimal("1.00");

// equals로 비교하기 때문에 다른 객체로 인식하여 원소가 2개로 잡힌다.
set = new HashSet<>();
set.add(decimal1);
set.add(decimal2);

assertEquals(2, set.size());

// compareTo로 비교하기 때문에 같은 객체로 인식하여 원소가 1개로 잡힌다.
set = new TreeSet<>();
set.add(decimal1);
set.add(decimal2);

assertEquals(1, set.size());

compareTo 메소드 작성 시 주의사항

1. compareTo 메소드의 인수 타입은 컴파일 타임에 정해진다.

  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메소드의 인수 타입은 컴파일 타임에 정해진다.
  • 이는 입력 인수의 타입을 확인하거나 형변환할 필요가 없다는 뜻이다. 만약 인수의 타입이 잘못됐다면 컴파일 자체가 되지 않는다. 또한 null을 인수로 넣어 호출하면 NPE가 던져질 것이다.

2. compareTo 메소드는 각 필드가 동치인지를 비교하는 게 아니라 그 순서를 비교한다.

  • 객체 참조 필드를 비교하려면 compareTo 메소드를 재귀적으로 호출한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 대신 사용한다. 비교자는 직접 만들거나 자바가 제공하는 것 중에 골라쓰면 된다.
// CaseInsensitiveString은 CaseInsensitiveString 참조끼리만 비교할 수 있다.
public class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {

    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    @Override
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
    }

    ...
}
public static final Comparator<String> CASE_INSENSITIVE_ORDER = new CaseInsensitiveComparator();

private static class CaseInsensitiveComparator
        implements Comparator<String>, java.io.Serializable {

    ... 

    public int compare(String s1, String s2) {
        byte v1[] = s1.value;
        byte v2[] = s2.value;
        if (s1.coder() == s2.coder()) {
            return s1.isLatin1() ? StringLatin1.compareToCI(v1, v2)
                                 : StringUTF16.compareToCI(v1, v2);
        }
        return s1.isLatin1() ? StringLatin1.compareToCI_UTF16(v1, v2)
                             : StringUTF16.compareToCI_Latin1(v1, v2);
    }

    ...
}

3. 기본 타입 필드를 비교하기 위해 compareTo에서 관계 연산자를 사용하지 마라.

  • compareTo에서 관계 연산자(<, >)를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니 사용하지 마라. Java 7부터 박싱된 기본 타입 클래스들에 추가된 정적 메소드 compare를 이용해 비교를 수행하라.

4. 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교하라.

  • 클래스에 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교하면 된다. 비교 결과가 0이 아니라면 그 결과를 반환하고, 같다면 똑같지 않은 필드를 찾을 때까지 그 다음으로 중요한 필드를 비교하면 된다.
@Override
public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode);  // 1번째로 중요한 필드
    if (result == 0) {
        result = Short.compare(prefix, pn.prefix);      // 2번째로 중요한 필드
        if (result == 0)
            result = Short.compare(lineNum, pn.lineNum);// 3번째로 중요한 필드
    }

    return result;
}

5. 값의 차를 기준으로 하는 비교자는 사용하지 마라.

  • 값의 차를 기준으로 하는 비교자는 정수 오버플로우를 일으키거나 부동소수점 계산 방식에 따른 오류를 낼 수 있어 compareTo 규약의 추이성을 위배하게 되므로 사용해서는 안된다.
static Comparator<Object> hashCodeOrder = new Comparator<>() {
    @Override
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode();
    }
};
  • 대신에 정적 compare 메소드를 활용하거나 비교자 생성 메소드를 활용하여 비교하여야 한다.
// 정적 compare 메소드 활용
static Comparator<Object> hashCodeOrder = new Comparator<Object>() {
    @Override
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCode());
    }
};

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

Comparator의 비교자 생성 메소드를 이용한 비교

  • Java 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메소드(comparator construction method)를 이용해 메소드 연쇄 방식으로 비교자를 생성할 수 있게 되었다. 그리고 이 비교자들을 이용해 Comparable 인터페이스가 원하는 compareTo 메소드를 구현하는 데 멋지게 활용할 수 있다.
  • 이 방식은 간결하지만 약간의 성능 저하가 뒤따른다. (약 10% 정도 느려짐)
Comparator<PhoneNumber> comparator =
        Comparator.comparingInt((PhoneNumber pn) -> pn.getAreaCode())
                .thenComparingInt(pn -> pn.getPrefix())
                .thenComparingInt(pn -> pn.getLineNum());

PhoneNumber p1 = new PhoneNumber(1, 1, 1);
PhoneNumber p2 = new PhoneNumber(2, 2, 2);

assertEquals(-1, comparator.compare(p1, p2));
assertEquals(0, comparator.compare(p1, p1));
assertEquals(1, comparator.compare(p2, p1));

비교자 생성 메소드 : comparingInt

  • comparingInt 는 객체 참조를 Int 타입 키에 매핑하는 키 추출 함수(key extractor function)를 인수로 받아 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메소드다.
public static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) {
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> Integer.compare(keyExtractor.applyAsInt(c1), keyExtractor.applyAsInt(c2));
}
/**
 * PhoneNumber 객체에서 areaCode를 추출하는 함수(=키 추출 함수)를 인자로 전달하고
 * 키 추출 함수를 이용해 두 객체를 비교하는 Comparator<PhoneNumber>를 반환한다.
 */
Comparator.comparingInt((PhoneNumber pn) -> pn.getAreaCode());

비교자 생성 메소드 : thenComparingInt

  • thenComparingInt 는 int 키 추출자 함수를 입력 받아 다시 비교자를 반환한다. (이 비교자는 첫 번째 비교자를 적용한 다음 새로 추출한 키로 추가 비교를 수행한다.) 이 메소드는 원하는 만큼 연달아 호출할 수 있다.
default Comparator<T> thenComparing(Comparator<? super T> other) {
    Objects.requireNonNull(other);
    return (Comparator<T> & Serializable) (c1, c2) -> {
        int res = compare(c1, c2);
        return (res != 0) ? res : other.compare(c1, c2);
    };
}
/**
 * comparingInt에서 생성한 Comparator로 비교한 후 결과 값이 0이라면 
 * 새로운 key 추출 함수를 이용해 Comparator를 만들고 이를 이용해 추가 비교를 수행한다.
 */
Comparator<PhoneNumber> comparator =
        Comparator.comparingInt((PhoneNumber pn) -> pn.getAreaCode())
                .thenComparingInt(pn -> pn.getPrefix())
                .thenComparingInt(pn -> pn.getLineNum());

비교자 생성 메소드 : comparingLong, comparingDouble

  • 이외에도 long과 double용 비교자 생성 메소드로 comparingLongcomparingDouble 를 제공한다.
  • short처럼 더 작은 정수 타입에는 int용 버전을 사용하고, float은 double용을 이용해 비교를 수행한다.

비교자 생성 메소드 : comparing

  • 객체 참조용 비교자 생성 메소드도 제공한다.
  • 앞에서와 마찬가지로 부차 순서를 정하기 위한 thenComparing 메소드도 제공한다. (설명 생략)
// 1. 객체의 키 추출자를 받아 비교자를 반환한다.
public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
            Function<? super T, ? extends U> keyExtractor) { ... }

// 2. 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받아
// keyExtractor로 추출한 데이터를 keyComparator를 통해 비교하는 Comparator를 반환한다.
public static <T, U> Comparator<T> comparing(
            Function<? super T, ? extends U> keyExtractor,
            Comparator<? super U> keyComparator);

참고 자료

  • Effective Java 3/E
⚠️ **GitHub.com Fallback** ⚠️