모던 자바 인 액션 - 람다

 

Chapter3: 람다

이 장에서는 다음 항목을 설명한다.
  • 람다 표현식을 어떻게 만드는가
  • 어떻게 사용하는가
  • 어떻게 코드를 간결하게 만들 수 있는가
  • 자바 8 API에서 추가된 중요한 인터페이스와 형식 추론 등의 기능
  • 람다 표현식과 함께 위력을 발휘하는 메서드 참조

1. 람다란 무엇인가?

람다 표현식 은 메서드로 전달할 수 있는 익명 함수를 단순화한 것

람다의 특징
  • 익명 보통의 메서드와 달리 이름이 없으므로 익명이라 표현한다.
  • 함수 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 한다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.
  • 전달 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.
람다 표현식
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 파라미터 리스트: 메서드 파라미터
  • 화살표: 람다의 파라미터 리스트와 바디를 구분
  • 람다 바디: 람다의 반환값에 해당하는 표현식

람다의 기본 문법은 (parameters) -> expression, (parameters) -> { statements; } 로 표현한다.

2. 어디에 어떻게 람다를 사용할까?

함수형 인터페이스

함수형 인터페이스 는 정확히 하나의 추상 메서드를 지정하는 인터페이스

ex) 함수형 인터페이스의 예

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

public interface Runnable {
    void run();
}

public interface ActionListner extends EventListner {
    void actionPerformed(ActionEvent e);
}

public interface interface Callable<V> {
    V call() throws Exception;
}
...

추상 메서드가 오직 하나라면 많은 디폴트 메서드를 포함할 수 있다.

람다 표현식은 함수형 인터페이스의 추상 메서드를 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급 할 수 있다.

함수 디스크립터

  • 함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가리킨다.
  • 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스트립터 라고 부른다.
  • 람다가 함수형 인터페이스의 시그니처와 같은 시그니처를 같는 다는 것을 기억하자.
@FunctionalInterface 어노테이션

해당 어노테이션이 붙은 인터페이스는 함수형 인터페이스임을 가리킨다. 만약 @FunctionalInterface로 선언했는데 실제로 함수형 인터페이스를 만족하지 않으면 컴파일러는 에러를 발생시킨다.

오직 추상 메서드가 하나인 인터페이스에 적용가능하다.

3. 람다 활용: 실행 어라운드 패턴

람다와 동작 파라미터화 코드로 유연하고 간결한 코드를 구현하는 데 도움을 주는 실용적인 예제를 살펴본다.

실행 어라운드 패턴

실행어라운드

public String processFile() throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return br.readLine(); // <- 실제 필요한 작업을 하는 행
    }
}
  • 실제 자원을 처리하는 코드를 설정과 정리 과정이 둘러싸는 형태를 갖는다.
  • try-with-resources 구문을 사용하여 자원을 명시적으로 닫지 않아도 된다.

1단계: 동작 파라미터화를 기억하라

기존의 설정, 정리과정은 재사용하고 processFile 메서드의 동작을 다르게 수행할 수 있으면 어떻게 할까?

-> 동작을 파라미터화 한다.

한번에 두 행을 출력하는 코드 - BufferedReader를 인수로 받아 String을 반환하는 람다가 필요하다.
String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());

2단계: 함수형 인터페이스를 이용해서 동작 전달

BufferedReader -> String과 IOException을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들어야 한다.

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}
public String processFile(BufferedReaderProcessor p) throws IOException {
...
}

3단계: 동작 실행

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접전달할 수 있으며 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리한다.

public String processFile(BufferedReaderProcessor p) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return p.process(br);
    }
}

4단계: 람다 전달

람다를 이용해서 다양한 동작을 processFile 메서드에 전달할 수 있다.

String oneLine = processFile((BufferedReader br) -> br.readLine());
String twoLines = processFile((BufferedReader br) -> br.readLine() + br.readLine());

4. 함수형 인터페이스 사용

다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다.

자바 8 라이브러리에서 java.util.function 패키지는 여러 가지 새로운 표준 함수형 인터페이스를 제공한다.

1) Predicate

java.util.function.Predicate<T> 인터페이스는 test라는 추상 메서드를 정의하며, 제네릭 T를 인수로 받아 불리언을 반환한다. T 형식의 객체를 사용하는 불리언 표현식이 필요한 상황에서 Predicate 인터페이스를 사용할 수 있다.

String이 비어있지 않으면 리스트에 담아 반환하는 예제
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

public <T> List<T> filter(List<T> list, Predicate<T> p) {
    List<T> results = new ArrayList<>();
    for(T t: list) {
        if(p.test(T)){
            results.add(t);
        }
    }
    return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);

2) Consumer

java.util.function.Consume<T> 인터페이스는 제네릭 T를 인수로 받아 void를 반환하는 accept라는 추상 메서드를 정의한다. T 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을때 Consumer 인터페이스를 사용할 수 있다.

forEach와 람다를 이용해서 리스트의 모든 항목을 출력하는 예제
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

public <T> void forEach(List<T> list, Consumer<T> c) {
    for(T t: list) {
        c.accept(t);
    }
}
forEach(Arrays.asList(1,2,3,4,5), (Integer i) -> System.out.println(i));

3) Function

java.util.function.Function<T, R> 인터페이스는 제네릭 T를 인수로 받아 제니릭 형식 R 객체를 반환하는 추상 메서드 apply를 정의한다. 입력을 출력으로 매핑하는 람다를 정의할 때 Function 인터페이스를 활용할 수 있다.

String 리스트를 인수로 받아 각 String의 길이를 포함하는 Integer 리스트로 변환하는 map 메서드를 정의하는 예제
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

public <T, R> List<R> map(List<T> list, Function<T, R> f) {
    List<R> result = new ArrayList<>();
    for(T t: list) {
        result,add(f.apply(t));
    }
    return result;
}

List<Integer> l = map(ArrayList("lambdas", "in", "action"),
                       (String s) -> s.length());

기본형 특화

자바의 모든 형식은 참조형아니면 기본형에 해당한다. 하지만 제네릭 파라미터에는 참조형만 사용할 수 있다. 그래서 자바에서는 기본형을 참조형으로 변환하는 기능을 제공하며, 이 기능은 기본형->참조형으로 변환하는 박싱, 참조형->기본형으로 변환하는 언박싱이다. 이 과정은 자동으로도 이루어지며 이를 오토박싱 이라고 한다.

하지만 박싱, 언박싱 과정은 비용이 소모되고 이에 자바 8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.

일반적으로 특정 형식을 받는 함수형 인터페이스의 이름 앞에는 DoublePredicate, IntConsumer, LongBinaryOperator, IntFunction 처럼 형식명이 붙는다.

5. 형식 검사, 형식 추론, 제약

형식 검사

  • 람다가 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다.
  • 어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식(target type) 이라고 부른다.
코드 형식 확인 과정
List<Apple> heavierThan150g =
filter(inventory, (Apple apple) -> apple.getWeight() > 150);
  1. filter 메서드의 선언을 확인한다.
  2. filter 메서드는 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대한다.
  3. Predicate<Apple> 은 test 라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스다.
  4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
  5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.

같은 람다, 다른 함수형 인터페이스

대상 형식이라는 특징 때문에 같은 람다 표현식이라도 호환되는 추상 메서드를 가진 다른 함수형 인터페이스로 사용될 수 있다.

Callable<Integer> c = () -> 42;
PrivilegedAction<Integer> () -> 42;

형식 추론

컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 형식을 생략할 수 있다.

// 형식을 추론하지 않음
Comparator<Apple> c =
    (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
// 형식을 추론함
Comparator<Apple> c =
    (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 상황에 따라 형식을 명시하거나 생략할 수도 있다.
  • 개발자 스스로가 어떤 코드가 가독성을 향상시키는 지 결정해야 한다.

지역 변수 사용

람다 캡처링: 람다 표현식에서는 익명 함수가 하는 것 처럼 자유 변수(free variable) 를 활용할 수 있다.

람다에서 사용하는 자유 변수는 final로 선언되었거나 fianl 처럼 사용되는 변수만 가능하다.

에러 - 컴파일 할 수 없는 코드
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337; // 변수에 값을 두 번 할당하므로 컴파일할 수 없다.

지역 변수의 제약

  • 람다가 지역 변수에서 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되어도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다.
  • 직접 접근을 허용하는 것이 아니라 복사본을 제공해야 한다.
  • 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생겼다.
  • 지역 변수의 제약 때문에 외부 변수를 변화시키는 일반적인 명령형 프로그래밍 패턴에 제동을 걸 수 있다.

6. 메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.

// 기존 코드
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 메서드 참조
inventory.sort(comparing(Apple::getWeight));

요약

  • 메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다.
  • 명시적으로 메서드명을 참조함으로써 가독성을 높일 수 있다.
  • 활용: 클래스::메서드 방식으로 메서드 참조를 활용할 수 있다.

메서드 참조를 만드는 방법

  1. 정적 메서드 참조
    • ex) Integer::parseInt
  2. 다양한 형식의 인스턴스 메서드 참조
    • ex) String::length
  3. 기존 객체의 인스턴스 메서드 참조
    • ex) Transaction 객체를 할당받은 expensiveTransaction의 getValue 메서드 expensiveTransaction::getValue
세 가지 종류의 람다 표현식을 메서드 참조로 바꾸는 방법
  1. (args) -> ClassName.staticMethod(args)

    ClassName::staticMethod

  2. (args, rest) -> arg0.instanceMethod(rest)

    ClassName::instanceMethod

  3. (args) -> expr.instanceMethod(args)

    expr::instanceMethod

메서드 참조는 콘텍스트의 형식과 일치해야 한다.

생성자 참조

ClassName::new 처럼 클래스명과 new 키워드를 이용해0서 기존 생성자의 참조를 만들 수 있다.

Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();
Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);
BiFunction<String, Integer, Apple> c3 = Apple::new;
Apple a3 = c3.apply(GREEN, 110);

BiFunction<String, Integer, Apple> c3 = (color, weight) -> new Apple(color, weight);
Apple a3 = c3.apply(GREEN, 110);

7. 람다 메서드 참조 활용하기

처음의 사과 리스트를 다양한 정렬하는 기법에서 동작 파라미터화, 익명 클래스, 람다 표현식, 메서드 참조 등을 총동원한다.

1단계: 코드 전달

sort 메서드의 시그니처 void sort(Comparator<? super E> c)

‘sort의 동작은 파라미터화 되었다.’

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단계: 람다 표현식 사용

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
Comparator의 Function 정적 메서드 comparing 사용
import static java.util.Comprator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));

4단게: 메서드 참조 사용

코드를 더 간소화할 수 있다.

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

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

  • 자바 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 포함한다.

  • 간단한 여러 개의 람다 표현식을 조합해 복잡한 람다 표현식을 만들 수 있다.
  • 디폴트 메서드라는 것으로 가능하다.

Comparator 조합

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

역정렬

주어진 비교자의 순서를 뒤바꾸는 reverse라는 디폴트 메서드를 제공한다.

// 무게를 내림차순으로 정렬
inventory.sort(comparing(Apple::getWeight).reversed());

Comparator 연결

무게가 같을 때 또 다른 기준으로 정렬하고 싶을 때

thenComparing 메서드로 두 번째 비교자를 만든다.

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

Predicate 조합

Predicate 인터페이스는 복잡한 프레디케이트를 위해 negate, and, or 세 가지 메서드를 제공한다.

// 기존 프레디케이트 객체 redApple의 결과를 반전시킨 객체를 만든다.
Predicate<Apple> notRedApple = redApple.negate();
// 두 프레디케이트를 연결해서 새로운 프레디케이트 객체를 만든다.
Predicate<Apple> redAndHeavyApple = 
redApple.and(apple -> apple.getWeight() > 150);
// or을 이용해서 다양한 조건을 만든다.
Predicate<Apple> redAndHeavyAppleOrGreen = 
redApple.and(apple -> apple.getWeight() > 150)
        .or(apple -> GREEN.equals(a.getColor()));

이것이 대단한 일인 이유는 단순한 람다 표현식을 조합해 복잡한 람다 표현식을 만들 수 있고, 표현식을 조합해도 코드 자체가 문제를 잘 설명한다.

Function 조합

Function 인스턴스를 반환하는 andThen, compose 두 가지 디폴트 메서드를 제공한다.

  • andThen 메서드는 주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 함수를 반환한다.

    Function<Integer, Integer> f = x -> x + 1;
    Function<Integer, Integer> g = x -> x * 2;
    Function<Integer, Integer> h = f.andThen(g);
    int result = h.apply(1); // 4를 반환
    
  • compose 메서드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공한다.

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

9. 비슷한 수학적 개념

수학적 관심이 있으면 책을 살펴보자. pass…

10. 마치며

  • 람다 표현식은 익명 함수의 일종이다. 이름은 없지만, 파라미터 리스트, 바디, 반환 형식을 가지며 예외를 던질 수 있다.
  • 람다 표현식으로 간결한 코드를 구현할 수 있다.
  • 함수형 인터페이스는 하나의 추상 메서드만을 정의하는 인터페이스다.
  • 함수형 인터페이스를 기대하는 곳에서만 람다 표현식을 사용할 수 있다.
  • 람다 표현식을 이용해서 함수형 인터페이스의 추상 메서드를 즉석으로 제공할 수 있으며 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급된다.
  • java.util.function 패키지는 자주 사용하는 다양한 함수형 인터페이스를 제공한다.
  • 자바 8은 제네릭 함수형 인터페이스와 관련한 박싱 동작을 피할 수 있는 기본형 특화 인터페이스도 제공한다.
  • 실행 어라운드 패턴을 람다와 활용하면 유연성과 재사용성을 추가로 얻을 수 있다.
  • 람다 표현식의 기대 형식을 대상 형식이라고 한다.
  • 메서드 참조를 이용하면 기존의 메서드 구현을 재사용하고 직접 전달할 수 있다.
  • Comparator, Predicate, Function 같은 함수형 인터페이스는 람다 표현식을 조합할 수 있는 다양한 디폴트 메서드를 제공한다.