Effective Java : 아이템14. Comparable 규약

    들어가기 전

    이 글은 인프런 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다. 


    아이템 14. Comparable을 구현할지 고민하라.

    핵심 정리 : CompareTo 규약

    • CompareTo는 Objects.equals에 더해서 순서까지 비교할 수 있으며 Generic을 지원한다.
    • 자기 자신(this)이 compartTo에 전달된 객체보다 작으면 음수, 같으면 0, 그면 양수를 리턴한다.
      • 특정 값(-1, 1)이 나오길 기대하는 것이 아니라 양수, 음수인 것을 기대하고 만들어야 한다. 
    • 반사성, 대칭성, 추이성을 만족해야 한다.
    • 반드시 따라야 하는 것은 아니지만 x.compareTo(y) == 0이라면 x.equals(y)가 true여야 한다. 
      • 소수점 자리인 경우에는 이 방식을 따르지 못할 수 있음. 

    Comparable을 객체간의 자연적인 순서 (Natural Order)를 정해줄 때 사용하는 인터페이스다.또한 Comparable은 제네릭 타입을 가지고 있기 때문에 컴파일 타임에 비교해야하는 타입을 추론하고 구현할 수 있는 장점도 존재한다. 


    CompareTo를 정의할 때의 규약 살펴보기

    CompareTo를 정의할 때는 반사성, 대칭성, 추이성, 일관성을 반드시 만족하도록 작성해야한다. 아래에서 각각이 무엇을 의미하는지 살펴보고자 한다.

     

    반사성 

    반사성은 거울에 대고 자기 자신을 보는 것이다. 자기 자신과 비교 했을 때, 두 값은 같다고 나와야한다. 즉, compareTo의 출력은 반드시 0이 되어야 한다. 

    public static void main(String[] args) {
        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));
    
    }
    >>>
    0

     

    추이성

    추이성은 각 객체 간의 비교값이 연쇄적으로 일어나야함을 의미한다. 말이 어려운데, 아래 수식을 만족하는 것을 추이성이라고 한다. 

    • n3 > n2, n2 > n1 → n3 > n1

    아래 코드에서 해당 내용을 자세히 살펴볼 수 있다. 

    public static void main(String[] args) {
        BigDecimal n1 = BigDecimal.valueOf(23134134);
        BigDecimal n2 = BigDecimal.valueOf(11231230);
        BigDecimal n3 = BigDecimal.valueOf(53534552);
        BigDecimal n4 = BigDecimal.valueOf(11231230);
    
        // p89, 추이성
        // n3 > n1, n1 > n2이면 n3 > n2 인 것을 만족하는 것임.
        System.out.println(n3.compareTo(n1) > 0);
        System.out.println(n1.compareTo(n2) > 0);
        System.out.println(n3.compareTo(n2) > 0);
    }
    
    >>>>
    
    true
    true
    true

     

    대칭성

    대칭성은 다음과 같은 수식을 모두 만족하는 성질을 의미한다.

    • n1 > n2 → n2 < n1 
    public static void main(String[] args) {
            BigDecimal n1 = BigDecimal.valueOf(23134134);
            BigDecimal n2 = BigDecimal.valueOf(11231230);
            BigDecimal n3 = BigDecimal.valueOf(53534552);
            BigDecimal n4 = BigDecimal.valueOf(11231230);
    
            // p88, 대칭성
            // n1이 n2보다 컷다면, n2를 가지고 n1을 가지고 비교하면 n2가 작다고 나와야함.
            // n2 < n1 --> n1 > n2 (이게 대칭성임)
            System.out.println(n1.compareTo(n2));
            System.out.println(n2.compareTo(n1));
    }
    
    >>
    1
    -1

     

    일관성

    동일한 인스턴스를 여러 번 비교해도 같은 결과가 나와야한다는 성질이다. 

    • 아래에서 n4, n2는 같은 인스턴스를 나타낸다. 따라서 n4/n1 , n2/n1을 비교한 값은 각각 같은 결과를 내야한다.
    public static void main(String[] args) {
            BigDecimal n1 = BigDecimal.valueOf(23134134);
            BigDecimal n2 = BigDecimal.valueOf(11231230);
            BigDecimal n3 = BigDecimal.valueOf(53534552);
            BigDecimal n4 = BigDecimal.valueOf(11231230);
    
            // p89, 일관성
            // 동일한 인스턴스를 여러번 비교해도 같은 결과가 나와야한다는 것이 일관성이긴 한데.
            // n4,n2가 같으니 n4,n1 비교 / n2,n1 비교가 같은 값이 나와야한다.
            System.out.println(n4.compareTo(n2));
            System.out.println(n4.compareTo(n1));
            System.out.println(n2.compareTo(n1));
    }
    
    >>>
    
    0
    -1
    -1

     

    CompareTo가 0이라면 equals도 같으면 좋다. (꼭 지키지 않아도 됨)

    compareTo는 일반적으로는 순서를 비교한다. 동일한 객체를 보장하는 것은 아니고, 순서상 우위가 있는지를 의미하기 때문에 compaeTo가 0이라고 해서 equals도 항상 같은 결과를 나타내지 않아도 된다. 하지만 그렇게 만들면 좋다고 한다. 

    그렇게 구현되지 않는 예제는 아래에서 살펴볼 수 있다. 1.0 / 1.00은 서로 다른 객체처럼 취급된다. 왜냐하면 각각을 3으로 나눈다면 1.0은 0.33의 값을 나타낼 것이고, 1.00은 0.333의 값을 나타낼 것이기 때문이다. 

    public static void main(String[] args) {
            // p89, compareTo가 0이라면 equals는 true여야 한다. (아닐 수도 있고...)
            // 지켜주는 것이 좋다. 그런데 아래 같은 경우에는 아닐 수도 있다.
            BigDecimal oneZero = new BigDecimal("1.0");
            BigDecimal oneZeroZero = new BigDecimal("1.00");
            System.out.println(oneZero.compareTo(oneZeroZero));
            System.out.println(oneZero.equals(oneZeroZero));
        }
        
    >>>
    0 // 순서는 같다
    false // 같은 객체는 아니다.

    Comparable 구현 방법1. 덜 추천하는 방법 

    • 자연적인 순서를 제공할 클래스에 implements Comprable<T>을 선언하고, compareTo 메서드를 재정의함. 
    • compareTo 메서드 안에서 기본 타입(primitive Type)은 참조 타입(ReferenceType)의 compareTo을 사용해 비교한다.
    • 핵심 필드가 여러 개라면 비교 순서가 중요하다. 순서를 결정하는데 있어서 가장 중요한 필드를 비교하고 그 값이 0이라면 다음 필드를 비교한다.
    • 기존 클래스를 확장하고 필드를 추가하는 경우, 상속보다는 Composition으로 구현하는 것이 좋다. 

    순서를 제공하고자 하는 곳에 Comparable<T>를 구현하도록 하고, compareTo 메서드를 재정의한다. T에는 비교할 클래스를 넣는데, 일반적으로 구현하는 클래스를 적는다. 이렇게 적어두면 compareTo 할 때, 컴파일 시점에 타입추론을 통해 어떤 비교 코드를 작성해야 하는지 알 수 있다.

    아래 코드에서는 <PhoneNumeber>를 제네릭에 넣었기 때문에 compareTo 메서드에는 PhoneNumber 타입이 전달된다.

    @Getter
    public class PhoneNumber implements Cloneable, Comparable<PhoneNumber>{
    
        private final short areaCode, prefix, lineNum;
    
        ...
    
        // 코드 14-2 기본 타입 필드가 여럿일 때의 비교자 (91쪽)
        @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;
        }
        ...
    
    }

    객체를 비교할 때의 우선순위는 compareTo에 작성한다.

    • 우선순위가 높은 필드부터 비교하고, 만약 비교한 값이 0이라면 그 다음 필드를 비교하는 형식으로 구현한다. 
    • Primitive Type이라면 Reference Type이 가지고 있는 compare, compareTo를 이용해서 구현한다. (shoft → Short, int → Integer)

    Comparable 구현 방법2. 상속받는 경우

    그렇다면 Comparable를 구현한 클래스를 상속받는 경우에 자식 클래스는 어떻게 작성해야할까? 예를 들어 아래 경우를 살펴보자.

    • Point는 Comparable을 구현함.
    • NamedPoint는 Point를 상속받음. 

    이 때, NamedPoint가 다시 Comparable을 상속받아서 구현할 수 있을까? 구현할 수 없다. 또한 부모 클래스의 compareTo()를 그대로 사용하기 위해서는 '오버라이드 된 메서드'에만 다형성이 적용되는데, 아래와 같은 경우는 오버라이드가 아니기 때문에 compareTo()를 사용할 수 없다. 굳이 오버라이드를 사용해서 처리하려면 다음과 같이 작성해야 한다.

    첫번째 방법. 다운캐스팅 하기 

    @Getter
    @ToString
    public class NamedPoint3 extends Point {
    
        final private String name;
    
    
        public NamedPoint3(int x, int y, String name) {
            super(x, y);
            this.name = name;
        }
    
    
        @Override
        public int compareTo(Point o) {
            int result = super.compareTo(o);
            if (result == 0) {
                NamedPoint3 o2 = (NamedPoint3) o;
                result = this.name.compareTo(o2.getName());
            }
            return result;
        }
    
        public static void main(String[] args) {
            NamedPoint3 p1 = new NamedPoint3(1, 0, "keesun");
            NamedPoint3 p2 = new NamedPoint3(1, 0, "whiteShip");
    
            System.out.println(p1.compareTo(p2));
        }
    }
    
    >>>> main 실행 결과
    -12

    compareTo() 메서드를 오버라이딩 하되, 오버라이딩 된 메서드에서 다운 캐스팅을 이용해서 한번 더 비교해서 compareTo를 구현할 수 있다. 이 결과 정상적으로 비교 값이 나온다.

     

    두번째 방법. 비교가 필요할 때 Comparator 인터페이스 전달하기

    예를 들어 Treeset 같은 것을 만들 때는 순서가 필요하다. 이 때, 순서를 정의하기 위한 Comparator 인터페이스를 전달하면서 해결할 수 있다. 즉, 이것은 Comparator 인터페이스가 필요한 곳에서만 사용 가능한 정리 방법이다. 

    @Getter
    @ToString
    public class NamedPoint2 extends Point {
    
        final private String name;
    
    
        public NamedPoint2(int x, int y, String name) {
            super(x, y);
            this.name = name;
        }
    
        public static void main(String[] args) {
            NamedPoint2 p1 = new NamedPoint2(1, 0, "keesun");
            NamedPoint2 p2 = new NamedPoint2(1, 0, "whiteShip");
    
            // TreeSet은 Comparator가 필요함. 
            TreeSet<NamedPoint2> points = new TreeSet<>(new Comparator<NamedPoint2>() {
                @Override
                public int compare(NamedPoint2 o1, NamedPoint2 o2) {
                    int result = Integer.compare(o1.getX(), o2.getX());
                    if (result == 0) {
                        result = Integer.compare(o1.getY(), o2.getY());
                    }
    
                    if (result == 0) {
                        result = o1.getName().compareTo(o2.getName());
                    }
                    return result;
                }
            });
    
            points.add(p1);
            points.add(p2);
    
            System.out.println("points = " + points);
        }
    
    }

     

    세번째 방법. Composition + deligate

    Composition을 사용하는 방법을 추천하는 이유는 다음과 같다. Comparable을 구현한 클래스를 상속받는다고 가정해보자.

    • 상속 받은 클래스에서 필드를 추가할 수도 있는데, 그러면 equals 규약이 깨지게 된다.
    • equals 규약을 지키면서 확장을 하려면 상속 쓰지 말고 Composition을 써야한다. Point를 상속받는 것이 아니라, 내부에 Deligation 하도록 해주면 된다. 

    예를 들어 다음과 같이 코드를 작성해 볼 수 있다.

    @Getter
    public class NamedPoint implements Comparable<NamedPoint> {
    
        final private Point point;
        final private String name;
    
    
        public NamedPoint(Point point, String name) {
            this.point = point;
            this.name = name;
        }
    
    
        @Override
        public int compareTo(NamedPoint o) {
            // deligate로 처리
            int result = point.compareTo(o.getPoint());
            if (result == 0) {
                result = name.compareTo(o.getName());
            }
            return result;
        }
    
        public static void main(String[] args) {
            NamedPoint3 p1 = new NamedPoint3(1, 0, "keesun");
            NamedPoint3 p2 = new NamedPoint3(1, 0, "whiteShip");
    
            System.out.println(p1.compareTo(p2));
        }
    }
    
    >>>
    -12

     

    Comparable 구현 방법2. Comparator를 이용.

    Comparator 인터페이스가 제공하는 기본 메서드 + Static 메서드를 이용해서 CompareTo를 구현하는 것을 추천한다.

    • Comparator를 Static 메서드로 생성하고, 필요한 내용을 메서드 체이닝으로 모두 기록한다.
    • compareTo는 단순히 Comparator.compare()를 이용하면 됨. 

    Comparator는 Static으로 comparing 메서드를 제공한다. 이 메서드는 두 가지 타입이 있는데, 인자로 KeyExtractor, KeyComparator를 전달하거나 KeyExtractor만 전달한다.

    KeyExtractor만 전달되는 경우, 비교할 값만 전달해주면 그 Key의 compareTo() 메서드를 이용해서 처리한다는 것을 의미한다. 따라서 기본적으로 정의되어있는 순서 비교를 따른다. 그런데 만약 KeyComparator까지 같이 전달할 경우 해당 Comparator를 이용해서 순서를 정의하게 된다.

    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));
    }

    compare() 스태틱 메서드로 시작하는 것도 괜찮지만, 이미 존재하고 있는 기본형 타입의 Compare 스태틱 메서드를 이용하고 메서드 체이닝으로 처리하는 방법도 괜찮다. 

    • comparingInt()를 이용해서 가장 먼저 비교하도록 설정함. 이 때, 리턴값은 Comparator다. 
    • Comparator 인스턴스를 받았으면, 인터페이스에 설정된 default 메서드인 then()을 이용해서 compareTo에 대한 메서드 체이닝을 계속할 수 있다. 
    private static final Comparator<PhoneNumber> COMPARATOR =
            Comparator.comparingInt((PhoneNumber pn) -> pn.getAreaCode())
                    .thenComparingInt(value -> value.getPrefix())
                    .thenComparingInt(value -> value.getLineNum());

    그리고 위와 같이 Static 변수로 들어간 Comparator는 다음과 같이 사용하면 된다.

    private static final Comparator<PhoneNumber> COMPARATOR =
            Comparator.comparingInt((PhoneNumber pn) -> pn.getAreaCode())
                    .thenComparingInt(value -> value.getPrefix())
                    .thenComparingInt(value -> value.getLineNum());
    
    // compareTo는 단순히 COMPARATOR를 deligate한다. 
    @Override
    public int compareTo(PhoneNumber pn) {
        return COMPARATOR.compare(this, pn);

    댓글

    Designed by JB FACTORY