LabHub
시작하기
배우기 러닝패스 코스

컴파일러 — 작은 언어를 처음부터 끝까지 만든다

문법은 맞는데 뜻이 틀렸다 — 의미 분석

LabHub 에서 이어서 보기

한 줄 요약

파서가 세운 트리는 문법만 맞을 뿐입니다. 의미 분석은 그 트리를 한 번 더 걸으며 이름마다 어느 선언을 가리키는지(이름 해석)와 알 수 있는 곳의 타입이 맞는지를 실행하기 전에 확인하고, 찾은 잘못을 위치 순으로 모두 모아 알려 줍니다.

왜 이게 필요했나

print cnt; 는 문법상 흠이 없습니다. 그런데 선언한 이름이 count 였다면, 인터프리터는 그 줄에 닿는 순간에야 멈춥니다 — 그 줄이 한 달에 한 번 도는 정산 분기 안에 있다면 한 달 뒤에. 실행하지 않고도 알 수 있는 잘못은 실행 전에 알려야 합니다. 그 경계가 어디인지를 정하는 것이 의미 분석입니다.

이름 해석의 결과는 오류만이 아닙니다. "이 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
호출 이름으로 직접 부르는 함수면 인자 수가 맞아야 모름

오류는 발견한 순서가 아니라 위치 순으로 늘어놓습니다. 트리를 걷는 순서는 구현마다 다를 수 있지만(연산자 노드를 먼저 볼지 피연산자를 먼저 볼지), 사용자가 읽는 순서는 파일 위에서 아래입니다.

현장에서 만나는 모습

다음 실습에서 할 것

checker.py 에 스코프 스택, 이름 해석(블록·섀도잉·자기 초기값 읽기), 함수·return·호출 검사, 알 수 있는 곳만 보는 타입 검사, 조건 타입과 결과 정리(analyze)를 차례로 만듭니다. 채점기는 오류 목록과 이름 해석 표를 기준과 통째로 대조하고, 무작위로 만든 올바른 프로그램에서 거짓 경보가 없는지, 한 군데씩 망가뜨린 프로그램에서 같은 오류를 같은 자리에 내는지 봅니다.