LabHub
Get started
배우기 러닝패스 코스

Compilers — Build a Small Language from Start to Finish

Why a Lexer Remembers Positions

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

렉서는 글자의 줄을 토큰(종류가 붙은 낱말)의 줄로 바꾸면서 공백과 주석을 걸러 내고, 토큰마다 어디서 왔는지(줄·칸) 를 붙여 둡니다. 파서는 그 덕분에 글자가 아니라 뜻 있는 조각을 보고, 뒤의 모든 오류 메시지는 그 위치를 물려받습니다.

왜 이게 필요했나

파서가 글자를 직접 읽는다고 해 봅시다. let x=10;let x = 10 ;let x /* 설명 */ = 10; 은 같은 문장인데, 파서의 모든 규칙이 공백과 주석을 건너뛰는 코드를 따로 가져야 합니다. <= 를 만날 때마다 "다음 글자가 = 인가" 를 규칙마다 확인해야 하고, letter 가 키워드 let 으로 시작한다는 사실도 규칙마다 조심해야 합니다.

렉서는 이 일을 한곳에 모읍니다. 파서가 보는 것은 let, IDENT(x), =, INT(10), ;, EOF 여섯 개뿐입니다. 그리고 한 가지를 더 합니다 — 위치를 기억합니다. 사용자에게 "뭔가 틀렸다" 가 아니라 "3번 줄 17번째 칸의 @ 를 모른다" 고 말할 수 있는 것은, 가장 앞 단계가 글자를 셀 때 줄과 칸을 함께 세어 두었기 때문입니다. 이 좌표는 파서의 "; 이 와야 한다", 의미 분석의 "선언되지 않은 이름", 실행 중의 "0 으로 나눔" 까지 전부 같은 좌표계를 씁니다.

어떻게 동작하나

렉서는 커서 하나로 글자를 한 번 훑습니다. 매 걸음마다 지금 글자를 보고 어떤 토큰이 시작되는지 정한 뒤, 그 토큰이 끝날 때까지 삼킵니다.

 let  x1 <= 007 ; // 끝
 ^^^  ^^ ^^ ^^^ ^
 let  IDENT  <=  INT  ;   (주석과 공백은 버리고) EOF
 1:2  1:7  1:10 1:13 1:17                        1:23

지켜야 할 규칙이 네 가지 있습니다.

가장 긴 것부터(maximal munch). <= 를 읽을 수 있으면 <= 로 자르지 않습니다. 두 글자 기호를 먼저 확인하고 안 되면 한 글자로 내려옵니다. 이름도 같습니다 — letter 는 글자를 끝까지 읽은 뒤에 키워드인지 봅니다. 앞 세 글자가 let 이라고 먼저 자르면 let + ter 가 됩니다.

칸은 글자로 셉니다. 파일은 UTF-8 바이트지만 사람이 보는 칸은 글자입니다. /* 한글 */ x 에서 x 는 10번째 글자이지만 14번째 바이트입니다. 바이트로 세면 한글 주석이 있는 줄의 모든 오류 위치가 밀립니다. 줄바꿈을 지나면 칸은 1로 돌아갑니다.

오류는 그 시작 위치로. 닫히지 않은 /* 는 파일 끝에서 발견되지만, 알려 줄 위치는 주석이 시작한 곳입니다. 끝 위치를 알려 주면 사용자는 파일 끝에서 헤맵니다. 64비트에 들지 않는 정수도 그 숫자의 첫 칸으로 알립니다.

끝을 토큰으로. 마지막에는 늘 EOF 토큰을 둡니다. 파서가 "토큰이 더 있나" 를 따로 확인하지 않아도 되고, "} 가 와야 하는데 파일이 끝났다" 는 오류도 EOF 의 위치(마지막 글자 바로 뒤)로 정확히 찍힙니다.

한 번만 훑는다는 것도 중요합니다. 남은 글자를 src = src[1:] 처럼 매번 잘라 새로 만들면 글자 하나 읽을 때마다 파일 전체를 복사하게 되어, 파일이 두 배가 되면 시간이 네 배가 됩니다. 커서는 자리 번호 하나를 옮길 뿐이어야 합니다.

현장에서 만나는 모습

다음 실습에서 할 것

미니 언어의 렉서를 lexer.py 에 다섯 조각으로 만듭니다 — 줄·칸을 세는 커서, 공백과 주석 건너뛰기, 정수와 이름·키워드, 가장 긴 것부터 자르는 기호, 그리고 이것들을 잇는 tokenize. 채점기는 토큰 하나하나의 줄·칸까지 기준 렉서와 대조하고, 한글이 섞인 주석·64비트를 넘는 정수·닫히지 않은 주석에서 오류 글자까지 같은지 보며, 끝으로 입력을 네 배로 늘려 시간이 선형으로 느는지 잽니다.