본문 바로가기
Language/JAVA

[Effective java] Comparable 구현 고려하기

by jepa 2023. 9. 2.
728x90
SMALL

목적

Comparable 인터페이스에는 compareTo 메서드가 있습니다.

compareTo는 단순 동치성 비교뿐 만이 아닌 순서를 비교할 수 있고, 추가로 제네릭한 특징을 가지고 있습니다.

 

Comparable을 구현했다는 것은 자연적인 순서가 있음을 뜻하게 되어

Arrays.sort()와 같은 메서드, 자동 정렬되는 컬렉션(ex. TreeSet) 등과 함께 사용하면 관리가 쉬워집니다.

 

단순히 Comparable을 구현해서 정렬만 쉽게 하면된다고 생각했는데, 몇 규약을 지키지 않을 경우 문제가 발생할 수 있다고 합니다.

 

그래서 오늘은 Comparable 인터페이스를 구현할 때 지켜야하는 규약과 규약을 지키지 않았을 때 문제점, 작성 요령에 대해 알아보고자 합니다.

 

  • Comparable 인터페이스를 구현할 때 지켜야하는 규약과 작성 요령에 대해 알아보자
  • 책을 읽고 정리하자.
참고: Comparable 간단 예제 보기 링크

https://je-pa.tistory.com/7

 

[JAVA] Comparator, Comparable을 이용해서 배열과 List를 정렬하자.

목적 Comparator과 Comparable의 뜻은 각각 '비교기', '비교할 수 있는' 입니다. Java에서는 이 두 가지가 인터페이스로 정의되어 있습니다. 이 두 인터페이스 Comparator과 Comparable은 배열이나 List들을 정렬

je-pa.tistory.com

 

compareTo 메서드 일반 규약

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

1. sgn(x.compareTo(y)) == -sgn(y.compareTo(x))

두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다.

 

예시

첫 번째 객체가 두 번째 객체보다 작으면, 두 번째가 첫번째보다 커야 한다.

x.compareTo(y)가 예외를 던질 때는 y.compareTo(x)가 예외를 던질 때 뿐이다.

 

2. 추이성 보장

x.compareTo(y) > 0 과 y.compareTo(z) >0이 참이면 x.compareTo(z) > 0 도 참이다.

 

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

 

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

크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야한다.

 

4. 권고: (x.compareTo(y) == 0) == (x.equals(y))

compareTo 메서드로 수행한 동치성 테스트의 결과가 equals와 같아야 한다.

 

결과가 일련되지 않아도 동작은 하지만, 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, Map 등)에 정의된 동작과 엇박자를 낼 것이다.

해당 인터페이스들은 정렬된 컬렉션들의 동치성을 비교할 때 equals대신 compareTo를 사용하기 때문이다.

예시 BigDecimal클래스
compareTo와 equals가 일관되지 않음.

빈 Set 인스턴스를 생성한 다음
new BigDecimal("1.0")과 
new BigDecimal("1.00")를 차례로 추가한다고 생각해보자.

이때 빈 Set이
HashSet을 사용했을 때는 원소를 2개를 갖게되고,
TreeSet을 사용했을 때는 원소를 하나만 갖게된다.

 
HashSet은 equals로 비교하여 두 BigDecimal 인스턴스가 서로 다르고,
TreeSet은 compareTo로 비교하여 두 BigDecimal 인스턴스가 똑같기 때문이다.

 

참고: 해당 권고는 지키지 않는다면 사실을 명시할 것
ex) 이클래스의 순서는 equals 메서드와 일관되지 않다.

 

 

추가 고려사항들

  • compareTo를 이용한 동치성 검사도 equals 규약과 같이 반사성, 대칭성, 추이성을 충족해야한다.
  • 확장한 클래스에서 새로운 값 컴포넌트를 추가한다면 compareTo 규약을 지킬 방법은 없다.
    • 우회법
      • 확장대신, 구성(해당 클래스에 원래 클래스의 인스턴스를 가리키는 필드두기)
        -> 뷰 메서드 제공하기
        -> 바깥 클래스에 compareTo 메서드 구현

        이렇게 하면 필요에 따라 바깥 클래스의 인스턴스를 필드 안에 담긴 원래 클래스의 인스턴스로 다룰 수도 있다.

 

compareTo 규약을 지키지 못했을 때 문제점

규약을 지키지 못하면 아래와 같이 비교를 활용하는 클래스의 사용에 문제가 발생할 수 있다.

 

정렬된 컬렉션 - 비교를 활용

TreeSet

TreeMap

 

유틸리티 클래스 - 정렬 알고리즘을 활용

Collections

Arrays

 

compareTo메서드 작성 요령

compareTo를 작성법에 대해서 알아봅시다.

인수 - 제네릭 

  • Comparable은 타입을 인수로 받기 때문에 compareTo 메서드의 인수타입이 컴파일타임에 정해집니다.
    -> 입력 인수 타입 확인이나 형변환 필요없음
  • null일 때는 NullPointerException 던져야합니다.

순서비교 작성법

1. compareTo 메서드 재귀 호출하기

  • 객체 참조 필드 비교는 compareTo 메서드를 재귀 호출하면 됩니다.

2. Comparator 대신 사용하기

  • Comparable 구현하지 않은 필드나 표준이 아닌 순서로 비교한다면 비교자(Comparator)를 대신 사용해줍니다.
// String에서 제공하는 CASE_INSENSITIVE_ORDER
    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) {
            int n1 = s1.length();
            int n2 = s2.length();
            int min = Math.min(n1, n2);
            for (int i = 0; i < min; i++) {
                char c1 = s1.charAt(i);
                char c2 = s2.charAt(i);
                if (c1 != c2) {
                    c1 = Character.toUpperCase(c1);
                    c2 = Character.toUpperCase(c2);
                    if (c1 != c2) {
                        c1 = Character.toLowerCase(c1);
                        c2 = Character.toLowerCase(c2);
                        if (c1 != c2) {
                            // No overflow because of numeric promotion
                            return c1 - c2;
                        }
                    }
                }
            }
            return n1 - n2;
        }

        /** Replaces the de-serialized object. */
        private Object readResolve() { return CASE_INSENSITIVE_ORDER; }
    }
final class CaseInsensitiveString implements Comparable<CaseInsensitiveString>{
    String s;
    public int compareTo(CaseInsensitiveString cis){
        return String.CASE_INSENSITIVE_ORDER.compare(s,cis.s);
    }
}

위 코드에서 CaseInsensitiveString(제네릭)용 compareTo에서 자바가 제공하는 비교자를 사용하는 것을 볼 수 있습니다.

 

3. 기본 타입 필드 비교하기

  • 관계 연산자(>,<)보다는 정적메서드(ex. Double.compare, Float.compare) 사용을 권장합니다.
    관계연산자를 사용하는 방식은 거추장스럽고 오류를 유발할 수 있습니다.
참고: 위와 비슷하게 값의 차로 비교하는 것은 좋지 않습니다.
// 추이성 위반 - 해시코드 값의 차를 기준으로 하는 비교자
static Comparator<Object> hashCodeOrder = new Comparator<>(){
	public int compare(Object o1, Object o2){
    	return o1.hashCode() - o2.hashCode();
    }
}

해당 방식은 정수 오버플로와 부동소수점 계산 방식에 따른 오류를 일으킬 위험이 있습니다.

 

아래처럼 바꿔봅시다.

// compare 활용
static Comparator<Object> hashCodeOrder = new Comparator<>(){
	public int compare(Object o1, Object o2){
    	return Integer.compare(o1.hashCode(), o2.hashCode());
    }
}
// 비교자 생성 메서드 활용
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

이처럼 값의 차로 비교하는 것 보다 compare나 비교자를 사용하는 것을 권장합니다.

 

4. 핵심필드가 여러개 일 때 비교 순서

  • 가장 핵심적인 필드부터 비교
    • 해당 비교 결과가 0이 아니면 순서 바로 결정
    • 0이면 똑같지 않을 필드를 찾을 때 까지 그 다음으로 중요한 필드를 비교해간다.
public class Student implements Comparable<Student> {
    private String name;
    private int age;
    private int id;

    // 생성자, getter 메소드, setter 메소드

    @Override
    public int compareTo(Student other) {
        // 먼저 이름으로 비교합니다.
        int result = this.name.compareTo(other.name);
        
        if (result == 0) { // 이름이 동일한 경우 나이로 비교합니다.
            result = Integer.compare(this.age, other.age);
            
            if (result == 0) { // 나이까지 동일한 경우 아이디로 비교합니다.
                result = Integer.compare(this.id, other.id);
            }
        }

        return result;
    }
}

 

5. 비교자 생성 메서드를 이용한 비교

  • 메서드 연쇄 방식으로 비교자를 생성할 수 있다.
  • 코드는 간결해지지만, 성능 저하가 있다.
public interface Comparator<T> {
    //비교자 생성 메서드들
    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));
    }
    
    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));
    }
    
    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 Comparator<T> thenComparingInt(ToIntFunction<? super T> keyExtractor) {
        return thenComparing(comparingInt(keyExtractor));
    }
    ...

각 비교생성자들은 키 추출 함수(key extractor function)를 인수로 받습니다.

 

해당 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드 입니다.

import java.util.Comparator;

class Student implements Comparable<Student>{
    private String name;
    private int age;
    
	// 생성자, getter 메소드, setter 메소드

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public static Comparator<Student> getStudentComparator() {
        return Comparator.comparing(Student::getName)
                .thenComparingInt(Student::getAge);
    }
    
    @Override
    public int compareTo(Student o) {
        return getStudentComparator().compare(this, o);
    }
}

 

class Student implements Comparable<Student>{
    private String name;
    private int age;

	// 생성자, getter 메소드, setter 메소드

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public static Comparator<Student> getStudentComparator() {
        return Comparator.comparing((Student student) -> student.getName()) // (Student student)
                .thenComparingInt(student -> student.getAge());
    }

    @Override
    public int compareTo(Student o) {
        return getStudentComparator().compare(this, o);
    }
}

위의 코드에서 비교자 생성 메서드는 키 추출 함수를 람다식 또는 메서드 참조(Method Reference) 형태로 인수를 받은 것을 확인할 수 있습니다. 해당 student에서 추출한 키 값으로 순서를 정하는 Comparator<Student>를 반환합니다.

 

참고
(Student student)를  쓴 이유는 자바 타입 추론 능력이 이 상황에서 타입을 알아낼 만큼 강력하지 않기 때문에 프로그램이 컴파일 되도록 도와준 것 입니다. 한 번만 명시해주면 다음부터는 자바 타입 추론이 가능합니다.

 

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

첫 번째는 키 추출자 하나와 추출된 키를 비교할 비교자까지 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));
    }

첫  번째는 비교자 하나만 인수로 받아 그 비교자로 부차 순서(1차는 comparing, thenComparing은 2차 3차)를 정한다.

두 번째는 키 추출자 하나와 추출된 캐를 비교할 비교자까지 2개의 인수를 받는다.

세 번째는 키 추출자를 인수로 받아 그 키의 자연적 순서를 보조 순서를 정한다.

 

정리

  • 순서를 고려해야 하는 값 클래스는 Comparable 인터페이스를 구현하자
    • 이를 통해 정렬과 검색, 비교 기능을 제공하는 컬렉션과 함께 쓰는 것이 용이해진다.
  • 비교할 때 관계연산자(>,<)와 값의 차는 사용하지 않는 것이 좋다.
    • 대신 정적 compare이나 비교자 생성 메서드는 적용하자.
  • 핵심 필드가 여러개 일 때는 가장 핵심적인 필드부터 비교하자.
  • Comparable 구현하지 않은 필드나 표준이 아닌 순서로 비교한다면 비교자(Comparator)를 대신 사용할 수 있다.
728x90
LIST