본문 바로가기

Reading/Effective Java

[Effective-Java] Item 14. Comparable을 구현할지 고려하라

Comparable은 제네릭 타입을 가지는 인터페이스이며, compareTo 메소드를 가진다.

Object의 equals와 다른 두 가지 성격을 가진다.

  • compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며 제네릭하다.
  • Comparable을 구현한 클래스의 인스턴스들이 자연적인 순서가 있음을 뜻한다.

따라서 Comparable을 구현한 객체들의 배열, 검색, 극단값 계산, 자동 정렬되는 컬렉션 관리를 쉽게할 수 있다.

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

 

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

 

compareTo 메소드의 일반 규약


이 객체와 주어진 객체의 순서를 비교한다.

sgn(표현식) 표기는 부호 함수(signum function)을 뜻하며 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1을 반환한다고 정의하자.

모든 객체에 전역 동치관계를 부여하는 equals와 달리 compareTo는 타입이 다른 객체를 신경쓰지 않아도 된다.

다른 타입도 허용은 하나 보통 비교할 객체들이 구현한 공통 인터페이스를 매개로 이뤄진다.

 

  • this < given : 음수
  • this == given : 0
  • this > given : 양수
  • 비교할 수 없는 타입: ClassCastException

 

1. sgn(x.compareTo(y)) == - sgn(y.compareTo(x))

반사성을 보장해야한다. 즉, 두 객체의 참조 순서를 바꿔 비교해도 결과가 같아야한다.

x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야한다.

 

2. x.compareTo(y) > 0 && y.compareTo(z) > 0 이라면 x.compareTo(z) > 0

추이성을 보장해야한다. 즉, x가 y보다 크고, y가 z보다 크다면 x는 z보다 커야한다

 

3. x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))

대칭성을 보장해야한다. 즉, 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야한다.

 

4. (x.compareTo(y)==0) == (x.equals(y)) [필수는아님]

compare 메소드로 수행한 동치성 테스트의 결과가 equals와 같아야 한다. 

 

compareTo의 순서와 equals의 결과가 일관되지 않은 클래스도 여전히 동작은 하지만 이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스( Collections, Set, Map..)에 정의된 동작과 달라지게 된다.이 인터페이스들이 equals 규야을 따른다고 되어 있지만 정렬된 컬렉션들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문이다.

 

@Test
@DisplayName("compareTo와 equals의 일반규약이 다르면 TreeSet과 HashSet에 저장된 원소의 개수는 다르다.")
public void differenceTest() throws Exception {
    //given
    BigDecimal oneDotZero = new BigDecimal("1.0");
    BigDecimal oneDotZeroZero = new BigDecimal("1.00");
    Set<BigDecimal> hashSet = new HashSet<>();
    Set<BigDecimal> treeSet = new TreeSet<>();
    //when
    hashSet.add(oneDotZero);
    hashSet.add(oneDotZeroZero);
    treeSet.add(oneDotZero);
    treeSet.add(oneDotZeroZero);
    //then
    assertEquals(2, hashSet.size());
    assertEquals(1, treeSet.size());
    assertFalse(oneDotZero.equals(oneDotZeroZero));
    assertTrue(oneDotZero.compareTo(oneDotZeroZero) == 0);
}

 

BigDecimal은 compareTo와 equals가 일관되지 않는다. 따라서 equals에는 false가, compareTo에는 true가 나온다.

그런데 왜 hashSet과 treeSet이 가지는 원소가 다를까?

 

HashSet 내부 HashMap의 put은 equals를 통해 저장한다.
Treeset 내부 TreeMap의 put은 compare를 통해 저장한다.

 

따라서 compare로 비교한 TreeSet은 값이 같다고 판단하여 1개의 원소를 가지고

equals로 비교한 HashSet은 다른 값으로 판단해 2개의 원소를 가진다

 

따라서 Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 이 사실을 명시해야한다.

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

 

 

규약에 따라 equals와 주의사항도 동일하다.

기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가했다면 compare 규약을 지킬 방법이 없다.

따라서 우회법도 컴포지션을 사용하는 것으로 같고, 내부 인스턴스를 반환하는 뷰 메소드를 제공하면 된다.

위의 이슈에 대한 우회법 참조

 

compareTo 메소드 작성 요령


Comparable은 타입을 인수로 받는 제네릭 인터페이스라서 compareTo 메소드의 인수 타입은 컴파일 타입에 정해진다.

인수의 타입을 잘못하면 컴파일 자체가 되지 않고 null을 인수로 넣어 호출하면 NPE가 발생한다.

 

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

만약 객체 참조 필드를 비교하려면 compareTo 메소드를 재귀적으로 호출해야한다.

그리고 Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야한다면 Comparator를 사용할 수 있다.

 

아래 코드와 같이 자바가 제공하는 비교자를 사용해 클래스를 비교할 수 있다.

 

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

 

그리고 자바7 이전에는 compareTo 메소드에서 정수 기본 타입을 비교할 때 관계 연산자인 <, >를 썼지만

자바7 이후에는 박싱된 기본 타입 클래스에 새로 추가된 정적메소드 compare를 이용하면 된다. 

 

class MyClass implements Comparable<MyClass> {
  private Short num;
  // 권장x, 관계연산자를 사용하지 말자.
  public int compareTo(MyClass m){
      if(this.num < m.num)
      	return -1;
      else if(this.num == m.num)
      	return 0;
      else if(this.num > m.num)
      	return 1;
  }
  
  //권장. 자신의 레퍼런스 정적 메소드 compare를 호출해서 비교하자.
  public int compareTo(MyClass m){
  	return Short.compare(this.num, m.num);
  }
}

 

관계 연산자를 사용하는 이전 방식은 거추장스럽고 필드의 상태에따라 오류(ex NPE)를 유발할 수 있으니 추천하지않는다.

 

 

만약 클래스에 핵심 필드가 여러개라면 어떻게 해야할까? 

가장 핵심이 되는 필드를 비교하고 결정되면 바로 반환하고 그렇지 않다면 순서대로 비교하면서 반환하면된다.

 

public final class PhoneNumber implements Comparable<PhoneNumber> {
    private final short areaCode, prefix, lineNum;
    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;
    }
}

 

 

 

Comparator


자바8에서 새롭게 나온 Comparator 인터페이스는 일련의 비교자 생성 메서드로, 메소드 연쇄 방식으로 비교자를 생성한다.

 

Comparator 인터페이스의 오버라이딩 함수들

 

직접 구현한다면 위의 함수들을 이용할 수 있다. compare 메소드를 통해서 직접적으로 비교연산을 수행한다.

thenComparingXX 메소드가 반환타입이 Comparator<T>기 때문에, 메소드를 연쇄적으로 호출할 수 있다.

그리고 Comparable의 compareTo를 내부의 Comparator 필드를 둬서 활용할 수 있다. 

간결하지만 약간의 성능저하가 뒤따르는 단점이 있다. 바로 위에 있던 PhoneNumber의 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);
}

 

  1. comparingInt는 객체 참조를 int 타입 키에 매핑하는 키 추출 함수(Key extractor function)를 인수로 받아 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메소드다. 위 예는 람다 인수로 받았으며 PhoneNumber에서 추출한 지역 코드를 기준으로 전화번호의 순서를 정하는 Comparator<PhoneNumber>를 반환한다.
  2. 지역코드가 같을 수 있으니 다음 비교 방식이 필요하고, thenComparingInt를 이용한다. 이 메소드는 이전의 비교자를 적용한 다음 새로 추출한 키로 추가 비교를 수행한다.
  3. prefix도 같을 수 있으니 다음 비교 방식으로 lineNum을 thenComparingInt를 이용해 호출하면, 이전의 비교자들을 적용한 다음 새로 추출한 키로 추가 비교를 수행한다.

 

람다에서 입력 인수의 타입(PhoneNumber pn)을 명시한 것은 자바의 타입 추론 능력이 이 상황에서 타입을 알아낼 만큼 강력하지 않기에 프로그램이 컴파일 되도록 도와주는 것이다. then 이후부터는 자바의 타입 추론 능력을 따를 수 있다.

 

Comparator는 수많은 보조 생성 메소드들을 가진다.

오버라이딩 할 수 있는 메소드들을 보면 comparingInt, comparingDouble, comparingLong이 있고

메소드 연쇄를 할 수 있게 thenComparing...들이 존재한다. 즉 숫자용 기본 타입을 모두 커버하는 것이다.

 

 

객체 참조용 비교자 생성 메소드도 준비되어 있다.

comparing은 정적 메소드 2개가, thenComparing은 정적 메소드 3개가 다중 정의 되어있다.

 

    // comparing - 키 추출자를 받아 그 키의 자연적 순서를 이용한다.
    public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
            Function<? super T, ? extends U> keyExtractor)
    {
        Objects.requireNonNull(keyExtractor);
        return (Comparator<T> & Serializable)
            (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
    }

    // comparing - 키 추출자와 추출된 키를 비교할 비교자까지 받는다.
    public static <T, U> Comparator<T> comparing(
            Function<? super T, ? extends U> keyExtractor,
            Comparator<? super U> keyComparator)
    {
        Objects.requireNonNull(keyExtractor);
        Objects.requireNonNull(keyComparator);
        return (Comparator<T> & Serializable)
            (c1, c2) -> keyComparator.compare(keyExtractor.apply(c1),
                                              keyExtractor.apply(c2));
    }
    
    // thenComparing - 비교자 하나만 인수로 받아 비교자로 부차 순서를 정한다.
    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);
        };
    }
    
    // thenComparing - 키 추출자 하나와 추출된 키를 비교할 비교자까지 두개의 인자를 받는다. 
    default <U> Comparator<T> thenComparing(
            Function<? super T, ? extends U> keyExtractor,
            Comparator<? super U> keyComparator)
    {
        return thenComparing(comparing(keyExtractor, keyComparator));
    }
    
    // thenComparing - 키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정한다.
    default <U extends Comparable<? super U>> Comparator<T> thenComparing(
            Function<? super T, ? extends U> keyExtractor)
    {
        return thenComparing(comparing(keyExtractor));
    }

 

 

값의 차를 이용한 방식을 사용하지 말자

이래 방법은 값의 차를 이용한 방식이다. 이 방식은

정수 오버플로를 일으키거나 부동 소수점 계산 방식에 따른 오류를 낼 수 있다. 그렇다고 속도도 아주 빠르지도 않다.

 

// 비권장 
public 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));

 

??: 해시코드를 이용해서 비교자를 생성하는게 옳을까? 해시코드는 다른 객체임을 나타내는데 순서랑 무슨 상관이지?

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.