[Java] 람다식에서 final 변수만 사용해야 하는 이유
람다 표현식에서 변수를 사용하다보면, 어느순간 만나게 되는 경고 문구가 있다.
왜 람다식에서는 이런 제약이 있는 걸까? 이것을 알기 위해선 자바에서의 메모리 개념과, 람다식이 어떻게 자유변수를 사용하는지 알아야 한다.
1. Stack 메모리, Heap 메모리
자바에 존재하는 메모리 중 두 개의 메모리 작동 방식을 알아야 한다.
1.1 Stack 메모리
- 메서드 실행 시 생성되고, 메서드 종료 시 제거된다.
- 메서드 내부에서 선언된 지역변수는 메서드 실행 시, 이 곳에 로드된다.
1.2 Heap 메모리
- 인스턴스화된 객체의 주소가 이 곳에 로드된다.
- 더 이상 참조되지 않는다면 GC의 대상이 된다.
- 참조타입의 변수의 경우 인스턴스화 될 시, 참조주소가 이 곳에 로드된다.
Heap 메모리에 존재하는 변수는 final 제약이 없지만, Stack 메모리에 존재하는 변수는 반드시 final 이어야 한다. 이는 람다식이 변수를 활용하는 방법에 이유가 있다.
2. 람다식에서 변수를 활용하는 방법
람다식은 인스턴스화 되는 시점에서 변수를 “복사” 한다. 이를 람다 캡쳐링
이라고 한다.
- 원시 타입 변수의 경우, 값을 그대로 복사한다.
- 참조 타입 변수의 경우, 참조 주소를 복사한다.
아직 잘 이해되지 않는다. 원시타입의 예를 들어 변수를 바꿔보자.
2.1 람다식 내부에서 변수를 바꾸려고 시도하는 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
runLambda();
}
public static void runLambda() {
int number = 10;
Runnable runnable = () -> {
System.out.println("Number is: " + number++); // 여기서 자유 변수 number를 복사
};
runnable.run();
}
}
이 runLambda()
메서드를 메인에서 실행시킨다고 해보자. 흐름은 다음과 같다.
main
에서runLambda()
메서드 실행runLambda()
와number = 10
가 stack 메모리에 로드됨- runnable 람다식이 인스턴스화 되어 heap 메모리에 로드됨. 이때 number 자체를 가지고 가는 것이 아닌, 값만(=10) 가져감.
run()
(컴파일 안됨)
엥? 아직 메서드가 종료되지 않았으니, number에 접근할 수 있는거 아니야?
라고 생각할 수 있다. 하지만 람다식은 값을 복사 해서 가져가기 때문에 변화시킬 수 없다.
또한 이 경우에선 number
가 stack 메모리에 살아있지만, 람다식은 언제 실행될지 모른다. 다음 예를 보자.
1
2
3
4
5
6
7
Runnable r;
{
int number = 10;
r = () -> System.out.println(number);
}
// 여기서 number는 stack 메모리에 존재하지 않음
r.run(); // ❗ 아직도 람다 실행 가능
이 경우엔 number
는 이미 stack 메모리에서 사라졌다. 이 경우에 number
를 변경하려고 시도하면, 없는 변수를 변화시키는 꼴이 된다.
2.2 람다식 외부에서 변수를 바꾸려고 시도하는 경우
runLambda()
가 이런 꼴이라면 어떨까?
1
2
3
4
5
6
7
8
9
10
public static void runLambda() {
int number = 10;
Runnable runnable = () -> {
System.out.println("Number is: " + number); // 여기서 자유 변수 number를 복사
};
number += 1
runnable.run();
}
위에서 말한 것 처럼 람다는 값을 복사한 후 인스턴스화되기 때문에 람다식 외부에서 변경한다 한들, 영향을 줄 수 없다.
이 코드에서는 프로그래머는 number 가 11이 될 것을 의도했지만, 의도랑 다르게 동작한다.
이것이 변수가 사실상 final
이어야 하는 이유다. 변경할 것이라면, 애초에 컴파일 단계에서 막는 것이다.
정리하자면 다음과 같은 이유에서, 람다식은 반드시 final(+ 사실상 final)을 사용해야 한다.
- 람다식은 값을 복사(캡쳐)함.
- 람다식은 어느 시점에 사용될지 모르기 때문에 불변을 보장해야함.
- 프로그래머의 의도와 다르게 동작할 수 있기 때문에 불변을 보장해야함.
그렇기 때문에 자바는 애초에 컴파일 단계에서부터 변경을 막아놓는 것이다.
3. 만약 지역변수가 참조타입이라면?
위 예시를 제대로 이해했다면 자연스럽게 이런 생각이 떠오를 것이다.
지역변수가 원시형이 아니고 참조형이면, 참조 주소를 복사하게 되니 변경시킬 수 있는거 아닌가?
코드로 확인해보자.
1
2
3
4
5
6
7
8
9
public void runLambda() {
Person person = new Person("Alice");
Runnable r = () -> {
System.out.println(person.getName());
person.setName("Bob"); // ❓이건 가능할까?
};
r.run();
}
여기서 person.setName()
은 가능할까? 정답은 가능하다. 람다식 내부에서 person
은 어떠한 변수명이 아니라, 복사한 참조주소 값이다. 참조주소는 heap에 존재하기 때문에, 변경이 가능해진다.
하지만 person
을 새로운 값으로 재할당하는 것은 불가능하다. 이유는 person
이라는 변수명은 이미 사라졌을 수도 있기 때문이다.
1
2
3
Runnable r = () -> {
person = new Person("Charlie"); // ❌ 컴파일 에러!
};