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

문법식·2022년 3월 19일
0

Effective Java 3/E

목록 보기
14/52

Comparable 인터페이스의 compareTo메서드는 두 가지만 빼면 Objectequals와 같다. 다른 두 가지는 다음과 같다.

  • compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있다.
  • 제너릭하다.

Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서가 있다는 것이다. 그래서 Comparable을 구현한 객체들의 배열은 다음처럼 쉽게 정렬할 수 있다.

Arrays.sort(a);

검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 쉽게 할 수 있다. String 또한 Comparable이 구현되어 있어서 TreeSet을 사용하면 중복을 제거하고 알파벳순으로 쉽게 정렬할 수 있다. Comparable을 구현하면 이 인터페이스를 활용하는 수많은 제너릭 알고리즘과 컬렉션의 힘을 누릴 수 있다. 조그만 노력으로 엄청난 효과를 얻을 수 있는 것이다. 사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입이 Comparable을 구현했다. 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable인터페이스를 구현하자.

다음은 compareTo메서드의 일반 규약이다. equals 규약과 비슷하다

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

  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y))==-sgn(y.compareTo(x))dudi gksek.
  • 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을 던져도 되며, 대부분 그렇게 한다. 물론, 이 규약에서는 다른 타입 사이의 비교도 허용하는데, 보통은 비교할 객체들이 구현한 공통 인터페이스를 매개로 이뤄진다.

규약에 대해 자세히 알아보자.

첫 번째 규약은 두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다는 얘기다. 객체 A, B로 설명하겠다. A<B이면 B>A여야 하고 A==B이면 B==A여야하고 A>B이면 B<A여야 한다는 것이다.
두 번째 규약은 추이성 이야기이다. 객체 A, B, C로 설명하겠다. A>B이고 B>C이면 A>C여야 한다는 것이다.
마지막 규약은 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다는 뜻이다.
위의 세 규약은 compareTo메서드로 수행하는 동치성 검사도 equals 규약과 똑같이 반사성, 대칭성, 추이성을 만족해야 함을 뜻한다. 그래서 주의사항도 똑같다. 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가했다면 compareTo규약을 지킬 방법이 없다. 우회법도 같다.

compareTo의 마지막 규약은 필수는 아니지만 꼭 지키킬 권한다. 마지막 규약은 간단히 말하면 compareTo 메서드로 수행한 동치성 테스트의 결과가 equals와 같아야 한다는 것이다. 이를 잘 지키면 compareTo로 줄지은 순서와 equals의 결과가 일관되게 한다. compareTo의 순서와 equals의 결과가 일관되지 않아도 클래스는 여전히 동작은 한다. 단, 이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set 혹은 Map)에 정의된 동작과 엇박자를 낸다. 이 인터페이스들은 equals 규약을 따른다고 되어있지만, 정렬된 컬렉션들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문이다. 아주 큰 문제는 아니지만 주의해야 한다.

compareTo 메서드 작성 요령은 equals와 비슷하다. 몇 가지 차이점만 주의하면 되는데 다음과 같다.

  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo메서드의 인수 타입은 컴파일 타입에 정해진다. 입력 인수의 타입을 확인하거나 형변환할 필요가 없다. 인수의 타입이 잘못됐다면 컴파일 자체가 되지 않는다. 또한 null을 인수로 넣어 호출하면 NullPointerException을 던져야 한다. 물론 실제로도 인수의 멤버에 접근하려는 순간 이 예외가 던져진다.

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

public final class CaseInsensitiveString
        implements Comparable<CaseInsensitiveString> {
    private final String s;
    
    // 자바가 제공하는 비교자를 사용해 클래스를 비교한다.
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
    }
    ...
}

박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare이용하는 것을 추천한다. 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(lineNum, pn.lineNum);
        }
   }
   return result;
}

자바 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메서드와 팀을 꾸려 메서드 연쇄 방식으로 비교자를 생성할 수 있게 됐다. 코드가 깔끔해지긴 하지만 조금 느려질 수 있다는 것을 알아야 한다.

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는 객체 참조를 int 타입 키에 매핑하는 키 추출 함수를 인수로 받아, 그 키를 기준으로 순서롤 정하는 비교자를 반환하는 정적 메서드다. 앞의 예에서 comparingInt는 람다를 인수로 받으며, 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로 전화번호의 순서를 정하는 Comparator<PhoneNumber>를 반환한다. 자바의 타입 추론 능력이 강력하지 않기 때문에 (PhoneNumber pn)과 같이 인수의 타입을 명시해서 프로그램이 컴파일되도록 도와줬다.
두 전화번호의 지역 코드가 같을 수 있는 상황도 고려해야 한다. 이 일은 두 번째 비교자 생성 메서드인 thenComparingInt가 수행한다. thenComparingIntComparator의 인스턴스 메서드로, int 키 추출자 함수를 입력 받아 다시 비교자를 반환한다.(이 비교자는 첫 번째 비교자를 적용한 다음 새로 추출한 키로 추가 비교를 수행한다.) thenComparingInt는 원하는 만큼 연달아 호출할 수 있다. thenComparingInt를 호출할 때는 타입을 명시하지 않았는데 이 정도는 또 자바가 추론할 수 있다.

Comparator는 수많은 보조 생성 메서드들을 가지고 있다. longdouble용으로는 comparingIntthenComparingInt의 변형 메서드를 준비했다. short처럼 더 작은 정수 타입에는 int용 버전을 사용하면 된다. 마찬가지로 floatdouble용을 이용해 수행한다. 이런 식으로 자바의 숫자용 기본 타입을 모두 커버한다.
객체 참조용 비교자 생성 메서드도 준비되어 있다. 우선, comparing이라는 정적 메서드 2개가 다중 정의되어 있다. 첫 번째는 키 추출자를 받아서 그 키의 자연적 순서를 사용한다. 두 번째는 키 추출자와 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다. 또한, thenComparing이란 인스턴스 메서드가 3개 다중 정의되어 있다. 첫 번째는 비교자 하나만 인수로 받아 그 비교자로부터 순서를 정한다. 두 번째는 키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정한다. 마지막 세 번째는 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.

이따금 '값의 차'를 기준으로 첫 번째 값이 두 번째 값보다 작으면 음수를, 두 값이 같으면 0을, 첫 번째 값이 크면 양수를 반환하는 compareTocompare메서드를 볼 수 있다.

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

위와 같은 방식을 쓰면 안된다. 이 방식은 정수 오버플로우를 일으키거나 IEEE 754부동소수점 계산 방식에 따른 오류를 낼 수 있다. 그렇다고 이번 아이템에서 설명한 방법대로 구현한 코드보다 월등히 빠르지도 않다. 그 대신 다음 두 방식 중 하나를 사용하면 된다.

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());
profile
백엔드

0개의 댓글