🤔 람다 및 익명 Inner Class에서 외부의 지역 변수에 접근할 때는
final 혹은 effectively final인 변수만 접근 가능하다
Java로 람다를 다룰 때 한 번쯤은 들어봤을 이야기다.
필자는 자바스크립트의 람다를 생각하고 개발하다가 여기저기서 컴파일 오류가 나서 뒤늦게 알게 됐었다.
'왜 자바스크립트는 되는데 자바는 안돼? 😟' 라는 생각이 들어서 당시 이것저것 찾아본 내용을 정리했다.
관련 용어
effectively final | final이 붙지 않았음에도 실행 전후로 값 수정이 없어 사실상 final인 변수 |
자유 변수 | 람다식의 인자로 넘겨지지 않은 변수, 즉 람다 기준 외부에서 정의된 변수 |
람다 캡처링 | 람다 스코프 외부의 자유변수를 복사해오는 것. 값 복사 방식과 참조 복사 방식으로 나뉜다. |
개요
Supplier<Integer> incrementer(int start) {
return () -> start++;
}
위 코드는 start++
에서 컴파일 에러가 난다.
람다 기준에서 외부 지역변수인 start
의 값을 변경하기 때문이다.
람다에서 사용하는 외부 지역변수는 effectively final해야 한다는 원칙을 위배한 것이다.
effectively final해야 하는 이유
결론부터 말하자면 final 제약조건은 자바 람다 캡처링 방식과 예측 가능성, 언어 철학, 성능 고려 등 여러 요인이 합쳐진 결과이다.
결론에 도달하기 위해 약간의 빌드업이 필요하다.
람다 캡처링
람다와 자유변수의 스코프 생명 주기는 서로 다르다.
이해를 돕기 위해 람다식을 익명 클래스로 풀어서 보자.
Supplier<Integer> incrementer(int start) {
return () -> start++;
}
// 익명 클래스로 풀어서 쓰면
Supplier<Integer> incrementer(int start) {
return new Supplier<Integer>() {
public Integer get() {
return start++;
}
}
}
start
는 지역변수이므로 스택에 저장된다.incrementer()
함수는 Supplier
구현체를 반환하고 종료한다.
이 경우 incrementer()
가 종료됐을 때 람다식은 메모리에 남아서 다른 context에서 호출될 수 있다.
하지만 start
라는 변수는 스택에서 사라지게 된다.
이런 일을 방지하기 위해 람다식은 자유 변수를 복사해오는 과정을 거치는데,
이를 람다 캡처링이라고 한다.
람다 캡처링은 값을 복사하는 방식과 참조를 복사하는 방식 두 가지로 나뉘는데
Java는 값을 복사하는 방식을 선택했다.
예측 가능성
Java는 멀티스레드를 지원하는 언어이다.
람다와 외부 코드가 동일한 변수를 변경할 수 있다면 여러 문제가 발생할 수 있다.
(경쟁상태, 실행 결과 예측 불가능, 디버깅 복잡성 증가 등등)
변수가 effectively final
하다면 코드의 예측 가능성을 높일 수 있다.
그렇다면 인스턴스 변수는?
인스턴스 변수는 스택이 아닌 힙에 저장되기 때문에 에러가 나지 않는다.
스택과 달리 힙은 메모리에 늘 고정돼있기 때문이다.
람다에서 해당 인스턴스 변수에 접근하고 싶다면 힙에 직접 접근하면 된다.
static 변수도 동일하다.
스택에 저장되지 않으므로 final 제약이 붙지 않는다.
다만 동시성 문제는 발생할 수 있으므로 적절한 처리가 필요하다.
다른 언어도 제약이 있을까?
이런 제약은 아무리 취지가 좋아도 개발하는 입장에선 불편하게 느껴진다.
지역 변수를 수정하는 (어찌보면) 간단한 작업도 우회를 해야하고,
다른 언어에서는 가능한 기법이 Java에서는 불가능하다거나
코드의 자연스러운 흐름을 방해한다.
그렇다면 멀티스레딩을 지원하는 다른 언어에서도 effectively final과 유사한 제약이 붙을까?
Kotlin
var counter = 0
val increment = { counter++ }
increment()
println(counter) // 1
자바 동생인 코틀린에서는 외부 변수 수정이 가능하다.
컴파일러가 내부적으로 캡처 변수를 래퍼 클래스로 감싸서 참조하도록 바꾼다고 한다.
(Ref
객체를 생성해서 복사한다고 함)
그러므로 지역변수 제약을 자동적으로 우회할 수 있다.
C#
int counter = 0;
Action increment = () => { counter++; };
increment();
Console.WriteLine(counter); // 1
C#에서는 캡처한 변수를 힙으로 옮김으로써 지역변수를 참조할 수 있도록 한다.
C++
int a = 0;
auto lambda = [&a]() { a++; };
lambda();
cout << a << endl; // 1
C++은 람다를 선언할 때 모든 종류의 괄호를 다 쓴다 ㅋㅋ
`[대괄호]` 안에 `&`을 넣으면 변수의 참조를 캡처한다.
다만 참조를 캡처하더라도 자동으로 힙으로 옮겨주진 않기에 변수가 소멸하면 dangling reference가 된다고 한다.
Javascript
let count = 0;
const increment = () => count++;
increment();
console.log(count);
자바스크립트는 람다 선언 시 변수의 참조를 캡처한다.
다른 언어와 마찬가지로 count
가 힙으로 복사되므로 람다에서 참조가 가능해진다.
왜 자바한테만..
결론적으론 자바에만 제약이 있었다.
이유를 생각해보자.
캡처링 방식 차이
위에서 살펴봤듯이 대부분의 언어는 람다 캡처 시 변수를 힙으로 옮긴 후 참조를 복사해온다.
하지만 자바는 변수의 값만 복사해온다.
람다 내부에서 접근하는 지역변수는 복사본이라서 원본 변수와는 다르다.
그렇기 때문에 개발자가 착각하지 않도록 명시적으로 제약을 둔 것으로 볼 수 있다.
멀티스레딩 안정성
자바는 멀티스레딩이 가능한 언어이다.effectively final
제약을 두면 멀티스레드 환경에서 안정성을 보장할 수 있다.
람다에서 캡처하는 변수가 final
이 아니라면, 실행 시점마다 복사한 값이 어떤 값을 갖는지 예측하기 힘들어진다.
람다식은 다양한 스레드에서 동시에 실행될 수 있다.
그러므로 람다에서 외부 변수를 수정하는 경우 원치 않은 race condition이 발생할 수도 있다.
그러므로 final 제약을 둬서 프로그램의 예측 가능성을 높인다고도 볼 수 있다.
언어의 철학
코틀린과 비교해서 생각해보자.
코틀린은 편의성과 생산성을 우선시한다.
따라서 컴파일러가 내부적으로 변수를 힙에 복사하여 참조를 캡처함으로써 람다식 내에서 변수 변경을 지원한다.
개발자에게 명시적인 코드 작성을 요구하지 않고, 언어 차원에서 알아서 센스있게 처리해준다.
자바는 안정성과 명시성을 중시한다.
final 제약 조건이 불편하긴 해도 이게 존재하는 한 멀티스레딩 환경에서 안정성을 확보할 수 있다.
자바도 코틀린과 비슷하게 변수를 Wrapper 클래스로 감싸서 제약을 우회할 수 있다.
다만 컴파일러가 알아서 해주진 않고 개발자가 직접 처리해야 한다.
개발자 입장에서는 귀찮을 수도 있지만 언어 차원에서는 컴파일러가 코드에서 의도한 대로만 동작하게 하려는 철학이 깃들어있다.
그리고 자바는 하위호환성을 칼같이 지킨다.
이제와서 코틀린처럼 참조를 캡처하도록 바꾸고 제약조건을 없앤다고 했을때 기존 코드에 어떤 영향이 갈지 예측할 수 없다.
이런 요인이 합쳐져서 지금까지 final 제약 조건이 남아있다고 생각한다.
해결 방법
힙에 저장하면 final 제약을 받지 않는다는 점을 이용한다.
인스턴스 변수 쓰기
public class Counter {
private int count = 0;
public Supplier<Integer> incrementer() {
return () -> count++;
}
}
지역변수가 아닌 인스턴스 변수를 캡쳐한다면 제약 없이 수정이 가능하다.
대신 람다 외부 클래스의 객체가 필요해진다.
배열로 우회하기
public class Counter {
public Supplier<Integer> incrementer() {
int[] counter = new int[1];
return () -> count[0]++;
}
}
배열은 참조형 객체이므로 람다식 내부에서 변경할 수 있다.
단순하지만 코드의 가독성이 떨어진다.
클래스로 감싸기
class Wrapper<T> {
public T value;
public Wrapper(T value) { this.value = value; }
}
public class Counter {
public Supplier<Integer> incrementer() {
Wrapper<Integer> counter = new Wrapper<>(0);
return () -> counter.value++;
}
}
래퍼 클래스로 변수를 감싸면 힙에 저장되므로 람다식에서 변경 가능해진다.
AtomicXXX
클래스 활용
public class Counter {
public Supplier<Integer> incrementer() {
AtomicInteger counter = new AtomicInteger(0);
return () -> counter.incrementAndGet()++;
}
}
AtomicInteger
나 AtomicReference
같은 클래스를 쓰면 스레드 안전한 방법으로 극복할 수 있다.
코드 구조 변경
int num = 5;
Supplier<Integer> supplier = () -> num + 1;
num = supplier.get();
System.out.println(num); // 출력: 6
코드 구조를 살짝 바꿔서, 람다식에서 지역변수를 수정하는 대신 새로운 값을 반환하도록 한다.
다만 코드 흐름에 따라 오히려 관리가 복잡해질 수도 있다.