클로저는 함수와 그 함수가 정의된 환경을 함께 묶은 것이고, 셀은 그 환경을 구현하는 한 칸짜리 상자다. 자유 변수를 어떻게 붙잡아 두느냐가 클로저 구현의 전부다.
문제가 어디서 생기는지부터 봐야 한다.
def outer(a): def inner(b): return a + b return innerinner 안의 a는 자기 지역변수도 전역변수도 아니다. 바깥 함수의 지역변수이고, 이런 변수를 자유 변수(free variable)라고 한다. a는 outer의 스택 프레임에 있는데, outer는 inner를 반환하면서 사라진다. 없어진 프레임의 변수를 어떻게 읽을 것인가.
함수가 탈출하지 않는다면
Section titled “함수가 탈출하지 않는다면”이 문제는 함수가 정의된 스코프 밖으로 나갈 수 있어야만 생긴다. Pascal이나 Algol처럼 중첩 프로시저는 있지만 프로시저를 값으로 반환할 수 없는 언어에서는 훨씬 간단하다.
기호표에 이름마다 레벨과 오프셋을 기록해 두면 된다. 레벨은 몇 겹 중첩된 블록인지, 오프셋은 그 프레임 안에서 몇 번째 자리인지다. 안쪽 프로시저가 바깥 변수를 읽을 때는 정적 링크를 레벨 차이만큼 따라 올라가 오프셋으로 접근한다. 바깥 프레임이 반드시 아직 살아 있기 때문에 가능하다.
호출이 끝나면 프레임이 사라지므로 스택만으로 끝난다. 힙도 필요 없고 셀도 필요 없다.
함수를 값으로 다루기 시작하면 이 전제가 깨진다. 안쪽 함수가 바깥 프레임보다 오래 살 수 있고, 이것을 upward funarg 문제라고 한다. 해법은 자유 변수를 스택에서 힙으로 옮기는 것 하나뿐이고, 어떻게 옮기느냐에서 구현이 갈린다.
누가 자유 변수인지 정하기
Section titled “누가 자유 변수인지 정하기”옮기려면 먼저 어느 이름이 자유 변수인지 알아야 한다. 이건 기호표의 일이고, 컴파일 시점에 끝난다.
CPython은 이름을 다섯 가지로 분류한다.
#define LOCAL 1#define GLOBAL_EXPLICIT 2 /* global 문 */#define GLOBAL_IMPLICIT 3 /* 어디에도 대입이 없어 전역으로 판단 */#define FREE 4 /* 바깥 함수의 변수를 읽는다 */#define CELL 5 /* 내 변수인데 안쪽 함수가 쓴다 */FREE와 CELL이 한 쌍이다. 안쪽 함수에서 FREE인 이름은 바깥 함수에서 CELL이 된다. CELL은 이 변수는 나만 쓰는 게 아니니 상자에 넣으라는 표시다.
symtable 모듈로 직접 볼 수 있다.
import symtablest = symtable.symtable(src, '<s>', 'exec')outer_ns = st.lookup('outer').get_namespace()inner_ns = outer_ns.lookup('inner').get_namespace()
inner_ns.get_frees() # ('a',)outer.__code__.co_cellvars # ('a',)inner.__code__.co_freevars # ('a',)a가 outer에서는 셀 변수, inner에서는 자유 변수로 잡혔다. 이 판단이 Python/symtable.c 3,377줄의 독립된 패스에서 이뤄지고, 코드 생성기는 그 결과를 받아 쓴다.
두 가지 붙잡는 방법
Section titled “두 가지 붙잡는 방법”값을 복사한다
Section titled “값을 복사한다”가장 단순한 방법은 함수 객체를 만들 때 자유 변수의 값을 복사해 함께 저장하는 것이다.
클로저 = { 코드, [자유변수 값들] }inner를 만들 때 a의 값 10을 꺼내 클로저에 넣어두고, inner 실행 중에는 그 복사본을 읽는다. 구현이 간단하고 접근도 빠르다. 배열 인덱스 한 번이다.
대신 쓰기가 안 된다. 안쪽 함수가 a를 바꿔도 복사본만 바뀌고 바깥에는 보이지 않는다. 두 개의 안쪽 함수가 같은 변수를 공유할 수도 없다. 캡처가 값 단위라 각자 자기 복사본을 갖기 때문이다.
읽기만 하는 클로저에는 충분하다. 실제로 Java의 람다가 이 모델이고, 그래서 캡처되는 지역변수가 final이거나 사실상 final이어야 한다.
상자를 공유한다
Section titled “상자를 공유한다”CPython은 변수를 한 칸짜리 힙 객체로 승격시키고 그 객체를 공유한다.
typedef struct { PyObject_HEAD PyObject *ob_ref; /* 셀의 내용, 비어 있으면 NULL */} PyCellObject;필드 하나짜리 객체다. 바깥 함수의 지역 슬롯에 값 대신 이 셀을 넣고, 안쪽 함수에는 셀 자체를 넘긴다. 양쪽이 같은 셀을 가리키므로 한쪽에서 쓰면 다른 쪽에서 보인다. 대신 자유 변수가 있는 함수를 호출할 때마다 PyCell_New가 불려서, 지역변수로 끝났을 것이 힙 객체가 된다.
세 방식을 나란히 놓으면 이렇다.
| 레벨+오프셋 | 값 복사 | 셀 공유 | |
|---|---|---|---|
| 함수 탈출 | 불가 | 가능 | 가능 |
| 저장 위치 | 스택 | 클로저 배열 | 힙 셀 객체 |
| 자유 변수 쓰기 | 가능 (프레임 직접) | 불가 | 가능 |
| 함수 간 공유 | 해당 없음 | 불가 | 가능 |
| 접근 비용 | 정적 링크 추적 | 배열 인덱스 | 슬롯 → 셀 → 값 |
| 루프 변수 캡처 | 해당 없음 | 각자 복사본 | 하나를 공유 |
| 예 | Pascal, Algol | Java 람다 | Python, JavaScript |
바이트코드로 보기
Section titled “바이트코드로 보기”앞의 outer/inner를 디스어셈블하면 네 개의 명령이 협력하는 게 보인다.
outer: MAKE_CELL 0 (a) ← 지역 슬롯의 값을 셀로 승격 RESUME 0 LOAD_FAST_BORROW 0 (a) ← 셀 객체 자체를 스택에 BUILD_TUPLE 1 LOAD_CONST 0 (<code object inner>) MAKE_FUNCTION SET_FUNCTION_ATTRIBUTE 8 (closure) ← 셀 튜플을 함수에 붙인다 STORE_FAST 1 (inner)
inner: COPY_FREE_VARS 1 ← 함수의 셀들을 프레임 슬롯으로 RESUME 0 LOAD_DEREF 1 (a) ← 셀을 열어 값을 꺼낸다 LOAD_FAST_BORROW 0 (b) BINARY_OP 0 (+) RETURN_VALUE각 명령의 구현이 그대로 설명이 된다.
inst(MAKE_CELL, (--)) { PyObject *initial = PyStackRef_AsPyObjectBorrow(GETLOCAL(oparg)); PyObject *cell = PyCell_New(initial); // 값을 상자에 넣고 GETLOCAL(oparg) = PyStackRef_FromPyObjectSteal(cell); // 슬롯을 상자로 교체}
inst(COPY_FREE_VARS, (--)) { PyObject *closure = func->func_closure; int offset = co->co_nlocalsplus - oparg; for (int i = 0; i < oparg; ++i) { frame->localsplus[offset + i] = PyStackRef_FromPyObjectNew(PyTuple_GET_ITEM(closure, i)); }}
inst(LOAD_DEREF, ( -- value)) { PyCellObject *cell = (PyCellObject *)PyStackRef_AsPyObjectBorrow(GETLOCAL(oparg)); value = _PyCell_GetStackRef(cell); // 상자를 연다}MAKE_CELL이 함수 맨 앞에 온다. 자유 변수로 쓰일 이름은 나중에 클로저를 만들 때가 아니라 함수가 시작하자마자 셀로 승격된다.
지역변수 접근이 LOAD_FAST(슬롯 직접 읽기)인 반면 셀 변수는 LOAD_DEREF(슬롯에서 셀을 꺼내 다시 그 안을 읽기)라 간접 참조가 한 단계 더 있다. 셀 공유의 비용이 여기다. 그래서 자유 변수는 지역변수보다 느리고, 성능이 중요한 루프에서 바깥 변수를 지역변수로 복사해 두는 관용구가 생긴다.
셀을 공유한다는 것
Section titled “셀을 공유한다는 것”셀을 공유해서 얻는 것은 쓰기다.
def counter(): n = 0 def inc(): nonlocal n n += 1 return n def get(): return n return inc, get
inc, get = counter()inc(); inc(); inc()get() # 3inc.__closure__[0] is get.__closure__[0] # True — 같은 셀 객체두 함수가 문자 그대로 같은 셀 객체를 가리킨다. 값 복사 모델로는 이게 불가능하다.
nonlocal n; n += 1의 바이트코드가 STORE_DEREF다.
LOAD_DEREF 0 (n)LOAD_SMALL_INT 1BINARY_OP 13 (+=)STORE_DEREF 0 (n) ← 셀 내용을 갈아끼운다STORE_DEREF는 셀의 ob_ref를 바꾼다. 셀 자체는 그대로이므로 그 셀을 보는 모든 함수가 새 값을 본다.
셀은 힙 객체이므로 바깥 프레임보다 오래 산다.
def outer(): x = [1, 2, 3] def inner(): return x return inner
f = outer() # outer의 프레임은 사라짐f() # [1, 2, 3] — 셀이 살아 있다같은 성질이 함정이 되기도 한다.
def make(): fs = [] for i in range(3): fs.append(lambda: i) return fs
[f() for f in make()] # [2, 2, 2]make()[0].__closure__[0] is make()[1].__closure__[0] # True0, 1, 2를 기대하지만 2, 2, 2가 나온다. 루프 변수 i가 반복마다 새로 만들어지는 게 아니라 하나의 셀을 계속 갈아끼우기 때문이다. 세 람다가 같은 셀을 보고 있고, 루프가 끝난 시점의 값이 2다.
값 복사 모델이었다면 각 람다가 만들어지는 시점의 값을 복사하므로 0, 1, 2가 나온다. 실제로 기본 인자로 복사를 흉내 내면 그렇게 된다.
fs.append(lambda i=i: i) # [0, 1, 2]공유가 맞을 때도 있고 복사가 맞을 때도 있어서 언어마다 선택이 갈린다. JavaScript는 var에서 함수 스코프 하나를 공유해 같은 문제가 있었고, let이 블록마다 새 바인딩을 만들도록 해서 해결했다. Go도 1.22에서 루프 변수를 반복마다 새로 만드는 쪽으로 바꿨다.
참고
- opcode
- 중간언어
- Lexical Scope와 Closure
- Python VM
- https://docs.python.org/3/library/symtable.html
- https://go.dev/wiki/LoopvarExperiment
- https://github.com/python/cpython/blob/main/Python/symtable.c
- https://github.com/python/cpython/blob/main/Include/cpython/cellobject.h
- https://github.com/python/cpython/blob/main/Include/internal/pycore_symtable.h