[Modern-Java-in-Action] 람다 표현식

이동엽·2022년 12월 25일
0

java

목록 보기
14/18

chapter 3. 람다 표현식

💡 이 장의 주요 내용

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

  • 익명 클래스로 다양한 동작을 구현할 수 있지만, 만족할 만큼 코드가 깔끔하지는 않았다.
    • 깔끔하지 않은 코드 → 동작 파라미터를 실전에 적용하는 것을 막는 요소다.

  • 3장에서는 더 깔끔한 코드로 동작을 구현하고 전달하는 자바 8의 새로운 기능인 람다 표현식을 설명한다.

🔥 람다란 무엇인가?

  • 람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다.
    • 람다식은 이름은 없지만, 파라미터 리스트, 바디, 반환 형식 등을 가질 수 있다.

람다의 특징

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

  • 람다식이 왜 중요할까?
    • 람다를 이용해 간결한 방식으로 코드를 전달할 수 있다.
    • 동작 파라미터를 이용할 때 익명 클래스 등 판에 박힌 코드를 구현할 필요가 없다.

  • 람다식 적용 전
Comparator<Apple> beWeight = 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 메서드 파라미터
  • 화살표
    • 화살표(→)는 람다의 파라미터 리스트와 바디를 구분한다.
  • 람다 바디
    • 두 사과의 무게를 비교한다. 람다의 반환값에 해당하는 표현식이다.

다음은 Java 8에서 지원하는 다섯 가지 람다 표현식 예제다.

(String s) -> s.length()
  • 객체에서 선택/추출
  • String 타입 파라미터를 가지며, int를 반환

(Apple a) -> a.getWeight() > 150
  • 불리언 표현식
  • Apple 타입 파라미터를 가지가 boolean을 반환

(int x, int y) -> { 
    System.out.println("Result: ");
    System.out.println(x + y);
}
  • 객체에서 소비
  • int 형식의 파라미터 두 개를 가지며, 리턴 값이 없다.

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

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())
  • 두 객체 비교
  • Apple 형식의 두 파라미터를 가지며, int를 반환

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

  • 이전 예제에서는 Comparator 형식의 변수에 람다를 할당했다.
  • 2장에서는 구현했던 필터 메소드에도 람다를 활용할 수 있었다.
List<Apple> greenApples = filter(inventory, (Apple a) -> GREEN.equals(a.getColor()));

그래서 정확히 어디에서 람다를 사용할 수 있다는 건가?
함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.


  • 위에서는 함수형 인터페이스 Predicate<T>를 기대하는 filter()의 인수로 람다 표현식을 전달했다.
    • 당장은 모르더라도, 걱정 말자. 일단 함수형 인터페이스가 무엇인지 자세히 살펴보자.

함수형 인터페이스

  • 함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스다.
    • 지금까지 살펴본 자바 API의 함수형 인터페이스로는 Comparator, Runnable 등이 있다.
    • 인터페이스는 디폴트 메소드를 포함하더라도, 추상 메서드가 오직 하나면 함수형 인터페이스이다.
public interface Predicate<T> {
		boolean test(T t);
}
public interface Comparator<T> {
		int compare(T o1, T o2);
}
public interface Runnable {
		void run();
}
  • 함수형 인터페이스로 뭘 할 수 있을까?
    • 람다표현식으로 추상 메소드 구현을 직접 전달할 수 있다.
      전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다.

  • Runnable이 오직 하나의 추상 메서드 Run() 을 정의하는 함수형 인터페이스이므로 가능한 코드들
    /* 람다 사용 */
    Runnable r1 = () -> System.out.println("Hello");
    
    /* 익명 클래스 사용 */
    Runnable r2 = new Runnable() {
    		public void run() {
    				System.out.println("Hello");
    		}
    };
    
    /* 직접 전달된 람다 표현식을 사용 */
    public static void process(Runnable r) {
    		r.run();
    }
    process(r1); process(r2);
    process(() -> System.out.println("Hello"));

함수 디스크립터

  • 함수형 인터페이스의 추상메서드 시그니처는 람다 표현식의 시그니처를 가리킨다.
    • 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다.
    • ex) Runnable의 run() 를 인수와 반환값이 없는 시그니처로 생각할 수 있다.

왜 함수형 인터페이스를 인수로 받는 메서드에서만 람다 표현식을 사용할 수 있을까?

  • 언어 설계자들은 자바에 함수 형식을 추가하는 방법도 대안을 고려했다.
  • 또한 대부분의 자바 프로그래머가 하나의 추상 메서드를 갖는 인터페이스에 이미 익숙한 것을 고려했다.

🌱 @FunctionalInterface는 무엇인가?

  • 함수형 인터페이스임을 가리키는 어노테이션이다.

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

  • 람다와 동작 파라미터화로 유연하고 간결한 코드를 구현하는 예제를 살펴보자.
  • 자원 처리(데이터베이스의 파일 처리)에 사용하는 순환 패턴 :
    • 자원 열기 → 처리 → 자원 닫기
    • 설정과 정리 과정은 여러 작업이 대부분 동일하다.

  • 즉, 실제 자원을 처리하는 코드를 설정과 정리 과정이 둘러싸는 형태를 가진다.
    • 이와 같은 형식의 코드를 실행 어라운드 패턴이라고 부른다.

  • 파일에서 한 행을 읽는 코드 : try-with-resource 구문 활용
public String processFile() throws IOException {
    try (
        BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    	    return br.readLine();
        }
   	}
}

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

  • 위 코드는 파일에서 한 번에 한 줄만 읽을 수 있다.
    • 한 번에 두 줄을 읽거나 가장 자주 사용되는 단어를 반환하려면 어떻게 해야 할까?
    • 기존의 설정, 정리 과정은 재사용하고, processFile() 메서드만 다른 동작을 수행하도록 해보자.
      • processFile()의 동작을 파라미터화하기!!

  • 람다를 이용해 한 번에 두 행을 읽도록 해보자.
    • BufferedReader 를 인수로 받아, String을 반환하는 람다
String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());

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

  • 함수형 인터페이스 자리에는 람다를 사용할 수 있다는 점을 이용해보자.
  • BufferedReader → String, IOException을 던질 수 있는 시그니처와 일치하는 인터페이스 만들기
@FunctionalInteface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}
    
//정의한 인터페이스를 processFile() 메서드의 인수로 전달할 수 있다.
public String processFile(BufferedReaderProcessor p) throws IOException {}

3단계 : 동작 실행

  • 이제 BufferedReaderProcessor에 정의된 process()의 시그니처와 일치하는 람다를 전달하자.
    • 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달하고,
    • 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리한다.
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 oneLine = processFile((BufferedReader br) -> br.readLine() + br.readLine());

🔥 함수형 인터페이스 사용

  • 함수형 인터페이스는 오직 하나의 추상 메서드를 지정한다.
  • 함수형 인터페이스의 추상 메서드는 람다 표현식의 시그니처를 묘사한다.
    • 이를 함수 디스크립터라고 한다.

  • Java 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 인터페이스들을 제공한다.
    • 이 중에서 Predicate , Consumer , Function 인터페이스를 살펴보자.

Predicate

  • Predicate 인터페이스는 test() 라는 추상 메서드를 정의한다.
    • 이 메서드는 제네릭 형식 T 객체를 인수로 받아 boolean 타입을 반환한다.
    • 따로 정의할 필요 없이 바로 사용할 수 있다는 점이 특징이다.

  • Predicate 예제
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}
    
pubilc <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);

Consumer

  • Consumer 인터페이스는 accept() 라는 추상 메서드를 정의한다.
    • 이 메서드는 제네릭 형식 T 객체를 받아서 void 를 반환한다.
    • 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 사용할 수 있다.
      • ex) Integer 리스트를 받아, 각 항목에 어떤 동작을 수행하는 forEach 메서드를 정의

  • Consumer 예제
@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)
);

Function

  • Function<T, R> 인터페이스는 apply() 라는 추상 메서드를 가진다.
    • 이 메소드는 제네릭 형식 T를 인수로 받아 제네릭 형식 R 객체를 반환한다.
    • 입력을 출력으로 매핑하는 람다를 정의할 때 활용할 수 있다.
      • ex) 사과의 무게 정보를 추출하거나 문자열을 길이와 매핑하기

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

기본형 특화

  • 지금까지 살펴본 함수형 인터페이스 이외에 특화된 형식의 함수형 인터페이스도 있다.
    • 자바의 모든 형식은 참조형 아니면 기본형에 해당한다.
    • 하지만 제네릭 파라미터(ex: Consumer의 T)에는 참조형만 사용할 수 있다.
      • 이는 제네릭의 내부 구현 때문에 어쩔 수 없는 일이다.

  • 자바에서는 기본형을 참조형으로 변환하는 기능을 제공한다.
    • 이 기능을 박싱이라고 하고, 반대 동작(참조형 → 기본형)을 언박싱이라고 한다.

  • 또한 프로그래머가 편리하게 코드를 구현하도록 박싱/언박싱이 자동으로 이루어지는 오토박싱도 있다.
  • 오토 박싱 예제 (int를 Integer로 박싱)
List<Integer> list = new ArrayList<>();
for (int i = 300; i < 400; i++) {
    list.add(i);
}

  • 단, 이런 변환 과정은 비용이 소모된다.
    • 박싱값은 기본형을 감싸는 래퍼로, 힙 영역에 저장된다.
    • 따라서 박싱값은 메모리를 추가로 소비하며, 가져올 때도 메모리 탐색 과정이 필요하다.

  • Java 8에서는 오토박싱 동작을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.
    • 일반적으로 특정 형식을 입력받는 함수형 인터페이스의 이름 앞에는 형식 명이 붙는다.
      • DoublePredicate , IntConsumer , LongBinaryOperator 등등
    • 필요시 우리가 직접 함수형 인터페이스를 만들 수 있다!

  • 사용 예제
public interface IntPredicate {
		boolean test(int t);
}

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

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

🌱 예외, 람다, 함수형 인터페이스의 관계

  • 함수형 인터페이스는 확인된 예외를 던지는 동작을 허용하지 않는다.
  • 즉, 예외를 던지는 람다 표현식을 만들려면 확인된 예외를 선언하는 함수형 인터페이스를 직접 정의하거나 람다를 try-catch 블록으로 감싸야 한다.
Function<BufferedReader, String> f = (BufferedReader b) -> {
    try {
	    return b.readLine();
    } catch {
	    throw new RuntimeException(e);
    }
};

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

  • 람다 표현식을 처음 설명할 때, 람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 언급했다.
  • 람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지는 않다.
    • 따라서 람다 표현식을 더 제대로 이해하려면 람다의 실제 형식을 파악해야 한다.

형식 검사

  • 람다가 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다.
    • 어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식이라고 부른다.
    • 이때, 콘텍스트는 람다가 전달될 메서드 파라미터나 람다가 할당되는 변수를 의미한다.

List<Apple> heabierThan150g = 
		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> p = () -> 42;

🌱 다이아몬드 연산자

자바의 변화에 익숙한 독자라면 이미 자바 7에서도 다이아몬드 연산다(<>) 로 콘텍스트에 따른 제네릭 형식을 추론할 수 있다는 사실을 기억할 것이다.

  • 주어진 클래스 인스턴스 표현식을 두 개 이상의 다양한 콘텍스트에 사용할 수 있다.
  • 이때, 인스턴스 표현식의 형식 인수는 콘텍스트에 의해 추론된다.
List<String> listOfStrings = new ArrayList<>();
List<Integer> listOfIntegers = new ArrayList<>();

🌱 특별한 void 호환 규칙

  • 람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립트와 호환된다.
    → 물론 파라미터 리스트도 호환되어야 한다.

  • 예를 들어 예제에서 List의 add()는 Consumer 콘텍스트(T → void)가 기대하는 void 대신 boolean을 반환하지만 유효한 코드다.

//Predicate는 불리언 반환값을 갖는다.
Predicate<String> p = s -> list.add(s);

//Consumer는 void 반환값을 갖는다.
Consumer<String> b = s -> list.add(s);

형식 추론

  • 코드를 좀 더 단순화할 수 있는 방법이 있다.
    • 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)을 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.
    • 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로, 컴파일러는 람다의 시그니처도 추론할 수 있다!
    • 결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.

  • 특히 여러 파라미터를 포함하는 람다 표현식에서는 코드 가독성 향상이 더 두드러진다.
    • Comparator 객체를 만드는 코드
/* 형식을 추론하지 않음 (= 형식을 명시적으로 포함) */
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
        
/* 형식을 추론 */
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getweight());

🌱 상황에 따라 명시적으로 형식을 포함한 것이 좋을때도 있고, 배제한 것이 가독성을 향상시킬 때도 있다.
어떤 방법이 좋은지 정해진 규칙은 없으니, 개발자는 가독성을 향상시키는 방법을 고민해보자!


지역 변수 사용

  • 지금까지 살펴본 모든 람다 표현식은 인수를 자신의 바디 안에서만 사용했다.
    • 하지만 람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수를 활용할 수 있다.
      • 자유 변수란? → 파라미터로 넘겨진 변수가 아닌 외부 변수
    • 이와 같은 동작을 람다 캡처링이라고 부른다.

  • portNumber 변수를 캡처하는 람다 예제
int portNumber = 8080; //자유 변수
Runnable r = () -> System.out.println(portNumber);

  • 하지만 자유 변수에도 약간의 제약이 있다.
    • 람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처할 수 있다.
    • 하지만 그러려면 지역 변수는 명시적으로 final 로 선언되거나, 실질적으로 그래야 한다.
    • 즉, 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있다.

  • 참고 : 인스턴스 변수 캡처는 final 지역 변수 this 를 캡처하는 것과 마찬가지다.
int portNumber = 8080; //자유 변수
Runnable r = () -> System.out.println(portNumber);
portNumber = 3000; //에러 : 람다에서 참고하는 지역 변수는 final로 선언되거나 취급되어야 한다.

지역 변수의 제약

  • 왜 지역 변수에 이런 제약이 필요한 지 이해할 수 없는 독자도 있을 것이다.
    • 우선 내부적으로 인스턴스 변수와 지역 변수는 태생부터 다르다.
    • 인스턴스 변수 : 힙에 저장
    • 지역 변수 : 스택에 저장

  • 람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면?
    • 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도
      람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다!
    • 따라서 자바에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공한다.
    • 복사본의 값이 바뀌지 않아야 하므로, 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생겼다!

  • 또한 지역 변수의 제약이 외부 변수를 변환시키는 일반적인 명령형 프로그래밍 패턴에 방해가 될 수 있다.

🌱 클로저(Closure)

클로저란 함수의 비지역 변수를 자유롭게 참조할 수 있는 함수의 인스턴스를 가리킨다.

예를 들어, 클로저를 다른 함수의 인수로 전달할 수 있다.
클로저는 클로저 외부에 정의된 변수의 값에 접근하고, 값을 바꿀 수 있다.

자바 8의 람다와 익명 클래스는 클로저와 비슷한 동작을 수행한다.
람다와 익명 클래스 모두 메서드의 인수로 전달될 수 있고, 자신의 외부 영역의 변수에 접근할 수 있다.

단, 람다와 익명 클래스는 람다가 정의된 메서드의 지역 변수의 값은 바꿀 수 없다.
→ 람다가 정의된 메서드의 지역 변숫값은 final 변수여야 하기 떄문!
→ 덕분에 람다는 변수가 아닌 값에 국한되어 어떤 동작을 수행한다는 사실이 명확해진다.

이전에도 설명했듯 지역 변수값은 스택에 존재하므로, 자신을 정의한 스레드와 생존을 같이 해야 하며, 따라서 지역 변수는 final 이어야 한다.


🔥 메서드 참조

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

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

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

요약

  • 메서드 참조가 왜 중요한가?
    • 메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다.
    • 실제로 메서드 참조를 이용하면 기존 메서드 구현으로 람다 표현식을 만들 수 있다.
      • 이때, 명시적으로 메서드명을 참조함으로써 가독성을 높일 수 있다.

  • 메서드 참조는 어떻게 활용할까?
    • 메서드명 앞에 구분자(::)를 붙이는 방식으로 메서드 참조를 활용할 수 있다.
      • 실제로 메서드를 호출하는 것은 아니므로 괄호는 필요 없음을 기억하자.

메서드 참조를 만드는 방법

  • 메서드 참조는 세 가지 유형으로 구분할 수 있다.
  1. 정적 메서드 참조 → Integer::parseInt
  2. 다양한 형식의 인스턴스 메서드 참조 → String::length
  3. 기존 객체의 인스턴스 메서드 참조 → expensiveTransaction::getValue

  • 세 번째 유형의 메서드 참조는 비공개 헬퍼 메소드를 정의한 상황에서 유용하다!
    • 비공개 헬퍼 메서드 예시

      private boolean isValidName(String string) {
      		return Character.isUpperCase(string.charAt(0));
      }
    • 이를 Predicate를 필요로 하는 적당한 상황에서 메서드 참조를 사용할 수 있다.

      fileter(words, this::isVaildName)


  • List에 포함된 문자열을 대소문자를 구분하지 않고 정렬하는 프로그램
    • List의 sort() 는 인수로 Comparator를 기대한다.
    • Comparator는 (T, T) → int 라는 함수 디스크립터를 갖는다.

  • String클래스에 정의되어 있는 compareToIgnoreCase() 메서드로 정의하기
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> c1 = Apple::new;
Apple a1 = c1.get();

  • 위 예제는 아래와 같다.
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

  • Apple(Integer weight)라는 시그니처를 가진 생성자로 가정
Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);
  • 위 예제는 아래와 같다.
Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

  • 아래 예제는 Integer를 포함하는 리스트의 각 요소를 이전에 정의한 map() 메서드를 이용해 Apple 생성자로 전달하는 코드이다.
List<Integer> weights = Arrays.asList(7, 3, 4, 10);
List<Apple> apples = map(weights, Apple::new);
    
public List<Apple> map(List<Integer> list, Function<Integer, Apple> f) {
    List<Apple> result = new ArrayLst<>();
    for (Integer i : list) {
        result.add(f.apply(i));
    }
    return result;
}

  • Apple(String color, Integer weight) 처럼 두 인수를 갖는 생성자는 BiFunction 인터페이스와 같은 시그니처를 가지므로 아래와 같이 작성할 수 있다.
BiFunction<Color, Integer, Apple> c3 = Apple::new; 
//Apple(String color, Integer weight)을 참조
Apple a3 = c3.apply(GREEN, 110);

  • 위 코드는 아래와 같이 수정할 수 있다.
BiFunction<String, Integer, Apple> c3 = (color, weight) -> new Apple(color, weight);
Apple a3 = c3.apply(GREEN, 110);

  • 인스턴스화하지 않고도 생성자에 접근할 수 있는 기능을 다양한 상황에 응용할 수 있다.
  • Map으로 생성자와 문자열 값을 관련시키기
static Map<String, Function<Integer, Fruit>> map = new HashMap();
static {
    map.put("apple", Apple::new);
    map.put("orange", Orange::new);
    // 등등
}

  • String과 Integer가 주어졌을 때 다양한 무게를 갖는 여러 종ㄹ의 과일을 만드는 메서드
public static Fruit giveMeFruit(String fruit, Integer weight) {
    return map.get(fruit.toLowerCase()) //map의 Fuction<Integer, Fruit>를 얻음
              .apply(weight);
}

💡 메소드 참조와 생성자 참조 이해 유무 확인하기!

Color(int, int, int)처럼 인수가 3개인 생성자의 생성자 참조를 이해하려면 어떻게 해야할까?

→ 힌트 : 생성자 참조와 일치하는 시그니처를 갖는 함수형 인터페이스가 필요!

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

→ 답안

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


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

  • 1, 2장에서부터 다뤄온 사과 리스트를 정렬하는 문제로 돌아가보자.
  • 지금까지 배운 동작 파라미터화, 익명 클래스, 람다 표현식, 메서드 참조 등을 총동원한다!

1단계 : 코드 전달

  • 다행히 Java 8의 List API에서 sort() 메서드를 제공하므로 정렬을 구현할 필요는 없다.
    • 이제 이 메서드에 정렬 전략을 어떻게 전달할 수 있을까?
    • sort() 의 시그니처는 아래와 같다

void sort(Comparator<? super E> c)

  • 위 코드는 Compartor 객체를 인수로 받아 두 사과를 비교한다.
  • 객체 안에 동작을 포함시키는 방식으로 다양한 전략을 전달해보자!
    sort()의 동작을 파라미터화하다!

  • 1단계 완성 코드
    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를 구현하는 것보단 익명 클래스를 이용하는 것이 좋다
  • 2단계 완성 코드
    inventory.sort(new Comparator<Apple>() {
    		public int compare(Apple a1, Apple a2) {
    				return a1.getWeight().compareTo(a2.getWeight());
    		}
    });

3단계 : 람다 표현식 사용

  • Java 8에서는 람다 표현식이라는 경량화된 문법을 이용해서 코드를 전달할 수 있다.
    • 함수형 인터페이스를 기대하는 곳 어디에서나 람다 표현식을 사용할 수 있음을 배웠다.

  • 추상 메서드의 시그니처(= 함수 디스크립터)는 람다 표현식의 시그니처를 정의한다.
    • 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는 comparing()을 포함한다.
    • Comparable 키를 추출해서 Comparator 객체로 만드는 Function 함수를 인수로 받는 정적 메서드이다.
//람다 표현식은 사과를 비교하는 데 사용할 키를 어떻게 추출할 것인지 지정하는 한 개의 인수만 포함
Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());

  • 3단계 완성 코드
import static java.util.Comparator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));

4단계 : 메소드 참조 사용

  • 메소드 참조를 이용해 람다 표현식의 인수를 더 깔끔하게 전달해보자.
inventory.sort(comparing(Apple::getWeight));

  • Java 8 이전의 코드에 비해 단지 코드만 짧아진 것이 아님을 알 수 있다!
    • 코드의 의미도 명확해졌다!
    • ‘Apple을 weight로 비교해서 inventory에 sort하라’ 라는 의미를 전달할 수 있다.

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

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

  • 예를 들어, 두 프리디케이트를 조합해서 두 프리디케이트의 OR 연산을 수행하는 프리디케이트를 만들 수 있다.

  • 여기서 등장하는 것이 디폴트 메소드이다.

    → 추상 메서드가 아니므로 함수형 인터페이스의 정의를 벗어나지 않는다.


Comparator 조합 기술

  • 정적 메서드 Comparator.comparing을 이용해 비교에 사용할 키를 추출하는 Comparator를 반환
Comparator<Apple> c = Comparator.comparing(Apple::getWeight);

Skill-1. 역정렬

  • 인터페이스 자체에서 주어진 비교자의 순서를 뒤바꾸는 reverse() 디폴트 메소드를 이용해보자.
inventory.sort(comparing(Apple::getWeight).reversed();

Skill-2. Comparator 연결

  • 만약 역정렬 후 무게가 같은 사과가 존재한다면? → 두 번째 비교자를 만들자!
inventory.sort(comparing(Apple::getWeight)
         .reversed()
    	 .thenComparing(Apple::getCountry)); //원산지 국가별로 정렬

Predicate 조합 기술

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

  • 빨간 색이 아닌 사과 고르기 → negate()
Predicate<Apple> notRedApple = redApple.negate();

  • 빨간색이면서 무거운 사과 고르기 → and()
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 인터페이스에서 제공하는 람다 표현식도 조합할 수 있다.
    • 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);
int result = h.apply(1); //3

🔥 마치며

  • 람다 표현식은 익명 함수의 일종이다.
  • 함수형 인터페이스는 하나의 추상 메서드만을 정의하는 인터페이스다.
  • 함수형 인터페이스를 기대하는 곳에서만 람다 표현식을 사용할 수 잇다.
  • 람다 표현식을 이용해 함수형 인터페이스의 추상 메서드를 즉석으로 제공할 수 있고, 이 때 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급된다.
  • 실행 어라운드 패턴을 람다와 활용하면 유연성과 재사용성을 추가로 얻을 수 잇다.
  • 메서드 참조를 이용하면 기존의 메서드 구현을 재사용하고 직접 전달할 수 있다.
profile
백엔드 개발자로 등 따숩고 배 부르게 되는 그 날까지

0개의 댓글