[교재 EffectiveJava] 아이템 14. Comparable을 구현할지 고려하라

반응형
728x90
반응형

Comparable 인터페이스

Comparable 인터페이스는 compareTo() 메서드가 유일하다. compareTo() 메서드는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다. Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서(natural order)가 있음을 뜻한다. 그래서 Comparable을 구현한 객체들의 배열은 손쉽게 정렬할 수 있다.

Arrays.sort(a);

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

 

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

 

 

compareTo() 메서드의 일반 규약

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수, 같으면 0, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.

객체 A와 주어진 객체 B가 있을때
객체 A < 객체 B 음의 정수
객체 A = 객체 B 0
객체 A > 객체 B 양의 정수

 

BigDecimal.java
@Override
public int compareTo(BigDecimal val) {
    // Quick path for equal scale and non-inflated case.
    if (scale == val.scale) {
        long xs = intCompact;
        long ys = val.intCompact;
        if (xs != INFLATED && ys != INFLATED)
            return xs != ys ? ((xs > ys) ? 1 : -1) : 0;
    }
    int xsign = this.signum();
    int ysign = val.signum();
    if (xsign != ysign)
        return (xsign > ysign) ? 1 : -1;
    if (xsign == 0)
        return 0;
    int cmp = compareMagnitude(val);
    return (xsign > 0) ? cmp : -cmp;
}

 

compareTo 메서드의 일반 규약은 equals 규약과 비슷하다.

1) Comparable을 구현한 클래스는 모든 x와 y에 대해서 x.compareTo(y) == -(y.compareTo(x)) 를 만족해야 한다.

BigDecimal n1 = BigDecimal.valueOf(23134134);
BigDecimal n2 = BigDecimal.valueOf(11231230);
BigDecimal n3 = BigDecimal.valueOf(53534552);
BigDecimal n4 = BigDecimal.valueOf(11231230);

// p88, 반사성
System.out.println(n1.compareTo(n1));

// p88, 대칭성
System.out.println(n1.compareTo(n2));
System.out.println(n2.compareTo(n1));

x.compareTo(y)가 예외를 던진다면 y.compareTo(x)도 예외를 발생시켜야 한다.

두 객체 참조의 순서를 바꿔 비교해도 예상한 결과를 나와야한다는 얘기다. 즉, 첫번째 객체가 두번째 객체보다 작으면, 두번째가 첫번째보다 커야한다.

 

2) Comparable을 구현한 클래스는 추이성을 만족해야 한다.

BigDecimal n1 = BigDecimal.valueOf(23134134);
BigDecimal n2 = BigDecimal.valueOf(11231230);
BigDecimal n3 = BigDecimal.valueOf(53534552);
BigDecimal n4 = BigDecimal.valueOf(11231230);

// p89, 추이성
System.out.println(n3.compareTo(n1) > 0);
System.out.println(n1.compareTo(n2) > 0);
System.out.println(n3.compareTo(n2) > 0);

x.compareTo(y) > 0 && y.compareTo(z)이면 x.compareTo(z) 를 만족해야 한다.

첫번째가 두번째보다 크고 두번째가 세번째보다 크면, 첫번째는 세번째보다 커야한다.

 

3) x.compareTo(y) == 0 을 만족하면 모든 z에 대해 x.compareTo(z) == y.compareTo(z)  을 만족해야 한다.

BigDecimal n1 = BigDecimal.valueOf(23134134);
BigDecimal n2 = BigDecimal.valueOf(11231230);
BigDecimal n3 = BigDecimal.valueOf(53534552);
BigDecimal n4 = BigDecimal.valueOf(11231230);

// p89, 일관성
System.out.println(n4.compareTo(n2));
System.out.println(n2.compareTo(n1));
System.out.println(n4.compareTo(n1));

반드시 성립해야 하는 것은 아니지만 x.compareTo(y) == 0 일 때 x.equals(y) 를 만족해야하며, 해당 조건을 만족하지 않을 시 이를 명시해야한다.

크기가 같은 객체들끼리는 어떤 객체와 비교해도 항상 같아야한다.
해당 규약은 필수는 아니지만 지키기를 권한다. 3)번의 규약은 간단히 말하면 compareTo 메서드를 수행한 동치성 테스트의 결과가 equals의 결과가 일관되게 된다. compareTo의 순서와 equals의 결과가 일관되지 않은 클래스도 여전히 동작은 한다. 단, 이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, Map)에 정의된 동작과 엇박자를 낸다. 이 인터페이스들은 equals 메서드의 규약을 따른다고 되어있지만, 정렬된 컬렉션들은 동치성을 비교할때 equals 대신 compareTo를 사용하기 때문이다.

 

 

예시 BigDecimal

BigDecimal 클래스를 예로 들어보자. 

public class CompareToConvention {

    public static void main(String[] args) {
        BigDecimal oneZero = new BigDecimal("1.0");
        BigDecimal oneZeroZero = new BigDecimal("1.00");
        
        System.out.println(oneZero.compareTo(oneZeroZero)); // 0, Tree, TreeMap
        System.out.println(oneZero.equals(oneZeroZero)); // false, 순서가 없는 콜렉션
    }
}

위 두 BigDecimal은 equals 메서드로 비교하면 서로 다르기 때문이 false다. 하지만 compareTo로 비교하면 같다는 의미로 0이 나온다. 위 규약을 지키지않으면, 이러한 아이러니한 상황이 생길 수 있다.

 

위 compareTo 규약을 지키지 못하면 비교를 활용하는 클래스와 어울리지 못한다. 

 

 

주의사항

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

 

composition

만약 Comparable을 구현한 클래스를 확장해 값 컴포넌트를 추가하고 싶다면, 확장하는 대신 독립된 클래스를 만들고, 이 클래스에 원래 클래스의 인스턴스를 가리키는 필드를 두자. 그런 다음 내부 인스턴스를 반환하는 '뷰' 메서드를 제공하면 된다. 

이렇게 하면 바깥 클래스에 우리가 원하는 compareTo 메서드를 구현해넣을 수 있다. 클라이언트는 필요에 따라 바깥 클래스의 인스턴스를 필드 안에 담긴 원래 클래스의 인스턴스로 다룰 수 있다.

 

컴포지션 방식으로 구현하는 예제

Point.java
public class Point implements Comparable<Point>{
    final int x, y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public int compareTo(Point point) {
        int result = Integer.compare(this.x, point.x);
        if (result == 0) {
            result = Integer.compare(this.y, point.y);
        }
        return result;
    }
}

 

NamedPoint.java
public class NamedPoint implements Comparable<NamedPoint> {
    private final Point point;
    private final String name;

    public NamedPoint(Point point, String name) {
        this.point = point;
        this.name = name;
    }

    public Point getPoint() {
        return this.point;
    }

    @Override
    public int compareTo(NamedPoint namedPoint) {
        int result = this.point.compareTo(namedPoint.point);
        if (result == 0) {
            result = this.name.compareTo(namedPoint.name);
        }
        return result;
    }
}

1) Point 클래스의 compareTo()를 호출하여 우선순위를 설정한다.

@Override
public int compareTo(NamedPoint namedPoint) {
    int result = this.point.compareTo(namedPoint.point);
    if (result == 0) {
        result = this.name.compareTo(namedPoint.name);
    }
    return result;
}

 

 

객체 참조 필드가 하나뿐인 비교자

compareTo 메서드는 각 필드가 동치인지를 비교하는게 아니라 그 순서를 비교한다. 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다. Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야한다면 비교자(Comparator)를 대신 사용한다. 비교자는 직접 만들거나 자바가 제공하는 것 중에 골라쓰면 된다.

 

CaseInsensitiveString.java
public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    // 수정된 equals 메서드 (56쪽)
    @Override public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    }

    @Override public int hashCode() {
        return s.hashCode();
    }

    @Override public String toString() {
        return s;
    }

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

    public static void main(String[] args) {
        Set<CaseInsensitiveString> s = new TreeSet<>();
        for (String arg : args)
            s.add(new CaseInsensitiveString(arg));
        System.out.println(s);
    }
}

1) CaseInsensitiveString이 Comparable<CaseInsensitiveString>을 구현하였다.

public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {

이는 CaseInsensitiveString의 참조는 CaseInsensitiveString 참조와만 비교할 수 있다는 뜻으로, Comparable을 구현할때 일반적으로 따르는 패턴이다.

 

2) String의 CASE_INSENSITIVE_ORDER의 compare 메서드를 호출한다.

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

 

String.java
public static final Comparator<String> CASE_INSENSITIVE_ORDER = new CaseInsensitiveComparator();

private static class CaseInsensitiveComparator implements Comparator<String>, java.io.Serializable {
    // use serialVersionUID from JDK 1.2.2 for interoperability
    private static final long serialVersionUID = 8575799808933029326L;

    public int compare(String s1, String s2) {
        byte v1[] = s1.value;
        byte v2[] = s2.value;
        if (s1.coder() == s2.coder()) {
            return s1.isLatin1() ? StringLatin1.compareToCI(v1, v2)
                                 : StringUTF16.compareToCI(v1, v2);
        }
        return s1.isLatin1() ? StringLatin1.compareToCI_UTF16(v1, v2)
                             : StringUTF16.compareToCI_Latin1(v1, v2);
    }

    /** Replaces the de-serialized object. */
    private Object readResolve() { return CASE_INSENSITIVE_ORDER; }
}

 

▶ 위의 결과로, 아래와 같이 한글 순서로 정상 출력됨을 확인할 수 있다.

public static void main(String[] args) {
    Set<CaseInsensitiveStringTest> s = new TreeSet<>();
    s.add(new CaseInsensitiveStringTest("다라마"));
    s.add(new CaseInsensitiveStringTest("가나다"));
    s.add(new CaseInsensitiveStringTest("나다라"));
    System.out.println(s);
}

 

실행결과
[가나다, 나다라, 다라마]

 

 

박싱된 기본 타입 클래스의 추가된 정적 메서드 compare()

compareTo 메서드에서 관계 연산자 <와 >를 사용하는 이전 방식은 오류를 유발하므로, 이제는 추천하지 않는다.

클래스에 핵심 필드가 여러개라면 어느 것을 먼저 비교하느냐가 중요해진다. 가장 핵심적인 필드부터 비교해나가자. 비교 결과가 0이 아니라면, 즉 순서가 결정되면 거기서 끝이다. 그 결과를 곧장 반환하자.

 

가장 핵심이 되는 필드가 똑같다면 똑같지 않은 필드를 찾을때까지 그 다음으로 중요한 필드를 비교해나간다. 아래 예제에서 1, 2, 3 중요한 필드 순서로 구현했다.

@Override
public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode); // 1
    if (result == 0)  {
        result = Short.compare(prefix, pn.prefix); // 2
        if (result == 0)
            result = Short.compare(lineNum, pn.lineNum); // 3
    }
    return result;
}

 

 

자바 8에서의 비교자 생성 메서드(comparator construction method)

Java 8에서는 메서드 연쇄 방식으로 비교자를 생성할 수 있게되었다. 그리고 이 비교자들을 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는데 멋지게 활용할 수 있다. 이 방식은 간결하지만 성능 저하가 뒤따른다.

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

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

1) comparingInt()

Comparator.java
public static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) {
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> Integer.compare(keyExtractor.applyAsInt(c1), keyExtractor.applyAsInt(c2));
}

객체 참조를 int 타입 키에 매핑하는 키 추출 함수(key extractor function)을 인수로 받아, 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드다. 이 메서드는 Comparator<PhoneNumber>을 반환한다.

 

▶ ToIntFunction.java

@FunctionalInterface
public interface ToIntFunction<T> {

    /**
     * Applies this function to the given argument.
     *
     * @param value the function argument
     * @return the function result
     */
    int applyAsInt(T value);
}

 

2) thenComparingInt()

Comparator.java
default Comparator<T> thenComparingInt(ToIntFunction<? super T> keyExtractor) {
    return thenComparing(comparingInt(keyExtractor));
}

// comparingInt() 호출
public static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) {
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        // Integer.compare() 호출
        (c1, c2) -> Integer.compare(keyExtractor.applyAsInt(c1), keyExtractor.applyAsInt(c2));
}

Comparator의 인스턴스 메서드로, int 키 추출자 함수를 입력받아 다시 비교자를 반환한다. 이 함수는 원하는만큼 연달아 호출 가능하다. 

 

 

반응형

Designed by JB FACTORY