[Ch3] 람다 표현식

이 내용은 모던 자바 인 액션 책을 읽고 개인적으로 정리한 내용입니다.

3장에서는 아래 내용을 확인해본다.

  • 람다 표현식
  • 형식 추론 기능
  • 메서드 참조

실습예제 소스는 여기에 있다

람다란

람다 표현식은 익명함수를 단순화한 것이다. 람다 표현식의 특징은 다음과 같다.

  • 익명 & 간결
    • 구현해야 할 코드에 대한 걱정거리가 줄어든다.
  • 함수
    • 메서드와 달리 특정 클래스에 종속되지 않으므로 함수라고 한다.
  • 전달
    • 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.

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

화면 캡처 2021-06-22 203739

  • 화살표는 람다의 파라미터 리스트와 바디를 구분한다.

이와 같이 람다의 기본 문법은 다음과 같다.

  • (parameters) -> expression
  • (parameters) -> {statements;}

이런 표현식은 함수형 인터페이스 에 사용된다.

함수형 인터페이스

오직 하나의 인터페이스만 지정하는 인터페이스이다. (많은 default 메서드가 있는 인터페이스라도 추상 메서드가 오직 하나면 함수형 인터페이스다)

public interface Runnable {
  void run();  추상 메서드
}

이러한 함수형 인터페이스를 직 접 구현한 인스턴스를 람다 표현식이라고 이해하면 된다.

Runnable r1 = () -> System.out.println("Hello World!"); // 람다사용

자바8부터는 @FunctionalInterface 어노테이션이 붙은걸 볼 수 있는데, 이는 함수형 인터페이스를 의미한다.

실제로 함수형 인터페이스가 아닌데 @FunctionalInterface 어노테이션이 아니라면 컴파일러가 에러를 발생시킨다.

Multiple nonoverriding abstract methods found in interface Foo

함수 디스크립터

람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다. 예를 들어, Runneable 인터페이스의 유일한 추상 메서드 run 은 파라미터와 반환값이 없으므로 (void 반환) Runnable 인터페이스는 인수와 반환값이 없는 시그니처로 생각할 수 있다.

(즉 무엇을 받고 리턴하는 명시하는 메서드를 함수 디스크립터라고 생각하면 될것 같다.)

예를 들어, 앞서 계속 살펴본 Apple 예제를 보자.

(Apple, Apple) -> int

는 두개의 Apple를 인수로 받아서 int를 반환하는 함수를 가르킨다. 이것을 함수 디스크립터라고 한다.

이와 같은 함수 디스크립터를 통해 자바8은 람다 표현식의 유효성을 확인한다. (형식 검사, 추론 등)

람다 작성 예제

BufferedReader 를 사용해서, 파일을 읽고 파일에 있는 문구를 출력하는 프로그램을 작성해보자


/**
 * try-with-resources 구문 사용
 * @return
 * @throws FileNotFoundException
 * @throws IOException
 */
public static String processFile() throws FileNotFoundException, IOException {
  try(BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    return br.readLine(); // <- 실제 필요한 작업을 하는 행
  }
}

우리는 이렇게 사용할 것이다. BufferedReader는 자원을 쓰는 것이기 때문에, 에러가 나면 회수해야하므로 try-catch 문으로 썼다. 다만, 최신 자바 부터는 try-with-resources 문을 쓴다. 그 이유는 AutoClosable 인터페이스를 구현하기 때문이다.

하지만 우리가 실제 필요한 작업을 하는 행은 br.readLine() 이며, try-with-resources 문도 껍데기로 볼 수 있다. 따라서 이를 람다로 바꿔서, 실제 작업을 하는 동작을 파라미터로 전달해보자

// 함수형 인터페이스 선언
@FunctionalInterface
public interface BufferedReaderProcessor {
  String process(BufferedReader b) throws IOException;
}

/**
 * BufferedReaderProcessor 함수형 인터페이스를 파라미터로 받는 메서드
 * 이렇게 하면 함수형 인터페이스를 어떻게 구현하느냐에 따라, 아래 메서드를 다양하게 활용할 수 있다.
 *
 * @param p
 * @return
 * @throws IOException
 */
public static String processFile(BufferedReaderProcessor p) throws IOException {
  try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    return p.process(br);
  }
}

// 이것을 람다 표현식으로 실제 쓴다면 아래와 같이 쓰면 된다.
String test2 = processFile((BufferedReader br) -> br.readLine() + br.readLine());

두개 행을 읽고 싶으면 람다 바디에 br.readLine() + br.readLine() 을 전달하면 끝이다!

함수형 인터페이스 종류

자바 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다.

따로 정의할 필요없이, 아래와 같은 인터페이스를 활용할 수 있다면 활용하자

Predicate

제네릭 형식의 T 의 객체를 인수로 받아 불리언을 반환하는 함수 디스크립터를 가지고 있다.

String 배열에서 Empty String이 아닌 문자열만 filtering 하는 예제

public static void main(String[] args) {

  // 파라미터로 받은 String s가 Empty 인지 확인하는 Predicate
  // return Boolean
  Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();

  String[] predicateTestString = new String[] {"룰루랄라", "Blank?", ""};

  List<String> filterString = Arrays.stream(predicateTestString).filter(x -> nonEmptyStringPredicate.test(x)).collect(Collectors.toList());

  System.out.println(filterString);

}

Consumer

T 객체를 받아서 void를 반환하는 accept 추상메서드를 정의한다.

Integer를 받아서 출력을 하는 예

public static void consumerTest() {

  // T 객체를 받아서 어떠한 동작을 하는 것		
  Arrays.asList(1, 2, 3, 4, 5).forEach((Integer i) -> System.out.println(i));

}

Function

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

public static void functionTest() {  
  List<Integer> l = Arrays.asList("lambdas", "in", "action")
              .stream()
              .map((String s) -> s.length()) // s 의 길이로 바꿔준다.
              .collect(Collectors.toList());

  System.out.println(l);  
}

자바8의 오토박싱

기본적으로 박싱한 값은 기본형을 감싸는 래퍼이며 힙에 저장된다. 따라서 박싱한 값은 메모리를 더 소비하며 기본형을 가져올 때도 메모리를 탐색하는 과정이 필요하다.

따라서 자바8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱 동작을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.

IntPredicate

public static void boxingTest() {

  IntPredicate evenNumbers = (int i) -> i % 2 == 0;
  System.out.println(evenNumbers.test(1000)); // true (박싱없음)

  Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
  System.out.println(oddNumbers.test(1000)); // false (박싱)		

}

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

람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지에 대한 정보가 포함되어 있지 않다. 아래와 같은 방법으로 람다가 구현한 함수형 인터페이스를 확인할 수 있다. (보통 IDE가 다 알려주지만 ㅎㅎ)

형식검사

람다가 사용되는 context(메서드 파라미터 혹은 변수)를 이용해서 람다의 type을 추론할 수 있다.

List<Apple> heavierThan150g = filter(inventory, (Apple apple) -> apple.getWeight() > 150);
  • (Apple apple) -> apple.getWeight() > 150) 는 Predicate 을 가리킨다. 이것이 target type이다.
  • Predicate 은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스다.
  • test 메서드는 Apple 을 받아 boolean을 리턴하는 함수 디스크립터다.
  • filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.

위 예제에서 람다 표현식은 Apple을 인수로 받아 boolean을 리턴하므로 유효한 코드로 볼 수 있다.

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

동일한 람다 표현식이더라도 호환되는 추상 메서드를 가진 함수형 인터페이스가 N개일 수 있다.

예를 들어, CallablePrivilegedAction 인터페이스는 모두 인수를 받지 않으며 제네릭 형식 T를 반환하는 디스크립터를 가지고 있다.

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

이 경우, 컨텍스트 추론에 의해서 첫번째 할당문의 target type은 Callable 이고, 두번째 할당문의 target type 은 PrivilegedAction 를 추론할 수 있다.

특별한 void 호환규칙 람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호환된다. 예를 들어 Consumer 콘텍스트(T->void) 가 기대하는 void 대신 boolean을 반환해도 유효한 코드다. Consumer b = s -> list.add(s);

반환하는 타입도 같고, Target Typing도 동일하고 함수 디스크립터도 동일하다면 어떤 함수형 인터페이스를 가리키는지 명확하지 않다. 그럴 경우, 캐스트를 해서 명확히 함수형 인터페이스를 지정해주면 된다.

20210623_타입캐스팅

형식 추론

자바 컴파일러는 람다 표현식이 사용된 context(Target Typing)을 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다. 즉 함수 디스크립터를 알 수 있으므로 람다의 시그니처(파라미터, 리턴 타입)도 추론할 수 있다. 결과적으로 자바 컴파일러는 람다 표현식의 파라미터 형식을 추론할 수 있으므로, 파라미터의 Type을 명시적으로 지정하지 않아도 된다.

List<Apple> greenApples = Arrays.stream(appleList)
                .filter(apple -> Color.GREEN.equals(apple.getColor())) // (Apple) 타입 명시하지 않아도 된다.
                .collect(Collectors.toList());

(상황에 따라,, 개발자의 성향에 따라 명시하면 된다.)

지역 변수 사용

람다 캡처링을 통해 익명 함수가 하는 것 처럼 free variable(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변)를 활용할 수 있다. 다만 이 값은 무조건 final이어야 한다.

클로저..

메서드 참조

메서드 참조란 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다. :: 구분자를 추가해서 메서드명을 참조할 수 있다. (이 메서드를 직접 호출해! 라고 말하는 것이다)

// 기존코드
Arrays.stream(appleList).sorted((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));

// 메서드 참조활용
Arrays.stream(appleList).sorted(Comparator.comparing(Apple::getWeight));

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

  • 정적 메서드 참조
    • Integer::parseInt
  • 다양한 형식의 인스턴스 메서드 참조
    • String::length
  • 기존 객체의 인스턴스 메서드 참조
    • 지역변수 item이 있고, getItemId 메서드가 있다면 item::getItemId로 표현할수도 있다.

여기서 세번째 케이스 같은 경우에는, private helper method를 정의한 상황에서 유용하게 활용할 수 있다.

/**
 * 인스턴스 메서드
 */
public void validNames() {
  String[] string_list = new String[] {"Lian", "judy"};
  List<String> validNames = Arrays.stream(string_list).filter(this::isValidName).collect(Collectors.toList());

  System.out.println(validNames);
}

/**
 * private helper method
 * @param s
 * @return
 */
private boolean isValidName(String s) {
  return Character.isUpperCase(s.charAt(0));
}

생성자 참조

메서드 참조처럼, :: 구분자를 이용해서 생성자도 참조할 수 있다.

// 인수없는 생성자
Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

Supplier<Apple> c2 = () -> new Apple();
Apple a2 = c2.get();

// weight를 인수로 가지는 생성자
Function<Integer, Apple> c3 = Apple::new;
Apple a3 = c3.apply(100);

// 두개의 인수를 가지는 생성자
BiFunction<Color, Integer, Apple> c4 = Apple::new;
Apple a4 = c4.apply(Color.GREEN, 110);

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


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

람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있다. 또한 한 함수의 결과가 다른 함수의 입력이 되도록 두 함수를 조합할수도 있다.

Comparator 조합

앞서 살펴봤듯이 Comparator.comparing을 이용해서 비교를 구현할 수 있다.

역정렬 내림차순으로 정렬하고 싶다면, Comparator 인스턴스를 만들 필요 없이, 인터페이스 자체에 주어진 비교자의 순서를 뒤바꾸는 reverse 디폴트 메서드를 제공한다.

Comparator 인터페이스의 default 메서드

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

또한, 연쇄적으로 비교를 하고자 하면 thenComparing 메서드로 두번째 비교자를 만들 수 있다.

Arrays.stream(appleList).sorted(Comparator.comparing(Apple::getWeight)
                      .reversed() // 무게를 내림차순으로 정렬
                      .thenComparing(Apple::getColor)); // 두 사과의 무게가 같으면 칼라별로 정렬

Predicate 조합

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

Predicate<Apple> redApple = (a -> Color.RED.equals(a.getColor())); // 빨간 사과만 true로 반환하는 Predicate
Predicate<Apple> notRedApple = redApple.negate(); // 반전해버림

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

Function 조합

Function 인터페이스는 andThen, compose 두 가지 디폴트 메서드를 제공한다.

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

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 메서드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공한다. f(g(x))

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을 반환

댓글남기기