선언될 때의 환경을 들고 다닌다 — 클로저
한 줄 요약
트리 워킹 인터프리터는 AST 의 노드마다 "이 노드를 만나면 무엇을 계산한다" 를 적은 함수 하나입니다. 이름은 환경(이름 → 값의 사전이 바깥 환경을 가리키는 사슬)에서 찾고, 함수 값은 선언될 때의 환경을 함께 들고 다닙니다. 이것이 클로저입니다.
왜 이게 필요했나
6모듈의 바이트코드 VM 과 8모듈의 기계어는 빠르지만, 뜻을 정의하기에는 멀리 있습니다. "&& 의 왼쪽이 거짓이면 오른쪽을 계산하지 않는다", "블록이 끝나면 안쪽 이름이 사라진다", "함수는 자기가 선언된 곳의 변수를 본다" 같은 규칙을 가장 곧게 적을 수 있는 곳이 트리 인터프리터입니다. 그래서 이 코스는 인터프리터를 기준으로 삼습니다. 뒤의 VM 과 네이티브 코드는 같은 프로그램을 돌렸을 때 인터프리터와 같은 줄을 찍어야 맞은 것입니다.
그런데 '곧게 적는다' 는 말이 '쉽다' 는 뜻은 아닙니다. 인터프리터를 파이썬으로 짜면 파이썬의 뜻이 몰래 섞여 들어옵니다.
- 파이썬 정수는 끝없이 커지지만 미니의 정수는 64비트에서 감깁니다(
9223372036854775807 + 1은 음수). 기계어가 그렇게 하기 때문입니다. - 파이썬의
-7 // 2는 -4 이지만 미니의-7 / 2는 C 처럼 -3 입니다. - 파이썬에서
True == 1이고isinstance(True, int)도 참입니다. 미니에서1 == true는 실행 오류입니다.
뜻을 정의하는 쪽이 이런 것을 흘리면, 뒤의 모든 구현이 틀린 기준에 맞춰집니다.
어떻게 동작하나
환경은 사슬입니다. 블록에 들어갈 때마다, 함수를 부를 때마다 새 환경을 만들고 부모를 가리키게 합니다. 이름 찾기는 사슬을 따라 올라가고, 대입은 그 이름이 사는 환경의 칸을 바꿉니다 — 지금 환경에 새로 만들지 않습니다.
fn counter(start) { counter(10) 을 부르면
let n = start; [호출 환경 {start:10, n:10}] → [전역 {counter, …}]
fn next() { n = n + 1; next 를 선언하는 순간, 함수 값 = (next 의 선언, 지금 환경)
return n; }
return next; counter 가 끝나도 호출 환경은 next 가 붙잡고 있어 살아남는다
}
let a = counter(10); a() → 새 환경 {} → 부모는 붙잡아 둔 호출 환경 → n 을 11 로
print a(); print a(); 11, 12 — 같은 칸을 바꾸기 때문에 이어진다
함수 값을 부를 때의 환경이 아니라 선언될 때의 환경을 부모로 삼는 것이 핵심입니다(정적 스코프). 부를 때의 환경을 부모로 삼으면(동적 스코프) a() 는 counter 의 n 을 찾지 못하고, 전역에 같은 이름이 있으면 엉뚱하게 그것을 바꿉니다. 클로저가 환경을 복사해 가도 틀립니다 — 같은 환경을 붙잡은 두 함수(값을 올리는 함수와 읽는 함수)가 서로 다른 칸을 보게 됩니다.
return 은 여러 층을 한 번에 빠져나갑니다. while 안의 if 안의 return 은 호출 자리까지 곧장 돌아가야 합니다. 파이썬으로는 예외 하나(ReturnSignal)를 던지고 호출 자리에서 잡는 것이 가장 간단합니다. 호출이 끝나면 호출 깊이를 반드시 되돌려야 하는데, 예외로 빠져나가는 길에서 빠뜨리기 쉽습니다(finally 가 필요한 자리).
실행 오류는 연산자의 위치로. a / b 에서 b 가 0 이면 / 의 줄:칸으로 "runtime error: division by zero" 를 냅니다. 의미 분석이 모르고 넘어간 타입(매개변수로 들어온 true)도 여기서 걸립니다. 동시에 살아 있는 호출이 200개를 넘으면 "stack overflow" 로 멈춥니다 — 파이썬 자신의 재귀 한도에 먼저 닿으면 사용자에게 파이썬 오류를 보여 주게 됩니다.
현장에서 만나는 모습
- CPython 의
nonlocal. 파이썬 클로저에서 바깥 변수에 대입하려면nonlocal을 적어야 합니다. 적지 않으면 대입이 지금 환경에 새 이름을 만든다는 규칙 때문입니다 — 이 모듈의 "대입은 이름이 사는 칸을 바꾼다" 와 정반대 기본값을 고른 셈이고, 그래서 생기는 UnboundLocalError 는 파이썬 초보자가 가장 자주 만나는 오류 가운데 하나입니다. - 반복문 안의 클로저. 자바스크립트의
var로 만든 반복 변수를 클로저 여러 개가 붙잡으면 모두 같은 칸(마지막 값)을 봅니다.let이 반복마다 새 환경을 만들도록 바뀐 까닭입니다. 환경이 언제 새로 생기는지가 곧 언어의 뜻입니다. - 기준 구현. 많은 언어가 사양 문서와 함께 '느리지만 곧은' 인터프리터를 기준으로 두고, 빠른 컴파일러가 그와 같은 결과를 내는지 대조합니다. 이 코스의 채점기가 하는 일도 같습니다.
다음 실습에서 할 것
interp.py 에 값 규칙(64비트 감기, 0 쪽 나눗셈, bool 과 int 가르기), 환경 사슬, 식 계산(단락 평가와 실행 중 타입 검사), 문장과 블록, if·while, 함수와 클로저·return·호출 깊이, 그리고 파싱부터 실행까지 잇는 run_source 를 만듭니다. 채점기는 무작위 수 수백 쌍, 고정 프로그램, 무작위 프로그램 150편에서 찍은 줄과 실행 오류를 기준과 대조합니다.