Programming/Java

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

VSFe 2021. 2. 18. 00:30
Comparable을 구현할지 고려하라!

의도하던 아니던, 값을 비교해야 하는 경우는 많다. 가령 정렬을 한다거나, 이분 탐색을 한다거나, TreeMap/Set을 쓴다면... 따라서 새로운 객체를 만들게 된다면 Comparable을 구현할지 고려해야 한다.

Comparable은 어떻게 생겼을까?

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

결국 Comparable을 구현한다는 것은 compareTo를 정의하는 것과 마찬가지 일 것이다.

equals를 열심히 공부했다면, compareTo의 규약도 쉽게 읽힐 것이다.

  • 이 객체의 주어진 객체의 순서를 비교하는데, 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
  • 비교할 수 없는 객체가 들어오게 되면 ClassCastException을 던진다.
  • Comparable을 구현한 클래스는 모든 x, y에 대하여 sgn(x.compareTo(y)) == -sgn(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)) 임이 권장된다.

sgn은 수학에서의 부호 함수 (Signum Function) 이며, 양수면 1, 음수면 -1, 0이면 0을 리턴한다고 가정하자.

우리가 hashCode()를 통해 hashMap/Set을 사용할 수 있던 것 처럼, compareTo()를 통해선 TreeMap/Set을 사용할 수 있고, 그거와 별개로 Collections와 Arrays의 일부 정렬을 활용하는 메소드를 사용할 수도 있다.

위 규약을 살펴보면, 결국 반사성, 대칭성, 추이성을 모두 만족해야 한다는 것을 알 수 있다. 지난번에 이야기 했듯이, 위 세가지를 모두 만족하는 것을 수학에선 동치 관계라고 한다고 했는데, 그 중에 대표적인 예시가 부등호이다. (지난번에 언급했을 땐 등호 이야기만 했지만, 부등호도 수학에선 동치관계이다!)

결국 우리가 조심해야 하는 부분도 똑같다. 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가하는 순간 compareTo 규약을 지킬 방법이 사라진다. 결국 이 친구도 마찬가지로 독립된 클래스를 따로 빼서 원래 클래스의 인스턴스를 가리키는 필드를 지정함으로써 해결할 수 있다.

또한, 마지막에 서술한 (x.compareTo(y) == 0) == (x.equals(y)) 은 지키는 것이 좋다고 나와있는데, 지키지 않으면 동적은 하지만 Collection을 사용하면서 서로 다른 결과가 나올 수 있다! 정렬을 활용하는 컬렉션의 경우 동치성을 확인할 때 equals가 아닌 compareTo를 사용하기 때문이다.

결국 equals와 유사하기 때문에 작성 과정도 비슷하긴 하나, 제네릭 인터페이스임에 유의하자. 즉, compareTo의 인수 타입은 컴파일타임에 정해진다. 즉, 형변환 할 필요 자체가 없다!

조금 신경 써야 할 부분이라면, 핵심 필드가 여러개 일 때 어떤 것을 먼저 비교하느냐인 것이다. 만약 값이 같다면 다음으로 넘어가겠지만 그 외의 경우에는 다음 우선순위를 갖는 핵심 필드를 비교해서 리턴해야 할 것이다.

public class Compare implements Comparable<Compare> {
    String name;
    int number;
    int height;

    @Override
    public int compareTo(Compare o) {
        int result = this.name.compareTo(o.name);
        if(result == 0) {
            result = Integer.compare(this.number, o.number);
            if(result == 0) {
                result = Integer.compare(this.height, o.height);
            }
        }
        return result;
    }
}

int, long, short, char 같은 기본 타입의 경우, 박싱된 클래스에 정의된 static 메소드인 compare()를 사용하면 수월하게 비교할 수 있다!

자바 8에서는 Comparator 인터페이스가 메소드 체이닝 방식을 활용한 비교자 생성 메소드 (comparator construction method) 를 활용하여 비교자를 생성할 수 있다! 간결하고 깔끔하지만, 성능 측면에서 약간의 손해가 있으니 고민해서 적용하도록 하자.

위에서 작성했던 코드를 새로 짜보자.

import java.util.Comparator;
import static java.util.Comparator.comparingInt;
import static java.util.Comparator.comparing;

public class Compare implements Comparable<Compare> {
    String name;
    int number;
    int height;

    private static final Comparator<Compare> COMPARATOR =
           comparing((Compare cpr) -> cpr.name)
            .thenComparingInt(cpr -> cpr.number)
            .thenComparingInt(cpr -> cpr.height);

    @Override
    public int compareTo(Compare o) {
        return COMPARATOR.compare(this, o);
    }
}

람다식을 사용한 것을 볼 수 있는데, 이 람다는 이름을 순서대로 해당 클래스의 순서를 정하는 Comparator<Compare> 을 반환하는 것을 볼 수 있다.

근데 잠깐, Comparator와 Comparable의 차이는 뭘까?

코드를 잘 쳐다보면, Comparable은 compareTo를 Override 함으로써 사용한다. 즉, 기본 정렬 규칙을 정하는 수단으로 사용된다. 반면 Comparator는 한 객체 내에 여러개 생성이 가능하다. 즉, 제2, 3의 정렬 규칙을 만들어야 할 필요가 있을 때 사용한다. 일반적인 Array.sort()나 Collections.sort()의 경우 정렬할 인스턴스만 던지면 Comparable의 compareTo에 정의된 정렬 기준을 활용해 정렬하지만, 만약 두번째 인자로 Comparator 인스턴스를 던져주면 해당 정렬 기준으로 정렬해준다!

참고로, comparing은 다중정의 되어 있다. 하나는 위에서 사용한 것 처럼 키 추출자를 받아서 자연적인 순서를 사용하는 것이고, 두번째 경우는 키 추출자 하나와 추출된 키를 비교할 비교자를 받는다.