[Effective Java]Comparable을 구현할지 고려하라

모든 객체의 공통 메소드, 다섯 번째 아이템

Posted by SungBeom on March 22, 2020 · 11 mins read

Intro

Comparable 인터페이스의 유일무이한 메소드는 compareTo이다. compareTo는 Object의 메소드가 아니지만, 두 가지만 빼면 Object의 equals와 같다.

equals와의 차이점

compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다. Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서(natural order)가 있는 것이므로, Comparable을 구현한 객체들의 배열은 다음처럼 손쉽게 정렬할 수 있다.

1
Arrays.sort(a);
cs

검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 역시 쉽게 할 수 있다. 예컨대 다음 프로그램은 명령줄 인수들을 중복을 제거하고 알파벳순으로 출력하는데, String이 Comparable을 구현한 덕분이다.

1
2
3
4
5
6
7
public class WordList {
    public static void main(String[] args) {
        Set<String> s = new TreeSet<>();
        Collections.addAll(s, args);
        System.out.println(s);
    }
}
cs

Comparable을 구현하면 이 인터페이스를 활용하는 수많은 제네릭 알고리즘과 컬렉션을 사용할 수 있다. 사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입이 Comparable을 구현했으며, 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

compareTo 메소드의 일반 규약

모든 객체에 대해 저녁 동치관계를 부여하는 equals 메소드와 달리, compareTo는 타입이 다른 객체를 신경 쓰지 않아도 된다. 타입이 다른 객체가 주어지면 간단히 ClassCastException을 던져도 되며, 대부분 그렇게 한다. 비교를 활용하는 클래스의 예로는 정렬된 컬렉션인 TreeSet과 TreeMap, 검색과 정렬 알고리즘을 활용하는 유틸리티 클래스인 Collections와 Arrays가 있다.

compareTo의 규약 중 3개는 equals 규약과 똑같이 반사성, 대칭성, 추이성을 충족해야 한다. 그래서 주의사항도 똑같으며, 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가했다면 compareTo 규약을 지킬 방법이 없다. 우회법도 같은데, Comparable을 구현한 클래스를 확장해 값 컴포넌트를 추가하고 싶다면, 확장하는 대신 독립된 클래스를 만들고, 이 클래스에 원래 클래스의 인스턴스를 가리키는 필드를 두면 된다.

compareTo 메소드로 수행한 동치성 테스트의 결과가 equals와 같아야하는 규약은 필수는 아니지만 꼭 지키길 권한다. 이를 잘 지키면 compareTo로 줄지은 순서와 equals의 결과가 일관되게 된다. 지키지 않으면 클래스의 객체를 정렬된 컬렉션에 넣었을 때, 해당 컬렉션이 구현한 인터페이스(Collection, Set, 혹은 Map)에 정의된 동작과 엇박자를 낼 것이다. 이유는 이 인터페이스들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문이다.

compareTo 메소드 작성 요령

compareTo 메소드 작성 요령은 equals와 비슷하나, 몇 가지 차이점만 주의하면 된다. Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메소드의 인수 타입은 컴파일타임에 정해진다. 따라서 입력 인수의 타입을 확인하거나 형변환할 필요가 없으며, 인수의 타입이 잘못됐다면 컴파일 자체가 되지 않는다.

compareTo 메소드는 각 필드가 동치인지를 비교하는 게 아니라 그 순서를 비교한다. 객체 참조 필드를 비교하려면 compareTo 메소드를 재귀적으로 호출한다. Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 대신 사용한다. 비교자는 직접 만들거나 자바가 제공하는 것 중에 골라 쓰면 된다.

객체 참조 필드가 하나뿐인 비교자

다음 코드는 CaseInsensitiveString용 compareTo 메소드로, 자바가 제공하는 비교자를 사용하고 있다.

1
2
3
4
5
6
7
8
9
10
/*
 * 객체 참조 필드가 하나뿐인 비교자
 */
public final class CaseInsensitiveString
        implements Comparable<CaseInsensitiveString> {
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSItiVE_ORDER.compare(s, cis.s);
    }
    ...  // 나머지 코드 생략
}
cs

CaseInsensitiveString이 Comparabl<CaseInsensitiveString>을 구현하고 있다. CaseInsensitiveString의 참조는 CaseInsensitiveString 참조만 비교할 수 있다는 뜻이다.

과거에는 compareTo 메소드에서 정수 기본 타입 필드를 비교할 때는 관계 연산자인 <와 >를, 실수 기본 타입 필드를 비교할 때는 정적 메소드인 Double.compare와 Float.compare를 사용하라고 권했으나, 자바 7부터는 박싱된 기본 타입 클래스들에 샐 추가된 정적 메소드인 compare를 이용하면 된다. compareTo 메소드에서 관계 연산자 <와 >를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니, 이제는 추천하지 않는다.

기본 타입 필드가 여럿일 때의 비교자

클래스에 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교해나가자. 가장 핵심이 되는 필드가 똑같다면, 똑같지 않은 필드를 찾을 때까지 그다음으로 중요한 필드를 비교해나간다.

1
2
3
4
5
6
7
8
9
10
11
12
/*
 * 기본 타입 필드가 여럿일 때의 비교자
 */
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(lineNuum, pn.lineNum);  // 세 번째로 중요한 필드
    }
    return result;
}
cs

비교자 생성 메소드를 활용한 비교자

자바 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메소드(comparator construction method)와 팀을 꾸려 메소드 연쇄 방식으로 비교자를 생성할 수 있게 되었다. 간결하다는 장점이 있으나, 약간의 성능 저하가 뒤따른다. 참고로, 자바의 정적 임포트 기능을 이용하면 정적 비교자 생성 메소드들을 그 이름만으로 사용할 수 있어 코드가 훨씬 깔끔해진다.

1
2
3
4
5
6
7
8
9
10
11
/*
 * 비교자 생성 메소드를 활용한 비교자
 */
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);
}
cs

값의 차를 기준으로 하는 비교자

이따금 '값의 차'를 기준으로 첫 번째 값이 두 번째 값보다 작으면 음수를, 두 값이 같으면 0을, 첫 번째 값이 크면 양수를 반환하는 compareTo나 compare 메소드와 마주할 것이다.

1
2
3
4
5
6
7
8
/*
 * 해시코드 값의 차를 기준으로 하는 비교자 - 추이성을 위배한다!
 */
static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode();
    }
};
cs

이 방식은 정수 오버플로를 일으키거나 IEEE 754 부동소수점 계산 방식에 따른 오류를 낼 수 있다. 그 대신 다음의 두 방식 중 하나를 사용하자.

1
2
3
4
5
6
7
8
/*
 * 정적 compare 메소드를 활용한 비교자
 */
static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCode());
    }
};
cs

1
2
3
4
5
/*
 * 비교자 생성 메소드를 활용한 비교자
 */
static Comparator<Object> hashCodeOrder =
    Comparator.comparingInt(o -> o.hashCode());
cs

핵심 정리

순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여, 그 인스턴스들을 쉽게 정렬하고, 검색하고, 비교 기능을 제공하는 컬렉션과 어우러지도록 해야 한다. compareTo 메소드에서 필드의 값을 비교할 때 <와 > 연산자는 쓰지 말아야 한다. 그 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메소드나 Comparator 인터페이스가 제공하는 비교자 생성 메소드를 사용하자.