Java

람다 캡처링(Capturing Lambda)

지금 느낌 그대로 2023. 9. 4. 14:28

 

의미

람다 표현식(Lambda Expression)은 기본적으로 (파라미터) -> 동작의 구조를 가집니다.

전달받은 파라미터를 가지고 동작을 정의하는 표현식입니다. 람다 캡처링은 전달받은 파라미터가 아닌 람다식 외부에서 정의된 변수를 람다식 내부에 저장하고 사용하는 것을 의미합니다.이 때 람다식 외부에 정의된 변수를 자유 변수(free variable) 이라고 합니다.

 

public int concatStr(List<String> strs) {
	String seperator = " and ";
	return strs.stream().collect(Collectors.joining(seperator));
}

 

람다식 내부에서 사용되는 지역 변수의 제약 조건

람다는 인스턴스 변수와 정적 변수를 람다식 내부에 저장하는 방식(Capturing)을 통해서 람다의 동작을 정의하는 부분에서 자유롭게 사용할 수 있지만, 람다식 내부에서 사용되는 지역변수에는 제약 조건이 있습니다.

 

Effectivly final variable 이어야 합니다.

명시적으로 final 키워드가 선언되어있는 변수거나, final 키워드가 선언된 것처럼 변수를 재할당하지 않아야 한다는 것입니다. 이를 지키지 않으면 컴파일 에러가 발생합니다.

public int concatStr(List<String> strs) {
	String seperator = " and ";
	return strs.stream().collect(Collectors.joining(seperator));
    
	seperator = ","; // Compile Error 발생!!
}

 

제약조건이 있는 이유?

람다식 내부에서 사용되는 지역변수에 제약조건이 존재하는 이유는 JVM의 메모리 구조와 관련이 있습니다.

JVM은 인스턴스 변수를 Heap 영역에 저장하지만, 지역변수는 Stack 영역에 저장합니다.

 

Stack 영역에 저장된 변수의 메모리는 변수가 선언된 메소드를 호출할 때 할당되고, 메소드가 종료되면 해제됩니다.

Heap 영역에 저장된 객체들의 메모리는 더 이상 객체를 참조하는 다른 객체가 없을 때 GC에서 처리합니다.

 

이렇게 두 영역에서 메모리가 해제되는 시점이 달라 제약조건을 만든 것입니다.

 

예를 들면, 지역변수를 참조하는 람다를 반환하는 메소드에서 메소드의 실행이 종료되면 JVM은 반환되는 람다식의 바디에 포함되어 있는 지역변수의 메모리 할당을 해제합니다.

그래도 람다는 지역변수의 값을 참조할 수 있습니다. 왜냐하면 람다에서 사용되는 지역변수는 원본 지역변수를 복제한 데이터이기 때문입니다. 

그래서 실제 지역변수의 메모리 할당이 해제되어도 람다 내부의 값은 유지되고, 복사한 변수의 값이 변경되지 않아야 한다는 제약 조건이 생긴 것입니다.

이렇게 람다식 내부에 저장되는 지역변수의 복사본은 원본이 사라져도 자유롭게 존재할 수 있기 때문에 자유변수라고 표현합니다.

 

주의사항

방금 이야기한 제약조건은 static 필드나 인스턴스의 필드를 캡쳐할 때에는 적용되지 않습니다. 

필드의 경우에는 값이 자유롭게 변경되어도 상관 없고, 람다는 자신이 실행되는 시점의 필드 값을 사용합니다.

이유는 필드와 람다식 내부의 복사본과 JVM의 Heap 영역 혹은 데이터 영역에 저장되어 있어, 람다식 내부에서는 클래스 혹은 인스턴스를 통해 해당 데이터에 접근하기 때문입니다.

필드의 값이 원시 타입(Int, long, byte...) 뿐만 아니라 객체여도 동일합니다.

 

class lambdaCapturing {
	private int instanceField = 0;
    
    void changeFieldValueInMethod() {
    	Supplier<Integer> supplier = () -> this.instacneField;;
        
        System.out.println(supplier.get()); // 0
        
        this.instanceField = 10;
        System.out.println(supplier.get()); // 10
    }
}

 

제약 조건이 없다는 점은 아주 편리할 것 같지만, 무분별하게 참조하고 복사본의 값을 변경한다면 Side Effect가 발생할 여지가 있습니다.

 

class lambdaCapturing {
	private int instanceField = 0;
    
    void changeFieldValueInMethod() {
    	Consumer<Integer> changeFieldVal = (x) -> this.instanceField = x;
        
        IntStream.rangeClosed(1,100)
				.parallel()
				.forEach(changeFieldVal::accept);
        
        System.out.println(this.instanceField); 
    }
}

위 코드처럼 병렬 스트림 내부에서 필드의 값을 변경하는 람다를 호출한다면, 병렬 스트림 내부에서는 데이터를 여러그룹으로 분할해서 병렬 처리를 진행하기 때문에 최종적으로 필드 값을 출력하는 프린트문에서 어떤 값이 출력될지 알 수 없습니다.

 

위의 예제 뿐만 아니라, 값이 변할 수 있는 변수를 람다식 내부에서 참조하는 것은 코드의 복잡도를 증가시키고, 버그를 발생시킬 수 있기 때문에 가급적 불변 데이터를 람다 캡처링으로 사용하는 것이 좋습니다.