📌 Why use Comparable.compareTo()?
두 가지 성격을 제외하면 Object의 equals와 동일하다. 그렇다면 언제 Comparable.compareTo를 구현하는가?
compareTo 메서드는 단순 동치성 비교에 더해 자연적인 순서(natural order) 비교까지 가능하며 제네릭하다.
Comparable을 구현한 객체들의 배열은 Arrays.sort()로 손쉽게 정렬이 가능해진다. (검색, 극단값 계산, 자동 정령되는 컬렉션 관리까지도 쉽게 가능하다.)
예를 들어, Comparable을 구현한 String 객체는 아래 스니펫으로 알파벳 순으로 출력이 가능하다.
public class WordList {
public static void main(String[] args) {
Set<String> s = new TreeSet<>();
Collections.addAll(s, args);
System.out.println(s);
}
}
즉, 정렬과 비교 기능을 함께 구현해야 한다면 Comparable을 고려해야 한다.
- 단순 비교: Comparable Interface의 compareTo 메서드를 사용하여 박신된 기본 타입 클래스가 제공하는 정적 compare 메서드를 사용하라.
- 복잡한 비교: Comparator Interface가 제공하는 비교자 생성 메서드를 사용하라.
둘의 인터페이스 종류가 다름에 유의하자.
📌 compareTo 일반 규약
/*
* 이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를,
* 같으면 0을, 크면 양의 정수로 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면
* ClassCastException을 던진다.
* 다음 설명에서 sgn(표현식) 표기는 수학에서 말하는 부호 함수(signum function)를 뜻하며,
* 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1을 반환하도록 정의했다.
*
* Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compartTo(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 메서드 또한 반사성, 대칭성, 추이성을 충족해야 한다.)
다만, 마지막 규약의 경우에는 필수는 아니지만 꼭 지키는 것이 좋다. (equals의 결과와 compareTo로 수행한 동치성 테스트 결과가 같아야 한다.)
compareTo와 equals의 결과가 일관되지 않아도 동작은 하지만, 해당 객체를 정렬된 컬렉션(TreeSet, TreeMap, Collections, Arrays)에 넣었을 때에 해당 컬렉션이 구현한 인터페이스에 정의된 동작과 엇박자가 발생한다.
예를 들어, compareTo와 equals가 일관되지 않은 BigDecimal 클래스로 "1.0", "1.00"의 값을 가지는 인스턴스를 두 개 생성해보자.
equals 메서드로 비교하는 HashSet은 두 개의 원소를 가지지만, compareTo로 작동하는 TreeSet을 사용하면 원소는 하나밖에 존재하지 않는다.
equals는 두 인스턴스가 다른 hashCode를 반환하므로 다른 객체로 인지한다.
하지만, compareTo 메서드는 객체의 순서를 정렬하기 위해 두 객체를 비교했을 때 같다고 판단하므로 하나의 원소만 가지게 된다.
📌 compareTo 메서드 작성 요령
대부분 equals와 비슷하므로, 몇 가지 차이점만 구분할 수 있으면 된다.
Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일 타입에 정해진다.
즉, 입력 인수의 타입을 확인하거나 형변환할 필요가 없다. (타입이 잘못되면 컴파일 자체가 안 된다.)
또한 null을 인수로 넣어 호출하면 NullPointerException을 던져야 한다.
📌 Comparator(비교자)
Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 할 때 사용한다.
비교자는 직접 만들거나, 자바가 제공하는 비교자를 사용하면 된다.
1️⃣ 직접 만드는 경우
public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
}
CaseInsensitiveString이 Comparable<CaseInsentiriveString>을 구현한 것은 해당 타입의 참조는 동일 참조와만 비교할 수 있다는 의미로, Compareable을 구현할 때 일반적으로 따르는 패턴이다.
클래스에 핵심 필드가 여러 개라면, 그 중에서도 핵심이 되는 필드부터 비교해야 한다.
비교 결과가 0이 아니라면, 그 시점에서 순서는 결정되었으므로 결과를 곧장 반환한다.
예전에는 정수 기본 타입 필드를 비교할 때 관계 연산자를 사용했지만, Java7부터는 박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드 compare을 이용하는 것이 권장된다.
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;
}
2️⃣ Comparator Interface 사용하는 경우
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 타입 키에 매핑하는 키 추출 함수를 인수로 받아, 그 키를 기준으로 순서를 정하는 비교자를 반환한다.
해당 람다에서는 입력 인수의 타입을 명시하고 있는데, 자바의 타입 추론 능력이 이 상황에서 알아낼 만큼 강력하지 않아 프로그램이 컴파일 되도록 도와주는 것이다.
하지만 이후 thenComparingInt를 이용하여 연달아 호출하는 경우에는 굳이 명시하지 않아도 자바가 추론해낼 수 있다.
이외에도 long, double, float 등과 같은 숫자용 기본 타입을 모두 커버하는 보조 생성 메서드들로 중무장하고 있다.
📌 객체 참조용 비교자를 활용하는 경우
Comparator에는 comparing이라는 객체 참조용 비교자 생성 메서드 2개가 다중 정의되어 있다.
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));
}
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));
}
첫 번째는 키 추출자(keyExtractor)를 받아서 그 키의 자연적 순서를 이용한다.
두 번째는 키 추출자와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받는다.
thenComparing이란 인스턴스 메서드 또한 3개나 다중정의되어 있다.
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);
};
}
default <U> Comparator<T> thenComparing(
Function<? super T, ? extends U> keyExtractor,
Comparator<? super U> keyComparator)
{
return thenComparing(comparing(keyExtractor, keyComparator));
}
default <U extends Comparable<? super U>> Comparator<T> thenComparing(
Function<? super T, ? extends U> keyExtractor)
{
return thenComparing(comparing(keyExtractor));
}
첫 번째는 비교자 하나만을 인수로 받아 해당 비교자로 부차 순서를 정한다.
두 번째는 키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정한다.
마지막 세 번째는 키 추출자와 추출된 키를 비교할 비교자까지 2개의 인수를 받는다.
예전에 학교 과제로 간단한 학생 관리 프로그램을 만들 때, Comparator을 써봤었다.
public List<Student> sort(int choice) throws IOException {
List<Student> students = studentRepository.findAll();
List<Student> result = new ArrayList<Student>();
switch (choice) {
case 1:
result = students.stream()
.sorted(Comparator.comparing(Student::getAge, Comparator.reverseOrder()))
.toList();
break;
case 2:
result = students.stream()
.sorted(Comparator.comparing(Student::getJava, Comparator.reverseOrder()))
.toList();
break;
case 3:
result = students.stream()
.sorted(Comparator.comparing(Student::getAlgorithm, Comparator.reverseOrder()))
.toList();
break;
}
return result;
}
그 때는 쓰면서 Comparator에 대한 정확한 이해를 하지 못해서 어떻게 추가로 더 정렬 기준을 정해야하는지 막막해서 여기까지만 구현했었는데, 이젠 어떤 논리로 흘러가는지 이해가 되어서 너무 기쁘다.
📌 HashCode 값의 차를 비교하는 경우
static Comparator<Object> hashCodeOrder = new Comparator<>() {
@Override public int compare(Object o1, Object o2) {
return o1.hashCode() - o2.hashCode();
}
};
이 방식을 사용해서는 안 된다!!
정수 오버 플로우를 일으키거나 부동 소수점 계산 방식에 따른 오류가 발생할 가능성이 있다.
그렇다고 이번 아이템에서 설명한 방법대로 구현한 방식보다 월등히 빠르지도 않다.
두 가지를 기억하자.
- 기본 타입의 compare 정적 메서드를 사용하라
- Comparator의 정적 타입 메서드를 사용하라.
이 규약을 지켜 재작성하면, 아래의 두 방법 중 하나를 택하면 된다.
static Comparator<Object> hashCodeOrder = new Comparator<>() {
@Override public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
}
};
static Comparator<Object> hashCodeOrder =
Comparator.comparingInt(o -> o.hashCode()); // 혹은, Object::hashCode