개발/Java

람다 표현식이란?

Debin 2022. 3. 8.
반응형

람다란 표현식이란?

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

 

람다 표현식에는 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다.

람다의 특징은 아래와 같다.

 

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

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

 (Apple a1, Apple a2)   ->   a1.getWeight().compareTo(a2.getWeight());
     (람다 파라미터)      (화살표)                             (람다 바디)

 

  • 파라미터 리스트 : 함수의 파라미터
  • 화살표 : 람다의 파라미터 리스트와 바디를 구분
  • 람다 바디 : 람다의 반환 값에 해당하는 표시

 

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

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

그러면 여기서 또 모르는 단어가 등장했다.

 

과연 함수형 인터페이스는 무엇인가??

간단히 말해 함수형 인터페이스란 정확히 하나의 추상 메서드를 지정하는 인터페이스다.

아래와 같은 Predicate, Comparator, Runnable 등이 함수형 인터페이스다.

public interface Predicate<T> {
    boolean test (T t);
}

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

public interface Runnable {
    void run();
}

//모두 정확히 하나의 추상 메서드를 가지는 함수형 인터페이스다.

 

디폴트 메서드가 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스다.

 

함수형 인터페이스로 무엇을 할 수 있을까?? 필자가 느끼기에는 지금 내용이 제일 중요하다!

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로,

전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다.

 

엄밀히 기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스다.

 

함수형 인터페이스보다는 덜 깔끔하지만 익명 내부 클래스로도 같은 기능을 구현할 수 있다.

다음 예제는 Runnable이 오직 하나의 추상 메서드 run을 정의하는 함수형 인터페이스이므로 올바른 코드다.

Runnable r1 = () -> System.out.println("Hello World 1"); //람다식 사용
 
Runnable r2 = new Runnable(){
    public void run(){
        System.out.println("Hello World 2");
    }
}

public static void process(Runnable r){
    r.run();
}

process(r1); // Hello World 1 출력
process(r2); // Hello World 2 출력
process(() -> System.out.println("Hello World 3")); //직접 람다식 전달

static 메서드 process에 람다 표현식이 들어갈 수 있는 이유는 차차 알아보자.

 

이제 또 중요한 개념을 하나 학습해보자.

함수 디스크립터

바로 함수 디스크립터다.

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

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

예를 들어 Runnable 인터페이스의 유일한 추상 메서드인 run은 인수와 반환 값이 없으므로

Runnable 인터페이스는 인수와 반환 값이 없는 시그니처로 생각할 수 있다.

 

이제 한 번 우리가 크게 많은 공을 들이지 않고 만든 메서드에서 람다를 사용해 리팩토링 하는 과정을 거쳐보겠다.

먼저 처음 시작 코드는 아래와 같다.

public String processFile() throws IOException {
    try { BufferedReader br =
    		new BufferedReader(new FileReader("data.txt"))){
        return br.readLine(); //실제 작업을 하는 행
        }
}

자원을 명시적으로 닫을 필요 없이 간결한 코드를 만들게 도와주는 try-with-resources 구문을 사용했다.

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

현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다.

한 번에 두 줄을 읽거나 가장 자주 사용되는 단어를 반환하려면 어떻게 할까?

기존의 설정, 정리 과정은 재사용하고 processFile 메서드만 다른 동작을 수행하도록 명령하면 되겠다.

이때 좋은 방법이 동작을 파라미터화 하는 것이다.

process 메서드가 BufferedReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달하자.

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

함수형 인터페이스 자리에 람다를 사용할 수 있다.

따라서 BufferedReader -> Stringrhk IOException을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들자.

 

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

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

public String processfile(BufferedReaderProcessor p) throws IOException{
	...
}

3단계 : 동작 실행

이제 BufferedReaderProcessor에 정의된 process 메서드의 시그니처와 일치하는 람다를 전달할 수 있다.

 

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으며

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

 

따라서 processFile 바디 내에서 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 twoLines = processFile((BufferedReader br) -> br.readLine() + br.readLine());

이제 우리는 함수형 인터페이스를 사용해서 람다를 전달하는 방법을 확인했다. 이때 인터페이스도 정의해보았다.

형식 검사

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

콘텍스트란 무엇인가??

예를 들어 람다가 전달될 메서드의 파라미터(매개변수)나 람다가 할당되는 변수 등을 가리킨다.

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

 

람다 표현식을 사용할 때 실제 어떤 일이 일어나는지 보여주는 예제를 확인하자.

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

아래 이미지는 위 코드의 형식 과정을 보여준다. 다음과 같은 순서로 형식 확인 과정이 진행된다.

람다 표현식의 형식 검사 과정의 재구성

 

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

즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다.

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

 

아래 코드를 살펴보자. 가독성이 향상된 것을 확인할 수 있다.

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

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

지역 변수의 사용과 제약

지금까지 살펴본 모든 람다 표현식은 인수를 자신의 바디 안에서만 사용했다.

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

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

 

아래와 같은 예시가 있다.

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

하지만 자유 변수에도 약간의 제약이 있다.

람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처(자신의 바디에서 참조)할 수 있다.

하지만 그러려면 지역 변수는 명시적으로 final로 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다.

즉, 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있다.

int portNumber = 8080;
Runnable r = () -> System.out.println(portNumber);
portNumber = 3000; //에러

이유가 무엇일까?!?!

우선 인스턴스 변수와 지역 변수는 태생부터 다르다.

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

람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면,

변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다.

(운영체제에서 공부하기로는 스택 영역은 스레드마다 가지고 있다.)

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

따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생겼다.

 

이상으로 람다 표현식에 대한 포스팅을 마치겠습니다.

 

참고 문헌

모던 자바 인 액션 (라울-게이브리얼 우르마) - 3장 람다 표현식

반응형

댓글