티스토리 뷰

반응형

사용되는 지식:

함수형 인터페이스

람다식(Lambda Expression)

메서드 참조

디폴트 메서드


 단순한 Array 혹은 숫자만 포함된 List의 구현체(ArrayList, LinkedList)들을 오름차순 정렬하려면 java.util.Arrays나 java.util.Collections의 sort() 메서드에 해당 자료구조를 파라미터로 넘겨주기만 하면 된다.

 

import java.util.Arrays;
public class test {
    public static void main(String[] args) {
    	int[] arr = {3,2,1};
        Arrays.sort(arr); // => arr = {1, 2, 3}
    }
}

 

 하지만 예를 들어, 단순하게 하나의 숫자의 비교를 통한 정렬이 아니라, 사람의 나이, 전화번호, 성적 등 여러 데이터가 저장된 객체들을 정렬하려 한다면, 어떤 데이터를 기준으로 정렬할 것인가? 만약, 성적을 기준으로 정렬하는 도중, 성적이 같은 두 사람이 있다면 무슨 기준으로 정렬할 것인가?

 

 이 글에서는 Comparator를 사용하여 객체의 비교를 통한 정렬 방법에 대해 소개한다.

 아래의 예시를 사용할 것이며, ArrayList에 담긴 이름과 나이를 포함한 Member객체를 이름을 사전순으로, 이름이 같다면 나이를 오름차순으로 정렬하는 방법을 단계적으로 설명한다.

 

  1.  직접 Comparator를 구현하여 정렬하는 방법부터
  2. 익명 객체를 통해 별도의 Comparator 클래스를 만들지 않는 방법,
  3. 람다를 사용하여 함수형 인터페이스의 구현체를 바로 생성하는 방법,
  4. Comparator의 유틸리티 메서드를 통해 이를 읽기 좋게 변경하는 방법까지로 구성되어 있다.

필요한 내용을 선택적으로 얻어가면 된다.

 

최종적으로는 아래의 모습을 띤다.

 

public class test {

    public static void main(String[] args) {
        Member member1 = new Member("다", 10);
        Member member2 = new Member("나", 10);
        Member member3 = new Member("가", 12);
        Member member4 = new Member("가", 9);

        List<Member> members = new ArrayList<>();
        members.add(member1);
        members.add(member2);
        members.add(member3);
        members.add(member4);
        
        // 정렬 전 members: {"다", 10}, {"나", 10}, {"가", 12}, {"가", 9}
        
        // 정렬
        members = members.sort(comparing(Member::getName)
            .thenComparing(Member::getAge));

        // 정렬 후 members: {"가", 9}, {"가", 12}, {"나", 10}, {"다", 10}
    }
}

// Member
public class Member {

    private String name;
    private int age;

    public Member(String name, int age){
    	this.name = name;
    	this.age = age;
    }

    public String getName() {
    	return name;
    }

    public int getAge() {
    	return age;
    }
}

 

 


 

sort 메서드

 

 Java.util.Collections.class의 sort 메서드는 두 가지로 오버로딩되어 있다. 하나는 파라미터로 list만을 넘겨받는 것이며, 우리의 관심사는 다른 하나인 Comparator 객체도 함께 넘겨받는 아래의 형태이다.

 

// java.util의 Collections

@SuppressWarnings({"unchecked", "rawtypes"})
public static <T> void sort(List<T> list, Comparator<? super T> c) {
    list.sort(c);
}

 

sort 메서드는 list와 그것을 비교할 방법을 가진 Comparator를 넘겨받아 해당 list의 sort 메서드를 다시 호출한다.

실질적인 정렬은 List의 sort 메서드에서 한번 더 호출된 Arrays의 sort 메서드에서 수행된다.

 

따라서 아래 두 코드는 실질적으로 같은 기능을 한다.

 

Collections.sort(members);
members.sort();

 

내부 구현

더보기
// java.util의 List

@SuppressWarnings({"unchecked", "rawtypes"})
default void sort(Comparator<? super E> c) {
    Object[] a = this.toArray();
    Arrays.sort(a, (Comparator) c);
    ListIterator<E> i = this.listIterator();
    for (Object e : a) {
        i.next();
        i.set((E) e);
    }
}

 

List의 sort 메서드는 배열로 리스트를 변환한 뒤 Arrays의 sort 메서드를 다시 호출한다.

 

// java.util의 Arrays

public static <T> void sort(T[] a, Comparator<? super T> c) {
    if (c == null) {
        sort(a);
    } else {
        if (LegacyMergeSort.userRequested)
            legacyMergeSort(a, c);
        else
            TimSort.sort(a, 0, a.length, c, null, 0, 0);
    }
}

 

실질적인 정렬은 Arrays에서 일어나며 MergeSort를 기반으로 정렬을 수행한다.

 

 

Comparator

 

 그렇다면 Comparator는 무엇인가? Java.util의 Comparator.class에서 Comparator는 하나의 public abstract 메서드를 구현해야 하는 함수형 인터페이스로, 두 개의 객체를 넘겨받아 그것의 크기 비교를 수행하는 추상메서드인 compare를 구현하도록 강제하고 있다.

 

@FunctionalInterface
public interface Comparator<T> {

    /**
    * Returns:
    * a negative integer, zero, or a positive integer as the first
    * argument is less than, equal to, or greater than the second.
    */
    
    int compare(T o1, T o2);

    . . .
}

 

compare 메서드는 두 객체 중 만약 앞의 객체가 더 작다면 음수, 같다면 0, 크다면 양수를 반환해야 한다.

 

 

 따라서 우리는 list를 정렬하기 위해 우리의 입맛에 맞는 Comparator 구현체를 만들고 Collections.sort를 호출하면 된다.

 


 

Comparator 직접 구현하기

 

 맨 위의 예시인, 멤버 객체들을 이름 순으로 오름차순 정렬하고, 이름이 같다면 나이 순으로 오름차순 정렬하기 위해 조건에 맞는 비교를 수행해주는 Comparator 구현체를 아래와 같이 생성한다.

 

import java.util.Comparator;

public class MyComparator implements Comparator<Member> {

    @Override
    public int compare(Member a, Member b) {
        // 이름이 같다면 나이 순 배열
        if(a.name.equals(b.name)) {
            return a.age - b.age;
        }
        // 이름이 다르다면 
        else {
            return a.name.compareTo(b.name);
        }
    }
}

 

 첫 번째 파라미터인 a가 b보다 크면 양수를, 같다면 0을, 작다면 음수를 반환하는 것이 compare 메서드이므로, 다른 이름에 대해선 문자열의 사전순 비교를 해주는 compareTo 메서드를, 같은 이름이면 나이 차이를 반환하도록 구성한다.

 

Member member1 = new Member("다", 10);
Member member2 = new Member("나", 10);
        
MyComparator comparator = new MyComparator();
System.out.println(comparator.compare(member1, member2)); // 588(양수)
// => {"나",10}, {"다",10} 로 정렬된다

 

 위와 같이 만든 comparator를 사용하면, 결과로 양수가 나오며 이는 곧 두 번째 원소인 Member2가 더 크다는 의미이다. 따라서 오름차순 정렬 시 우리가 원하는 사전 순 정렬이 된다.

 

 이 comparator 구현체를 sort 메서드에 넣으면 우리가 원하는 결과를 얻을 수 있다. 전체 코드 참고.

 

더보기
public class test {

    public static void main(String[] args) {
        Member member1 = new Member("다", 10);
        Member member2 = new Member("나", 10);
        Member member3 = new Member("가", 12);
        Member member4 = new Member("가", 9);

        List<Member> members = new ArrayList<>();
        members.add(member1);
        members.add(member2);
        members.add(member3);
        members.add(member4);
		
        MyComparator comparator = new MyComparator();
        Collections.sort(members, comparator);
        // 혹은 members.sort(comparator);
    }
    
    public static class Member {
        public String name;
        public int age;
        public Member(String name, int age){
            this.name = name;
            this.age = age;
        }
    }
    
    public static class MyComparator implements Comparator<Member> {

        @Override
        public int compare(Member a, Member b) {
            // 이름이 같다면 나이 순 배열
            if(a.name.equals(b.name)) {
                return a.age - b.age;
            }
            // 이름이 다르다면 
            else {
                return a.name.compareTo(b.name);
            }
        }
    }
}

 


 

 

익명 객체 사용

 

 Member에 대한 Comparator 구현 클래스를 만든 것으로 목표했던 기능을 충실히 수행하긴 하지만 아직 충분히 좋은 코드는 아니다. 정렬을 위해 사용되는 Comparator 구현체는 보통 한 번만 사용되는 경우가 매우 많은데 이렇게 1회만 사용되는 인스턴스의 경우 익명 클래스를 통해 처리하는 편이 좋다.

 

members.sort(new Comparator<Member>() {
    @Override
    public int compare(Member a, Member b) {
        if(a.name.equals(b.name)) {
            return a.age - b.age;
        } else {
            return a.name.compareTo(b.name);
        }
   }
});

 

 위와 같이 클래스 정의와 동시에 객체를 바로 생성해 넣어줄 수 있다. 불필요하게 네이밍을 고민할 필요도 없으며 메모리적으로도 인스턴스의 사용이 끝나면 stack에서 빠르게 사라져 좋은 방법이다.

 


 

 

람다식을 사용한 간결화

 

 람다식을 활용하여 Comparator의 구현체를 만들어 바로 넘기는 것이 훨씬 간단하다.

람다식을 만드는 방법에 대해선 게시글 (람다식(Lambda Expression)) 참고

 

members.sort((m1, m2) -> 
    m1.getName().equals(m2.getName()) ?
        m1.getAge() - m2.getAge() :
        m1.getName().compareTo(m2.getName()));

 

 위의 코드는 컴파일 단계에서 형식 추론을 통해 처리된다.

 컴파일러가 sort 메서드에 Comparator의 구현체를 입력받는 경우가 있다는 사실을 안 뒤, 함수형 인터페이스인 Comparator의 추상 메서드 compare의 시그니처(함수 디스크립터)가 (T, T) -> int임을 참조, T가 Member 클래스의 인스턴스임을 추론한다.

 삼항 연산자로 표현된 식은 기존과 같은 맥락이며 이름이 같다면 나이를 비교하고 아니라면 이름을 비교한다.

 

 


 

 

메서드 참조와 유틸리티 메서드 사용하기

 

 람다식을 사용하긴 하였지만 여기서 마지막 한 단계가 남았다. 바로 유틸리티 메서드와 메서드 참조를 통해 가독성을 챙기는 일이다.

 함수형 인터페이스인 Comparator는 추상 메서드인 compare 외에 몇몇 유틸리티 메서드를 추가로 가지고 있다. 이런 메서드들은 정적 호근 디폴트 메서드로 추상 메서드가 아니므로 함수형 인터페이스의 정의를 해치지 않는다.

 

정적 메서드인 comparing과 디폴트 메서드인 thenComparing만 가볍게 살펴보자.

 

// java.util.Comparator의 comparing
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));
}

 

복잡해 보이는 정적 메서드 comparing는 비교에 사용될 값을 가져오는 메서드를 입력해주면 그것을 기반으로 하는 Comparator를 알아서 만들어 반환해준다. 여기서는 Member 클래스의 getName(), getAge() 등을 keyExtractor로 활용할 수 있다.

 

// java.util.Comparator의 thenComparing
default <U extends Comparable<? super U>> Comparator<T> thenComparing(
        Function<? super T, ? extends U> keyExtractor)
{
    return thenComparing(comparing(keyExtractor));
}

 

디폴트 메서드인 thenComparing은 마찬가지로 keyExtractor를 받아 comparing 메서드를 호출함으로써 Comparator 객체를 얻어낸다.

 

 

 Member의 getter의 메서드 참조를 통해 위의 두 메서드에 넣어주어 최종적으로 아래의 형태를 얻을 수 있다.

 

// comparing을 static import
members.sort(comparing(Member::getName)
    .thenComparing(Member::getAge));

 

기존의 람다식보다 의미가 명확하게 이해되지 않는가?

'members를 정렬할건데, 먼저 멤버의 이름을 비교하고, 이후에 (이름이 같다면)멤버의 나이를 비교해줘' 라는 의미가 명확하다.

 

추가적으로 역순 정렬을 해야 한다면 디폴트 메서드인 reversed를 이용하면 편리하다. Collections의 reverseOrder를 통해 순서를 뒤집는다.

 

// comparing을 static import
members.sort(comparing(Member::getName)
    .reversed()
    .thenComparing(Member::getAge));

 

이렇게 된다면 이름을 사전순의 반대로(z부터) 정렬하고 같다면 나이는 오름차순으로 정렬한다.

간단히 메서드를 체이닝하는 것으로 의도를 명확히 밝힐 수 있다.


 

 

 Comparator의 직접 구현부터 익명 객체를 통해 내제화, 이후 람다로 변경한 뒤 다시 Comparator의 유틸리티 메서드들을 통한 가독성을 끌어올리는 모든 과정을 수행하였다.

 정렬 자체는 굉장히 빈번히 사용되는 개념이나 자바의 경우 Comparator를 구성함에 있어서 제네릭, 익명 객체, 람다식과 함수형 인터페이스의 유틸리티 메서드 등 다양한 개념들을 알아야 해 배경 지식이 부족하다면 모든 과정을 완전히 이해하기는 어려울 수 있다. 그러나 이들 모두 정렬 이외의 다양한 곳에서 편의를 위해 사용되는 개념들이므로 꼭 시간을 들여 정확히 이해하자.

 

반응형