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

1. Comparable 인터페이스란?

  • 정의:

    • 자바에서 객체 간의 순서를 정의하고 비교하는 데 사용되는 인터페이스

    public interface Comparable<T> {
        int compareTo(T o);
    }
  • compareTo(T o) 메서드:

    • Comparable 인터페이스의 유일한 메서드.

    • 객체의 순서를 비교하여 정렬과 같은 작업에 활용

    • Objectequals와 유사하지만, 순서 비교 및 제네릭 타입 지원에서 차이.

    • 제네릭(Comparable<T>)을 사용하여 타입 안정성 보장.

2. Comparable 구현의 이점

  • 자연스러운 순서: 클래스의 인스턴스에 자연스러운 순서 부여.

  • 쉬운 정렬: Arrays.sort(a); 와 같이 간단하게 배열 정렬 가능.

  • 다양한 활용: 검색, 극단값 계산, 자동 정렬 컬렉션(TreeSet, TreeMap) 등에서 활용.

  • 제네릭 알고리즘 및 컬렉션과의 호환: 수많은 제네릭 알고리즘과 컬렉션을 활용 가능.

    • Arrays.sort(): 배열을 정렬하는 제네릭 알고리즘

    • Collections.sort(): 리스트를 정렬하는 제네릭 알고리즘

    • TreeSet: 정렬된 집합을 유지하는 컬렉션

    • TreeMap: 정렬된 키-값 쌍을 유지하는 컬렉션

    • 이러한 알고리즘과 컬렉션들은 Comparable를 구현한 객체들을 자동으로 정렬하거나 순서를 유지하도록 사용.

  • Java 플랫폼 라이브러리 활용:

    • 대부분의 값 클래스와 열거 타입이 Comparable 구현.

      • Integer, String, Date, Enum 등

3. compareTo 메서드 일반 규약 (equals 규약과 유사)

  1. 주어진 객체보다 작으면 음의 정수, 같으면 0, 크면 양의 정수를 반환한다.

  2. 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다

  3. 반사성:

    • 모든 x에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))

    • x.compareTo(y)가 예외를 던지면, y.compareTo(x)도 예외를 던져야 함.

  4. 추이성:

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

  5. 대칭성:

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

  6. (권고) 일관성:

    • (x.compareTo(y) == 0) == (x.equals(y))

    • 꼭 지켜야 하는 것은 아니지만, 지키지 않을 경우 "주의: 이 클래스의 순서는 equals 메서드와 일관되지 않다"라고 명시해야 함.

참고:

  • sgn(표현식): 표현식의 값에 따라 -1, 0, 1을 반환하는 부호 함수.

  • compareTo는 타입이 다른 객체에 대해 ClassCastException을 던져도 됨 (대부분 그렇게 함).

    • 다른 타입 간 비교는 객체들이 구현한 공통 인터페이스를 통해 이루어질 수 있음.

4. compareTo 규약 미준수 시 문제점

  • hashCode 규약 미준수 시 해시 기반 컬렉션(HashMap, HashSet)과 문제 발생.

  • compareTo 규약 미준수 시 비교 기반 컬렉션/클래스(TreeSet, TreeMap, Collections, Arrays)와 문제 발생.

5. 기존 클래스 확장 시 compareTo 규약 문제

public class Point implements Comparable<Point> {
    protected int x;
    public Point(int x) { this.x = x; }

    @Override
    public int compareTo(Point other) {
        int result = Integer.compare(this.x, other.x);
        return result;
    }
}
public class ColorPoint extends Point implements Comparable<ColorPoint> {
    private String color;

    public ColorPoint(int x, String color) {
        super(x);
        this.color = color;
    }

    @Override
    public int compareTo(ColorPoint other) {
        int result = super.compareTo(other);
        if (result == 0) result = this.color.compareTo(other.color);
        return result;
    }
}
  • 문제: 기존 클래스를 확장하여 새로운 값 컴포넌트를 추가하면 compareTo 규약을 지키기 어려움.

    • Point 클래스를 상속받아 ColorPoint 클래스를 만들고 color 필드를 추가할 경우, PointColorPoint 간의 비교가 모호해짐

  • 해결책: 상속 대신 컴포지션 사용.

    1. 독립된 클래스를 만들고, 원래 클래스의 인스턴스를 가리키는 필드를 추가.

    2. 내부 인스턴스를 반환하는 '뷰' 메서드 제공.

  • 결과: 새로운 compareTo 작성이 가능, 클라이언트는 원래 클래스처럼 사용 가능

public class ColorPoint implements Comparable<ColorPoint> {
    private Point point;
    private String color;

    public ColorPoint(Point point, String color) {
        this.point = point;
        this.color = color;
    }

    @Override
    public int compareTo(ColorPoint other) {
        int result = this.point.compareTo(other.point);
        if(result == 0) result = this.color.compareTo(other.color);
        return result;
    }
}

6. compareTo와 equals의 일관성 (권장)

  • 권장: compareTo의 결과와 equals의 결과가 같도록 구현하는 것이 좋음.

  • 일관성 유지 시: compareTo로 정렬된 순서와 equals의 결과가 일관됨.

  • 일관성 미유지 시: 정렬된 컬렉션(TreeSet, TreeMap)에서 예상치 못한 동작 발생 가능성.

    • 정렬된 컬렉션은 equals 대신 compareTo를 사용하기 때문.

    • BigDecimal

      • compareTo는 수치적인 값만을 비교하지만, equals는 수치적인 값과 scale(소수점 자리수)까지 비교합니다.

      • 예를 들어, BigDecimal("5.0")과 BigDecimal("5.00")은 compareTo로는 같지만, equals로는 다릅니다.

      BigDecimal bd1 = new BigDecimal("5.0");
      BigDecimal bd2 = new BigDecimal("5.00");
      
      int compareResult = bd1.compareTo(bd2);
      System.out.println("compareTo 결과: " + compareResult); // 0 (같음)
      
      boolean equalsResult = bd1.equals(bd2);
      System.out.println("equals 결과: " + equalsResult); // false (다름)
    • hashSet vs treeSet

      Set<BigDecimal> hashSet = new HashSet<>();
      hashSet.add(bd1); hashSet.add(bd2);
      System.out.println("HashSet 크기: " + hashSet.size()); // 2 (다르게 처리)
      
      Set<BigDecimal> treeSet = new TreeSet<>();
      treeSet.add(bd1); treeSet.add(bd2);
      System.out.println("TreeSet 크기: " + treeSet.size()); // 1 (같게 처리)

7. compareTo 메서드 작성 요령

  • 타입 안정성: Comparable은 제네릭 인터페이스이므로 인수 타입은 컴파일 타임에 결정.

    • 타입 확인, 형변환 불필요.

    • 잘못된 타입은 컴파일 오류 발생.

    • null 입력 시 NullPointerException 발생.

  • 필드 비교:

    • 객체 참조 필드: compareTo 재귀 호출.

    • Comparable 미구현 필드, 표준이 아닌 순서: Comparator 사용 (직접 만들거나, 자바 제공 Comparator 사용).

  • 핵심 필드 우선 비교: 여러 핵심 필드가 있다면 가장 핵심적인 필드부터 비교.

    • 비교 결과가 0이 아니면 즉시 반환, 0이면 다음 필드 비교.

  • Java 8 이후 비교자 생성 메서드 활용:

    import static java.util.Comparator.*; // 정적 임포트
    
    Comparator<Person> ageThenName = comparingInt(Person::getAge)
                                  .thenComparing(Person::getName)
                                  .thenComparing(Person::getAddress);
    java.util.Arrays.sort(people, ageThenName);
    • Comparator 인터페이스의 비교자 생성 메서드(comparingInt, thenComparingInt 등)를 이용해 메서드 연쇄 방식으로 비교자 생성.

      • 정적 임포트와 함께 사용하면 코드가 간결해짐, 남용시 약간의 성능 저하 가능성 있음.

      • comparingInt: 객체에서 int 키를 추출하여 비교

      • thenComparingInt: 이전 비교 결과가 같을 때, 추가로 int 키를 추출하여 비교 (연쇄 호출 가능)

    • 다양한 보조 생성 메서드: comparingLong, comparingDouble, comparing, thenComparing 등.

  • 값의 차를 이용한 비교 지양:

    // Bad
      public int compareTo(Person other) {
      return this.age - other.age;
    }
    
    
    // Good
    return Integer.compare(this.age, other.age);
    // or
    return Comparator.comparingInt(o -> o.getAge());
    return Comparator.comparingInt(Person::getAge);
    • 정수 오버플로, 부동소수점 오류 발생 가능성.

    • 정적 compare 메서드나 Comparator의 비교자 생성 메서드 사용 권장.

    • 관계연산자 <,> 사용 지양

핵심 정리

  • Comparable 인터페이스의 정의와 목적:

    • 객체 간의 순서를 비교하기 위해 제네릭 타입을 활용하여 타입 안전성을 보장

    • compareTo 메서드가 순서 비교의 기준

  • 구현의 이점:

    • 자연스러운 정렬, 간편한 배열 정렬, 다양한 컬렉션과 제네릭 알고리즘에서의 활용이 가능

    • 자바 라이브러리와의 호환성이 뛰어남.

  • compareTo 메서드의 규약:

    • 반사성, 추이성, 대칭성, 그리고 (권고되는) equals와의 일관성을 유지하는 것이 중요함.

    • 규약을 어길 경우 해시 기반 및 정렬 기반 컬렉션에서 문제 발생 가능성

  • 상속과 비교 규약 문제:

    • 기존 클래스를 확장할 때 새로운 값 컴포넌트를 추가하면 compareTo 규약을 지키기 어려워질 수 있으므로, 컴포지션을 통해 해결하는 방안을 제시함.

  • 작성 요령 및 주의사항:

    • 제네릭을 통해 타입 검증을 수행하고, 잘못된 타입은 컴파일 오류를 유도.

    • 필드 비교 시 핵심 필드를 우선으로 하고, 재귀적 비교 혹은 Comparator 사용을 권장.

    • 숫자 차이 연산 대신 Integer.compare와 같은 안전한 비교 방법 또는 Comparator의 메서드 체인을 활용.


Last updated