본문 바로가기

개발 서적/모던 자바 인 액션

[모던 자바 인 액션] chapter 3. 람다 표현식(2)

 

 


[목차]
chapter 1. 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가?
chapter 2. 동작 파라미터화 코드 전달하기
chapter 3. 람다 표현식(1)
chapter 3. 람다 표현식(2)


 

 

 

(이전 내용 요약)

유연한 구조를 가져가기 위한 전략 : 동적 파라미터화 → 함수형 인터페이스 → 익명 클래스 → 람다 표현식 → 형식 추론

 

3.6 메서드 참조

메서드 참조는 자바 8 코드의 또 하나의 새로운 기능. 람다 표현식이 단 하나의 메소드만을 호출하는 경우에 해당 람다 표현식에서 불필요한 매개변수를 제거하고 사용할 수 있도록 해준다.

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 람다 표현식보다 더 가독성이 좋으며 자연스러울 수 있다.

// 기존의 람다 표현식
inventory.sort((Apple a1, Apple a2) 
	-> a1.getWeight().compareTo(a2.getWeight()));

// 메서드 참조와 java.util.Comparator.comparing을 활용한 코드
inventory.sort(comparing(Apple::getWeight));

요약

메서드 참조가 왜 중요한가?

→ 메서드 참조를 이용하면 기존 메서드 구현으로 람다 표현식을 만들 수 있다. 명시적으로 메서드명을 참조함으로써 가독성을 높일 수 있다.

메서드 참조는 어떻게 활용할까?

→ 메서드명 앞에 구분자(::)를 붙이는 방식으로 활용한다.

Apple::getWeight는 Apple 클래스에 정의된 getWeight의 메서드 참조임

→ 결과적으로 람다 표현식 (Apple a) → a.getWeight()를 축약한 것임

메서드 참조를 새로운 기능이 아니라 하나의 메서드를 참조하는 람다를 편리하게 표현할 수 있는 문법으로 간주할 수 있다. 메서드 참조를 이용하면 같은 기능을 더 간결하게 구현할 수 있다.

메서드 참조는 세 가지 유형으로 구분할 수 있다.

  1. 정적 메서드 참조
  2. ex) Integer.parseInt() → Integer::parseInt
  3. 다양한 형식의 인스턴스 메서드 참조
  4. ex) String의 length() → 람다로 표현하면 (String s) -> s.length() → 메서드 참조로 표현하면 String::length
  5. 기존 객체의 인스턴스 메서드 참조
  6. ex) (Transaction) expensiveTransaction.getValue() → 람다로 표현하면 () -> expensiveTransaction.getValue() → 메서드 참조로 표현하면 expensiveTransaction::getValue

세 번째 유형의 메서드 참조는 비공개 헬퍼 메서드를 정의한 상황에서 유용하게 활용할 수 있다.

 

헬퍼 메소드?

→ A helper method is used to perform a particular repetitive task common across multiple classes.

 

What exactly is a helper method? (Example) | Treehouse Community

Participate in discussions with other Treehouse members and learn.

teamtreehouse.com

// 헬퍼 메소드
private boolean isValidName(String string) {
	return Character.isUpperCase(string.charAt(0));
}

// 익명 클래스
filter(list, new Predicate<String>() {
            @Override
            public boolean test(String s) {
                return isValidName(s);
            }
        });

// 람다 표현식
filter(list, (String s) -> isValidName(s));

// 메서드 참조
filter(list, this::isValidName);

→ Predicate를 필요로 하는 적당한 상황에서 메서드 참조를 사용할 수 있음. 더 간결해짐.

생성자, 배열 생성자, super 호출 등에 사용할 수 있는 특별한 형식의 메서드 참조도 있다.

예제를 통해 메서드 참조 활용법을 확인하자.

// List에 포함된 문자열을 대소문자를 구분하지 않고 정렬하는 예제
// List의 sort()는 인수로 Comparator를 기대함
// Comparator는 (T, T) → int라는 함수 디스크립터를 가짐
List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));

람다 표현식을 메서드 참조를 사용해서 다음처럼 줄일 수 있다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort(String::compareToIgnoreCase);

컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷한 과정으로 메서드 참조가 주어진 함수형 인터페이스와 호환하는지 확인한다. 즉, 메서드 참조는 콘텍스트의 형식과 일치해야 한다.

생성자 참조

기존의 메서드 구현을 재활용하는 방법에 이어, 이번에는 클래스의 생성자를 이용해서 참조를 만드는 방법을 알아본다.

ClassName::new 처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다. 정적 메서드의 참조를 만드는 방법과 유사함.

예를 들어 인수가 없는 생성자, 즉 Supplier의 () → Apple과 같은 시그니처를 갖는 생성자가 있다고 가정하자.

// Supplier.java
@FunctionalInterface
public interface Supplier<T> {
    T get(); // 매개변수를 받지 않고, T를 반환하는 추상 메서드. () -> T
}

// 람다 표현식
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

// 생성자 참조
Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

Apple(Integer weight)라는 시그니처를 갖는 생성자는 Function 인터페이스의 시그니처와 같다.

// Function.java
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t); // (T) -> R
}

// 람다 표현식
Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

// 생성자 참조
Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);

Apple(String color, Integer weight)처럼 두 인수를 갖는 생성자는 BiFunction 인터페이스와 같은 시그니처를 가지므로 다음처럼 할 수 있다.

// BiFunction.java
@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u); // (T, U) -> R
}

// 람다 표현식
BiFunction<Color, Integer, Apple> c3 = (color, weight) -> new Apple(color, weight);
Apple a3 = c3.apply(GREEN, 110);

// 생성자 참조
BiFunction<Color, Integer, Apple> c3 = Apple::new;
Apple a3 = c3.apply(GREEN, 110);

인스턴스화하지 않고도 생성자에 접근할 수 있는 기능을 다양한 상황에 응용할 수 있다.

static Map<String, Function<Integer, Fruit>> map = new HashMap<>();
static {
	map.put("apple", Apple::new);
	map.put("orange", Orange::new);
}

public static Fruit giveMeFruit(String fruit, Integer weight) {
	return map.get(fruit.toLowerCase())
            .apply(weight);
}

→ 핵심은 람다 표현식 처럼 함수 디스크립터와 같은 시그니처를 가진 경우에 참조가 가능하다는 것!

 

3.7 람다, 메서드 참조 활용하기

사과 리스트 정렬 문제를 해결하면서 지금까지 배운 동작 파라미터화, 익명 클래스, 람다 표현식, 메서드 참조 등을 총동원한다.

1단계: 코드 전달

자바 8의 List API에서 sort메서드를 제공한다. sort()는 다음과 같은 시그니처를 갖는다.

void sort(Comparator<? super E> c) -> Comparator 객체를 인수로 받아 두 사과를 비교

객체 안에 동작을 포함시키는 방식으로 다양한 전략을 전달할 수 있다. sort에 전달된 정렬 전략에 따라 sort의 동작이 달라진다.

→ sort의 동작은 파라미터화 되었음

// sort에 전달할 정렬 전략을 가진 Comparator 구현 클래스
public class AppleComparator implements Comparator<Apple> {
	public int compare(Apple a1, Apple a2) {
		return a1.getWeight().compareTo(a2.getWeight());
	}
}

inventory.sort(new AppleComparator());

2단계: 익명 클래스 사용

한 번만 사용할 Comparator라면, 구현하는 것보다 익명 클래스를 이용하는 것이 더 좋다. 익명 클래스는 선언과 인스턴스화를 동시에 할 수 있기 때문이다.

inventory.sort(new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2) {
		return a1.getWeight().compareTo(a2.getWeight());
	}
});

3단계: 람다 표현식 사용

자바 8에서 제공하는 람다 표현식이라는 경량화된 문법을 이용해 더 간결한 코드를 전달할 수 있다.

람다 표현식은 함수형 인터페이스를 기대하는 곳이라면 어디서나 사용 가능하다.

함수형 인터페이스란 오직 하나의 추상 메서드를 정의하는 인터페이스다.

추상 메서드의 시그니처(함수 디스크립터)는 람다 표현식의 시그니처를 정의한다.

// Comparator의 함수 디스크립터는 (T, T) -> int
// 따라서 람다 표현식의 시그니처는 (Apple, Apple) -> int
inventory.sort((Apple a1, Apple a2) -> 
	a1.getWeight().compareTo(a2.getWeight()
);

자바 컴파일러는 람다 표현식이 사용된 콘텍스트를 활용해서 람다의 파라미터 형식을 추론할 수 있다.

inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

이 코드의 가독성을 더 향상시킬 수 없을까?

→ Comparator의 정적 메서드 comparing을 사용

// comparing의 시그니처
// Comparable 키를 추출해서 Comparator 객체로 만드는 Function을 인수로 받음
// 함수 디스크립터 : (Function<T, U>) -> Comparator<T>
// Function의 함수 디스크립터 : (T) -> U
// 이 예제에서 T는 Apple, U는 (apple의 getWeight()의 리턴타입)의 상위 타입(?)
public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
            Function<? super T, ? extends U> keyExtractor
)

Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());
  • 제네릭의 ?(와일드카드)의 역할
    • 제네릭 타입<?>: Unbounded Wildcards(제한 없음)
    • 타입 파라미터를 대치하는 구체적인 타입으로 모든 클래스나 인터페이스 타입이 올 수 있습니다.
    • 제네릭 타입<? extends 상위 타입>: Upper Bounded Wildcards(상위 클래스 제한)
    • 타입 파라미터를 대치하는 구체적인 타입으로 상위 타입이나 하위 타입이 올 수 있습니다.
    • 제네릭 타입<? Super 하위 타입>: Lower Bounded Wildcards(하위 클래스 제한)[Java] 제네릭(generic) - 제한된 타입 파라미터, 와일드카드 타입
    • 타입 파라미터를 대치하는 구체적인 타입으로 하위 타입이나 상위타입이 올 수 있습니다.
  • 제네릭 타입을 매개값이나 리턴 타입으로 사용할 때 구체적인 타입 대신 와일드 카드를 다음과 같이 세 가지 형태로 사용할 수 있습니다.

다음처럼 간소화할 수 있다.

import static java.util.Comparator.comparing;

inventory.sort(comparing(apple -> apple.getWeight()));
  • Compartor는 함수형 인터페이스지만 단순히 추상 메서드 하나 달랑 가지고 있는게 아니었음
  • 자바8에서 지원하기 시작한 default 메소드, static 메소드를 이용해 다양한 기능 제공
  • 그 중 하나가 comparing()
  • 다중 조건 정렬을 위해 체이닝 형식으로 사용할 수 있는 메소드 thenComparing() 등을 제공
  • Comparator<String> c = Comparator.comparing((String s) -> s.length()) .thenComparing((String s) -> s); java 8 Compartor의 기능들
 

Java 8 Comparator

이번에는 Comparator interface에 대해서 다룹니다. 정렬을 하기 위해서 구현하는 클래스 이면 Collection.sort()를 통하여 list를 소팅하거나, TreeMap같이 정렬이 필요한 자료구조에 Comparable과 같이 구현하

tourspace.tistory.com

4단계: 메서드 참조 사용

메서드 참조를 이용하면 람다 표현식의 인수를 더 깔끔하게 전달할 수 있다.

inventory.sort(comparing(Apple::getWeight));

최적의 코드가 완성됐다!

코드만 짧아진 것이 아니라 코드 자체로 ‘Apple을 weight별로 비교해서 inventory를 sort하라'는 의미를 전달할 수 있게 되었다.

 

3.8 람다 표현식을 조합할 수 있는 유용한 메서드

자바 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 포함한다. 간단한 여러 개의 람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있다. 또한 한 함수의 결과가 다른 함수의 입력이 되도록 두 함수를 조합할 수 있다.

→ 디폴트 메서드(default method)의 등장으로 함수형 인터페이스의 정의를 벗어나지 않고도 추가로 메서드를 제공할 수 있음

Comparator 조합

Comparator.comapring()

비교에 사용할 키를 추출하는 Function 기반의 Comparator를 반환하는 static 메서드

Comparator<Apple> = Comparator.comparing(Apple::getWeight);

역정렬: reversed()

인터페이스 자체에서 주어진 비교자의 순서를 뒤바꾸는 default 메서드

default Comparator<T> reversed() {
	return Collections.reverseOrder(this);
}

inventory.sort(comapring(Apple::getWegiht).reversed());

Comparator 연결: thenComparing()

다중 조건 정렬이 필요할 때 다수의 비교자를 객체에 전달하는 default 메소드

default <U extends Comparable<? super U>> Comparator<T> thenComparing(Function<? super T, ? extends U> keyExtractor);

inventory.sort(comparing(Apple::getWeight)
				 .reversed()
				 .thenComparing(Apple::getCountry));

Predicate 조합

negate()

프레디케이트의 조건을 반전시키는 default 메소드

default Predicate<T> negate() {
	return (t) -> !test(t);
}

// 빨간색인 사과 → 빨간색이 아닌 사과
Predicate<Apple> notRedApple = redApple.negate();

and(), or()

두 프레디케이트를 연결해서 새로운 프레디케이트 객체를 만드는 default 메소드

default Predicate<T> and(Predicate<? super T> other) {
	Objects.requireNonNull(other);
	return (t) -> test(t) && other.test(t);
}

default Predicate<T> or(Predicate<? super T> other) {
	Objects.requireNonNull(other);
	return (t) -> test(t) || other.test(t);
}

// 빨간색인 사과 -> 빨간색이면서 무거운 사과
Predicate<Apple> redAndHeavyApple = 
		redApple.and(apple -> apple.getWeight() > 150);

// 빨간색이면서 무거운 사과 또는 그냥 녹색 사과
Predicate<Apple> redAndHeavyApple = 
		redApple.and(apple -> apple.getWeight() > 150)
						.or(apple -> GREEN.equals(a.getColor()));

단순 람다 표현식을 조합해서 더 복잡한 람다 표현식을 만들 수 있다는 것. 심지어 람다 표현식을 조합해도 코드 자체가 문제를 잘 설명하고 있다는 것이 중요.

Function 조합

andThen()

주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 함수(Function)를 반환하는 default 메소드

default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
	Objects.requireNonNull(after);
	return (T t) -> after.apply(apply(t));
}

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g); // h(x) = g(f(x))
int result = h.apply(1) // result = 4

compose()

인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공하는 default 메소드

default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
	Objects.requireNonNull(before);
	return (V v) -> apply(before.apply(v));
}

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.compose(g); // h(x) = f(g(x))
int result = h.apply(1) // result = 3

여러 유틸리티 메서드를 조합해서 다양한 변환 파이프라인을 만들 수 있음

반응형