Comparable & Comparator

Comparable & Comparator

자바에서 정렬시 객체를 비교하는 기준


비교 기준


Comparable, Comparator 는 자바에서 객체 비교를 하는 방법을 제공하는 인터페이스입니다.


쉽게 생각해봅시다.

두 숫자 3과 5가 있습니다. 어떤 수가 더 클까요?

당연히 5가 더 큽니다. 이견의 여지가 없죠?


자 그럼 학생1학생2가 있습니다.

두 학생 중 어느 학생이 더 클까요?

이 질문에는 즉답을 할 수가 없습니다.

왜냐하면 두 학생간 크다 작다를 어떤 기준으로 판별할지에 대한 정보가 제공되지 않았기 때문입니다.

키로 비교를 할 것인지, 머리의 길이로 비교를 할 것인지, 몸무게로 비교를 할 것인지 등이요.


즉, 자바의 원시 타입(primitive)간의 비교는 어떤것이 더 크다 작다가 명백하지만, 객체간의 비교에서는 어떤 기준으로 비교 할 것인가에 대한 정의가 필요해지게 됩니다.


그리고 바로 이러한 기준을 제공해주는것이 Comparable & Comparator 인터페이스입니다.

그리고 두 인터페이스간에는 사용 방법이라는 명확한 차이가 존재합니다.


Comparator


Comparator는 이름대로 비교기입니다.

이 인터페이스의 명세를 먼저 살펴보겠습니다.

인터페이스가 아주 많은데, 중요한것은 compare 단 하나뿐입니다.

compareequals를 제외한 다른 추상메서드들은 default거나 static으로 선언돼있기 때문에 기본적으로 신경쓰지 않아도 무방하며, equals는 핵심인 객체간의 비교와는 상관이 없기 때문에 역시 신경쓰지 않아도 됩니다. (서로 다른 Comparator간의 비교를 의미합니다. 정확한 것은 javadoc을 참고하세요.)


@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
}


Compares its two arguments for order. Returns a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than the second. The implementor must ensure that signum(compare(x, y)) == -signum(compare(y, x)) for all x and y. (This implies that compare(x, y) must throw an exception if and only if compare(y, x) throws an exception.) The implementor must also ensure that the relation is transitive: ((compare(x, y)>0) && (compare(y, z)>0)) implies compare(x, z)>0. Finally, the implementor must ensure that compare(x, y)==0 implies that signum(compare(x, z))==signum(compare(y, z)) for all z.


뭔가 설명이 많은데 중요한것은 아래 단 한 줄입니다.


두 인수를 비교합니다. 첫 번째 인수가 두 번째 인수보다 작다면 음의 정수, 같다면 0, 크다면 양의 정수를 반환합니다.


일단 코드를 봅시다.


public record Student(Integer id) {}


class StudentTests {
    List<Student> students;

    @BeforeEach
    void setUp() {
        students = IntStream.rangeClosed(1, 50)
                .mapToObj(Student::new)
                .toList();

        Collections.shuffle(students);
    }

    @Test
    void comparator() {
        Collections.sort(students, (o1, o2) -> 1); // Comparator 사용 (람다식)
    }
}


Comparator는 이렇게 위와 같이 정렬에 사용됩니다.

위 코드에서는 자바 컬렉션을 정렬할 때 사용하는 Collections 클래스를 사용하였으며, 마찬가지로 자바 배열을 정렬할 때 사용하는 Arrays.sort에도 Comparator를 인수로 하는 메서드가 오버로딩 되어 있습니다.

도입부에서도 언급한바와 같이, 정렬을 할 때 대상이 객체인 경우 명확한 기준을 통해 객체간의 비교가 되어야지만 정렬을 할 수가 있는데, 이 객체를 어떤 기준으로 비교 할 것인가라는 책임동작 파라미터화한 것이 ComparatorComparable이라고 정의 할 수 있습니다.

즉, 정렬 구현체들은 정렬만 하고, 정렬을 위한 객체 비교는 ComparatorComparable위임하는 것입니다.


따라서 개발자들은 ComparatorComparable를 오버라이딩하여 객체끼리 어떻게 비교 할 것인지를 정해주기만 하면 됩니다.

여기서 Student에는 id라는 속성이 있기 때문에 저는 Student끼리 비교할 때 id를 기준으로 비교하도록 하겠습니다.

정렬 구현체 입장에서는 간단합니다.

Comparator.compare에 정의된 javadoc과 정확히 동일한데, Comparator.compare(student1, student2)라고 호출했을 때,


  • 결과로 -1이 나왔다면 첫번째 인수인 student1이 student2보다 작다는 것입니다.
  • 결과로 0이 나왔다면 첫번째 인수인 student1과 student2는 같다는 것입니다.
  • 결과로 1이 나왔다면 첫번째 인수인 student1이 student2보다 크다는 것입니다.


그리고 정렬 구현체는 Comparator가 내린 이러한 판단을 근거로 정렬을 할 수 있게 됩니다.

실제 사용 코드는 다음과 같이 됩니다.


class StudentTests {
    List<Student> students;

    @BeforeEach
    void setUp() {
        students = IntStream.rangeClosed(1, 50)
            .mapToObj(Student::new)
            .toList();

        Collections.shuffle(students);
    }

    @Test
    void comparator() {
        Collections.sort(students, (s1, s2) -> s1.id() - s2.id());
    }
}


여기서 알아야 할 것이 한가지 있습니다.

대부분의 컴퓨팅 시스템은 기본적으로 오름차순(ASC)을 기준으로 구현이 돼있습니다.

이는 자바에 구현된 수많은 정렬 구현체들도 동일합니다.

한가지 예를 들어보겠습니다.


{5,2,1} 이라는 정수형 배열이 있습니다.

버블 정렬을 기준으로 설명드리자면, 버블 정렬은 기본적으로 왼쪽에서 오른쪽으로 두 원소를 비교해나가면서 정렬이 이뤄지기 때문에 처음에는 5와 2를 비교할 것입니다.

따라서, 버블 정렬 구현체는 내부적으로 Comparator.compare(5, 2)를 호출하고, 이에 대한 결과는 첫번째 인수인 5에서 두번째 인수인 2를 뺀 양의 정수 3이 반환됩니다.

즉, Comparator의 명세대로라면 배열의 선행 원소인 5가 후행 원소인 3보다 크다는 의미와 동일합니다.

그리고 기본적인 정렬 방식은 오름차순이기 때문에 버블 정렬 구현체는 5와 2의 위치를 바꾸어 더 큰 수인 5가 오른쪽으로 가도록 할 것입니다.

따라서 비교 결과는 {2,5,1}이 됩니다.


감이 잡히시나요?

다시 원래 코드를 봅시다.


위 코드에서 저는 두 객체의 id값의 차이를 반환하도록 하였습니다.

s1.id = 5이고, s2.id = 2라고 가정하고 코드를 보면 5 - 2 = 3이므로, 양의 정수인 3이 반환됩니다.

이는 s1이 s2보다 크다는 말과 같습니다.

즉, 정렬 구현체는 s1을 s2보다 크다고 판단하여 s1을 오른쪽으로 옮길것입니다.


그렇다면 만약 내림차순(DESC)으로 정렬을 하고 싶다면 어떻게 해야 할까요?

아주 간단합니다.


class StudentTests {
    List<Student> students;

    @BeforeEach
    void setUp() {
        students = IntStream.rangeClosed(1, 50)
            .mapToObj(Student::new)
            .toList();

        Collections.shuffle(students);
    }

    @Test
    void comparator() {
        Collections.sort(students, (s1, s2) -> s2.id() - s1.id()); // s2에서 s1을 빼도록 위치 변경
    }
}


s1과 s2의 위치를 바꾸면 됩니다.


Comparable


Comparable은 문자 그대로 비교 가능한입니다.

역시 인터페이스에 정의된 사양을 먼저 봅시다.


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


Compares this object with the specified object for order. Returns a negative integer, zero, or a positive integer as this object is less than, equal to, or greater than the specified object.

이 객체를 지정된 객체(compareTo)와 비교하여 순서를 지정합니다. 이 객체(this)가 지정된 객체보다 작다면 음의 정수, 같다면 0, 크다면 양의 정수를 반환합니다.


마찬가지로 음의 정수, 0, 양의 정수를 반환하므로 본질적인 비교 방법에 대해서는 Comparator와 차이가 없습니다.

다만, Comparator는 비교하려는 객체 두개를 인수로 받지만, Comparable은 비교하려는 객체에 서브타이핑을 통해 다른 객체를 입력받아 자기 자신과 비교한다는 사용방법의 차이가 있을 뿐입니다.


서브타이핑: 인터페이스 상속

서브클래싱: 구현 상속


실제 사용 예는 다음과 같습니다.


public record Student(Integer id) implements Comparable<Student>{ // 서브타이핑
    @Override
    public int compareTo(Student o) {
        return this.id - o.id;
    }
}


만약 정렬 방식을 내림차순으로 바꾸고 싶다면 역시 다음과 같이 구현하면 되겠죠?


public record Student(Integer id) implements Comparable<Student>{
    @Override
    public int compareTo(Student o) {
        return o.id - this.id;
    }
}


이러한 이유들로 인해 자바에서 사용되는 객체간의 모든 정렬에는 Comparator 혹은 Comparable가 반드시 필요합니다.

그리고 반대로, 두 인터페이스 중 하나라도 정렬 구현체에 제공해줄수만 있다면 객체간의 정렬이 가능해지게 됩니다.


자바의 자료형들은 전부 내부적으로 Comparator 혹은 Comparable를 구현해두었습니다.

자주 사용하는 String(문자열)의 비교 코드를 한번 보죠.

내부적으로 메서드 추출 리팩토링이 많이 되어있지만, 중요한 부분은 다음 코드입니다.


public static int compareToCI(byte[] value, byte[] other) {
    int len1 = value.length;
    int len2 = other.length;
    int lim = Math.min(len1, len2);
    for (int k = 0; k < lim; k++) {
        if (value[k] != other[k]) {
            char c1 = (char) CharacterDataLatin1.instance.toUpperCase(getChar(value, k));
            char c2 = (char) CharacterDataLatin1.instance.toUpperCase(getChar(other, k));
            if (c1 != c2) {
                c1 = Character.toLowerCase(c1);
                c2 = Character.toLowerCase(c2);
                if (c1 != c2) {
                    return c1 - c2;
                }
            }
        }
    }
    return len1 - len2;
}


문자열 두개를 입력받아, 두 문자열의 길이를 먼저 비교합니다.

문자열 abcdabc가 입력됐다고 가정합시다.

Math.min 메서드를 통해 두 문자열의 길이 중 작은 값을 얻어낸 후 얻어낸 길이만큼만 for문을 돌며 대문자로 변경 후 비교 -> 소문자로 변경 후 비교를 하고 있습니다.

그리고 사전순으로 앞서는 값을 반환하도록 하고 있네요.

그러면 abcdabc가 입력됐을 경우 abcabcd보다 사전순으로 더 앞서므로 abc, abcd순으로 정렬이 될 것임을 짐작해볼 수 있습니다.


public class StringSortTest {
    @Test
    void sort() {
        String[] strings = {"abcd", "abc"};
        Arrays.sort(strings);
        assertThat(Arrays.asList(strings).toString()).isEqualTo("[abc, abcd]"); // 테스트 통과
    }
}


마지막으로 이 내용은 본문과는 크게 상관없는 내용이지만, 자바의 정렬은 정렬 메서드에 넘어간 배열의 사이즈에 따라 다른 정렬 알고리즘이 선택되어 사용됩니다.

참고해두시면 도움이 될 것 같습니다.


image



© 2022. All rights reserved.