item 14 : Comparable 을 구현할지 고려하라

1. compareTo

ConpateTo란?

Comparable 인터페이스의 유일무이한 메서드로 단순 동치성 비교에 더해 순서까지 비교할 수 있으며,

  • 3장의 다른 메서드들과 달리 compareTo는 Object의 메서드가 아니다.

  • 성격은 2가지를 제외하면, Object equals와 같음

  • compareTo 를 구현했다는 것은, 순서가 존재하다는 것이고, Arrays.sort 를 활용한 정렬이 가능하다는 것

  • 보통은 sql에서 order by로 한다는 것

public class WordList {
    public static void main(String[] args) {
        Set<String> s = new TreeSet ();
        Collections.addAll(s, args); 
        System.out.println(s);
    } 
}
  • 에 자동 정렬되는 TreeSet 자료구조 상 출력하면 알파벳순으로 정렬되어 출력

사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입(아이템 34)이 Comparable을 구현했다. 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

2. CompareTo 일반규약

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

한 눈의 정리 🔥

compareToequals와 일관성을 유지하는 것이 권장되지만, 이는 필수는 아니다. 대칭성과 추이성은 필수이다. 하지만만약 compareToequals가 일관되지 않으면 TreeSet 같은 정렬된 컬렉션에서 예상치 못한 동작이 발생할 수 있다.

  1. 대칭성: x.compareTo(y)가 음수면, y.compareTo(x)는 양수여야 하고, x.compareTo(y)가 0이면 y.compareTo(x)도 0이어야 한다.

  2. 추이성: x.compareTo(y)가 양수이고, y.compareTo(z)가 양수라면, x.compareTo(z)도 양수여야 한다.

  3. 일관성: x.compareTo(y)가 0이면, x.compareTo(z)y.compareTo(z)는 같은 값을 가져야 한다.

부가적인 내용 ⚡

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

타입이 다른 객체가 주어지면 간단히 ClassCastException을 던져도 된다. compareTo 규약을 지키지 못하면 비교를 활용하는 클래스와 어울리지 못한다.

compareTo 메서드의 구현과 equals와의 일관성 유지의 중요성

compareTo 메서드의 마지막 규약은 필수 사항은 아니지만, 반드시 지키는 것이 좋다. 이 규약은 간단히 말해, compareTo 메서드를 통해 수행한 동치성 테스트의 결과가 equals 메서드의 결과와 일치해야 한다는 것이다. 이 규약을 잘 지키면 compareTo에 따른 정렬 순서와 equals의 결과가 일관되게 된다.

그러나 compareTo의 순서와 equals의 결과가 일치하지 않는 클래스도 여전히 동작은 한다. 다만, 이러한 클래스를 정렬된 컬렉션(예: TreeSet, TreeMap)에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, Map)의 동작과 엇박자가 발생할 수 있다. 이는 이 인터페이스들이 equals 메서드의 규약을 따르도록 정의되어 있지만, 정렬된 컬렉션은 동치성 비교 시 equals 대신 compareTo를 사용하기 때문이다.

권장 사항:

  • 가능하다면 x.equals(y)true일 때 x.compareTo(y)는 반드시 0을 반환하도록 구현해야 한다.

  • 반대로 x.compareTo(y)0일 때 x.equals(y)true가 되도록 구현하면 더욱 좋다.


BigDecimal 클래스의 예시

compareToequals일관되지 않는 클래스BigDecimal을 예로 들 수 있다.

BigDecimal 클래스에서 equals는 정밀도(precision)까지 고려하여 비교한다.

  • 예를 들어, new BigDecimal("1.0")new BigDecimal("1.00")equals로 비교하면 다른 객체로 간주

  • 하지만 compareTo수학적인 값만을 비교하므로, 두 객체를 동일한 값으로 간주

  • 이로 인해 HashSetTreeSet에서 원소의 개수가 달라지는 문제가 발생한다.

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.TreeSet;

public class BigDecimalExample {
    public static void main(String[] args) {
        // HashSet 예제
        HashSet<BigDecimal> hashSet = new HashSet<>();
        hashSet.add(new BigDecimal("1.0"));
        hashSet.add(new BigDecimal("1.00"));
        System.out.println("HashSet 크기: " + hashSet.size()); // 출력: 2

        // TreeSet 예제
        TreeSet<BigDecimal> treeSet = new TreeSet<>();
        treeSet.add(new BigDecimal("1.0"));
        treeSet.add(new BigDecimal("1.00"));
        System.out.println("TreeSet 크기: " + treeSet.size()); // 출력: 1
    }
}

설명:

  • HashSetnew BigDecimal("1.0")new BigDecimal("1.00")을 추가하면, equals 메서드로 비교하여 두 객체는 다르다고 판단하므로 HashSet의 원소는 2개가 된다.

  • 반면에 TreeSetcompareTo 메서드로 비교하여 두 객체를 같다고 판단하므로 TreeSet의 원소는 1개가 된다.

  • 이로 인해 HashSetTreeSet에서 같은 BigDecimal 객체를 사용하더라도 원소의 개수가 달라지는 문제가 발생한다.

🤔 음.. 이거는 HashSet과 TreeSet 의 자료구조의 문제 아냐?

답변 : 이러한 현상은 BigDecimal 클래스의 설계와 컬렉션 프레임워크의 동작 방식의 차이로 인해 발생하는 것입니다. 일반적으로 equals()compareTo() 메서드는 일관되게 동작하도록 구현하는 것이 권장됩니다. 즉, equals()로 같다고 판단되는 객체는 compareTo()로 비교했을 때 0을 반환해야 합니다.

하지만 BigDecimal 클래스는 정밀도(precision)를 중요시하기 때문에 equals() 메서드에서 스케일까지 고려하도록 설계되었습니다. 반면에 수치적인 비교를 위한 compareTo()는 스케일을 무시하고 값만 비교하도록 되어 있습니다.

따라서 이는 클래스의 설계 의도에 따른 결과이며, 컬렉션 프레임워크의 동작과 충돌할 수 있는 부분입니다. 이러한 차이로 인해 발생하는 문제는 개발자가 인지하고 조심해야 할 부분이지, Java 언어나 컬렉션 프레임워크의 구조적인 결함이라고 보기는 어렵습니다.

따라서, 구조적인 문제라기보다는 해당 클래스와 컬렉션 사용 시 주의해야 할 사항으로 보는 것이 맞습니다

🤔 하지만 위의 코드는 compareTo메서드가 아니라 size라는 메서드를 쓴거 아닌가?

  • 예제 코드에서 size() 메서드를 사용한 것은 집합에 실제로 몇 개의 원소가 저장되었는지 확인하기 위한 것입니다.

  • 원소의 개수가 달라지는 이유는 HashSetTreeSet이 원소의 동일성을 판단하는 기준이 다르기 때문입니다.

    • HashSetequals()hashCode()를 사용하고,

    • TreeSetcompareTo()를 사용합니다.

    • BigDecimal 클래스의 equals()compareTo() 메서드가 일관되지 않게 동작하기 때문에 이런 차이가 발생합니다.

즉, size() 메서드를 호출하기 전에 원소를 추가하는 과정에서 compareTo()equals() 메서드가 어떻게 동작하는지가 집합의 원소 개수에 영향을 미치게 됩니다.

  1. HashSet의 동작 원리

  • HashSet에 원소를 추가할 때, 원소의 hashCode() 값을 사용하여 버킷을 결정합니다.

  • 이미 같은 hashCode()를 가진 원소가 있는 경우, equals() 메서드를 사용하여 두 원소가 동일한지 비교합니다.

  • equals() 메서드가 false를 반환하면, 해당 원소는 집합에 새로운 원소로 추가됩니다.

2. TreeSet의 동작 원리

  • TreeSet이진 탐색 트리를 기반으로 구현되어 있으며, 원소를 정렬된 순서로 유지합니다.

  • 원소를 추가할 때, compareTo() 메서드나 제공된 Comparator를 사용하여 원소의 순서와 동일성을 판단합니다.

  • compareTo() 메서드가 0을 반환하면, 두 원소는 동일한 것으로 간주되어 새로운 원소로 추가되지 않는다.

  1. 해결 방법 🍁

  • compareTo()equals()의 구현을 일관되게 수정한다.

    • 하지만 BigDecimal 클래스는 Java 표준 라이브러리의 클래스이므로 우리가 수정할 수 없다.

  • 커스텀 Comparator를 사용하여 TreeSet의 동작을 수정한다.

    • 스케일까지 고려하도록 Comparator를 정의하면 TreeSet에서도 원소의 개수가 2개가 됨

compareTo 메서드를 올바르게 구현하고, equals와의 일관성을 유지하는 방법을 이해할 수 있다. 이는 코드의 신뢰성과 유지보수성을 높이고, 컬렉션 프레임워크를 사용할 때 발생할 수 있는 오류를 예방하는 데 중요

🔥 컬렉션 사용 시 주의사항

  • 컬렉션에 원소를 추가할 때, 해당 컬렉션이 원소의 동일성을 어떤 기준으로 판단하는지 알아야 한다.

  • equals()와 compareTo() 메서드의 구현이 일관되지 않으면 컬렉션에서 예기치 않은 동작이 발생할 수 있다.

3. compareTo 메서드 작성 요령

compareTo 메서드를 작성할 때는 equals 메서드와 비슷한 요령을 따르지만, 몇 가지 차이점을 주의해야 한다.

  1. 타입 검사와 형변환 불필요:

    • Comparable은 제네릭 인터페이스이므로, compareTo 메서드의 인수 타입은 컴파일 타임에 정해진다.

    • 인수의 타입이 잘못되면 컴파일 자체가 되지 않으므로, 런타임 타입 검사나 형변환이 필요 없다.

  2. null 처리:

    • compareTo 메서드에 null을 인수로 넣으면 NullPointerException을 던지는 것이 일반적이다.

    • 실제로도 인수(null)의 멤버에 접근하려는 순간 이 예외가 발생한다.

  3. 필드 비교 방법:

    • compareTo 메서드는 각 필드가 동치인지를 비교하는 것이 아니라, 순서를 비교한다.

    • 기본 타입 필드는 <, > 연산자를 사용하여 비교한다.

    • 객체 참조 필드는 해당 클래스의 compareTo 메서드를 재귀적으로 호출하여 비교한다.

    • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 하는 경우에는 Comparator를 사용한다.

4. compareTo 메서드 구현과 Comparator 활용

자바 7이후부터는compareTo 메서드에서 관계 연산자 <와 > 를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니 , 이제는 추천하지 않는다. 박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 이용하면 된다.

1) 여러 필드를 비교하는 compareTo 메서드 구현

클래스에 핵심 필드가 여러 개라면 어느 것을 먼저 비교하느냐가 중요해진다. 가장 중요한 필드부터 비교해나가는 것이 좋다.

  • 비교 결과가 0이 아니라면, 즉 순서가 결정되면 그 결과를 곧장 반환한다.

  • 가장 중요한 필드가 같다면, 그다음으로 중요한 필드를 비교해나간다.

예시: PhoneNumber 클래스의 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;
  • short.compare를 사용하여 short 타입 필드를 비교한다.

  • 이렇게 하면 코드가 간결해지고, 오버플로우 등의 문제를 방지할 수 있다.

2) Comparator 생성 메서드를 활용한 compareTo 구현

자바 8부터는 Comparator 인터페이스에 비교자 생성 메서드들이 추가되어, 메서드 연쇄 방식으로 비교자를 생성할 수 있다. 이를 활용하면 compareTo 메서드를 더 간결하게 구현할 수 있다.

예시: PhoneNumber 클래스의 compareTo 메서드

import static java.util.Comparator.comparingInt;

private static final Comparator<PhoneNumber> COMPARATOR =
    comparingInt((PhoneNumber pn) -> pn.areaCode)
        .thenComparingInt(pn -> pn.prefix)
        .thenComparingInt(pn -> pn.lineNum);

@Override
public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}
  • comparingInt 메서드는 키 추출 함수를 받아 그 키를 기준으로 비교하는 Comparator를 생성

  • thenComparingInt 메서드를 사용하여 추가적인 필드를 순차적으로 비교한다.

주의사항:

  • 이 방식은 코드의 간결함을 제공하지만, 약간의 성능 저하가 있을 수 있다. 테스트 결과 약 10% 정도 느려질 수 있다.

  • 따라서 성능이 중요한 상황에서는 전통적인 방식으로 구현하는 것이 좋을 수 있습니다.

  • 전통방식

public class PhoneNumber implements Comparable<PhoneNumber> {
    private final short areaCode;
    private final short prefix;
    private final short lineNum;

    // 생성자 및 기타 메서드 생략

    @Override
    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;
    }
}

3) Comparator의 다양한 메서드 활용

Comparator는 다양한 보조 생성 메서드들을 제공한다.

  • 숫자 타입 필드를 비교하기 위한 메서드:

    • comparingInt, comparingLong, comparingDouble

    • thenComparingInt, thenComparingLong, thenComparingDouble

  • 객체 참조 타입 필드를 비교하기 위한 메서드:

    • comparing 메서드: 키 추출자를 받아 키의 자연 순서나 지정한 Comparator로 비교

    • thenComparing 메서드: 추가적인 비교 기준을 지정

예시: 객체 참조 필드 비교

java코드 복사Comparator<Person> personComparator = Comparator
    .comparing(Person::getLastName)
    .thenComparing(Person::getFirstName)
    .thenComparingInt(Person::getAge);
  • Person 클래스의 lastName, firstName, age 필드를 순차적으로 비교

4) 잘못된 compareTo 구현 방식 피하기

가끔 값의 차이를 반환하여 비교하는 compareTo 메서드를 볼 수 있다.

// 잘못된 구현 - 사용하지 말 것!
public int compareTo(PhoneNumber pn) {
    return areaCode - pn.areaCode;
}
  • 이 방식은 오버플로우언더플로우가 발생할 수 있어 신뢰할 수 없다.

  • 또한, 부동소수점 타입에서는 정밀도 손실이 발생할 수 있다.

올바른 구현 방식:

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;
}
  • 정적 compare 메서드를 사용

  • 또는 Comparator 생성 메서드를 활용한다.

5) 올바른 비교자

정적 compare 메서드 활용:

static Comparator<Object> hashCodeOrder = new Comparator<Object>() {
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCode());
    }
};
  • Integer.compare를 사용하여 오버플로우 없이 안전하게 비교한다.

2. 비교자 생성 메서드 활용:

static Comparator<Object> hashCodeOrder =
    Comparator.comparingInt(o -> o.hashCode());
  • Comparator.comparingInt를 사용하여 더욱 간결하게 구현한다.

유의 사항

  • compareTo 메서드에서 필드를 비교할 때는:

    • 기본 타입 필드는 해당 박싱 클래스의 정적 compare 메서드를 사용

    • 객체 참조 필드는 해당 필드의 compareTo 메서드를 재귀적으로 호출한다.

    • Comparable을 구현하지 않은 필드는 적절한 Comparator를 사용한다.

  • 필드의 비교 순서중요도에 따라 정하기

    • 가장 중요한 필드부터 비교하여 순서가 결정되면 즉시 반환

  • Comparator 생성 메서드를 사용하면 코드가 간결해짐

    • 그러나 성능 저하가 있을 수 있으므로 상황에 맞게 선택

  • 값의 차이를 반환하는 방식은 피해야 한다.

    • 오버플로우, 언더플로우 등의 문제를 일으킬 수 있다.

  • compareTo 메서드 구현 시:

    • 정적 비교자 생성 메서드를 활용하여 간결하고 명확한 코드를 작성한다.

    • 필드의 중요도 순서에 따라 비교를 진행한다.

    • 타입 추론의 한계로 인해 람다 표현식에서 타입을 명시해야 할 때가 있으니 주의해야 한다.

✨ 결론

  1. 순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여, 그 인스턴스들을 쉽게 정렬하고, 검색하고, 비교 기능을 제공하는 컬렉션과 어우러 지도록 해야 한다.

  2. compareTo 메서드에서 필드의 값을 비교할 때 <와 > 연산자는 쓰지 말아야 한다.

  • 그 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

Last updated