어제 자려고 침대에 누웠는데, 갑자기 현업 개발자 분이 나에게 연쇄 질문을 던지셨다.
"람다와 내부 함수의 차이를 알아?"
"그럼, 람다/내부 함수와 클로저의 차이를 알아?"
"람다를 외부에 전달할 수 있을까?"
여기까진 어찌저찌 기억을 더듬어서 잘 답변했는데, 그 다음 질문이 날 곧장 침대에서 일으켜 세웠다.
"그럼 람다/내부함수를 외부 함수에 지역적으로 존재시킬 때, 람다/내부함수가 함수의 지역 변수인 외부 변수를 같이 들고간다면, 이 외부 변수는 언제까지 살아있어야 할까?"
자자, 차근차근 찢어봅시다.
📕 목차
1. 렉시컬 스코프(Lexical Scope)
2. 람다(Lambda) vs 클로저(Closure)
3. Closure가 외부 변수를 참조한 채로 다른 함수에 지역적으로 존재한다면?
1. 렉시컬 스코프(Lexical Scope)
📌 Scope
public class Scope {
public static void test() {
String local = "local";
}
public static void main(String[] args) {
System.out.println(local); // compile error
}
}
- 참조 대상 식별자(변수, 함수 이름처럼 어떤 대상을 다른 대상과 구분하여 식별할 수 있는 유일한 이름)를 찾아내기 위한 규칙
- 함수 레벨 스코프의 변수를 다른 스코프에서 참조하려 하면 컴파일에러가 발생한다.
이는 지역 변수와 전역 변수만이 존재하던 시절의 이야기
📌 렉시컬 스코프(Lexical Scope)란?
public class LexicalScope {
private int x = 0;
private void foo() {
System.out.println(x);
}
private void bar() {
int x = 10;
foo();
}
public static void main(String[] args) {
LexicalScope scope = new LexicalScope();
scope.foo();
scope.bar();
}
}
- 중첩된 함수 그룹에서 내부 함수가 외부의 변수 및 리소스에 액세스할 수 있다.
- 함수를 어디서 호출하는지가 아니라, 어디서 선언하였는지에 따라 상위 스코프가 결정된다.
- 위의 예시를 보면 foo()가 x를 출력하기 위해서 x의 선언 위치를 알아야 하는데, foo()가 클래스 메서드로 선언되었으므로 상위 스코프는 전역 스코프(클래스 멤버 변수)가 된다.
Lexical Scope를 Static Scope라고도 부르는데, 이는 일반적인 자바 프로그래머가 아는 static과는 다른 개념이다.
Static Scope는 변수의 유효 범위는 코드의 구조에 의해 정적으로 결정됨을 의미한다.
2. 람다(Lambda) vs 클로저(Closure)
📌 Lambda
람다는 외부의 값을 건드리지 않는, input에 대한 고정된 output만을 내놓는 순수 함수
// 람다식을 사용한 코드 1
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
// 람다식을 사용한 코드 2. 비교자 생성 메서드 사용
Collections.sort(words, Comparator.comparingInt(String::length));
// 람다식을 사용한 코드 3. List 인터페이스 sort 메서드 사용
words.sort(Comparator.comparingInt(String::length));
@FunctionalInterface
interface Listener<T> {
void onClick(T t);
}
@FunctionalInterface
interface Listener2<T> {
String add(T a, T b);
}
public class Lambda {
public static void main(String[] args) {
Lambda lambda = new Lambda();
Listener<String> listener = (u) -> System.out.println("clicked : " + u);
listener.onClick("button"); // clicked : button
Listener2<String> listener2 = (a, b) -> a + ", " + b;
System.out.println(listener2.add("hello", "world")); // hello, world
}
}
- Functional Interface의 인스턴스를 람다식을 사용해서 만들 수 있다.
- 람다는 자신의 매개변수만을 참조하며, 외부 변수를 참조하지 않는다.
📌 클로저(Closure)
public class Closure {
private Integer global = 10;
private Function<Integer, Integer> foo(Integer b) {
return (a) -> a + b + global;
}
public static void main(String[] args) {
Closure closure = new Closure();
Function<Integer, Integer> function = closure.foo(20);
System.out.println(function.apply(30)); // 60
}
}
- 내부 함수가 외부 함수의 맥락(context)에 접근 가능한 것을 말한다.
- 내부 함수가 외부 함수의 지역 변수를 사용할 수 있다.
- 외부 함수의 실행이 끝나서 소멸된 이후에도, 내부함수가 외부 함수의 지역 변수에 접근 가능하다.
- 클로저란 코드 블록이나 글로벌 콘텍스트에서 정의되지 않고, 코드 블록이 정의된 환경에서 정의되는 자유 변수들을 포함하고 있는 코드 블록이다.
- "클로저"라는 명칭은 실행할 코드 블록과 자유 변수들에 대한 바인딩을 제공하는 평가 환경(범위)의 결합으로부터 탄생한 것이다.
- 즉, 반환된 내부 함수가 자신이 선언되었을 때의 환경인 scope를 기억하여, 자신이 선언됐을 때의 환경 밖에서 호출되어도 그 환경에 접근할 수 있는 함수를 말한다.
클로저라는 이름은 말 그대로 무언가를 닫는다는 것을 의미하는데,
자바의 클로저는 함수가 정의된 시점의 환경을 닫아서, 외부 함수가 소멸되더라도 환경 자체를 가지고 있기 때문에 지역 변수에 접근 가능하다는 원리다. -> 밑에서 다시 다룰 거지만, 람다 표현식이 외부 변수를 capture하여 다루는 방식으로 구현하였다.
이미 예전에 람다와 클로저에 대한 논의로 스택 오버 플로우에서 심도 깊은 이야기들이 오고 갔었다.
여기서 가장 중요한 내용은 Derek Mahar의 람다 표현식의 두 가지 범주에 대한 내용이다. (진짜 좋은 내용이니까 꼭 읽어보면 좋다)
- 람다 추상화는 하위 표현식의 기호를 binding하여 대체 가능한 매개변수가 되도록 할 수 있다.
- 그런데 표현식에 다른 기호가 들어가 있으면, 람다는 해당 기호가 무엇인지, 어디서 왔는지, 의미나 가치를 파악할 수 없기 때문에 그 표현을 평가할 수 없다.
- 람다 표현식은 두 가지 범주로 나눌 수 있다.
- CLOSED : 모든 기호는 일부 람다 추상화에 의해 binding되며, 평가를 위한 주변 context가 필요없다.
- OPEN : 일부 기호가 binding되어 있지 않으며(자유 기호), 외부 정보가 필요하다. 기호의 정의를 제공할 때까지 평가할 수 없다.
- 환경을 제공하여 개방(OPEN)형 람다의 표현식을 닫을 수(CLOSED) 있다.
- 즉, 클로저란 이 표현식의 자유 기호에 값을 제공하여, 더 이상 비자유가 아닌 외부 context(환경)에 정의된 특정 기호 세트가 된다. (개방형 람다 표현식 → 폐쇄형 람다 표현식)
- 람다 표현식 + 정의되지 않은 기호에 대한 환경을 모두 포함한 것이 람다 표현식의 종결이며, 열려 있는 람다 표현식을 닫을 수 있게 된다.
✒️ 유사 파이널(effectively final)
클로저가 필요로 하는 렉시컬 스코프 내의 외부 변수의 값이 변경되면 의도치 않은 결과가 나올 수 있다.
따라서 클로저는 생성 시점에 함수 자체가 복사되어 따로 context를 유지한다.
위 클로저에서 전역 변수 global, 외부 변수 b는 내부 함수인 클로저에서 참조하고 있다.
이 때, global과 b는 컴파일러가 final로 간주하기 때문에 값을 변경하려 하면 컴파일 에러가 발생한다.
(람다 안에서 사용되는 enclosing scope의 지역 변수는 final 또는 effectively final이어야 한다.)
📌 Java에서 Closure 사용 시 문제점
🟡 As-is
public IntUnaryOperator createAccumulator() {
int value = 0;
return (x) -> { value += x; return value; };
}
- 클로저는 다음과 같은 특성을 갖는다.
- 자신이 선언된 환경(scope)을 기억한다.
- 선언된 위치의 scope에 대한 참조를 유지한다.
- 선언된 위치에서 자유 변수를 참조할 수 있다. (심지어 자유 변수가 정의된 외부함수가 소멸했음에도)
- 외부 변수에 접근할 수 있다.
- lambda와 달리, 자신인 선언된 scope에서 벗어난 변수에 접근 가능하다.
- 생성 당시의 값에 접근할 수 있다.
- 변수가 아닌 값에 대한 참조를 가지므로, 클로저가 생성된 시점의 값에 계속해서 접근할 수 있다.
- "생성 당시의 값"이어야 하기 때문에, 람다 표현식이 외부 변수를 캡쳐할 때 effectively final이어야 한다는 규칙이 생긴다.
- 자신이 선언된 환경(scope)을 기억한다.
- 문제는 위의 방식으로는 람다 표현식이 지역 변수를 캡처할 때, 해당 변수가 명시적으로 final 키워드로 선언되지 않아도 사실상 final로 취급되어야 한다는 규칙으로 인해 발생한다.
- value 변수가 람다 표현식 내에서 캡쳐되고 있음과 동시에, 람다 표현식 내에서 수정되고 있기 때문에 JVM은 effectively final이 아닌 상태로 캡처되었다고 판단하여 에러를 발생시킨다.
🟡 class를 생성하여 우회하는 방법
public class AccumulateClosure {
private int value = 0;
private IntUnaryOperator createAccumulator() {
return (x) -> {
value += x;
return value;
};
}
public static void main(String[] args) {
AccumulateClosure closure = new AccumulateClosure();
IntUnaryOperator accumulator = closure.createAccumulator();
int res = accumulator.applyAsInt(10);
System.out.println(res); // 10
System.out.println(closure.value); // 10
res = accumulator.applyAsInt(20);
System.out.println(res); // 30
System.out.println(closure.value); // 30
}
}
- 함수형 프로그래밍은 기본적으로 Stateless하고, 불변성을 유지해야 한다.
- 위의 우회 방법은 람다식이 외부 변수를 변경하고 있기 때문에, IntUnaryOperator 인터페이스 디자인 계약을 파기한다.
- final로 취급되어야 할 지역 변수 값이 변했기 때문에, 해당 closure가 functional object를 받는 내장 함수에 전달되면 충돌을 일으킬 가능성이 높다.
🟡 배열을 사용하여 우회하는 방법
public IntUnaryOperator createAccumulator() {
final int[] value = {0};
return (x) -> { value[0] += x; return value[0]; };
}
- 명시적으로 value를 final로 선언하여 우회할 수 있다.
🟡 Java10 이상에서 사용 가능한 우회 방법
public IntUnaryOperator createAccumulator() {
var value = new int[]{0};
return (x) -> { value[0] += x; return value[0]; };
}
- Java8부턴 final 키워드를 사용하지 않아도 캡처 가능하도록 사실상 final로 취급되도록 코드를 작성해야 한다.
- 위의 방식을 사용하면 첫 번째 요소를 수정하여 변수의 값을 수정할 수 있다.
다만, 위 방식 모두 thread-safe하지 않다는 문제가 있다.
그리고 외부 변수는 배열로 두고, 내부의 값만 바꾼다는 꼼수를 부린 거라서 인터페이스 설계를 준수했다고 보기도 어렵다.
Java에서는 변경 가능한 상태를 캡슐화할 거라면, 그냥 일반 클래스와 메서드로 구현하는 게 최선이다.
public class Accumulator {
private int value = 0;
public int accumulate(int x) {
value += x;
return value;
}
}
아, 애초에 여긴 객체 지향이라고 ㅋㅋ
3. Closure가 외부 변수를 참조한 채로 다른 함수에 지역적으로 존재한다면?
📌 람다 캡처링(Capturing Lambda)
Java에서는 클로저(외부 변수를 참조하는 람다 표현식)처럼 자유 변수(free variable)를 활용할 수 있다.
여기엔 몇 가지 제약이 붙는다.
- 외부 local 변수가 final로 선언되어 있어야 한다.
- final이 아니라면, 사실상 불변(effectively final)해야 한다.
즉, 외부 local 변수가 값이 바뀌거나 다시 할당되어선 안 된다는 의미.
이런 제약이 붙는 이유는 인스턴스 변수와 지역 변수의 저장 위치 차이에서 비롯한다.
- stack 영역 : 지역 변수, 파라미터, 리턴 값 등의 임시 값
- heap 영역 : 인스턴스(Java는 람다도 인스턴스로 다룬다.)
클로저에서 지역 변수로 바로 접근할 수 있다는 가정 하에,
람다가 thread에서 실행(지역 변수는 thread 간에 공유할 수 없지만, 인스턴스는 thread 간에 공유가 가능하다)되거나, 다른 외부 함수에 지역화되었을 때 실제 실행 시점에서 해당 변수에 접근하려 할 수 있다.
따라서, JVM은 원본 변수 접근 허용이 아닌 자유 지역 변수의 복사본을 제공한다.
복사본의 값이 불변해야 하므로, 지역 변수는 한 번만 값을 할당(final)해야 한다는 제약이 생긴 것이다.
람다는 원본 값이 직접 접근하지 않고, 변수를 자신이 동작하는 thread의 stack에 복사하기 때문에 외부 함수가 종료되어도 지역 변수와 동일한 값을 참조할 수 있다.
📌 그럼 복사된 외부 변수는 언제까지 살아 있을까?
복사된 외부 변수는 Stack 영역에 저장된다.
- 데이터, 텍스트 영역에 할당되면, 메모리 해제도 안 되고 공유 자원이 되므로 여기에 저장될 이유는 없다.
- Java는 JVM의 통제를 받기 때문에 new 연산자를 사용하지 않으면 heap 영역에 할당할 수 없다.
그렇다면, 클로저가 종료된 후에 stack 영역에 저장된 값은 언제 사라질까?
바로 인스턴스(클로저)가 자유로워짐으로써 GC에 의해 회수되는 시점이다.
💡 클로저를 모두 사용했다면, 반드시 null 처리를 하여 GC가 수거하게 만들어라!
참 멀리도 왔다.