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

이주오·2021년 8월 9일
0

도서

목록 보기
3/15

람다 표현식이란?


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

  • 람다 표현식은 익명 클래스처럼 이름이 없는 함수면서 메서드를 인수로 전달할 수 있으므로 익명 클래스와 비슷하다고 일단 생각하며 이해를 하나씩 해보자
  • 람다 표현식은 이름은 가질 수 없지만 파라미터, 바디, 리턴 타입, 예외 리스트는 가질 수 있다.
  • 람다는 기술적으로 자바8 이전의 자바로 할 수 없었던 일을 제공하는 것이 아니라는 점을 명심하자, 코드가 간결해지고 유연해지는 것!

람다의 특징

  • 익명 : 이름이 없으므로 익명이라 표현하고 구현해야할 코드가 적고 간결하다.
  • 함수 : 메서드처럼 특정 클래스에 종속되지 않고 독립적인 기능을 하기 때문에 함수라고 불린다.
  • 전달 : 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성 : 익명클래스처럼 로직과 필요없는 코드를 구현할 필요가 없다.

람다 사용법

  • 파라미터 + 화살표 + 바디로 이루어진다
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
//   람다 파라미터
//                  화살표
//                                  람다 바디
(parameters) -> expression
          또는 블록스타일
(parameters) -> { statements; }

그렇다면 람다 표현식은 어디에 사용할 수 있을까??


람다 표현식은 함수형 인터페이스 문맥에서 사용할 수 있다.


함수형 인터페이스

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

  • java.util.function, 기본제공 함수형 인터페이스
  • 함수형 인터페이스로 뭘 할 수 있을까?
    • 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스를 구현한 클래스의 인스턴스 라고 취급 할 수 있다.
    • 람다를 이해하기 위해 가장 중요한 개념
interface Runnable {
		void run();
}
public class Main {
    public static void process(Runnable r){
        r.run();
    }
    public static void main(String[] args)
    {
        Runnable r1 = () -> System.out.println("Hello, r1");
        Runnable r2 = new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello, r2");
            }
        };
 
        r1.run(); // hello, r1
        r2.run(); // hello, r2
        process(() -> System.out.println("Hello, r3")); // hello, r3
    }
}

함수 디스크립터(function descriptor)

  • 함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가리킨다.
  • 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다.
    • 예를들어 MyFunc 인터페이스의 함수 디스크립터는 (int, int) → int 이다.
  • 람다 표현식은 함수형 인터페이스를 인수로 받는 메서드에만 람다 표현식을 사용할 수 있다.

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


  • 자원 처리(예를 들면 데이터베이스의 파일 처리)에 사용하는 순환 패턴(recurrent pattern)은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어 진다.
  • 설정(setup)과 정리(cleanup) 과정은 대부분 비슷하다. 즉, 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는다.
    • 설정 - 작업 a - 정리
    • 이와 같은 형식의 코드를 실행 어라운드 패턴이라고 한다.
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을 던질(throw) 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들어야 한다.
    • 이 인터페이스를 BufferedReaderProcessor라고 정의하자.
    @FunctionalInterface
    public interface BufferedReaderProcessor {
	String process(BufferedReader b) throws IOException;
    }

    public String processFile(BufferedReaderProcessor p) throws IOException {
 	// ...
    }

3단계 : 동작 실행

  • 이제 BufferedReaderProcessor에 정의된 process 메서드의 시그니처(BufferedReader -> String)와 일치하는 람다를 전달할 수 있다.
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((BufferedReaer br) -> br.readLine());
String twoLine = processFile((BufferedReaer br) -> br.readLine() + br.readLine());

함수형 인터페이스 사용

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

Predicate<T>
  • 조건식을 표현하는데 사용
  • T -> boolean
Supplier<T>
  • void를 받아 제네릭 형식 T로 반환
  • () -> T
Consumer<T>
  • Supplier 와 반대
  • T -> ()
Function<T, R>
  • 일반적인 함수, T를 받아 R을 반환
  • T -> R

기본형 특화

  • 자바의 모든 형식은 참조형 혹은 기본형

  • 하지만 제네릭은 내부 구현상 어쩔 수 없이 참조형만 사용 가능하다.

  • 그래서 박싱(기본형 -> 참조형)과 언박싱(참조형->기본형) 제공한다.

  • 박싱한 값은 기본형을 감싸는 래퍼이며 힙에 저장된다.

  • 따라서 박싱한 값은 메모리를 더 소비하며 기본형을 가져올 때도 메모리를 탐색하는 과정이 필요하다.

    • 오토박싱으로 저 과정은 자동으로 해주지만, 자원을 소모하게 된다.
    • 그래서 오토박싱을 피할 수 있는 버전의 함수형 인터페이스 제공
    • IntConsumer, LongConsumer...

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


람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다.
따라서 람다 표현식을 더 제대로 이해하려면 람다의 실제 형식을 파악해야 한다.

대상형식

  • 어떤 컨텍스트에서 기대되는 람다 표현식의 형식
  • 람다 표현식이 예외를 던질 수 있다면, 추상 메소드도 같은 예외를 던질 수 있어야 함.

형식 검사

  • 람다가 사용되는 콘텍스트(context)를 이용해서 람다의 형식(type)을 추론할 수 있다.
List<Apple> heavierThan150g = filter(apples, (Apple apple) -> apple.getWeight() > 150**);
1. 람다가 사용된 콘텍스트는 무엇이지? filter의 정의 확인
   -> filter(List<Apple> apples, Predicate<Apple> p)

2. 대상 형식은 Predicate<Apple>이다.

3. Predicate<Apple> 인터페이스의 추상메서드는 무엇이지? 
   -> boolean test(Apple apple)

4. test 메소드의 Apple -> boolean 함수 디스크립터 묘사

5. 찾은 함수의 디스크립터가 전달된 람다 표현식과 일치하는지 확인

6. 형식 검사 성공적으로 완료

형식 추론

  • 자바 컴파일러는 람다 표현식이 사용된 컨텍스트(대상 형식)를 이용해서 함수 디스크립터를 알 수 있으므로 람다의 시그니처도 추론할 수 있다.
  • 따라서 컴파일러는 람다 표현식의 파라미터 형식을 추론할 수 있으므로 람다 문법에서 생략할 수 있다.
// 형식 추론 하지 않음
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

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

자유변수(free variable) 와 람다 캡처링(capturing lambda)

  • 지금까지 살펴본 모든 람다 표현식은 인수를 자신의 바디 안에서만 사용했다.
  • 하지만 람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다.
    • 이와 같은 동작을 람다 캡처링이라고 한다.
  • 다음은 portNumber 변수를 캡처하는 람다 예제이다.
    int portNumber = 1337;
    Runnable r = () -> System.out.println(portNumber);
  • 하지만 자유 변수에도 제약이 있다.
    • 람다는 인스턴스 변수정적 변수를 자유롭게 캡처(자신의 바디에서 참조할 수 있도록) 할 수 있다.
    • 하지만 그러러면 지역 변수명시적으로 final로 선언되어 있어야 하거나, 실질적으로 final로 선언된 변수와 똑같이 사용(effectively final)되어야 한다.
    • 한번만 할당할 수 있는 지역 변수를 캡처 가능
  • 아래는 portNumber에 값을 두 번 할당하므로 컴파일 할 수 없는 코드이다.
    int portNumber = 1337;
    // error
    Runnable r = () -> System.out.println(portNumber);
    portNumber = 31337;
  class CapturingTest {
    private int a = 1;

    public void test() {
        final int b = 2;
        int c = 3;
        int d = 4;

        final Runnable r = () -> {
            // 인스턴스 변수 a는 final로 선언돼있을 필요도, final처럼 재할당하면 안된다는 제약조건도 적용되지 않는다.
            a = 123;
            System.out.println(a);
        };
        r.run();
        // 지역변수 b는 final로 선언돼있기 때문에 OK
        final Runnable r2 = () -> System.out.println(b);
        r2.run();

        // 지역변수 c는 변수에 값을 재할당하지 않았으므로 OK
        final Runnable r3 = () -> System.out.println(c + " " + b);
        r3.run();

        // 지역변수 d는 final이 아니고 effectively final도 아니다.
        d = 12;
        final Runnable r4 = () -> System.out.println(d);
    }
}

이유가 무엇일까??

  1. 람다 표현식은 여러 쓰레드에서 사용할 수 있다.
  2. 힙 영역에 저장되는 인스턴스 변수와 달리 스택 영역에 저장되는 지역 변수는 외부 쓰레드에서 접근 불가능하다.
    1. 지역 변수에 바로 접근할 수 있다는 가정하에 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다.
  3. 따라서 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공하는데. 이를 람다 캡쳐링이라고 한다.
  4. 복사본은 원본의 값이 바뀌어도 알 수 없기 때문에 쓰레드 동기화를 위해 지역 변수는 final 또는 effectively final 상태여야 한다.

즉 가변 지역 변수를 새로운 스레드에서 캡쳐할 수 있다면 안전하지 않은 동작이 수행될 가능성이 생기기 때문!!!


메서드 참조


메서드 참조는 특정 람다 표현식을 축약한 것이라고 생각하면 좋다.

  • 메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.
  • 람다식이 하나의 메서드만 호출하는 경우에는 메서드명을 참조함으로써 가독성을 높일 수 있다.
// (Apple apple) -> apple.getWeight()
Apple::getWeight

// () -> Thread.currentThread().dumpStack()
Thread.currentThread()::dumpStack()

// (str, i) -> str.substring(i)
String::substring

// (String s) -> System.out.println(s) 
System.out::println

// (String s) -> this.isValidName(s)
this::isValidName

메서드 참조의 세 가지 유형

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

  • 정적 메서드 참조
    • 람다 : (x) → ClassName.method(x)
    • 메서드 참조 : ClassName::method
    • ex) Integer::parseInt
  • 인스턴스 메서드 참조
    • 람다 : (obj, x) → obj.method(x)
    • 메서드 참조 : ClassName::method
    • ex) String::length
  • 기존 객체의 인스턴스 메서드 참조
    • 이미 생성된 객체의 메서드를 람다식에서 사용하는 경우
        MyClass obj = new MyClass();
        (x) -> obj.equals(x);
        obj::equals
- 람다 : (x) → obj.method(x)
- 메서드 참조 : obj::method
  • 컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷하게 함수형 인터페이스와 호환하는지 확인한다.
  • 즉, 메서드 참조는 콘텍스트의 형식과 일치해야 한다.

생성자 참조

생성자를 호출하는 람다식도 메서드 참조로 변환할 수 있다.

Supplier<MyClass> s = () -> new MyClass();
Supplier<MyClass> s = MyClass::new;
MyClass obj1 = s.get();
  • 매개변수가 있는 생성자라면, 매개변수의 개수에 따라 알맞은 함수형 인터페이스를 사용하거나 새로 정의해서 사용하면 된다.
Function<Integer, MyClass> f = (i) -> new MyClass(i);
Function<Integer, MyClass> f2 = MyClass::new;

Function<Integer, int[]> ff = x -> new int[x];
Function<Integer, int[]> ff2 = int[]::new;

요약


  • 람다 표현식은 익명 함수의 일종이다.
  • 함수형 인터페이스는 하나의 추상 메서드만을 정의하는 인터페이스이다.
  • 함수형 인터페이스를 기대하는 곳에서 람다 표현식을 사용할 수 있으며, 추상 메서드를 즉석으로 제공할 수 있다. 이 때 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급한다.
  • 메서드 참조를 이용하면 기존의 메서드 구현을 재사용하고 직접 전달할 수 있다.

부족했던 부분(TODO)


Function의 합성과 Predicate의 결합

  • Function의 addThen, compose 디폴트 메서드 코드 이해 및 활용 해보기
  • Predicate의 and, or, negate 디폴트 메서드 코드 이해 및 활용 해보기

왜 람다에서 지역변수는 final or effectively final?

참고 문헌

profile
동료들이 같이 일하고 싶어하는 백엔드 개발자가 되고자 합니다!

0개의 댓글