Today I Learned

[모던 자바 인 액션] 3장. 람다 표현식 본문

JAVA & Spring/모던 자바 인 액션

[모던 자바 인 액션] 3장. 람다 표현식

하이라이터 2021. 6. 16. 02:14
728x90

이 장의 내용

  • 람다란 무엇인가?
  • 어디에, 어떻게 람다를 사용하는가?
  • 실행 어라운드 패턴
  • 함수형 인터페이스, 형식 추론
  • 메서드 참조
  • 람다 만들기

3.1 람다란 무엇인가?

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

 

람다의 특징

  • 익명 - 보통의 메서드와 달리 이름이 없으므로 익명이라 표현한다.
  • 함수 - 메서드처럼 특정 클래스에 종속되지 않으므로 함수라 부른다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.
  • 전달 - 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성 - 익명 클래스처럼 많은 코드를 구현할 필요가 없다.

기존의 comparator 객체 구현 코드

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

 

람다를 이용한 코드

Comparator<Apple> byWeight = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

 

람다 표현식은 파라미터, 화살표 바디로 이루어진다.

  • 파라미터 리스트 - Comparator의 compare 메서드 파라미터(사과 두 개)
  • 화살표 - 파라미터 리스트와 바디를 구분
  • 람다 바디 - 두 사과의 무게를 비교하다. 람다의 반환값에 해당하는 표현식

 

람다 표현식 예제

(String s) -> s.length() //String 형식의 파라미터를 가지며, int를 반환

(Apple a) -> a.getLength() > 150 //Apple 형식의 파라미터를 가지며, boolean을 반환

(int x, int y) -> {  //int 형식의 파라미터 2개를 가지며, 리턴값이 없음
  System.out.println("Result :");
  System.out.println(x + y);
}

() -> 42 //파라미터가 없으며, int 42를 반환

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())
//Apple 형식의 파라미터 2개를 가지며, int(두 사과의 무게 비교 결과)를 반환

3.2 어디에, 어떻게 람다를 사용할까?

3.2.1 함수형 인터페이스

함수형 인터페이스는 (디폴트 메서드를 제외한) 정확히 하나의 추상 메서드를 지정하는 인터페이스이다.

앞서 살펴본 자바 API의 Comparator, Runnable 등이 있다.

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

public interface Runnable {
  void run();
}

 

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

//람다 사용
Runable r1 = () -> System.out.println("hello world");

//익명 클래스 사용
Runable r2 = new Runnable() {
  public void run() {
    System.out.println("hello world");
  }
};

3.2.2 함수 디스크립터

함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가리킨다.

람다 표현식의 시그니처를 기술하는 메서드를 함수 디스크립터라고 부른다.

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

자원처리(DB 파일처리 등)에 사용하는 순환 패턴은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어진다.

다음과 같은 설정-작업-정리 형식의 코드를 실행 어라운드 패턴(execute around pattern)이라고 부른다.

public String processFile() throws IOException {
  try (BufferedReader br = new BufferReader(new FileReader("data.txt"))) {
    return br.readline(); //실제 작업
  }
}

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

processFile의 동작을 파라미터화하면 기존의 설정, 정리 과정을 재사용하고 메서드만 다른 동작을 수행하도록 할 수 있다.

한 번에 두행을 읽게 하는 기능을 수행하기 위해, BufferReader를 인수로 받아서 String을 반환하는 람다를 구현해보자.

String result = processFile((BufferedReader br) -> br.readline() + br.readline());

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

함수형 인터페이스 자리에 람다를 사용하기 위해, BufferedReader -> String과 IOException을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들어야 한다.

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

3.3.3 3단계 : 동작 실행

이제 BufferedReaderProcessor에 정의된 process 메서드의 시그니처(BufferedReader -> String)과 일치하는 람다를 전달할 수 있다.

람다 표현식으로 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리된다.

따라서 processFile 바디 내에서 BufferedReaderProcessor 객체의 process를 호출할 수 있다.

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

3.3.4 4단계 : 람다 전달

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

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

3.4 함수형 인터페이스 사용

다양한 람다 표현식을 사용하기 위해서는 공통의 함수 디스크립터(함수형 인터페이스의 추상 메서드 시그니처)를 기술하는 함수형 인터페이스 집합이 필요하다.

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

3.4.1 Predicate

Predicate<T> 인터페이스는 test라는 추상 메서드를 정의하며, test는 제네릭 형식 T의 객체를 인수로 받아 불리언을 반환한다.

    @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);
    

3.4.2 Consumer

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

    @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.4.3 Function

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

    @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;
    }
    
    //[7,2,6]
    List<Integer> l = map(
        Arrays.asList("lambdas", "in", "action"),
        (String s) -> s.length()
    );

 

기본형 특화

자바의 모든 형식은 참조형(Byte, Integer, Object, List, ...) 또는 기본형(int, double, byte, char, ...)에 해당한다.

제네릭의 내부 구현으로 인해 제네릭 파라미터에는 참조형만 사용할수 있다.

 

자바에서는 기본형을 참조형으로 변환하는 박싱(boxing) 기능과 참조형을 기본형으로 변환하는 언박싱(unboxing) 기능을 지원한다. 박싱과 언박싱을 자동으로 처리하는 오토박싱(autoboxing) 기능도 지원한다.

 

하지만 이러한 변환 과정에는 비용이 들기 때문에 자바8에서는 오토 박싱을 피할 수 있도록 특별한 버전의 함수형 인터페이스도 제공한다.

public interface IntPredicate {
  boolean test(int t);
}

IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbes.test(1000); //참(박싱x)

Predicate<Integer> oddNumbers = (integer i) -> i % 2 != 0;
oddNumbes.test(1000); //거짓(박싱)

3.5 형식 검사, 형식 추론, 제약

3.5.1 형식 검사

람다가 사용되는 콘텍스트를 이용해 람다의 형식을 추론할 수 있다.

어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식이라 부른다.

List<Apple> heavyThen150g = filter(inventory, (Apple aplle) -> apple.getWeight() > 150);

위 코드의 형식 확인 과정은 다음과 같다.

1. filter 메서드의 선언을 확인한다.

2. filter 메서드의 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대한다.

3. Predicate<Apple>은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스이다.

4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.

5. filter 메서드로 전달되는 인수는 이와 같은 요구사항을 만족해야 한다.

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

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

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

람다 표현식의 콘텍스트는 대상형식을 명확히 해야한다.

Object o = () -> { System.out.println("example") }; // X
Runnalbe r = () -> { System.out.println("example") }; // O
Object o = (Runnable) () -> { System.out.println("example") }; // O

같은 함수형 디스크립터를 가진 두 함수형 인터페이스를 갖는 메소드를 오버로딩할 때에도 람다를 명시적으로 캐스팅할 수 있다.

public void execute(Runnable runnable) {
  rununable.run();
}

public void execute(Action<T> action) {
  action.act();
}

@FunctionalInterface
interface Action {
  void act();
}

execute((Action) () -> {} );

3.5.3 형식 추론

자바 컴파일러는 람다 표현식이 사용된 콘텍스트를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.

즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 람다의 시그니처도 추론 가능하다.

따라서 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.

Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()); //형식을 추론하지 않음
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight()); //형식을 추론함

명시적으로 형식을 포함하는 것과 생략하는 것은 상황에 따라 가독성을 향상시킬 수 있는 방향으로 개발자가 결정하면 된다.

3.5.4 지역 변수 사용

람다 표현식에서는 익명함수처럼 자유변수(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다.

이와 같은 동작을 람다 캡처링이라 부른다.

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);

람다에서 자유변수로 사용하기 위해서는 해당 지역변수가 final로 선언되어 있거나 실직적으로 final로 선언된 변수와 똑같이 사용되어야한다.

 

지역 변수의 제약

이러한 제약이 생기는 이유는 인스턴스 변수와 지역 변수의 차이 때문이다.

인스턴스 변수는 힙에 저장되는 반면 지역변수는 스택에 위치한다.

변수가 할당한 스레드가 사라지더라도 람다를 실행한 스레드에서는 해당 변수에 접근하려 할 수 있다.

따라서 자바 구현에서는 원래 변수에 접근하는 것이 아니라 자유 지역 변수의 복사본을 제공한다.

복사본의 값이 바뀌지 않아야 하므로 지역변수는 한번만 할당되어야 한다.


3.6 메서드 참조

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

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())); //기존 코드

inventory.sort(Comparing(Apple::getWeight)); //메서드 참조 활용

3.6.1 요약

메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 할 수 있으며, 메서드명 앞에 구분자(::)를 붙이는 방식으로 활용할 수 있다.

예를들어 람다 표현식 (Apple a) -> a.getWeight를 축약하여 Apple::getWeight로 Apple 클래스에 정의된 getWeight의 메서드 참조를 사용할 수 있다.

 

메서드 참조를 만드는 방법

  1. 정적 메서드 참조
    Integer의 parseInte 메서드 -> Integer::parseInt
  2. 다양한 형식의 인스턴스 메서드 참조
    String의 length 메서드 -> String::lenght  
  3. 기존 객체의 인스턴스 메서드 참조
    Transaction 객체에 getValue 메서드가 있고, 이 객체를 할당받은 expensiveTransaction 지역 변수가 있다면
    expensiveTransaction::getValue로 표현 가능

메서드 참조는 람다 표현식과 마찬가지로 콘텍스트의 형식과 일치해야 한다.

3.6.2 생성자 참조

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

Supplier<Apple> c1 = () -> new Apple();
Supplier<Apple> c2 = Apple::new;

Apple a1 = c1.get();
Apple a2 = c2.get();

Apple(Integer weight) 라는 시그니처를 갖는 생성자는 Function 인터페이스와 시그니처가 같다. 따라서 다음과 같은 코드를 구현할 수 있다.

Function<Integer, Apple> c3 = ( weight) -> new Apple(weight);
Function<Integer, Apple> c4 = Apple::new;

Apple a3 = c3.apply(110);
Apple a4 = c4.apply(110);

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

BiFunction<Color, Integer, Apple> c5 = (color, weight) -> new Apple(color, weight);
BiFunction<Color, Integer, Apple> c6 = Apple::new;

Apple a5 = c5.apply(GREEN, 110);
Apple a6 = c6.apply(GREEN, 110);

Color(int, int, int) 처럼 인수가 세 개인 생성자를 사용하려면 직접 함수형 인터페이스를 생성해야 한다.

public interface TriFunction<T, U, V, R> {
  R apply (T t, U u, V v);
}

TriFunction<Integer, Integer, Integer, Color> colorFactory = Color::new;

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

지금까지 배운 동작 파라미터화, 익명 클래스, 람다 표현식, 메서드 참조 등을 활용해서 앞서 살펴본 사과 리스트 정렬 문제를 해결해보자.

3.7.1 1단계 : 코드 전달

자바 8의 List API에서 제공하는 sort 메소드에 정렬 전략을 전달해보자.

void sort(Comparator<? super E> c)

이 코드는 Comparator 객체를 인수로 받아 두 사과 객체를 비교한다.

객체 안에 동작을 포함하는 방식으로 다양한 전략을 전달할 수 있다.

public class AppleComparator implements Comparator<Apple> {
  public int compare(Apple a1, Apple a2) {
    return a1.getWeight().compareTo(a2.getWeight());
  }
}

inventory.sort(new AppleComparator());

3.7.2 2단계 : 익명 클래스 사용

한번만 사용할 compartor는 익명 클래스를 이용하는 것이 좋다.

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

3.7.3 3단계 : 람다 표현식 사용

Comparator의 함수 디스크립터(T, T) -> int를 사용해 람다 표현식으로 작성할 수 있다.

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

자바 컴파일러는 람다 표현식이 사용된 콘텍스트를 활용해 파라미터 형식을 추론하므로 한번 더 줄일 수 있다.

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

Comparator는 Comparable 키를 추출해서 Comparator 객체로 만드는 Function 함수를 인수로 받는 정적 메서드 comparing을 포함한다.

Comparator<Apple> c = Comparator.comparing((apple a) -> a.getWeight());

이제 코드를 다음처럼 간소화할 수 있다.

import static java.util.Compartor.comparing;
inventory.sort(comparing(apple -> apple.getWeight());

3.7.4 4단계 : 메서드 참조

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

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

코드만 짧아진 게 아니라 코드의 의미도 명확해졌다.

코드 자체로 'Apple을 weight 별로 비교해서 inventory를 sort하라'는 의미를 전달할 수 있다.


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

간단한 여러 개의 람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있다.

함수형 인터페이스가 제공하는 디폴트 메서드를 사용하면,

두 프레디케이트의 or 연산을 수행하거나 한 함수의 결과가 다른 함수의 입력이 되도록 조합할 수도 있다.

3.8.1 Comparator 조합

앞서 정적 메서드 Comparator.comparing을 이용해서 비교에 사용할 키를 추출하는 Function 기반의 Comparator를 반환할 수 있단 것을 보았다.

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

 

역정렬

사과를 내림차순으로 정렬하고 싶다면 어떻게 해야할까?

다른 Compator 인스턴스를 만들 필요 없이, 인터페이스 자체에서 주어진 비교자의 순서를 뒤바꾸는 reverse 디폴트 메서드를 사용하면 된다.

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

 

Comparator 연결

무게가 같은 사과를 나열하는 순서를 정할땐 어떻게 해야할까?

thenComparing 메서드를 사용하면 비교 결과를 더 다듬도록 두 번째 Comparator를 만들 수 있다.

thenComparing은 함수로 인수로 받아 첫 번째 비교자에서 같다고 판단된 객체를 처리한다.

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

3.8.2 Predicate 조합

Predicate 인터페이스는 복잡한 프레디케이트를 만들 수 있도록 negate, and, or 세가지 메서드를 제공한다.

예를 들어 '빨간 색이 아닌 사과'처럼 특정 프레디케이트를 반전시킬 때 negate 메서드를 사용할 수 있다.

Predicate<Apple> notRedApple = redApple.negate();

and 메서드를 이용해 빨간색이면서 무거운 사과를 선택하도록 람다를 조합할 수도 있다.

Predicate<Apple> RedHeavyApple = redApple.and(apple -> apple.getWeight > 150);

or 메서드를 이용해서 '빨간색이면서 무거운 사과 또는 그냥 녹색사과' 등의 조건을 만들 수 있다.

Predicate<Apple> RedHeavyOrGreenApple = 
  redApple.and(apple -> apple.getWeight > 150)
          .or(apple -> GREEN.equals(a.getColor()));

3.8.3 Function 조합

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); //g(f(x))
int result = h.apply(1); // 4를 반환

compose 메서드는 인수로 주어진 함수를 먼저 실행한 다음 그 결과를 외부 함수의 인수로 제공한다.

즉 f.andThen(g) 대신 compose를 사용하면 g(f(x))가 아니라 f(g(x))가 된다.

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); // 4를 반환

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

헤더를 추가(addHeader)한 다음에, 철자 검사(checkSpelling)를 하고, 마지막에 푸터를 추가(addFooter) 할 수도 있다.

Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transFormationPipeline =
  addHeader
    .andThen(Letter::checkSpelling)
    .andThen(Letter::addFooter);
728x90
Comments