文法は正しいのに意味が違う — 意味解析
한국어 원문으로 표시합니다.
한 줄 요약
파서가 세운 트리는 문법만 맞을 뿐입니다. 의미 분석은 그 트리를 한 번 더 걸으며 이름마다 어느 선언을 가리키는지(이름 해석)와 알 수 있는 곳의 타입이 맞는지를 실행하기 전에 확인하고, 찾은 잘못을 위치 순으로 모두 모아 알려 줍니다.
왜 이게 필요했나
print cnt; 는 문법상 흠이 없습니다. 그런데 선언한 이름이 count 였다면, 인터프리터는 그 줄에 닿는 순간에야 멈춥니다 — 그 줄이 한 달에 한 번 도는 정산 분기 안에 있다면 한 달 뒤에. 실행하지 않고도 알 수 있는 잘못은 실행 전에 알려야 합니다. 그 경계가 어디인지를 정하는 것이 의미 분석입니다.
- 이름: 쓰인 이름이 어느 선언을 가리키는가. 없으면 오류, 같은 스코프에 두 번 선언하면 오류,
let x = x + 1;처럼 선언하는 중에 자기를 읽으면 오류. - 구조: 함수 밖의
return, 인자 수가 다른 호출. - 타입:
1 + true, 조건이 정수인if (n).
이름 해석의 결과는 오류만이 아닙니다. "이 x 는 3번 줄의 그 x 다" 라는 표는 뒤 단계의 재료가 됩니다. 인터프리터는 이 표 덕분에 섀도잉된 이름을 헷갈리지 않고, 컴파일러는 이 표로 지역 변수의 슬롯 번호를 정합니다.
어떻게 동작하나
스코프는 스택입니다. 맨 아래가 전역이고, 블록과 함수에 들어갈 때마다 빈 사전을 하나 쌓고 나올 때 버립니다. 이름을 찾을 때는 안쪽부터 봅니다. 그래서 안쪽의 같은 이름이 바깥 것을 가립니다(섀도잉). 블록을 나올 때 사전을 버리지 않으면 안쪽 이름이 밖으로 새어 나옵니다 — 스코프 누수입니다.
let x = 1; 스택 [ {x:1:5} ]
{ 스택 [ {x:1:5}, {} ]
let x = true; 스택 [ {x:1:5}, {x:3:7} ] ← 여기서 x 는 3:7 (bool)
print !x;
} 스택 [ {x:1:5} ] ← 안쪽 사전을 버린다
print x + 1; x 는 다시 1:5 (int)
선언은 두 번에 나눠 넣습니다. let 은 이름을 '아직 정의되지 않음' 으로 먼저 넣고, 초기값을 검사한 뒤 '정의됨' 으로 바꿉니다. 그 사이에 같은 스코프에서 자기 이름을 읽으면 "자기 초기값에서 읽기" 오류입니다. 반대로 함수는 이름을 먼저 완전히 넣은 뒤 몸체를 검사합니다. 그래야 몸체에서 자기 자신을 부를 수 있습니다(재귀). 매개변수와 몸체의 맨 바깥 선언은 한 스코프를 씁니다 — fn f(a) { let a = 1; } 은 중복입니다.
타입은 알 수 있는 곳만 봅니다. 미니는 매개변수에 타입을 적지 않습니다. 그러니 fn f(p) { return p + 1; } 의 p 는 타입을 모릅니다. 모르는 곳까지 오류로 잡으면 멀쩡한 프로그램이 거절되고(거짓 경보), 모르는 곳을 아무거나로 치면 뒤에서 거짓말을 합니다. 그래서 규칙은 하나입니다 — 양쪽 타입을 다 알 때만 검사하고, 모르면 넘어갑니다. 넘어간 곳은 실행할 때 인터프리터가 한 번 더 검사합니다. 이런 방식을 점진적 타입 검사라고 부릅니다.
| 식 | 요구 | 결과 |
|---|---|---|
+ - * / % ^, 앞의 - |
양쪽 int | int |
< <= > >= |
양쪽 int | bool |
== != |
양쪽이 같은 타입 | bool |
! && || |
bool | bool |
if·while 의 조건 |
bool | — |
| 호출 | 이름으로 직접 부르는 함수면 인자 수가 맞아야 | 모름 |
오류는 발견한 순서가 아니라 위치 순으로 늘어놓습니다. 트리를 걷는 순서는 구현마다 다를 수 있지만(연산자 노드를 먼저 볼지 피연산자를 먼저 볼지), 사용자가 읽는 순서는 파일 위에서 아래입니다.
현장에서 만나는 모습
- 정적 검사의 끝을 어디로 잡느냐가 언어의 성격이다. 파이썬은 이름 오류조차 실행해야 알고(그래서 mypy·pyright 같은 도구가 따로 있습니다), Java 는 타입과 이름을 컴파일에 모두 봅니다. Rust 는 여기서 더 나아가 값이 언제까지 살아 있는지와 누가 바꿀 수 있는지까지 컴파일할 때 봅니다 — 그 이야기는 Rust — 컴파일러가 막는 것들 코스가 오류 메시지를 읽으며 다룹니다. 이 모듈의 스코프 스택은 그 검사기들이 모두 딛고 선 바닥입니다.
- 섀도잉 경고. 여러 린터가 "바깥 변수를 가린다" 를 경고로 둡니다. 오류는 아니지만, 스코프 누수와 섀도잉을 헷갈려 생기는 버그가 흔하기 때문입니다.
- 거짓 경보의 비용. 정적 검사기가 멀쩡한 코드를 자꾸 오류로 잡으면 사람들은 검사기를 끕니다. '모르면 넘어간다' 는 규칙은 느슨해 보이지만, 검사기가 계속 켜져 있게 하는 규칙입니다.
다음 실습에서 할 것
checker.py 에 스코프 스택, 이름 해석(블록·섀도잉·자기 초기값 읽기), 함수·return·호출 검사, 알 수 있는 곳만 보는 타입 검사, 조건 타입과 결과 정리(analyze)를 차례로 만듭니다. 채점기는 오류 목록과 이름 해석 표를 기준과 통째로 대조하고, 무작위로 만든 올바른 프로그램에서 거짓 경보가 없는지, 한 군데씩 망가뜨린 프로그램에서 같은 오류를 같은 자리에 내는지 봅니다.