DEV ℧ Developer Diary

[EffectiveJava] item14 - Comparable을 구현할지 고려하라.

Comparable을 구현할지 고려하라.

Comparable 개요

이번에는 Comparable 인터페이스의 유일무이한 메서드인 compareTo를 알아보자.

compareTo의 성격은 두 가지만 제외하면 Object의 equals와 같다.

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

배열의 정렬에 사용하는 Arrays.sort() 또한 Comparable 인터페이스를 구현한 것이다.

예를 들어, 아래의 코드를 살펴보자.

public class WordList {
    public static void main(String[] args) {
        Set<String> s = new TreeSet<>();
        s.add("apple");
        s.add("banana");
        s.add("carrot");
        s.add("apple");
        Collections.addAll(s, args);
        System.out.println(s);
    }
}

위의 코드를 실행한다면 String객체에서 Comparable을 구현하였기 때문에 중복을 제거하고 알파벳 순으로 정렬되어 나온다.

[apple, banana, carrot]

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

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

compareTo 메서드의 일반 규약

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다. 다음 설명에서 sgn(표현식) 표기는 수학에서 말하는 부호 함수(signum function)를 뜻하며, 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1을 반환하도록 정의했다.

  • 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을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 다음과 같이 명시하면 적당할 것이다.

“주의 : 이 클래스의 순서는 equals메서드와 일관되지 않다.”

모든 객체에 대해 전역 동치관계를 부여하는 equals 메서드와 달리, compareTo는 타입이 다른 객체를 신경 쓰지 않아도 된다. 타입이 다른 객체가 주어지면 간단히 ClassCastException을 던져도 되며, 대부분 그렇게 한다.

이제 compareTo 규약을 자세히 풀어보자

  • 첫번째 규약은 두 객체 참조의 순서를 바꿔 비교해도 예산한 결과가 나와야 한다.
  • 첫 번째 객체가 두 번째 객체보다 작다면, 두 번째 객체는 첫 번째 객체보다 커야 한다.
  • 첫 번째가 두 번째와 크기가 같다면, 두 번째는 첫 번째와 같아야한다.
  • 첫 번째가 두 번째보다 크면, 두 번째는 첫 번째보다 작아야 한다.
  • 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.

이상의 세 규약은 compareTo 메서드로 수행하는 동치성 검사도 equals item10 규약과 똑같이 반사성, 대칭성, 추이성을 충족해야 함을 뜻한다. 그래서 compareTo와 equals는 주의사항도 똑같으며, 구현이나 우회법도 비슷하다.

compareTo의 마지막 규약은 필수는 아니지만 꼭 지키길 권장한다. 간단하게 설명하자면, compareTo 메서드로 수행한 동치성 테스트의 경과가 equals의 결과와 같아야 한다는 것이다.

BigDecimal을 통해 equals와 compareTo가 다를 경우의 예시를 만들어 보자.

public class CompareTest {
    public static void main(String[] args) {
        BigDecimal decimal1 = new BigDecimal("1.0");
        BigDecimal decimal2 = new BigDecimal("1.00");
        BigDecimal decimal3 = new BigDecimal("1.000");

        HashSet<BigDecimal> hashSet = new HashSet<>();
        hashSet.add(decimal1);
        hashSet.add(decimal2);
        hashSet.add(decimal3);

        TreeSet<BigDecimal> treeSet = new TreeSet<>();
        treeSet.add(decimal1);
        treeSet.add(decimal2);
        treeSet.add(decimal3);

        System.out.println("HashSet size : " + hashSet.size());
        System.out.println("TreeSet size : " + treeSet.size());
    }
}

1.0, 1.00, 1.000의 값을 가진 BigDecimal을 생성 후 각각 HashSet과 TreeSet에 넣어준다. 이후 각 Set 컬렉션의 사이즈를 비교한다면 아래와 같이 출력된다.

HashSet size : 3
TreeSet size : 1

이와 같이 결과가 나온 이유는 무엇일까?

HashSet은 equals를 이용해 동치성을 비교하고, TreeSet은 compareTo를 이용해 동치성을 비교하기 때문이다.

compareTo 메서드 작성 요령은 equals와 비슷하며, 몇가지 차이점만 주의하면 된다.

  1. Comparable은 타입을 인수로 받는 제네릭 인터페이스 이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다. 인수의 타입이 잘못됐다면 컴파일 자체가 되지 않는다.
  2. null을 인수로 넣어 호출하면 NullPointerException을 던져야 한다.

Comparable의 사용

compareTo 메서드는 각 필드가 동치인지 비교하는 게 아니라 그 순서를비교한다. 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출하는데, Comparable을 구현하지 안은 필드나 표준이 아닌 순서를 비교해야 한다면 비교자(Comparator)를 대신 사용한다. Comparable을 사용한 예시는 다음과 같다.

public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
    }
}

해당 소스는 Comparable의 제네릭에 CaseInsensitiveString을 넣음으로써 CaseInsensitiveString의 참조는 CaseInsensitiveString 참조와만 비교할 수 있다는 뜻으로, Comparable을 구현할 때 일반적으로 따르는 패턴이다.

기본 타입의 compare 정적 메서드

자바7 이후 기본 타입 클래스들에 compare의 정적 메서드가 추가되어 기본타입을 비교 할 수 있게 되었다. 자바 7 이후에는 **compareTo 메서드에 관계연산자 <와>를 사용하는 방식은 오류를 유발하니, 추천하지 않는다.**

기본타입 필드가 여럿일때 비교자는 아래와 같이 구현 하면 된다.

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(line, pn.lineNum);
    }
    return result;
}

자바 8 이후 Comparator

기본타입의 Comparator

자바 8에서는 Comparator 인터페이스가 비교자 생성 메서드를 생성해 메서드 연쇄 방식으로 비교자를 생성 할 수 있게 되었다. 그리고 이 비교자들을 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는데 활용할 수 있다. 해당 방식은 간결하지만 약간의 성능 저하가 뒤따른다.

해당 방법은 아래와 같이 구현 할 수 있다.

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

해당 코드의 흐름을 간략하게 정리해 보자. comparingInt는 객체 참조 int 타입 키에 매핑하는 키 추출함수를 인수로 받아 그 키를 기준으로 순서의 비교자를 반환하는 정적 메서드 이다.

  1. 클래스를 초기화 할 때 비교자 생성 메서드 2개를 이용해 비교자를 생성한다.
  2. comparingInt는 람다(lambda)를 인수로 받으며, 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로 전화번호의 순서를 정하는 Comparator를 반환한다.
  3. 두 전화번호가 지역번호가 같다면, 두번째 비교자 생성 메서드인 thenComparingInt에서 순서를 정한다. 두번째 키는 PhoneNumber의 prefix를 받아 비교를 해주고, 세번째 키로는 PhoneNumber의 lineNum을 받아 비교를 해준다.
  4. thenComparingInt는 원하는 만큼 연달아 호출 할 수 있으며, 앞에 예시에서는 2개를 연달아 호출한 예시이다.

참조타입의 Comparator

객체 참조용 비교자 생성 메서드도 준비 되어있다. 우선 comparing이라는 정적 메서 2개가 다중 정의 되어있다.

  • 첫 번째는 키 추출자를 받아서 그 키의 자연적 순서를 사용한다.
  • 두 번째는 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.

여기까지 1차적으로 순서를 정하고 이어서 thenComparing으로 2차, 3차 순서를 정한다.

thenComparing에서는 인스턴스 메서드가 3개 다중정의되어 있다.

  • 첫 번째는 비교자 하나만 인수로 받아 그 비교자로 부차(2차 이상) 순서를 정한다.
  • 두 번째는 키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정한다.
  • 마지막 세 번째는 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.

아래의 예시는 ‘값의 차’를 기준으로 첫번째 값이 두번째 값보다 작으면 음수를, 두 값이 같으면 0을, 첫 번째 값이 크면 양수를 반환하는 compareTo나 compare메서의 예시이다.

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());