woowacourse-study / 2022-modern-java-in-action

우아한테크코스 4기 모던 자바 인 액션 스터디
10 stars 4 forks source link

람다 캡처링이란 무엇이며 제약 및 주의 사항은 무엇인가? #22

Open bugoverdose opened 2 years ago

bugoverdose commented 2 years ago

문제

자유변수라는 용어를 활용하여, 람다 캡처링이란 동작에 대해 서술하시오. 그리고 이러한 동작을 수행할 때 존재하는 제약 사항과 주의사항에 대해 서술하시오.

선정 배경

클로저, 스트림에서 공유 가변 자원을 사용하지 말아야 하는 이유 등 다양한 주제들과 연결된 핵심 개념으로 보임.

관련 챕터

bugoverdose commented 2 years ago

람다 캡처링

기본적으로 람다 표현식은 (파라미터) -> 동작과 같은 구조를 지니며, 파라미터로 넘겨진 변수를 활용하여 바디에서 작업을 수행한다. 람다 캡처링(capturing lambda)이란 간단히 말해 이처럼 파라미터로 넘겨받은 데이터가 아닌 "람다식 외부에서 정의된 변수"를 참조하는 변수람다식 내부에 저장하고 사용하는 동작을 의미한다. 아래는 그 예이다.

void lambdaCapturing() {
   int localVariable = 1000;

   Runnable r = () -> System.out.println(localVariable);
}

제약 조건: 지역변수는 final이어야 한다.

람다는 값이 단 한 번만 할당되는 지역변수만을 캡처할 수 있으며, 만일 람다에서 캡처되는 지역변수의 값을 재할당되는 경우 컴파일 에러가 발생한다. 즉, 명시적으로 final로 선언되었거나, 실질적으로 final인 지역변수만 람다식 바디에 들어올 수 있다는 것이다.

이러한 제약조건은 JVM 메모리 구조와 관련이 있다. JVM 메모리상으로 힙에 저장되는 인스턴스 변수 등과는 달리, 지역변수는 스택에 저장되기 때문이다.

예를 들어 '지역변수의 값을 캡처하는 람다'를 반환하는 메서드를 한 번 생각해보자. 해당 메서드의 실행이 종료되는 경우, JVM은 반환되는 람다식의 바디에 포함되어 있는 지역변수의 할당을 해제한다. 그럼에도 불구하고 람다는 지역변수의 값을 아무 문제 없이 참조하여 사용할 수 있다. 이는 람다 내부에서 사용되는 지역변수는 원본 지역변수를 복제한 데이터이기 때문이다. 그렇기 때문에 실제 지역변수의 할당이 해제되어도 람다 내부의 값은 유지되는 것이며, 복제품의 값이 변경되지 않아야 한다는 이유로 단 한 번만 값을 할당해야 한다는 제약이 생겨난 것이다.

void useLambda() {
   Supplier<Integer> lambda = getLambda();

   int actual = lambda.get();

   System.out.println(actual); // 1005
}

// 지역변수를 캡처하여 사용하는 람다를 외부로 반환하는 메서드
private Supplier<Integer> getLambda() {
   int localVariable = 1000; // 지역변수 localVariable

   return () -> localVariable + 5; // 자유변수 localVariable
}

그리고 이처럼 람다식 내부에서 저장되는 지역변수의 복제품은 원본이 되는 지역변수이 사라져도 자유롭게 존재할 수 있기 때문에 자유변수라고 불린다.

제약 조건의 이점

사실 지역변수의 불변성을 강제하는 것은 딱히 문제라고 보기 어렵다. 불변성이 지닌 다양한 이점도 있겠지만, 외부 변수의 값을 직접적으로 변화하는 일반적인 절차형/명령형 프로그래밍 패턴을 예방한다는 점이 가장 큰 이점이라고 볼 수 있다.

주의 사항

주의 사항은 클래스의 static 필드나 인스턴스 필드를 캡처하는 데에는 위에서 언급한 제약조건이 적용되지 않는다는 점이다. 필드의 경우 값이 자유자재로 변해도 되며, 람다는 자신이 실행될 때 해당 시점의 필드 값을 사용하게 된다. 이는 이들이 JVM 메모리상으로 힙 영역 혹은 데이터 영역에 저장되어 있으며, 람다식 내부에서는 클래스 혹은 인스턴스를 통해 해당 데이터에 접근하게 되기 때문이다. 이는 필드의 값이 객체든, 원시값이든 동일하게 적용된다.

그러나 이처럼 제약 조건이 없다는 점은 좋은 것만은 아니다. 오히려 무분별한 자유변수 참조로 인해 다양한 사이드 이펙트를 유발시킬 가능성이 있다. 이해하기 쉬운 예로 instanceField 필드의 값을 특정 값으로 재할당하는 람다를 병렬 스트림 내부에서 호출한다고 생각해보자. 1부터 100으로 구성된 IntStream의 값을 할당하는 작업을 수행하도록 했을 때, 우리는 100번째로 수행된 람다가 어떤 것인지를 알 수가 없으므로 최종적인 instanceField의 값을 예상할 수가 없다.

class LambdaCapturingTest {

    private int instanceField = 0;

    void lambdaCapturing_parallelStreamCanCauseSideEffects() {
        Consumer<Integer> changeFieldValue = (a) -> this.instanceField = a;

        IntStream.rangeClosed(1, 100)
                .parallel()
                .forEach(changeFieldValue::accept);

        System.out.println(this.instanceField); // ??: 알 수 없다.
    }
}

이러한 경우가 아니더라도 공유되는 가변 자원을 스트림 내부에서 사용하게 되면 다양한 side-effect가 예상된다. 그러므로 람다 캡처링을 사용하더라도 가급적이면 불변 데이터만을 사용하기 위해 노력하는 것이 이상적일 것이다.


세부적인 설명은 블로그에 정리해두었습니다.