#컴파일러 #compiler #파서 #parser ![[13. Parser (7) - Post-lecture.pdf]] # Part VIII: Bottom-Up Parsing (4) - LR(1) Parser ## 다루고자 하는 내용 - SLR(1) 파서의 근본적인 문제 - **LR(1) Parsing**: lookahead token을 보다 섬세하게 고려한 파서 ## SLR(1)은 항상 잘 작동할까? ### 예제: 대입 문법 (Assignment Grammar) ```f# 문법: S ::= L = R | R L ::= * R | [id] R ::= L (좌변은 L-value, 우변은 R-value. C에서 *p = x 같은 대입문) FIRST/FOLLOW: NULLABLE FIRST FOLLOW S false *, [id] $ L false *, [id] $, = R false *, [id] $, = ← 주목! ``` ### LR(0) DFA를 만들어보면 Conflict 발생 ```f# Parsing Table (LR(0)): Action Goto = * [id] S L R $ 0 S4 S5 G1 G2 G3 1 A 2 S6/R5 R5 R5 ← SHIFT/REDUCE Conflict! 3 R2 R2 R2 4 S4 S5 G7 G8 5 R4 R4 R4 6 S4 S5 G7 G9 7 R5 R5 R5 8 R3 R3 R3 9 R1 R1 R1 State 2: { S → L . = R, R → L . } - '=' 받으면 Shift (S → L = R 진행)하거나 - '=' 받으면 R → L로 Reduce할 수도 있음 (R의 FOLLOW에 '='가 있으니까) → SHIFT/REDUCE Conflict ``` ### SLR(1)로도 해결 안 됨! - SLR(1)은 `R → L`로 reduce할 때 lookahead ∈ FOLLOW(R) = {$, =}인지 확인 - lookahead가 `=`면 FOLLOW(R)에 속하므로 **Reduce를 허용** - 그런데 실제로는 **`=`가 오면 Reduce하면 안 됨** (여전히 충돌) ### 왜 문제인가? `a = b` 예제로 추적 ```f# 올바른 derivation: S → L = R → [id] = R (a = R) → [id] = L (a = L) → [id] = [id] (a = b) Parse Tree: S / | \ L = R | | a L | b 그런데 SLR(1)로 파싱하면: Stack: L, lookahead: = → R의 FOLLOW에 =가 있으니 R → L로 reduce할 수 있음 → 결과: Stack이 R이 됨 Stack: R, lookahead: = → 이제 뭘 할 수가 없음! R 다음에 =가 올 수 없는데 파싱은 진행됨 → "뭔가 이상한데요?" ``` ## 문제의 원인 - **`R` 뒤에는 `=`가 올 수 없음** - `* R = R` (ok): S → L = R → (*R) = R - `R = R` (no): R 바로 뒤에 =가 나오는 derivation은 없음 - 그런데 FOLLOW(R) = {$, =}라서 SLR(1)은 reduce를 허용해버림 - **FOLLOW Set은 과하게 근사한 결과** - `R 뒤에 항상 따라오는 문자들의 집합`이 아니라 - `경우에 따라 올 수도, 안 올 수도 있는 문자들의 집합` ## SLR(1)의 근본적인 문제 - **근본 원인: LR(0) item 기반이기 때문** - Viable prefix DFA를 lookahead를 고려하지 않고 설계 - Reduce가 일어날 수 있는 상황에서 부정확한 FOLLOW를 사용하여 lookahead를 고려 - **상황에 따라 FOLLOW가 달라질 수 있음을 인식 불가** - → **Lookahead token을 고려하여 Item을 설계하자!** = LR(1) Item ## LR(1) Item - LR(1) item은 LR(0) item의 **세분화된** 개념 - LR(0) item: `L → [id] .` - LR(1) item: `L → [id] . , =` 와 `L → [id] . ,
(lookahead에 따라 구분) - LR(1) item은 LR(0) item과 lookahead token $a$의 pair - `type LR1Item = LR0Item * T` - $A \to \alpha . \beta,; a$의 형태 - $\alpha$: stack top에 존재 - $\beta a$: 남은 입력 token list는 $\beta a$로부터 유도된 문자열을 prefix로 가짐 - $A \to \alpha \beta$를 reduce하고 나면, 남은 입력의 제일 처음은 **반드시 $a$여야 한다** ### 각 LR(1) Item의 의미 - $A \to .\alpha \beta,; a$: 지금까지 읽은 입력은 $A \to \alpha \beta$로 reduce하기에 문제 없는 상태 - $A \to \alpha . \beta,; a$: 위 + $\alpha$까지 인식한 상태 - $A \to \alpha \beta .,; a$: 위 + $\alpha \beta$까지 인식한 상태이고, **lookahead가 $a$이면 reduce 가능** ## LR(1) Item 계산: 어떤 lookahead가 올까? - LR(0) item은 모든 경우가 바로 보임: `L → . [id], L → [id] .` - LR(1) item은 **어떤 lookahead token이 올지 알아야 함** - 예: `S → . L = R, [id]`는 의미 없음 — S 뒤에 [id]가 올 수 없음 (FOLLOW(S) = {$}) ### 아이디어: LR(1) Item의 정의로부터 계산 - LR(1) Item $(A \to \alpha . \beta,; a)$의 의미: $\alpha$를 읽었고, 다음 입력은 $\beta a$로부터 유도됨 - = $\alpha$까지 읽었을 때, 다음에 올 첫 입력은 **FIRST($\beta a$)** ### Closure 규칙 - LR(1) Item $(A \to \alpha . B\beta,; a)$가 주어지면, - 모든 $b \in \text{FIRST}(\beta a)$에 대해 $(B \to . \gamma,; b)$는 유효한 LR(1) Item - 예: `(L → * . R, =)`가 주어지면 `(R → . L, =)`는 유효 - 시작점: $(S' \to . S,; $)$로부터 따라가면서 만들면 됨 ### LR(1) Item 구하는 알고리즘 ```f# 입력: (T, NT, S, P) // Grammar FIRST // Map<T ∪ NT, Set<T>> 변수: Items ← {} // Set<LR1Item> WorkList ← {(S' → S, $)} // Set<P * T> pairs ← {(S' → S, $)} // 이미 처리한 pair // 1. (prod, lookahead) pair들을 BFS로 확장 while (WorkList ≠ ∅) do WorkList, (prod, t) ← pop(WorkList) // prod = A → γ₁ γ₂ … γₙ for X ∈ {γ₁, …, γₙ} ∩ NT do // X = γᵢ, α = γ₁…γᵢ₋₁, β = γᵢ₊₁…γₙ for prod' in P[X] && a ∈ FIRST(β · t) do if (prod', a) ∉ pairs then pairs ← pairs ∪ {(prod', a)} WorkList ← WorkList ∪ {(prod', a)} // 2. 각 pair에 대해 LR(0) item들을 LR(1) item으로 승격 for (prod, t) ∈ pairs do for item ∈ LR0(prod) do // prod의 모든 LR(0) item (marker 위치별) Items ← Items ∪ {(item, t)} ``` ## 예제: 대입 문법의 LR(1) DFA ```f# 문법: S ::= L = R | R L ::= * R | [id] R ::= L LR(1) DFA 핵심 관찰 (LR(0) vs LR(1) 비교): LR(0)에서는 하나의 state에 { L → [id] . } 하나만 있어서 reduce 시 FOLLOW(L) = {$, =} 전체를 허용. LR(1)에서는 L → [id]가 등장하는 context에 따라 lookahead가 다름: - L이 S ::= L = R의 시작에서 왔을 땐 (L → [id] ., =) - L이 R ::= L을 거쳐 우변에 있을 땐 (L → [id] ., $) → 이제 state가 분리되어 conflict 발생 안 함! ``` ### LR(1) Parsing Table (주요 state) ```f# State 내용 S L R = * [id] 0 (S'→.S, $), (S→.L=R, $), (S→.R, $), (L→.*R, =), (L→.[id], =), 1 2 3 4 5 (R→.L, $), (L→.*R, $), (L→.[id], $) 1 (S'→S., $) 2 (S→L.=R, $), (R→L., $) 6 3 (S→R., $) 4 (L→*.R, =), (L→*.R, $), (R→.L, =), (R→.L, $), ... 7 8 4 5 5 (L→[id]., =), (L→[id]., $) 6 (S→L=.R, $), (R→.L, $), (L→.*R, $), (L→.[id], $) 9 10 11 12 7 (R→L., =), (R→L., $) 8 (L→*R., =), (L→*R., $) 9 (R→L., $) 10 (S→L=R., $) 11 (L→*.R, $), ... 9 13 11 12 12 (L→[id]., $) 13 (L→*R., $) 핵심: State 2에서 이제 (R → L ., $)만 있고 '='는 없음! → lookahead가 '='일 때 S → L = R로 shift만 하면 됨 → Conflict 해결! ``` ## LR(1) Parsing의 장단점 - **장점**: 더 정확한 LR(1) item 기반으로 더 많은 문법 분석 가능 - 많은 Programming Language가 LR(1) 문법에 속함 - **C / C++ / Java / Python** 등 - **단점**: **Viable Prefix DFA 상태 개수가 엄청 많아짐** - LR(0)에서 state가 10개라면 LR(1)에서는 100개+ 될 수도 있음 - → 상태 수를 줄이는 heuristic: **LALR(1)** (같은 core를 가진 state들을 병합) ## Reduce/Reduce Conflict는? - 문법이 **모호(ambiguous)** 해서 생기는 경우가 많음 - 해결: **문법 자체를 고치거나, 우선순위(precedence)를 통해 해결** ## LR(1) 문법 계층 ```f# 포함 관계: Regular Grammar ⊊ SLR(1) Grammar ⊊ LR(1) Grammar ⊊ Context-Free Grammar Ambiguous Grammar는 CFG 안에 있지만 LR(1)과 disjoint (모호한 문법은 LR(1)로도 처리 불가) ``` ## Part VIII 정리 - SLR(1)의 근본 문제: LR(0) item 기반, FOLLOW가 과하게 근사 - **LR(1) Item** = (LR(0) item, lookahead token) pair - Closure 규칙: $(A \to \alpha . B \beta, a)$ → 모든 $b \in \text{FIRST}(\beta a)$에 대해 $(B \to . \gamma, b)$ 추가 - LR(1) parser는 더 많은 문법을 커버하지만 state 수가 폭발함 --- # Part IX: Parser 마무리 ## 다루고자 하는 내용 - AST 생성 과정: CST→AST, Token list→AST - Parser 생성기 – Yacc - Parser 구현에서의 고민거리들 ## Parser의 추상적 정의 (복습) - `val parser: Token list -> AST` - 내부 구조: `Token list → [CST-Generator] → CST → [AST-Generator] → AST` ## CST vs AST - **CST** (Concrete Syntax Tree = Parse Tree): 문법의 모든 세부사항 포함 (괄호, 세미콜론 등) - **AST** (Abstract Syntax Tree): 핵심 표현만 추상화 - 괄호 X - Child 1개인 node X (예: `CSTExpr → CSTNum → NUM`은 AST에선 `ASTNum` 하나로) - 여전히 문법적으로 중요한 요소는 포함 (중첩 구조 확인 가능) - 특정 언어와 독립적인 표현 - **의미적(semantic) 요소들을 포함하기 시작** - 컴파일러에서 사용되는 **핵심 자료구조 중 하나** - 의미 분석, IR로의 변환 등 ### 예: `if (x == 0) y = 1;` ```f# CST (parse tree) AST ───────────────── ─── CSTStmt(CSTIfStmt) ASTStmt(ASTIfStmt) CSTIfStmt ASTExpr(ASTRelOp) IF ASTExpr(ASTId "x") LPAREN ASTRelOpKind(ASTEq) CSTExpr(CSTRelOp) ASTExpr(ASTNum 0) CSTExpr(CSTId) → ID ASTStmt(ASTAssignStmt) CSTRelOpKind(CSTEq) → EQ ASTExpr(ASTId "y") CSTExpr(CSTNum) → NUM ASTExpr(ASTId "1") ← 대입값 RPAREN CSTStmt(CSTAssignStmt) ID, ASSIGN, ID, SEMI ``` ## CST → AST 변환 - CST를 순회하면서 불필요한 node 제거, 의미적인 구조만 남김 - Recursive하게 각 CST node 타입마다 변환 함수 작성 - `CSTIfStmt → ASTIfStmt` - `CSTRelOp → ASTRelOp` 같은 식 ## Token list → AST (직접 변환) - CST를 안 거치고 **바로 AST를 생성**하는 방식 - Shift-Reduce parsing 중에 **두 개의 stack**을 운영 - **CST Stack**: 원래의 파싱 stack - **AST Stack**: CST와 병렬로 AST node를 쌓음 - Reduce할 때: - CST Stack: production 우변을 pop하고 CST node 생성 후 push - AST Stack: 대응되는 AST node 생성 후 push (필요한 정보만 추출) - 장점: 메모리 절약, CST를 구축할 필요 없음 - 실전 컴파일러들은 보통 이 방식 사용 ## Parser 생성기: Yacc - **Yacc**: Context Free Grammar → Parser - **Y**et **A**nother **C**ompiler-**C**ompiler - 1970년대 Stephen C. Johnson 제안 - C언어를 위한 도구 (생성되는 parser는 C 코드) - 각 언어마다 다양한 버전 존재 - **FsYacc**: F#을 위한 Yacc - `Parser.fsy` (grammar 정의) → `Parser.fs` 생성 - 비슷한 도구들 (Spring/Java 생태계 연결) - **Bison** (GNU의 Yacc 확장판, C/C++) - **CUP** (Java용 Yacc) - **ANTLR** (Java 기반, LL(*) — 가장 널리 쓰이는 parser generator) - Spring Expression Language(SpEL), Hibernate HQL 파서가 ANTLR 기반 ## Parsing을 굳이 배우는 이유 (Yacc 쓰면 되는데?) - **문법을 잘못 디자인하면 Conflict가 발생** - LR(1) 문법이 아닌 경우 등 - Yacc로 해결되는 부분도 있지만, **문법 구조를 고쳐야 해결되는 경우**가 존재 - 즉, 문법을 **어떻게 고쳐야 할지 이해하기 위함** ## Parser 구현에서의 고민거리들 ### 1. LR(1) Parser의 DFA 상태 수 - LR(1) item을 쓰니 state 수가 폭발 → **LALR(1)** heuristic - 같은 LR(0) core를 공유하는 state들을 병합 - lookahead set만 합치면 됨 → state 수는 LR(0) 수준으로 유지 - **대부분의 실전 parser generator (Yacc/Bison)가 LALR(1) 사용** ### 2. 여전히 다루지 못하는 Conflict - LL(1): First/First, First/Follow conflict - LR(1): Shift/Reduce, Reduce/Reduce conflict - → 연산자 우선순위 선언, precedence directive로 해결 (Yacc의 `%left`, `%right` 등) ### 3. 어떤 문법을 사용하여 언어를 디자인할 것인가? - 언어 설계 시 파싱 난이도 고려 - 보통 **LR(1) 또는 LL(*)** 급 문법으로 설계 - C/C++/Java: LR(1) 계열 - Python: LL(1) 기반 (단, indentation은 lexer에서 처리) - 문법을 너무 자유롭게 설계하면 파서 구현이 불가능하거나 복잡해짐 ### 4. 파싱 에러 복구 (Error Recovery) - `IDE의 Linter를 구현하고 싶은데 중간에 오류가 나면 그 이후는 검사 못하나요?` - **Panic mode recovery**: 에러 발생 시 synchronizing token(예: `;`, `}`)까지 skip 후 재개 - **Error productions**: 문법에 에러 패턴을 미리 정의 - IDE용 parser는 이 error recovery가 핵심 (IntelliJ, VSCode 언어 서버 등) ## Part IX 정리 - **AST는 컴파일러의 핵심 자료구조** — 이후 단계(의미 분석, 최적화, 코드 생성)의 입력 - CST→AST 변환, Token→AST 직접 변환 두 가지 방식 존재 - Yacc 같은 parser generator가 있지만, **문법 설계와 conflict 해결을 위해 이론 공부 필요** - 실전 고민: 상태 수 최적화(LALR(1)), 남은 conflict 처리, 에러 복구 --- # 전체 Parser 시리즈 정리 (개인 복습용) ```f# Parsing 방식 요약: Top-down (LL 계열): ├─ Recursive Descent + Backtracking : 비효율적 └─ LL(1) : FIRST/FOLLOW로 lookahead 1개 └─ Left-recursion 불가 → right-recursion 변환 필요 Bottom-up (LR 계열): ├─ LR(0) : lookahead 없음, conflict 많음 ├─ SLR(1) : FOLLOW로 reduce 결정 (느슨한 근사) ├─ LALR(1) : LR(1) state를 core 기준 병합 (실전 표준) └─ LR(1) : 정확한 lookahead, state 수 폭발 문법 포함 관계: Regular ⊊ LL(1) ⊊ ? ⊊ LR(1) ⊊ Unambiguous CFG ⊊ CFG SLR(1) ⊊ LALR(1) ⊊ LR(1) 도구: LL(*) → ANTLR (Java 생태계 최강) LALR(1) → Yacc, Bison, CUP, FsYacc ``` ## Spring/Java 개발자 관점 실무 연결 - **직접 파서를 짤 일**: 드묾. 하지만 다음 경우엔 필수 - 커스텀 DSL 설계 (비즈니스 규칙 DSL, 쿼리 DSL) - 템플릿 엔진, Expression Language 확장 - 코드 생성기 / 정적 분석 도구 - **ANTLR 활용 예시** (Spring 생태계) - SpEL, Hibernate HQL/JPQL, Drools 룰 엔진 - `@Query` 어노테이션의 JPQL 파싱 - **이 강의에서 얻을 것** - ANTLR/Yacc 문법 작성 시 conflict 경고 이해 - Left-recursion, 우선순위 declaration이 왜 필요한지 - AST 기반 작업(AOP 프록시, 바이트코드 조작) 시 tree 구조 이해