...
Comparator 람다식 리팩토링 해보기
다음은 실제로 자바 프로그래밍에서 배열을 정렬(sort) 할때 사용되는 Comparator 인터페이스 사용 예제이다.
Apple 클래스가 있고 생성자 인자로 사과의 무게(weight)값을 받는다. 그리고 실행부에서 배열로 사과 객체를 담고, 사과 무게에 따라 배열 요소들을 정렬하려고 한다. 이를 코드로 구현하면 아래와 같이 구현할 수 있다.
class Apple {
private final int weight; // 사과 무게
public Apple(int weight) {
this.weight = weight;
}
public int getWeight() {
return weight;
}
@Override
public String toString() {
return "Apple{" + "weight=" + weight + '}';
}
}
public class Main {
public static void main(String[] args) {
Apple[] inventory = new Apple[] {
new Apple(34),
new Apple(12),
new Apple(76),
new Apple(91),
new Apple(55)
};
Arrays.sort(inventory, new Comparator<Apple>() {
@Override
public int compare(Apple o1, Apple o2) {
return Integer.compare(o1.getWeight(), o2.getWeight());
}
});
System.out.println(Arrays.toString(inventory));
}
}
배열을 출력해보니, 사과 객체들이 무게에 따라 순서대로 정렬됨을 볼 수 있다.
하지만 인텔리제이와 같은 IDE를 쓴다면, Comparator 익명 구현 클래스를 람다표현식으로 리팩토링 하라고 조언을 해줄 것이다.
그런데 람다식으로 변환했더니 이번엔 또 변환하라고 IDE에서 조언해준다.
변환해보니 확실히 기존 람다식 보단 코드 줄이 확연히 줄었음을 볼 수 있다. 그런데 갑자기 쌩뚱맞게 Comparator의 정적 메소드를 호출하고 그 안에는 람다의 메서드 참조 문법을 써 놓았다. 대체 어떤 원리로 람다표현식을 이렇게 축약을 할 수 있는지 가늠이 안간다.
실제로 이는 실전에서 정말 많이 쓰이는 기법인데, 단순하게 암기하고 넘기지 말고 왜 저런식으로 줄일 수 있는지 지금부터 하나하나 파헤쳐 보자.
Comparator의 축약 원리 파헤치기
우선 해당 람다식은 메소드 참조로 문법적으로 생략이 불가능 하다.
Arrays.sort(inventory, (o1, o2) -> Integer.compare(o1.getWeight(), o2.getWeight()));
만일 (a1, a2) -> Integer.compare(a1, a2) 처럼 되어있으면 Integer::compare 로 생략할 수 있었겠지만, 매개변수가 Integer.compare 메서드의 인자로 그대로 들어간게 아니라 별도로 메서드를 호출하고 있기 때문이다.
그러면 영원히 코드 식을 줄일수 없는것 인가?
생각을 환기 시켜보면 해답은 간단하다. 바로 람다 함수 자체를 메서드가 반환하도록 만들면 된다.
람다 함수식은 일급 객체로 취급된다. 일급 객체의 특징으로는 변수에 함수를 할당할수 있고 함수를 리턴값으로도 활용할 수 있다. 대표적으로 자바스크립트의 함수가 변수에 넣거나 자체 함수를 반환하는 기법을 사용한다.
즉, 이러한 특성을 이용해 별도의 정적 메서드를 만들고, 그 메서드가 특정 람다 함수식을 리턴하도록 해주면, 길다란 람다식을 단순히 메서드 호출 코드로 줄일수 있는 것이다. 그래서 우선 간단하게 프로토타입으로 람다식을 반환하는 정적 메서드를 작성해보면 다음과 같이 되겠다.
public class Main {
public static Comparator<Apple> comparingInt() {
// 람다식을 Comparator 인터페이스 타입으로 반환
return (a1, a2) -> Integer.compare(a1.getWeight(), a2.getWeight());
}
public static void main(String[] args) {
Apple[] inventory = new Apple[]{
new Apple(34),
new Apple(12),
new Apple(76),
new Apple(91),
new Apple(55)
};
Arrays.sort(inventory, comparingInt()); // 단순한 메서드 호출로 생략함 (가독성 ↑)
System.out.println(Arrays.toString(inventory));
}
}
구현한 메서드를 좀 더 사용성 좋게 바꿔보자. 지금은 Apple 객체의 weight 값만 다루니까 저렇게 작성해도 되지만, 나중에 Banana 객체의 prize 값으로 정렬할수도 있는 기능을 확장할 수 도 있다. 따라서 외부에 의해 바뀔수 있는 부분인 a.getWeight() 부분을 매개변수화 하여 처리하도록 하면 된다.
그런데 a.getWeight() 는 단순히 데이터가 아닌 함수를 호출하는 코드 로직이다. 이를 어떻게 매개변수화 하면 되는 것일까?
우리는 자바 람다에 대해 배우고 있다. 파라미터로 들어올 값은 반드시 정수나 문자열과 같은 고정된 데이터만 들어오라는 법은 없다. 즉, a.getWeight() 를 리턴하는 람다식으로 매개변수화 하여 함수형 인터페이스로 받으면 되는 것이다.
이제 어떠한 함수형 인터페이스 타입을 매개변수의 타입으로 정해야 할지 고민해야 되는데, Apple 타입의 객체를 파라미터로 받아 int 값인 무게를 반환하기 때문에 여기에 적절한 함수형 인터페이스는 ToIntFunction<Apple> 이다.
public static Comparator<Apple> comparingInt(ToIntFunction<Apple> keyExtractor) {
// 매개변수로 람다식을 받아서 추상 메서드 실행 : keyExtractor.applyAsInt(a1) == a1.getWeight()
return (a1, a2) -> Integer.compare(keyExtractor.applyAsInt(a1), keyExtractor.applyAsInt(a2));
}
마지막으로 다른 제네릭 타입도 받을수 있게 타입 파라미터를 T 기호로 바꿔주면 완성이다.
그리고 (a) -> a.getWeight() 람다식은 메서드 참조로 Apple::getWeight 로 축약이 가능하니 최종 완성본은 다음과 같다.
public class Main {
public static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) {
return (a1, a2) -> Integer.compare(keyExtractor.applyAsInt(a1), keyExtractor.applyAsInt(a2));
}
public static void main(String[] args) {
Apple[] inventory = new Apple[]{
new Apple(34),
new Apple(12),
new Apple(76),
new Apple(91),
new Apple(55)
};
Arrays.sort(inventory, comparingInt(Apple::getWeight)); // (a) -> a.getWeight()
System.out.println(Arrays.toString(inventory));
}
}
실제 Comparator와 비교하기
실제 자바에 정의되어 있는 Compator 인터페이스의 comparingInt() 메서드 시그니처를 보면 지금까지 우리가 구현한 것과 별반 차이가 없는 것을 볼 수 있다.
Arrays.sort(inventory, Comparator.comparingInt(Apple::getWeight));
이런식으로 람다식이 오히려 코드의 가독성을 해친다고 생각하면 입글 객체의 특성을 이용하여 메서드로 람다식을 반환하도록 구성해줌으로써 코드식이 단순히 메서드 호출로 구성해준다면 보다 클린한 리팩토링 코드가 될 것이다.
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.