# 5. Bottom-Up Parsing (1) — Overview ## 5.1. Bottom-Up Parsing이란? → Right-most Derivation의 역순 > §5.1에서는 Bottom-up parsing이 right-most derivation을 거꾸로 읽는 과정임을 확인한다. 이 관점이 §5.2의 Shift-Reduce 연산으로 구체화되고, §5.3에서 "언제 reduce하는가"라는 핵심 질문으로 이어진다. ### 5.1.1 Bottom-Up Parsing — 개념 Bottom-up parsing은 입력 문자열 $s$에서 시작해 $S$까지 **reduce**해 나가는 과정이다. 이는 right-most derivation을 역순으로 읽는 것과 동일하다. $s ;\to; \gamma_n ;\to; \cdots ;\to; \gamma_2 ;\to; \gamma_1 ;\to; S$ 왼쪽에서 오른쪽으로 token을 읽어가면서 진행하며, right-most derivation은 가장 오른쪽 non-terminal부터 확장하므로, 역순에서는 reduce가 왼쪽부터 진행된다. 즉, 1. 왼쪽에서 오른쪽으로 token 읽어가며 진행 2. 현 위치에서 적용할 수 있는 production이 없으면 다음 토큰으로 이동 3. 현재 위치에서 올바르게 적용할 수 있는 production을 "전부 적용" ### 5.1.2 Bottom-Up Parsing — 예제 문법 `S ::= E + S | E`, `E ::= [number] | ( S )` 로 `(1 + 2) + 3` 파싱 ``` Right-most derivation (순방향): S → E + S → E + E → E + 3 → ( S ) + 3 → ( E + S ) + 3 → ( E + E ) + 3 → ( E + 2 ) + 3 → ( 1 + 2 ) + 3 Bottom-up (역방향): ( 1 + 2 ) + 3 → ( E + 2 ) + 3 [E → [number] 적용] → ( E + E ) + 3 [E → [number] 적용] → ( E + S ) + 3 [S → E 적용] → ( S ) + 3 [S → E + S 적용] → E + 3 [E → ( S ) 적용] → E + E [E → [number] 적용] → E + S [S → E 적용] → S [S → E + S 적용] ``` ```mermaid graph TD S["S"] S --> E["E"] S --> p1["+"] S --> S2["S"] E --> lp["("] E --> Si["S"] E --> rp[")"] Si --> Ei["E"] Si --> p2["+"] Si --> Sii["S"] Ei --> one["1"] Sii --> Eii["E"] Eii --> two["2"] S2 --> E2["E"] E2 --> three["3"] ``` ### 5.1.3 Bottom-Up Parsing — 구체화된 개념 1. 왼쪽에서 오른쪽으로 토큰 읽어가면서 진행 1. 지금까지 확인한 위치를 `. (Marker)`로 표시 1. $\alpha \beta. \gamma \delta$: 지금까지 $\alpha \beta$까지 읽었고, 앞으로 $\gamma \delta$가 남음 2. $\gamma \delta$는 터미널의 나열. 1. 핵심은 **"아직 읽지 않았다"** 는 것. 아직 읽지 않은 부분은 lexer가 뱉어낸 **raw token 그대로**일 것. 2. 현재 위치에서 적용할 수 있는 P가 없으면 다음 토큰으로 이동 1. $\alpha A. \gamma \delta$에서 $\alpha A \gamma . \delta$로 진행. 3. 현재 위치에서 올바르게 적용할 수 있는 P를 전부 적용 1. $A \to \beta$에 대해 유도 $S \to^* \alpha A \gamma \delta \to \alpha \beta \gamma \delta$가 존재하면, 1. 즉, $\alpha \beta \gamma \delta$ 라는 현재 상태가 **진짜로 S에서 유도 가능한 중간 단계**라는 것 2. $A \to \beta$ 를 적용하면 **결국 S까지 reduce할 수 있는 올바른 경로**가 있다는 것 3. 거꾸로 가는 거라는 걸 잊지 말자! 2. $\alpha \beta. \gamma \delta$에서 $\alpha A. \gamma \delta$로 진행. ## 5.2. Shift-Reduce Parsing — Bottom-Up의 보다 구체적 구현 → Reduce는 언제 하지? > §5.1에서 bottom-up parsing이 역방향 유도임을 봤다. §5.2에서는 이것을 실제로 어떻게 수행하는지, 두 가지 연산(Shift·Reduce)으로 정형화한다. 이 연산의 "타이밍"을 결정하는 문제가 §5.3의 Handle로 이어진다. ### 5.2.1 Marker 현재까지 확인한 위치를 `.` (Marker)로 표시한다. - $\alpha\beta ;.; \gamma\delta$: 지금까지 $\alpha\beta$를 읽었고, 앞으로 $\gamma\delta$가 남음 - $\gamma\delta$는 아직 읽지 않은 부분이므로 terminal들의 나열 ### 5.2.2 Shift 연산과 Reduce 연산 | 연산 | 조건 | 동작 | | ---------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | **Shift** | 현재 위치에서 적용할 production이 없음 | 다음 token으로 이동<br>$\alpha A. \gamma \delta$에서 $\alpha A \gamma . \delta$로 진행. | | **Reduce** | $A \to \beta$에 대해 $S \Rightarrow^* \alpha A a\delta \to \alpha\beta a\delta$가 존재 | 현재 위치에서 올바르게 적용할 수 있는 P를 모두 적용.<br>production 우변을 좌변 NT로 치환<br>$\alpha \beta. \gamma \delta$에서 $\alpha A. \gamma \delta$로 진행. | ### 5.2.3 Stack 사용 **Marker 기준 왼쪽의 token list를 Stack으로 표현한다.** 즉, 지금까지 읽고 처리한 모든 것의 현재 상태가 Stack에 들어있음 (shift, reduce 처리 구분 X) - **Shift**: Terminal을 stack에 `push` - **Reduce**: T or NT들을 stack에서 `pop`한 후, 적절한 NT를 `push` - $A \to \alpha\beta\gamma$라면 $\gamma, \beta, \alpha$ 순으로 pop하고 (stack top부터) $A$를 push ### 5.2.4 예제: `(1 + 2) + 3` 파싱 흐름 > 마커 기준으로 왼쪽 = 스택. 오른쪽 = 아직 읽을 거 (토큰) > stack top은 오른쪽 ``` // 문법 S ::= E + S | E E ::= [number] | ( S ) Marker 상태 Stack Input 연산 . ( 1 + 2 ) + 3 ( 1 + 2 ) + 3 shift ( ( . 1 + 2 ) + 3 ( 1 + 2 ) + 3 shift 1 ( 1 . + 2 ) + 3 ( 1 + 2 ) + 3 reduce E → [number] // +를 shift하면 stack이 ( 1 +가 되는데, 이걸로 완성할 수 있는 P는 없음. // 1 +로 시작하는 우변은 어디에도 없으니까. // 반면 지금 당장 E → [number]를 적용하면 1 → E로 reduce되고, // 이후 ( E + ...로 이어지면서 // E → ( S ) 나 S → E + S 같은 // production을 완성할 수 있는 경로가 열림 ( E . + 2 ) + 3 ( E + 2 ) + 3 shift + ( E + . 2 ) + 3 ( E + 2 ) + 3 shift 2 ( E + 2 . ) + 3 ( E + 2 ) + 3 reduce E → [number] // 2를 그냥 shift하면 stack이 ( E + 2가 되는데, // 다음 token이 )이므로 stack은 ( E + 2 )가 됨. // 그런데 ( E + 2 )로 완성할 수 있는 production은 없음. // E + 2 나 ( E + 2 같은 우변은 어디에도 없으니까. // 반면 지금 E → [number]를 적용하면 2 → E로 reduce되고, // stack이 ( E + E가 되면서 // S → E + S, S → E 같은 production을 완성할 수 있는 경로가 열림. ( E + E . ) + 3 ( E + E ) + 3 reduce S → E // ( E + E . ) 상태에서 )를 shift하면 stack이 ( E + E )가 되는데, // ( E + E )로 완성할 수 있는 production은 없음. // ( E + E 같은 우변은 어디에도 없으니까. // 반면 지금 S → E를 적용하면 E → S로 reduce되고, // stack이 ( E + S가 되면서 // S → E + S production을 완성할 수 있는 경로가 열림. ( E + S . ) + 3 ( E + S ) + 3 reduce S → E + S // ( E + S . ) 상태에서 )를 shift하면 stack이 ( E + S )가 되는데, // ( E + S )로 완성할 수 있는 production은 없음. // ( E + S 같은 우변은 어디에도 없으니까. // 반면 지금 S → E + S를 적용하면 E + S → S로 reduce되고, // stack이 ( S가 되면서 // E → ( S ) production을 완성할 수 있는 경로가 열림. ( S . ) + 3 ( S ) + 3 shift ) ( S ) . + 3 ( S ) + 3 reduce E → ( S ) // ( S ) . 상태에서 +를 shift하면 stack이 ( S ) +가 되는데, // ( S ) +로 완성할 수 있는 production은 없음. // ( S ) + 같은 우변은 어디에도 없으니까. // 반면 지금 E → ( S )를 적용하면 ( S ) → E로 reduce되고, // stack이 E가 되면서 // S → E + S, S → E production을 완성할 수 있는 경로가 열림. E . + 3 E + 3 shift + E + . 3 E + 3 shift 3 E + 3 . E + 3 reduce E → [number] // 3을 그냥 놔두면 다음 token이 $(입력 끝)이므로 // shift할 것도 없는 상황. // 지금 E → [number]를 적용하면 3 → E로 reduce되고, // stack이 E + E가 되면서 // S → E + S, S → E production을 완성할 수 있는 경로가 열림. E + E . E + E reduce S → E // E + E 상태에서 S → E를 바로 적용하면 안 됨. // 오른쪽 E만 S로 reduce해야 하는데, 왼쪽 E까지 건드리면 // S → E + S production을 완성할 수 없게 됨. // 오른쪽 E에만 S → E를 적용하면 E → S로 reduce되고, // stack이 E + S가 되면서 // S → E + S production을 완성할 수 있는 경로가 열림. E + S . E + S reduce S → E + S // 입력이 끝났고($), stack에 E + S가 있음. // 지금 S → E + S를 적용하면 E + S → S로 reduce되고, // stack이 S 하나만 남으면서 // accept 조건(stack = [S], 입력 = $)을 만족함. S . S accept // stack = [S], 입력 = $ 만족 ✅ ``` ## 5.3. Handle → 언제 Reduce해야 하는지 확정 > §5.2에서 Shift·Reduce 연산을 정의했지만, > "아무 때나 reduce하면 안 된다"는 문제가 남아 있다. > > §5.3은 그 타이밍 기준인 Handle을 정의하고, > §6의 LR(0) Parser가 이 Handle을 자동으로 찾는 방법을 제시한다. 아무 때나 reduce하면 틀린다. 예를 들어 `( E + 2 ) + 3` 상태에서 첫 번째 `E`에 `S → E`를 바로 적용하면 `( S + 2 ) + 3`이 되고, `( S + ...` 꼴은 어떤 production 우변과도 매칭되지 않아서 오류가 난다. ### 5.3.1 Reduce가 필요한 타이밍 정의 - Right-most derivation $S \Rightarrow^* \alpha A s \to \alpha\beta s$ 가 존재하고, - Marker가 production 우변의 **끝**에 위치할 때 - `.` 기준으로 왼쪽이 stack, 오른쪽이 남은 입력. - "Marker가 production 우변의 끝에 위치한다"는 건, **어떤 production $A \to \gamma$의 우변 $\gamma$ 전체가 이미 stack에 들어와 있고, 그 직후에 marker가 찍혀있다**는 뜻. - 즉, production 우변이 **stack top**에 있을 때 - 예시: $\alpha \gamma . \beta$ - - $\gamma$ = production $A \to \gamma$의 우변 - $\gamma$ 전체가 stack top에 있음 - marker `.`가 $\gamma$ 바로 뒤에 있음 = **우변의 끝에 위치** - Reduce가 가능해짐. ### 5.3.2 Handle 정의 1. reduce될 수 있고, 2. 그 결과 궁극적으로 3. $S$까지 reduce되게 하는 token list와 production의 쌍. 1. 반대로 말하면, **handle만 reduce를 해야 한다.** ### 5.3.3 Handle은 항상 Stack Top에만 존재한다 귀납법으로 증명: 1. Base Case - Stack 비어 있음 2. Induction Hypothesis - Handle이 Stack Top에 있다고 가정 3. Reduce를 하면 $\alpha A . \beta$ ``` Base case: Stack이 비어있는 경우 — trivially 성립 Stack이 비어있으면 handle도 없으니까 "handle이 stack top에 있다"는 명제가 그냥 성립. Induction hypothesis: 현재 handle이 stack top에 있다고 가정 α γ . β 일 때 handle: γ, A → γ Reduce 후 → α A . β - A가 가장 오른쪽에 존재하는 NT - 다음 handle이 A보다 왼쪽에 있다고 가정하면 right-most derivation이 아니게 됨. Right-Most는 항상 가장 오른쪽 NT부터 전개. right-most 반대가 bottom-up이니까 오른쪽부터 reduce해야 함. α 안에 다음 handle이 있음. 즉, A보다 왼쪽에 있는 걸 다음에 reduce한다는 뜻. 근데 right-most derivation의 역순이라면 A보다 왼쪽에 있는 건 A보다 먼저 reduce됐어야 함 → 모순 - 따라서 다음 handle도 stack top (혹은 shift 후 stack top)에 위치 ``` ## 5.4 Shift-Reduce 기반 Bottom-Up Parsing 알고리즘 → Handle은 어떻게 찾지? ### 5.4.1 전체 ```fsharp 입력: (T, NT, S, P) // Grammar tokens // Token들의 list (입력 문자열) 변수: idx ← 0 // 현재 읽고 있는 token의 위치 stack ← [] // Marker 기준 왼쪽, 즉 지금까지 읽고 처리한 것들 while (true) do // Stack에 S 하나만 남아있으면 파싱 성공 if len(stack) = 1 && stack.top = S then return S else // 현재 stack top에서 handle을 찾아봄 match tryFindHandle stack with // handle을 찾은 경우 → Reduce // stack top에서 발견된 우변 기호들의 리스트, 그 우변에 해당하는 production // 예를 들어 E → ( S )라면, // [γ₁; γ₂; ...; γₙ] = [ (; S; ); ] // A → γ₁γ₂…γₙ = E → ( S ) | Some ([γ₁; γ₂; ...; γₙ], A → γ₁γ₂…γₙ) -> // production 우변(γ₁…γₙ)을 stack에서 pop // stack top부터 꺼내므로 γₙ, γₙ₋₁, ..., γ₁ 순서로 pop for 1 ≤ i ≤ n do stack.pop() // 우변 대신 좌변 NT(A)를 push // 예: ( 1 → E → [number] 적용 후 ( E 가 됨 stack.push(A) // handle을 못 찾은 경우 → Shift | None -> // 다음 token을 stack에 push stack.push(tokens[idx]) // 다음 token으로 이동 idx ← idx + 1 ``` > ⚠️ `tryFindHandle`을 일반적으로 효율적으로 구현하는 방법은 없다 — 일반적인 방법은 Backtracking으로 비효율적이다. §6에서 이 문제를 Viable Prefix DFA로 해결한다. ### 5.4.2 Reduce ```fsharp // Handle: [γ₁; γ₂; ...; γₙ], A → γ₁γ₂…γₙ // production 우변의 길이(n)만큼 pop for 1 ≤ i ≤ n do stack.pop() // stack top부터 꺼내므로 γₙ → γₙ₋₁ → … → γ₁ 순서로 pop stack.push(A) // 우변을 다 꺼낸 자리에 좌변 NT를 push ``` ### 5.4.3 Shift ```fsharp stack.push(tokens[idx]) // 현재 위치의 token을 stack에 push idx ← idx + 1 // 다음 token으로 이동 ``` --- # 6. 🚨 Bottom-Up Parsing (2) — LR(0) Parser > §5.4에서 Bottom-Up Parsing 알고리즘의 기본 틀을 보았는데, > 여기서 Handle을 찾는 tryFindHandle의 알고리즘은 다루지 않았다. > > §6에서는 Handle을 효율적으로 찾기 위한 방법을 소개하고, > 그걸 적용한 LR(0) Parser에 대해 다룬다. ## 6.1. Handle을 효율적으로 찾을 수 없다 → 관찰에 의한 Heuristic이 필요하다 > §5.3에서 Handle을 찾는 것이 bottom-up parsing의 핵심임을 확인했다. > §6.1은 일반적 방법(Backtracking)이 비효율적이므로 heuristic이 필요함을 설명하고, > 그 heuristic의 출발점인 Stack 관찰로 넘어간다. 일반적인 handle 탐색 방법은 Backtracking이지만 비효율적이다. 대신 **heuristic 알고리즘**을 사용한다. - 항상 완벽하게 작동하지 않을 수 있음 (증명 X) - 대부분의 경우 잘 작동함 - **관찰(observation)에 의해 만들어짐** ## 6.2. Stack 관찰 — Stack에는 항상 Production들의 Prefix만 존재한다 → 뭐라고 부를까? > §6.1에서 heuristic이 관찰에 의해 만들어진다고 했다. §6.2는 그 핵심 관찰을 제시한다. 이 관찰에서 Viable Prefix 개념이 등장하고(§6.3), 이것이 Regular Language임이 밝혀지면서 FSM 기반의 LR(0) Parser(§6.4 이후)가 가능해진다. 문법 `S ::= E + E`, `E ::= [num] | ( S )` 로 `(1 + 2) + 3`을 파싱할 때의 stack: ``` 문법: S ::= E + E, E ::= [num] | ( S ) 입력: ( 1 + 2 ) + 3 ``` | Stack | Prefix 상태 | 연산 | | --------- | ------------------------------------------------------------------------------------------------------- | ------------------ | | (빈) | — | shift `(` | | `(` | `(` → `E ::= ( S )`의 prefix | shift `1` | | `( 1` | `(` → `E ::= ( S )`의 prefix<br>`1` → `E ::= [num]`의 prefix → **완전 매칭** | reduce `E → [num]` | | `( E` | `(` → `E ::= ( S )`의 prefix<br>`E` → `S ::= E + E`의 prefix | shift `+` | | `( E +` | `(` → `E ::= ( S )`의 prefix<br>`E +` → `S ::= E + E`의 prefix | shift `2` | | `( E + 2` | `(` → `E ::= ( S )`의 prefix<br>`E +` → `S ::= E + E`의 prefix<br>`2` → `E ::= [num]`의 prefix → **완전 매칭** | reduce `E → [num]` | | `( E + E` | `(` → `E ::= ( S )`의 prefix<br>`E + E` → `S ::= E + E` → **완전 매칭** | reduce `S → E + E` | | `( S` | `(` → `E ::= ( S )`의 prefix | shift `)` | | `( S )` | `( S )` → `E ::= ( S )` → **완전 매칭** | reduce `E → ( S )` | | `E` | `E` → `S ::= E + E`의 prefix | shift `+` | | `E +` | `E +` → `S ::= E + E`의 prefix | shift `3` | | `E + 3` | `E +` → `S ::= E + E`의 prefix<br>`3` → `E ::= [num]`의 prefix → **완전 매칭** | reduce `E → [num]` | | `E + E` | `E + E` → `S ::= E + E` → **완전 매칭** | reduce `S → E + E` | | `S` | — | **accept** | **관찰 요약:** - Stack에는 항상 production들의 prefix들이 `prefix₁ prefix₂ … prefixₙ` 형태로 존재 - ( E + 2 - 완전한 production이 될 수 있는 것은 **prefixₙ** (가장 오른쪽 prefix) — handle은 stack top에서만 발견 가능하기 때문 - 교수님 설명: 같은 맥락의 설명을 다르게 하면 다음과 같습니다: Bottom-up parsing의 shift / reduce 원칙을 잘 따라서 parsing이 수행되었다면 stack 중간에는 완전한 production이 있을 수 없습니다. 완전한 production이 stack에 있다면 shift하기 이전에 reduce가 되었어야 합니다. 그러니까 완전한 production이 될 수 있는 것은 가장 바깥쪽에 있는 prefix n만 될 수 있는거죠. - prefixₙ이 shift를 거쳐 완전한 production이 되면 prefixₙ₋₁의 부족한 부분을 채워줌 - 채워진 형태: ( E + E ## 6.3 Viable Prefix — 아직 파싱 오류가 없다는 게 보장된, stack에 올라올 수 있는 올바른 문자열 > §6.2의 관찰에서 Stack에는 항상 올바른 prefix들만 있음을 봤다. > §6.3은 이것을 Viable Prefix로 정의하고, > 그 집합이 Regular Language임을 밝힌다. > > 이것이 §6.4에서 FSM을 구성하는 이론적 근거가 된다. ### 6.3.1 Viable Prefix — 정의 > 말로 이해하기 "handle이 완성되기까지의 과정에서 stack에 올라오는 모든 중간 상태" "아직 파싱 오류가 없다는 게 보장된, stack에 올라올 수 있는 올바른 문자열" > 수식으로 이해하기 **정의:** $\alpha \in (T \cup NT)^*$, $s \in T^*$ 일 때, $S \to^* \alpha . s$ 이면 $\alpha$를 **viable prefix**라고 한다. $S \to^* \alpha . s$ 의 의미 - $S$에서 시작해서 유도를 거듭하다 보면 - 어떤 중간 단계에서 $\alpha s$ 라는 문자열이 나오고 - 그 중 $\alpha$는 이미 stack에 쌓인 부분, $s$는 아직 안 읽은 나머지 입력 즉 **"$S$로부터 올바르게 유도되는 과정 중에 실제로 등장할 수 있는 stack의 내용"** 이 viable prefix. > "S로부터 올바르게 유도되는 과정 중에 실제로 등장할 수 있는 stack의 내용" = viable prefix인데, 그 stack 내용이 결국 어떤 production 우변(= 결과적으로 S까지 reduce되게 하는 handle)의 prefix이기 때문에 "viable"이라고 부름. **특징** - Stack에 viable prefix들만 있다는 것 = 아직 parsing error를 만나지 않았다는 뜻 **예시** 문법 `S ::= E + E`, `E ::= [num] | ( S )` 의 viable prefix 예시 (문법 3개 각각) - $\epsilon$, `E`, `E +`, `E + E` - $\epsilon$, `[num]` - $\epsilon$, `(`, `( S`, `( S )` ### 6.3.2 Viable Prefix — 활용 아이디어: Viable prefix를 인식하면 handle을 찾을 수 있다 → 어떻게 인식할 것인가? - 현재 Stack에 Viable Prefix만 있을 때 알 수 있는 것 - 지금까지 읽은 입력은 정상적 - 마지막 viable prefix (prefix$_n$)의 상태에 따라 reduce 여부 결정 가능 - 그러므로, - **아이디어:** Viable prefix를 인식하면 handle을 찾을 수 있다 - prefixₙ이 **완전한 production**(= handle) → **Reduce** - prefixₙ이 **불완전한 prefix** 수준 → **Shift** ### 6.3.3 Viable Prefix — 인식 아이디어: Viable Prefix는 RL이니, FSM으로 인식해보자 #### 6.3.3.1 왜 Viable Prefix가 Regular Language인가? > P에 마커로 현재 상태를 표시한 것을 item (or LR(0) item 이라고 함) 1. RL은 FSM으로 표현 가능한 언어다. ([[1. Lexer]]을 떠올려보자!) 2. P $A \to \gamma_1 \gamma_2 \ldots \gamma_n$ 에 대해 viable prefix를 인식하는 과정을 이렇게 생각해보자 (마커의 위치에 주목!) 1. $A \to . \gamma_1 \gamma_2 \ldots \gamma_n$ 2. $A \to \gamma_1 . \gamma_2 \ldots \gamma_n$ 3. ... 4. $A \to \gamma_1 \gamma_2 \ldots \gamma_n .$ 3. 이걸 보면 각 item이 FSM의 **상태**이고, γᵢ를 읽으면 다음 상태로 전이하는 구조. 4. 즉 이 과정 자체가 FSM이고, 5. FSM으로 표현 가능한 언어 = Regular Language이므로 6. viable prefix들의 집합이 RL이 됨. 즉, **FSM(유한 상태 기계)으로 표현 가능**하다. 이것이 LR(0) Parser의 핵심 이론적 기반이다. #### 6.3.3.2 그렇다면, Viable Prefix에 대한 State Machine은 어떻게 정의될까? 정의 - Production $A \to \gamma_1\gamma_2 \ldots \gamma_n$에 대한 FSM $(\Sigma, S, s_0, \delta, F)$ - $\Sigma = \{\gamma_1, \ldots, \gamma_n\} \subseteq T \cup NT$ - $S = \{A \to .\gamma_1\gamma_2 \ldots \gamma_n,\ A \to \gamma_1.\gamma_2 \ldots \gamma_n,\ \ldots,\ A \to \gamma_1\gamma_2 \ldots \gamma_n.\}$: Item들의 집합 - 만약 $A \to \epsilon$이면 Item 집합은 $\{A \to .\}$ 로 정의. - $s_0 = A \to .\gamma_1\gamma_2 \ldots \gamma_n$ - $\delta(A \to \gamma_1 \ldots \gamma_{i-1}.\gamma_i \ldots \gamma_n,\ \gamma_{i-1}) = A \to \gamma_1 \ldots \gamma_i.\gamma_{i+1} \ldots \gamma_n$ - $F = S$ (어느 아이템에서 멈추든 viable prefix로 인정) 예시: `S → E + E` 에 대한 FSM - $\Sigma =$ {E, +} - $S =$ {$S \to . E + E$, $S \to E. + E$, $S \to E +. E$, $S \to E + E.$} - $s_0$ = $S \to . E + E$ - $\delta$는 오히려 수식이 직관적이라 냅둠 - $F = S$. 모든 상태가 viable prefix로 인정. ![[스크린샷 2026-04-28 오후 2.13.15.png]] 밑에서 더 자세하게 ㄱㄱ ## 6.4. LR(0) Item과 Viable Prefix FSM 구성 > §6.3에서 viable prefix들의 집합이 Regular Language임을 확인했다. > §6.4에서는 이를 FSM으로 실제로 구성하는 방법을 다룬다. > 먼저 단일 production에 대한 FSM을 만들고, > §6.5에서 여러 production을 합쳐 NFA를 만든다. ### 6.4.1. LR(0) Item **(정의)** Production에 marker `.`로 현재 상태를 표시한 것을 **item** (혹은 **LR(0) item**)이라 한다. $A \to \gamma_1 \dots \gamma_i . \gamma_{i+1} \dots \gamma_n$ 이 item은 viable prefix $\gamma_1 \dots \gamma_i$를 인식한 상태를 나타낸다. ### 6.4.2. 단일 Production에 대한 Viable Prefix FSM → 여러 개는? Production $A \to \gamma_1\gamma_2\dots\gamma_n$에 대한 FSM $(\Sigma, S, s_0, \delta, F)$: - $\Sigma = {\gamma_1, \dots, \gamma_n} \subseteq T \cup NT$ - $S$ = item들의 집합 ${A \to .\gamma_1\gamma_2\dots\gamma_n,; A \to \gamma_1.\gamma_2\dots\gamma_n,; \dots,; A \to \gamma_1\gamma_2\dots\gamma_n.}$ - $A \to \epsilon$이면 item 집합은 ${A \to .}$ - $s_0 = A \to .\gamma_1\gamma_2\dots\gamma_n$ - $\delta(A \to \gamma_1\dots\gamma_{i-1}.\gamma_i\dots\gamma_n,; \gamma_i) = A \to \gamma_1\dots\gamma_i.\gamma_{i+1}\dots\gamma_n$ - $F = S$ (모든 상태가 accepting) **예제:** `S → E + E`에 대한 FSM ![[스크린샷 2026-04-28 오후 2.13.15.png]] ### 6.4.3 여러 Production에 대한 Viable Prefix FSM → ε-transition은? #### 6.4.3.1 여러 Production에 대한 Viable Prefix FSM 만드는 규칙 각각의 production에 대해 viable prefix FSM을 만든 후, ε-transition으로 연결한다. **규칙 1:** $A \to \alpha.B\beta$와 $B \to \gamma$라는 두 Production이 있다면 $\delta(A \to \alpha.B\beta, \epsilon) = B \to .\gamma$ 의미: $B$를 인식하러 가는 순간, $B$의 production 시작 상태로 점프할 수 있음. **규칙 2:** 또한 $S' \to S$라는 **특별한 production**을 하나 추가한다. - $S$를 인식했다는 것을 나타내기 위한 장치 - Items: $S' \to .S$ 와 $S' \to S.$ #### 6.4.3.2 FSM 예시 **예제:** `S → E + E`, `E → ( S )` 두 production을 합친 NFA ![[스크린샷 2026-04-28 오후 2.20.34.png]] **S → E + E, E → ( S ) 에 대한 NFA** $\Sigma = {E, +, (, S, )}$ $S = {S \to .E+E,\ S \to E.+E,\ S \to E+.E,\ S \to E+E.,\ E \to .(S),\ E \to (.S),\ E \to (S.),\ E \to (S).}$ $s_0 = S \to .E+E$ $\delta$: | 현재 상태 | 입력 | 다음 상태 | 합쳐진 부분들 | | ------------ | ---------- | ------------ | ------- | | $S \to .E+E$ | $E$ | $S \to E.+E$ | | | $S \to E.+E$ | $+$ | $S \to E+.E$ | | | $S \to E+.E$ | $E$ | $S \to E+E.$ | | | $E \to .(S)$ | $($ | $E \to (.S)$ | | | $E \to (.S)$ | $S$ | $E \to (S.)$ | | | $E \to (S.)$ | $)$ | $E \to (S).$ | | | $S \to .E+E$ | $\epsilon$ | $E \to .(S)$ | ✅ | | $S \to E+.E$ | $\epsilon$ | $E \to .(S)$ | ✅ | | $E \to (.S)$ | $\epsilon$ | $S \to E.+E$ | ✅ | [[#6.4.3.1 여러 Production에 대한 Viable Prefix FSM 만드는 규칙]] 이랑 같이 보자! $F = S$ (모든 상태가 accepting) **예제:** 간단해진 덧셈 문법의 모든 production을 합친 NFA ``` // 문법 S ::= E + E E ::= [num] | ( S ) ``` ![[스크린샷 2026-04-28 오후 2.28.54.png]] ## 6.5. 🚨 NFA → DFA (Subset Construction) — Viable Prefix DFA → 어떻게 활용할까? > §6.4에서 ε-transition을 가진 NFA를 만들었다. > §6.5에서 subset construction으로 DFA로 변환한다. > 이 DFA가 §6.6에서 실제 parsing에 사용되고, §6.7에서 Action/Goto Table로 최적화된다. Subset construction으로 NFA를 DFA로 변환한다. 각 DFA 상태는 NFA 상태들의 집합(closure)이다. **예제:** 문법 `S' ::= S`, `S ::= E + E`, `E ::= [num] | ( S )` 에 대한 Viable Prefix DFA **Transition Table** - **행(State)**: 현재 DFA 상태. 각 상태는 item들의 집합. - **Items 열**: 그 상태에서 "지금 어떤 production들을 인식 중인가" - **나머지 열(S, E, +, \[num], (, ))**: "이 상태에서 해당 기호를 읽으면 어느 상태로 전이하는가" 초기 | State | Items | S | E | + | [num] | ( | ) | | ----- | ----- | --- | --- | --- | ----- | --- | --- | | 0 | | | | | | | | | 1 | | | | | | | | | 2 | | | | | | | | | 3 | | | | | | | | | 4 | | | | | | | | | 5 | | | | | | | | | 6 | | | | | | | | | 7 | | | | | | | | | 8 | | | | | | | | 완성 | State | Items | S | E | + | [num] | ( | ) | | ----- | ----------------------------------------------------- | --- | --- | --- | ----- | --- | --- | | 0 | $S' \to .S,\ S \to .E+E,\ E \to .[num],\ E \to .(S)$ | 1 | 2 | | 3 | 4 | | | 1 | $S' \to S.$ | | | | | | | | 2 | $S \to E.+E$ | | | 5 | | | | | 3 | $E \to [num].$ | | | | | | | | 4 | $E \to (.S),\ S \to .E+E,\ E \to .[num],\ E \to .(S)$ | 6 | 2 | | 3 | 4 | | | 5 | $S \to E+.E,\ E \to .[num],\ E \to .(S)$ | | 7 | | 3 | 4 | | | 6 | $E \to (S.)$ | | | | | | 8 | | 7 | $S \to E+E.$ | | | | | | | | 8 | $E \to (S).$ | | | | | | | **DFA 다이어그램:** ![[스크린샷 2026-04-28 오후 2.40.13.png]] ## 6.6 Viable Prefix DFA 활용 → Shift/Reduce 판단 → 매번 DFA를 실행시켜야 할까? > §6.5에서 Viable Prefix DFA를 구성했다. > §6.6에서는 이 DFA를 실제로 어떻게 써서 Shift/Reduce를 판단하는지 예제로 확인한다. > §6.7에서는 "매번 DFA를 처음부터 돌려야 하는가"라는 비효율성을 해결한다. ### 6.6.1 DFA를 보고 실행시키는 과정 **상태 전이 규칙** stack에 쌓인 기호들을 왼쪽부터 순서대로 DFA에 입력한다. 즉 $\delta$를 반복 적용해서 최종 상태를 구한다. $\text{State}_0 \xrightarrow{x_1} \text{State}_1 \xrightarrow{x_2} \cdots \xrightarrow{x_n} \text{State}_n$ 여기서 $x_1 x_2 \cdots x_n$이 현재 stack의 내용이고, $\text{State}_n$이 현재 DFA 상태다. **Shift / Reduce 결정 규칙** 최종 상태의 items를 보고 결정한다. | 최종 상태의 item 형태 | 의미 | 결정 | | -------------------------------- | -------------------------- | ---------- | | $A \to \alpha . \beta$ (`.`이 중간) | 아직 불완전한 prefix | **Shift** | | $A \to \gamma .$ (`.`이 맨 끝) | 완전한 production = handle 발견 | **Reduce** | ### 6.6.2 DFA - 간단해진 덧셈 문법 예시 간단해진 덧셈 문법의 예시를 보자. #### 문법 ``` S ::= E + E E ::= [num] | ( S ) ``` #### STEP 1 — 현재 상태와 DFA 시작 현재 파싱 상태는 `( E + . 2 ) + 3`, stack = `( E +` 이다. 이 stack을 DFA에 입력해서 현재 어느 상태에 있는지 찾아야 한다. > 슬라이드 121 page ![[스크린샷 2026-04-28 오후 3.01.16.png]] #### STEP 2 — stack을 DFA에 입력: `(` 읽기 State 0에서 `(`를 읽으면 State 4로 전이한다. > 슬라이드 122 page ![[스크린샷 2026-04-28 오후 3.01.30.png]] State 4의 items: $E \to (.S),\ S \to .E+E,\ E \to .[num],\ E \to .(S)$ State 0과 구조가 똑같아 보이는 이유는, `(`를 읽고 나서도 다시 E나 S를 기다려야 하기 때문이다. #### STEP 3 — stack을 DFA에 입력: `E` 읽기 State 4에서 `E`를 읽으면 State 2로 전이한다. > 슬라이드 123 page ![[스크린샷 2026-04-28 오후 3.01.44.png]] State 2의 items: $S \to E.+E$ #### STEP 4 — stack을 DFA에 입력: `+` 읽기 State 2에서 `+`를 읽으면 State 5로 전이한다. > 슬라이드 124 page ![[스크린샷 2026-04-28 오후 3.01.56.png]] State 5의 items: $S \to E+.E,\ E \to .[num],\ E \to .(S)$ 모든 item이 아직 `.`이 우변 끝에 없다 → **불완전한 prefix** → **Shift** - 완성된 production = handle = reduce 가능한 상태는 `A → γ .` 처럼 `.`이 **맨 끝**에 있을 때. > 슬라이드 124,5 page ![[스크린샷 2026-04-28 오후 3.01.56.png]] #### STEP 5 — Shift 후 다음 상태: `2` 읽기 `2`를 shift해서 stack = `( E + 2`. 이제 DFA를 다시 돌린다. State 5에서 `[num]`을 읽으면 State 3으로 전이한다. > 슬라이드 126 page ![[스크린샷 2026-04-28 오후 3.02.28.png]] #### STEP 6 — stack을 DFA에 입력: 경로 재추적 `( E + 2` 전체를 처음부터 다시 DFA에 입력하면: State 0 → (State 4) → (State 2) → (State 5) → **State 3** > 슬라이드 127 page ![[스크린샷 2026-04-28 오후 3.02.39.png]] > 슬라이드 128 page ![[스크린샷 2026-04-28 오후 3.02.51.png]] > 슬라이드 129 page ![[스크린샷 2026-04-28 오후 3.03.03.png]] > 슬라이드 130,1 page ![[스크린샷 2026-04-28 오후 3.03.12.png]] #### STEP 7 — State 3 도달: 완전한 Production 발견 > 슬라이드 130,1 page ![[스크린샷 2026-04-28 오후 3.03.12.png]] State 3의 items: $E \to [num].$ `.`이 우변 끝에 있다 → **완전한 production** → **Reduce!** $E \to [num]$ 을 적용해서 `2`를 `E`로 reduce한다. ## 6.7 DFA 상태를 Stack에 함께 저장 → Action & Goto Table > §6.6에서 매번 DFA를 stack 처음부터 돌려야 했다. > §6.7은 "같은 prefix에 대해서는 항상 같은 DFA 상태가 나온다"는 사실을 이용해 > DFA 상태를 stack element에 함께 저장하는 최적화를 도입한다. > > 이를 통해 Action Table과 Goto Table이 만들어지고, > §6.8의 최종 LR(0) Parsing 알고리즘으로 완성된다. ### 6.7.1 아이디어 — DFA 상태를 Stack에 함께 저장 **아이디어:** 같은 prefix에 대해서는 항상 같은 DFA 상태가 나온다. 따라서 DFA 상태를 stack element에 함께 저장하면 매번 처음부터 돌릴 필요가 없다. - Stack element: $(T \cup NT) \to (T \cup NT, \text{DFA State})$ - 기존 $(T \cup NT)$ 단순 기호에서 - 변형 $(T \cup NT, \text{DFA State})$ 기호와 상태 쌍을 같이 저장 - Stack bottom에 `(dummy symbol, DFA start state = 0)`을 미리 push - stack이 비어있을 때도 "현재 DFA 상태가 뭔가"를 참조할 수 있어야 하기 때문 - 비어있을 경우에는 시작 상태인 $s_0$에서 시작해야 하니까, 참조할 더미 심볼! 이를 위해 총 2개의 테이블이 필요하다 - Action - Shift/Reduce에 대한 결정 - Goto - DFA 구현 Reduce가 일어난 후를 생각해보면 됨 Reduce가 일어나면, 1. stack에서 우변을 pop하고 2. **NT를 push** 1. 이 NT를 push할 때 DFA 상태도 함께 저장해야 하는데, NT는 입력 token이 아니라 reduce 결과물이라서 Action table로는 처리 불가능. 그래서: - **Action**: "지금 읽은 terminal을 보고 Shift할지 Reduce할지" 결정 - **Goto**: "Reduce 후 나온 NT를 보고 다음 DFA 상태가 어디인지" 결정 두 역할이 처리하는 입력 종류 자체가 다르기 때문에 테이블을 분리. ### 6.7.2 DFA 상태를 Stack에 함께 저장 — 필요한 Table 2개 (Action, Goto) **Action Table** — terminal 기준, Shift/Reduce/Accept 결정 | 표기 | 의미 | | ---- | --------------------- | | `SN` | state N으로 shift | | `RN` | production N으로 reduce | | `A` | accept | **Goto Table** — non-terminal 기준, DFA 상태 이동 |표기|의미| |---|---| |`GN`|state N으로 이동| ### 6.7.3 Action & Goto Table — 예제 ![[스크린샷 2026-04-28 오후 3.16.37.png]] ``` Production (1) S ::= E + E (2) E ::= [num] (3) E ::= ( S ) ``` | | Goto | | Action | | | | | | ----- | ----- | ----- | ------ | --------- | ----- | ----- | ----- | | State | **S** | **E** | **+** | **[num]** | **(** | **)** | **$** | | **0** | G1 | G2 | | S3 | S4 | | | | **1** | | | | | | | A | | **2** | | | S5 | | | | | | **3** | | | R2 | R2 | R2 | R2 | R2 | | **4** | G6 | G2 | | S3 | S4 | | | | **5** | | G7 | | S3 | S4 | | | | **6** | | | | | | S8 | | | **7** | | | R1 | R1 | R1 | R1 | R1 | | **8** | | | R3 | R3 | R3 | R3 | R3 | > 💡 State 3, 7, 8처럼 `A → γ .` (완전한 production)인 상태에서는 다음 token에 관계없이 모두 Reduce — 이것이 LR(**0**)의 의미다. lookahead 없이(0개) 결정. ## 6.8 LR(0) Parsing 알고리즘 > §6.7에서 Action/Goto Table을 구성했다. §6.8은 이 Table을 이용한 최종 LR(0) Parsing 알고리즘을 제시한다. 이 알고리즘은 §5.4의 일반 알고리즘을 Table 기반으로 최적화한 것이다. ```fsharp 입력: (T, NT, S, P) // Grammar tokens // Token들의 list Action // Action Table: Map<State, Map<T, Action>> Goto // Goto Table: Map<State, Map<NT, State>> 변수: idx ← 0 // 현재 Token 위치 stack ← [(_, 0)] // Stack bottom에 (dummy, start state) 미리 push while (true) do (symb, state) ← stack.top token ← tokens[idx] if Action[state][token] = 'A' then return // Accept elif Action[state][token] = 'SN' then // Shift: state N으로 이동 stack.push((token, N)) idx ← idx + 1 elif Action[state][token] = 'RN' then // Reduce: RN이 A ← γ₁ … γₙ이라면 for 1 ≤ i ≤ n do stack.pop() (_, state') ← stack.top M ← Goto[state'][A] stack.push((A, M)) else raise Error ``` **예제: `(1+2)+3` 파싱 trace (문법: `S::=E+E`, `E::=[num]|(S)`)** ``` Stack (symbol, state) Input Action ───────────────────────────────────────────────────────────── (_, 0) (1+2)+3$ S4 → push ((, 4) (_, 0)( 4) 1+2)+3$ S3 → push (1, 3) (_, 0)( 4)(1, 3) +2)+3$ R2 → pop 1개, Goto[4][E]=2, push (E,2) (_, 0)( 4)(E, 2) +2)+3$ S5 → push (+, 5) (_, 0)( 4)(E, 2)(+, 5) 2)+3$ S3 → push (2, 3) (_, 0)( 4)(E, 2)(+, 5)(2, 3) )+3$ R2 → pop 1개, Goto[5][E]=7, push (E,7) (_, 0)( 4)(E, 2)(+, 5)(E, 7) )+3$ R1 → pop 3개, Goto[4][S]=6, push (S,6) (_, 0)( 4)(S, 6) )+3$ S8 → push (), 8) (_, 0)( 4)(S, 6)(), 8) +3$ R3 → pop 3개, Goto[0][E]=2, push (E,2) (_, 0)(E, 2) +3$ S5 → push (+, 5) (_, 0)(E, 2)(+, 5) 3$ S3 → push (3, 3) (_, 0)(E, 2)(+, 5)(3, 3) $ R2 → pop 1개, Goto[5][E]=7, push (E,7) (_, 0)(E, 2)(+, 5)(E, 7) $ R1 → pop 3개, Goto[0][S]=1, push (S,1) (_, 0)(S, 1) $ A → Accept ``` ## 6.9 LR(k) Parsing과 LR(0)의 한계 > §6.8에서 LR(0) Parsing 알고리즘을 완성했다. > §6.9은 LR(0)가 "lookahead 0개"로 동작하기 때문에 > 모든 문법을 처리할 수 없음을 정리하고, > 다음 강의에서 다룰 LR(1), SLR, LALR의 방향을 제시한다. **LR(k) Parsing:** - **L**: Left-to-right으로 입력을 읽음 - **R**: Right-most derivation을 역방향으로 탐색 - **k**: Reduce 결정 시 미리 살펴보는 lookahead token의 수 |Parser|Lookahead|처리 가능 문법 범위|특징| |---|---|---|---| |LR(0)|0|가장 좁음|완전한 production 상태에서 무조건 Reduce| |SLR|1 (FOLLOW)|중간|FOLLOW set으로 Reduce 조건 제한| |LALR|1|중간~넓음|LR(1) DFA를 병합해 크기 절감| |LR(1)|1|가장 넓음|DFA 상태 수 많아짐| > 💡 LR(0)의 한계: `A → γ .` 형태의 item이 있는 상태에서는 다음 token을 보지 않고 항상 Reduce한다. 동일한 상태에 Shift item과 Reduce item이 공존하면 **Shift-Reduce conflict**, Reduce item이 2개 이상 공존하면 **Reduce-Reduce conflict**가 발생 → 이를 해결하는 것이 다음 강의(LR(1), SLR, LALR)의 주제다. ## 6.10 LL(1) vs LR(0) 비교 > (개인 정리) §5~6에서 다룬 LR(0)와 이전 강의에서 다룬 LL(1)을 핵심 차원에서 비교한다. | 비교 항목 | LL(1) | LR(0) | | -------------- | ------------------------------ | ---------------------------- | | 방향 | Top-down | Bottom-up | | Derivation | Left-most | Right-most (역순) | | Lookahead | 1 | 0 | | Table 구성 | FIRST / FOLLOW | Viable Prefix DFA | | Left-recursion | ❌ 불가 (right-recursion으로 변환 필요) | ✅ 처리 가능 (별 상관 ㄴ bottom-up이라) | | 처리 가능 문법 범위 | 상대적으로 좁음 | LL(1)보다 넓음 | | Stack 내용 | 아직 파생할 NT | 지금까지 읽은 viable prefix | --- # 7. Bottom-Up Parsing (3) - SLR(1) Parser ## 7.1 이번 장에서 다루는 내용 > 이전 장(LR(0))에서 우리는 Viable Prefix DFA를 만들고, > 이를 바탕으로 Action / Goto Table을 구성하여 > Shift-Reduce parsing을 자동화하는 방법을 배웠다. > > 그런데 LR(0)는 한 가지 큰 가정을 깔고 있었다 > "DFA의 각 상태에서 해야 할 행동(shift인지 reduce인지)이 항상 명확히 하나로 결정된다"는 것. > > 이번 장에서는 그 가정이 깨지는 경우, 즉 **Conflict**가 발생하는 상황을 살펴보고, > 그것을 lookahead 한 글자로 해결하는 **SLR(1) Parser**를 다룬다. > > SLR(1)은 다음 장에서 배울 본격적인 LR(1) 파서로 가기 위한 디딤돌이기도 하다. - LR(0) 파서에서 발생할 수 있는 Conflict - Shift/Reduce Conflict - Reduce/Reduce Conflict - SLR(1) Parsing - Shift/Reduce Conflict를 해결할 수 있는 Heuristic ## 7.2 LR(0)는 항상 잘 작동할까? — Conflict의 발견 > 💡 LR(0)는 stack의 상태(= Viable Prefix DFA의 상태)만 보고 shift/reduce를 결정한다. 이 정보가 부족한 경우가 있을 수 있는데, 그것이 바로 **Conflict**다. ### 7.2.1 예제 1 — Shift/Reduce Conflict (같은 상태에 shift, reduce 모두 가능) 다음 문법을 보자. ``` S' ::= S S ::= a | a b ``` LR(0) Item을 이용해 Viable Prefix FSM을 만들고, ε-transition은 `A → α . B β`와 `B → γ` 사이에 추가한다. 초기: 세 개 따로 ![[스크린샷 2026-04-28 오후 5.05.22.png]] 여기에 ε-transition을 추가해 연결하면 이렇게 된다 ![[스크린샷 2026-04-28 오후 5.06.26.png]] 이건 NFA니, Subset Construction으로 DFA를 만들면: ![[스크린샷 2026-04-28 오후 5.06.45.png|440]] |State|Items|S|a|b| |---|---|---|---|---| |0|S' → . S, S → . a, S → . a b|1|2|| |1|S' → S .|||| |2|**S → a .** , **S → a . b**|||3| |3|S → a b .|||| 문제는 **상태 2**다. 이 상태에는 두 개의 LR(0) item이 함께 있다. 각각의 마커 위치를 보면, - `S → a .` : 이미 handle이 완성되었으니 **reduce** 해야 한다. - `S → a . b` : 아직 handle이 미완성이고 다음에 b가 오면 **shift** 해야 한다. 같은 상태에서 두 가지 행동(shift / reduce)이 모두 가능한 상황이 **Shift/Reduce Conflict**다. ### 7.2.2 예제 2 — Reduce/Reduce Conflict (같은 상태에 두 가지 reduce 후보) 다음 문법을 보자. ``` S' ::= S S ::= A | B A ::= a B ::= a ``` 초기: 5개 따로 ![[스크린샷 2026-04-28 오후 5.08.02.png]] epsilon transition 추가한 NFA 상태 ![[스크린샷 2026-04-28 오후 5.08.47.png]] DFA로 변환하면: ![[스크린샷 2026-04-28 오후 5.09.04.png|434]] | State | Items | S | A | B | a | | ----- | -------------------------------------------- | --- | --- | --- | --- | | 0 | S' → . S, S → . A, S → . B, A → . a, B → . a | 1 | 2 | 3 | 4 | | 1 | S' → S . | | | | | | 2 | S → A . | | | | | | 3 | S → B . | | | | | | 4 | **A → a .** , **B → a .** | | | | | 이번에는 **상태 4**가 문제다. 두 개의 reduce 가능한 item이 함께 들어있다. - `A → a .` 으로 reduce 해야 할까? - `B → a .` 으로 reduce 해야 할까? 같은 상태에서 두 가지 reduce 후보가 존재하는 상황이 **Reduce/Reduce Conflict**다. ### 7.2.3 두 Conflict의 비교 | 구분 | Shift/Reduce Conflict | Reduce/Reduce Conflict | | --------------- | ----------------------------------- | --------------------------- | | 충돌하는 행동 | shift vs reduce | reduce vs reduce | | 한 상태 안의 item 형태 | `A → α .` 와 `B → β . γ` 가 공존 | `A → α .` 와 `B → β .` 가 공존 | | 예제 | `S ::= a \| a b` (상태 2) | `A ::= a`, `B ::= a` (상태 4) | | SLR(1)로 해결 가능? | 일부 가능 (lookahead가 FOLLOW와 겹치지 않을 때) | 일부 가능 | ## 7.3 Shift/Reduce 충돌이 일어나면 어떻게 해야 하나? — Lookahead Token의 도입 > 💡 > §7.2에서 우리는 LR(0)의 한계를 보았다. > stack 상태(= DFA 상태)만으로는 행동을 결정할 수 없는 경우가 있다는 것. > 그렇다면 한 가지 정보를 더 보면 어떨까? > 바로 **다음에 올 token(lookahead)**이다. > 이 아이디어는 사실 우리가 이미 Top-Down parsing에서 본 적이 있다. ### 7.3.1 Top-Down에서 가져온 아이디어 Top-down parsing의 LL(1)에서, `A → ε` 인 production을 안심하고 적용하기 위해 우리가 살펴본 것은 무엇이었나? → **FOLLOW(A)** `A` 뒤에 올 수 있는 token이 현재 input의 다음 token과 일치할 때만 ε-production을 적용했다. 같은 아이디어를 Bottom-Up parsing에도 적용해 보자. ### 7.3.2 예제 1로 직관 잡기 — Shift는 언제? Reduce는 언제? 다시 [[#7.2.1 예제 1 — Shift/Reduce Conflict (같은 상태에 shift, reduce 모두 가능)]]로 돌아가, 충돌이 일어나는 상태 2에서 어떤 입력이 들어왔을 때 shift / reduce가 옳은지 생각해 보자. ![[스크린샷 2026-04-28 오후 5.06.45.png|440]] **Shift가 옳은 경우 — 입력이 `a . b
일 때** ``` 입력: a . b $ 스택 상태: 2 (S → a . / S → a . b) 다음 token: b → b가 들어오면 a b로 가서 S → a b 로 reduce 해야 하므로 SHIFT ``` **Reduce가 옳은 경우 — 입력이 `a .
일 때** ``` 입력: a . $ 스택 상태: 2 (S → a . / S → a . b) 다음 token: $ → $는 입력의 끝, S로 환원하고 끝내야 하므로 S → a 로 REDUCE ``` 핵심은 — **lookahead가 b이면 shift**, **lookahead가 \$이면 reduce**. 그리고 `
는 정확히 `FOLLOW(S)`에 들어있는 token이다. ### 7.3.3 개선된 Parsing 알고리즘 — FOLLOW 확인 → 이걸 뭐라고 부를까? LR(0)의 reduce 결정에 한 가지 조건을 추가한다. > ✅ Reduce action `A → γ`를 수행하려면, **lookahead token이 FOLLOW(A)에 속해야 한다**. 예제 1에 적용해보면, `S`에 대한 NULLABLE / FIRST / FOLLOW는: | |NULLABLE|FIRST|FOLLOW| |---|---|---|---| |S|false|a|$| 상태 2에서: - `S → a .` 으로 reduce는 lookahead가 `
일 때만 (∵ FOLLOW(S) = {$}) - `S → a . b` 의 shift는 lookahead가 `b`일 때만 서로 겹치지 않으므로 충돌 해소! ## 7.4 SLR(1) Parsing — Simple LR(1) > 💡 > §7.3의 아이디어를 정식 알고리즘으로 정리한 것이 SLR(1) 파서다. > 이름이 "Simple" LR(1)인 이유는, > LR(0)의 자료구조(Viable Prefix DFA, Action/Goto Table)는 그대로 두고 > **reduce 시점에 FOLLOW 한 번 더 보는** 아주 작은 변경만 가했기 때문이다. ### 7.4.1 핵심 규칙 `A → γ` 를 기반으로 한 reduce 연산을 수행할 때, **FOLLOW(A)** 를 확인한다. - lookahead token `a` 가 `a ∈ FOLLOW(A)` 일 때만 reduce! ### 7.4.2 알고리즘 — 입력과 변수 ``` 입력: (T, NT, S, P) // Grammar tokens // Token들의 list FOLLOW // FOLLOW Table: Map<NT, Set<T>> Action // Action Table: Map<State, Map<T, Set<Action>>> Goto // Goto Table: Map<State, Map<NT, State>> 변수: idx ← 0 // 현재 Token 위치 stack ← [(_, 0)] // Stack (symbol, state) ``` LR(0)와 비교했을 때 새로 추가된 입력은 `FOLLOW` 하나뿐이다. ### 7.4.3 알고리즘 — 메인 루프 [[#6.7 DFA 상태를 Stack에 함께 저장 → Action & Goto Table]] 참고해 비교해 보자. ``` while (true) do symb, state ← stack.top // 스택의 top에서 (심볼, 상태) 쌍을 읽음 (pop 아님, peek) token ← tokens[idx] // 현재 lookahead token (아직 소비하지 않음) if Action[state][token] ∋ 'A' then return // Accept: 입력을 모두 읽고 시작 심볼로 reduce 완료 → 파싱 성공 // ── SLR(1)과 LR(0)의 유일한 차이점 ────────────────────────────────── // LR(0): Reduce item이 있으면 lookahead에 무관하게 무조건 Reduce // SLR(1): Reduce item이 있어도 token ∈ FOLLOW(A) 일 때만 Reduce // → lookahead를 한 번 더 확인하여 불필요한 Reduce를 방지 elif Action[state][token] ∋ 'RN' && token ∈ FOLLOW(A) then // RN: production rule N 번으로 reduce // A ← γ₁ γ₂ … γₙ (우변 길이 n만큼 스택에서 pop 후 A를 push) goto Reduce elif Action[state][token] ∋ 'SN' then // SN: state N으로 shift // 현재 token을 소비(idx++)하고 (token, N)을 스택에 push goto Shift else raise Error // 위 세 경우 모두 해당 없음 → 문법 오류 ``` LR(0) 알고리즘과의 유일한 차이는 첫 elif 블럭 — `token ∈ FOLLOW(A)` 검사가 추가되었다는 점이다. 참고: [[#6.7.3 Action & Goto Table — 예제]] 맨 마지막 인용부분 참고. ### 7.4.4 알고리즘 — Shift 동작 ``` // Action[state][token] = 'SN' stack.push((token, N)) idx ← idx + 1 ``` 현재 token을 stack에 쌓고, 상태 N으로 전이하며, 입력 포인터를 한 칸 전진시킨다. ### 7.4.5 알고리즘 — Reduce 동작 ``` // Action[state][token] = 'RN' // RN: A ← γ₁ … γₙ for 1 ≤ i ≤ n do stack.pop() _, state ← stack.top M ← Goto[state][A] stack.push((A, M)) ``` handle을 이루는 n개의 symbol을 stack에서 pop하고, 새로 드러난 stack top의 상태에서 Goto table을 참조해 non-terminal A로 전이한 새 상태 M을 push한다. ## 7.5 SLR(1) 적용 예제 — 덧셈 문법 심화 > 💡 복잡한 문법에서 SLR(1)이 어떻게 Shift/Reduce Conflict를 해소하는지 살펴보자. ### 7.5.1 문법 정의 ``` (1) S ::= E + S (2) | E (3) E ::= [number] (4) | ( S ) ``` ### 7.5.2 NULLABLE / FIRST / FOLLOW 계산 | |NULLABLE|FIRST|FOLLOW| |---|---|---|---| |S|false|[num], (|$, )| |E|false|[num], (|$, +, )| `E`의 FOLLOW에 `+`가 포함된 이유: `S → E + S`에서 E 바로 뒤에 `+`가 올 수 있기 때문이다. ### 7.5.3 Viable Prefix DFA 구성 초기에 epsilon transition 추가하면 ![[스크린샷 2026-04-28 오후 6.33.00.png]] NFA이므로 Subset Construction으로 DFA로 바꾸면 ![[스크린샷 2026-04-28 오후 6.32.45.png]] |State|주요 Items| |---|---| |0|`S' → . S`, `S → . E + S`, `S → . E`, `E → . [num]`, `E → . ( S )`| |1|`S → E . + S`, **`S → E .`** ← Conflict 발생 상태| |2|`S → E + . S`, `S → . E + S`, `S → . E`, `E → . [num]`, `E → . ( S )`| |3|`S → E + S .`| |4|`E → [num] .`| |5|`E → ( . S )`, `S → . E + S`, `S → . E`, `E → . [num]`, `E → . ( S )`| |6|`E → ( S . )`| |7|`E → ( S ) .`| **상태 1**에서 `S → E . + S`(shift 가능)와 `S → E .`(reduce 가능)가 공존 → **Shift/Reduce Conflict**. ### 7.5.4 SLR(1)으로 Conflict 해소 ![[스크린샷 2026-04-28 오후 6.41.18.png]] | |NULLABLE|FIRST|FOLLOW| |---|---|---|---| |S|false|[num], (|$, )| |E|false|[num], (|$, +, )| Conflict가 발생하는 상태 1에서 SLR(1) 규칙을 적용한다. - `S → E .` 로 **reduce**하려면 lookahead가 `FOLLOW(S) = {$, )}`에 속해야 한다. - `S → E . + S` 의 **shift**는 lookahead가 `+`일 때만 발동한다. | lookahead | 행동 | | ---------- | ------------------------------------ | | `+` | Shift (다음에 `+`가 오면 `E + S` 형태가 계속된다) | | `
or `)` | Reduce `S → E` | 이렇게 충돌이 해소된다. ![[스크린샷 2026-04-28 오후 6.41.30.png]] 빨간 상태에서 `E`를 받으면 conflict 상태(`S → E . + S` / `S → E .`)에 도달한다. 여기서 lookahead가 `+`이면 shift, lookahead가 `FOLLOW(S) = {$, )}`에 속하면 `S → E`로 reduce한다. ### 7.5.5 `(1 + 2) + 3` 파싱 trace with SLR(1) 아래는 상태 번호를 포함한 상세 trace다. `(state, symbol)` 쌍으로 stack을 표현한다. ``` Stack Input Action ───────────────────────────────────────────────── (_, 0) (1+2)+3$ Shift ( → state 5 (_, 0)(( ,5) 1+2)+3$ Shift [1] → state 4 (_, 0)((,5)(1,4) +2)+3$ Reduce E→[num]; Goto[5][E]=? → push E (_, 0)((,5)(E,…) +2)+3$ Shift + → … … … (계속) (_, 0)(E, 1) +3$ Shift + → state 2 (_, 0)(E,1)(+,2) 3$ Shift [3] → state 4 (_, 0)(E,1)(+,2)(3,4) $ Reduce E→[num] (_, 0)(E,1)(+,2)(E,…) $ Reduce S→E (lookahead=$∈FOLLOW(S)) (_, 0)(E,1)(+,2)(S,3) $ Reduce S→E+S (_, 0)(S, …) $ Accept ``` 이처럼 SLR(1)은 lookahead 하나를 참조함으로써 LR(0)가 해결하지 못했던 Shift/Reduce Conflict를 올바르게 처리한다. > 근데 이 좋은 걸 두고 왜 또 배우냐? 다음 장에 한계 나옴. --- # 8. Bottom-Up Parsing (4) — LR(1) Parser ## 8.1 SLR(1)은 항상 잘 작동할까? → 작동하지 않는 예시를 분석해보자 > §7에서 SLR(1)은 LR(0)의 DFA를 그대로 두고 > reduce 시점에 `token ∈ FOLLOW(A)` 조건만 추가해서 conflict를 해소했다. > 그런데 이 방식이 언제나 통하는 건 아니다. > > §8.1에서는 SLR(1)조차 실패하는 구체적인 예제를 보고, > §8.2에서 그 근본 원인을 진단한다. ### 8.1.1 대입 문법 — LR(0) DFA 구성 다음 문법으로 실험한다. ``` (1) S ::= L = R (2) | R (3) L ::= * R (4) | [id] (5) R ::= L ``` LR(0) item으로 Viable Prefix DFA를 구성하면: 초기 + epsilon transition ![[스크린샷 2026-04-28 오후 7.30.51.png]] Subset Construction으로 DFA 전환 ![[스크린샷 2026-04-28 오후 7.31.12.png]] | State | Items | = | * | [id] | S | L | R | | ----- | ----------------------------------------- | --- | --- | ---- | --- | --- | --- | | 0 | S'→.S, S→.L=R, S→.R, L→.*R, L→.[id], R→.L | | 4 | 5 | 1 | 2 | 3 | | 1 | S'→S. | | | | | | | | 2 | S→L.=R, R→L. | 6 | | | | | | | 3 | S→R. | | | | | | | | 4 | L→\*.R, R→.L, L→.*R, L→.[id] | | 4 | 5 | | 7 | 8 | | 5 | L→\[id]. | | | | | | | | 6 | S→L=.R, R→.L, L→.*R, L→.[id] | | 4 | 5 | | 7 | 9 | | 7 | R→L. | | | | | | | | 8 | L→*R. | | | | | | | | 9 | S→L=R. | | | | | | | Action/Goto table로 펼치면: | State | Action = | Action * | Action [id] | Goto S | Goto L | Goto R | Goto $ | | ----- | --------- | -------- | ----------- | ------ | ------ | ------ | ------ | | 0 | | S4 | S5 | G1 | G2 | G3 | | | 1 | | | | | | | A | | **2** | **S6/R5** | R5 | R5 | | | | | | 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, `=` 칸: S6/R5 → Shift/Reduce Conflict 발생.** State 2는 `S → L . = R`(shift 가능)과 `R → L .`(reduce 가능)이 공존하는 상태다. ### 8.1.3 SLR(1)으로 해소를 시도하면 → 여전히 실패 SLR(1) 방식: `R → L .`으로 reduce하려면 lookahead가 `FOLLOW(R)`에 속해야 한다. [[#5.3.1 Reduce가 필요한 타이밍 정의]]와 [[#7.4.1 핵심 규칙]] 참고. NULLABLE / FIRST / FOLLOW를 계산하면: | |NULLABLE|FIRST|FOLLOW| |---|---|---|---| |S|false|`*`, `[id]`|`
| |L|false|`*`, `[id]`|`
, `=`| |R|false|`*`, `[id]`|`
, `=`| 스택 관련해서는 [[#6.7.1 아이디어 — DFA 상태를 Stack에 함께 저장]] 참고. **16p**: 아무것도 안 읽은 초기 상태. 스택 비어있음. 상태 0번에 가 있음. 입력은 `a = b`. 스택: (\_, 0) **17-18p**: `a`를 읽음. `a`는 `[id]`이므로 `L → [id] .` 상태에 도달. 스택엔 `a`가 쌓임. 그리고 이 상태는 **reduce item** — `L → [id] .` 이므로 SLR(1)은 reduce 여부를 판단해야 함. FOLLOW(L) = `{$, =}` 이고 다음 lookahead가 `=` 이므로 **`= ∈ FOLLOW(L)` → L로 reduce.** 스택: `L`. `L`을 쌓았으니 FOLLOW 테이블에서 `L`의 FOLLOW인 `{$, =}`가 하이라이트. 방금 이걸 근거로 reduce함. 스택: (\_, 0) (a, 5) reduce 뒤에 (\_, 0) (L, 2) **19p-20**: `L`로 reduce됐으니 이제 다시 stack에서 (L, 2) 빼서 0에서 시작해서 L 받고 2로 `S → L . = R` / `R → L .` 상태(분홍 테두리)에 도달. 스택: `L`. SLR(1)은 또 판단 — 다음 lookahead가 `=`이고 **FOLLOW(R) = `{$, =}`** 이므로 `= ∈ FOLLOW(R)` → **`R → L .`로 reduce.** 스택: `R` 위에 `L`. R로 reduce됐으니 이번엔 FOLLOW(R) = `{$, =}` 하이라이트. 방금 이걸 근거로 reduce했다는 것. 스택: (\_, 0) (L, 2) reduce 뒤에 (\_, 0) (R, 3) **21p**: "뭔가 이상한데요?" — R로 reduce한 결과 스택엔 `R`만 남았는데, 다음 입력이 `=`. 근데 `R = ...` 을 처리할 수 있는 production이 문법 어디에도 없음. 3에서도 처리 못함. **막힘.** --- ## 8.2 SLR(1) 실패의 근본 원인 — LR(0) item 기반 Follow Set 과근사 → 다른 item을 사용해야 한다 > §8.1에서 SLR(1)이 대입 문법에서 실패함을 확인했다. > §8.2에서는 왜 구조적으로 이 문제가 생길 수밖에 없는지를 분석한다. > > 이 진단이 §8.3의 LR(1) 도입 동기가 된다. ### 8.2.1 `a = b`는 올바른 문장 — 올바른 parse tree가 존재한다 ``` (1) S ::= L = R (2) | R (3) L ::= * R (4) | [id] (5) R ::= L ``` `a = b`는 이 문법에서 올바른 문장이다. 유도 과정: ``` S → L = R → [id] = R (a = R) → [id] = L (a = L) → [id] = [id] (a = b) ``` 올바른 parse tree: ``` 올바른 tree: SLR(1)이 시도하는 tree: S S /|\ | L = R R = b ← R 뒤에 =가 오므로 막힘 | | | [a] L L | | [b] [a] ``` SLR(1) 파서는 `a`를 읽고 State 2에 도달한 뒤, lookahead `=`를 보고 `FOLLOW(R) = {$, =}` 때문에 `R → L .`로 reduce해버린다. 그 결과 오른쪽 tree를 만들려 하지만, `R` 뒤에 `=`가 오는 경로는 이 문법에 없으므로 파싱 불가. ### 8.2.2 FOLLOW(R)에 왜 `=`가 들어갔나? 과하게 근사했기 때문. R 뒤에 '항상' 따라오는 문자 집합이 아닌, '경우에 따라' 따라오는 문자 집합이기 때문. - `= ∈ FOLLOW(R)`인 이유: `R ::= L`이고 `L`의 FOLLOW에 `=`가 있기 때문 - `L`의 FOLLOW에 `=`가 있는 이유: `S ::= L = R`에서 `L` 바로 뒤에 `=`가 오기 때문 즉 **`=`가 R 뒤에 오는 게 아니라 L 뒤에 오는 것**인데, `R ::= L`이라는 파생 관계 때문에 FOLLOW가 섞여버린 것이다. 실제로: - `* R = R` — `L ::= * R`이므로 `*R`이 L 역할, 그 뒤 `=`가 옴 (ok) - `R = R` — 이런 production은 없다 (no) FOLLOW set은 **"어떤 문맥에서든 한 번이라도 뒤에 올 수 있는 token을 모두 모은 것"** 이기 때문에, 문맥에 따라 달라지는 lookahead를 표현하기엔 너무 부정확하다. ### 8.2.3 SLR(1)의 근본적 문제 — LR(0) item 기반이다 "LR(0) item 기반"인 것이 근본적 문제. | 문제 | 설명 | | ------------------------- | ------------------------------------ | | DFA 설계 시 lookahead 미고려 | Viable Prefix DFA가 LR(0) item만으로 구성됨 | | Reduce 조건에 부정확한 FOLLOW 사용 | 상황에 따라 FOLLOW가 달라질 수 있음을 인식 불가 | | 결론 | DFA 자체를 lookahead를 고려해서 다시 설계해야 함 | > 💡 SLR(1)은 "DFA는 그대로, table 채우는 방식만 변경"이었다. > LR(1)은 여기서 한발 더 나아가 **DFA 설계 단계부터** lookahead를 반영한다. --- ## 8.3 LR(1) Item — Lookahead를 Item에 내재화 > §8.2에서 문제의 핵심은 > "어떤 문맥에서 이 rule을 reduce해도 되는가"를 > item 수준에서 표현하지 못한다는 것이었다. > > §8.3에서는 lookahead token을 > item의 구성 요소로 포함시킨 LR(1) item을 도입한다. > > 이를 기반으로 §8.4에서 conflict-free DFA를 새로 구성한다. ### 8.3.1 LR(0) Item vs LR(1) Item | 구분 | 표현 | reduce 조건 | | ---------- | ---------------------- | ---------------------- | | LR(0) item | `L → [id] .` | 무조건 reduce | | SLR(1) | `L → [id] .` (DFA는 동일) | `token ∈ FOLLOW(L)` | | LR(1) item | `L → [id] . , =` | lookahead가 정확히 `=`일 때만 | | LR(1) item | `L → [id] . ,
| lookahead가 정확히 `
일 때만 | **타입**: `LR1Item = LR0Item × T` — LR(0) item과 terminal token의 쌍 ### 8.3.2 LR(1) Item의 정의 — LR(0) item과 terminal token의 쌍 → LR(1) Item 기반 VP DFA를 만들면 LR(1) Parser → 그런데, LR(1) Item은 어떻게 계산할까? `type LR1Item = LR0Item × T` — LR(0) item과 terminal token의 쌍 `A → α . β , a` 형태에서: - `α` : 현재 stack top에 존재하는 부분 - `βa` : 남은 입력은 `βa`로부터 유도된 문자열을 prefix로 가짐 - **`A → αβ`를 reduce하고 나면, 남은 입력의 제일 처음은 반드시 `a`여야 한다** dot 위치별 의미: ``` A → . αβ , a // αβ를 인식하기 위해 대기 중인 상태 // 지금까지 읽은 입력은 A → αβ로 reduce하기에 문제 없는 상태 A → α . β , a // α까지 인식, 아직 β가 남음 // 지금까지 읽은 입력은 A → αβ로 reduce하기에 문제 없는 상태 // α까지 인식 A → αβ . , a // αβ를 전부 인식했고, 다음 token이 a이면 reduce 가능 // 지금까지 읽은 입력은 A → αβ로 reduce하기에 문제 없는 상태 // αβ까지 인식 // lookahead token이 a이면 reduce할 수 있는 상태 ``` **예**: `(L → [id] . , =)` — `[id]`까지 읽었고, 이걸 `L`로 reduce하려면 다음 token이 반드시 `=`여야 한다. ### 8.3.3 LR(1) Item 계산 — Lookahead 계산 아이디어: `(S' → . S , $)`로부터 시작해, LR(1) item `(A → α . β , a)`가 주어졌을 때: - $\alpha$를 읽었고, - 그 다음에 올 입력은 $\beta a$로부터 유도되어야 함 - 즉, $\alpha$까지 읽었을 때, 다음에 올 첫 입력은 FIRST($\beta a$) - 그냥 FIRST의 정의를 생각하면, "어떤 문자열에서 맨 처음 나올 수 있는 terminal들의 집합"이니까 말이 됨. LR(1) item `(A → α . Bβ , a)`가 주어졌을 때: > B는 non-terminal이니까 실제 입력에서 읽히려면 > **어떤 production으로 전개**되어야 함. > 그 전개의 시작점이 `(B → . γ , b)` 인데, > 이때 lookahead b가 뭐가 될지는 — > "B를 다 읽고 난 뒤 다음에 올 수 있는 첫 terminal이 뭔가"로 결정. B 뒤에는 β가 오고, β 뒤에는 a가 오니까, B 다음에 올 수 있는 첫 terminal = FIRST(βa). 그래서 `b ∈ FIRST(βa)` 인 모든 b에 대해 `(B → . γ , b)` 가 유효한 LR(1) item이 되는 거예요. - `b ∈ FIRST(βa)` — β 이후에 a가 올 때 나올 수 있는 첫 token들에 대해 - `(B → . γ , b)` 는 유효한 LR(1) Item > 💡 예: `(L → * . R , =)`가 주어지면, R production 전개 시 lookahead는 `FIRST(ε · =) = {=}`이므로 `(R → . L , =)`가 유효한 LR(1) item. 시작점: `(S' → . S , $)` — 이것으로부터 전체를 전개한다. ## 8.4 LR(1) DFA 구성 — 대입 문법 예제 > §8.3에서 LR(1) item의 lookahead 계산 방법을 파악했다. §8.4에서는 실제로 대입 문법에 적용해 LR(1) DFA를 구성하고, §8.5에서 Parsing Table로 변환한다. ### 8.4.1 LR(0) → LR(1)에서 일어나는 일 LR(0)에서는 item `L → . * R` 하나였던 것이, LR(1)에서는 **lookahead에 따라 분리**된다: ``` LR(0): L → . * R (하나) LR(1): (L → . * R , =) (= 가 뒤에 올 문맥) (L → . * R , $) ($ 가 뒤에 올 문맥) ``` 이것이 더 정밀한 lookahead 구분의 핵심이다. ### 8.4.2 LR(1) DFA 전체 구성 (대입 문법) `(S' → . S , $)` 출발 #### Step 0. 시작점 > 슬라이드 40 시작 item: (S' → . S , $) ``` [경우2 — dot 다음이 NT S] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (S → . γ , b) 추가 ``` β = ε (S 뒤에 아무것도 없음), a = $ → FIRST(ε · \$) = {$} → b = $ 하나 → S의 production 전개: (S → . L = R , $) (S → . R , $) S를 읽으면 → (S' → S . , $) \[일반 transition] $\epsilon$ → (S → . L = R , \$) \[$\epsilon$ transition] #### Step 1. (S → . L = R , $) 처리 > 슬라이드 41 ``` [경우2 — dot 다음이 NT L] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (L → . γ , b) 추가 ``` β = = R, a = $ → FIRST(= R · $) = {=} ← = 가 terminal이므로 바로 결정 → b = = 하나 → L의 production 전개: (L → . * R , =) (L → . \[id] , =) L을 읽으면 → (S → L . = R , $) \[일반 transition] = 를 읽으면 → (S → L = . R , $) \[일반 transition] R을 읽으면 → (S → L = R . , $) \[경우1 — reduce item, lookahead $ 일 때만 reduce] #### Step 2. (S → . R , $) 처리 > 슬라이드 42 \[경우2 — dot 다음이 NT R] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (R → . γ , b) 추가 β = ε, a = $ → FIRST(ε · \$) = {$} → b = $ 하나 → R의 production 전개: (R → . L , $) R을 읽으면 → (S → R . , $) \[경우1 — reduce item, lookahead $ 일 때만 reduce] #### Step 3. (L → . * R , =) 처리 > 슬라이드 43 * 를 읽으면 → (L → * . R , =) \[일반 transition] \[경우2 — dot 다음이 NT R] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (R → . γ , b) 추가 β = ε, a = = → FIRST(ε · =) = {=} → b = = 하나 → R의 production 전개: (R → . L , =) R을 읽으면 → (L → * R . , =) \[경우1 — reduce item, lookahead = 일 때만 reduce] #### Step 4. (R → . L , =) 처리 > 슬라이드 43~44 \[경우2 — dot 다음이 NT L] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (L → . γ , b) 추가 β = ε, a = = → FIRST(ε · =) = {=} → b = = 하나 → L의 production 전개: (L → . * R , =) ← 이미 Step 1에서 추가됨, 중복이므로 skip (L → . \[id] , =) ← 이미 Step 1에서 추가됨, 중복이므로 skip L을 읽으면 → (R → L . , =) \[경우1 — reduce item, lookahead = 일 때만 reduce] #### Step 5. (L → . \[id] , =) 처리 > 슬라이드 44 \[경우1 — dot 다음이 terminal \[id]] 별도 expand 없음. 그냥 transition만. \[id] 를 읽으면 → (L → \[id] . , =) \[경우1 — reduce item, lookahead = 일 때만 reduce] #### Step 6. (R → . L , $) 처리 > 슬라이드 45~46 \[경우2 — dot 다음이 NT L] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (L → . γ , b) 추가 β = ε, a = $ → FIRST(ε · \$) = {$} → b = $ 하나 → L의 production 전개: (L → . * R , $) ← Step 1의 = 버전과 별개! lookahead가 다름 (L → . \[id] , $) ← 마찬가지 L을 읽으면 → (R → L . , $) \[경우1 — reduce item, lookahead $ 일 때만 reduce] ※ 핵심: LR(0)에서는 R → L . 하나였는데 LR(1)에서는 (R → L . , =) 와 (R → L . , $) 로 분리됨 → = 가 왔을 때 reduce하지 않아도 되는 문맥을 구분할 수 있게 됨 → SLR(1)의 conflict 해소 #### Step 7. (L → . * R , $) 처리 > 슬라이드 47~48 * 를 읽으면 → (L → * . R , $) \[일반 transition] \[경우2 — dot 다음이 NT R] rule: b ∈ FIRST(βa) 인 모든 b에 대해 (R → . γ , b) 추가 β = ε, a = $ → FIRST(ε · \$) = {$} → b = $ 하나 → R의 production 전개: (R → . L , $) ← 이미 Step 2에서 추가됨, 중복이므로 skip R을 읽으면 → (L → * R . , $) \[경우1 — reduce item, lookahead $ 일 때만 reduce] #### Step 8. (L → . \[id] , $) 처리 > 슬라이드 49 \[경우1 — dot 다음이 terminal \[id]] 별도 expand 없음. 그냥 transition만. \[id] 를 읽으면 → (L → \[id] . , $) \[경우1 — reduce item, lookahead $ 일 때만 reduce] #### Step 9. 재귀 구조 확인 — 전체 완성 > 슬라이드 50~52 L → . * R 에서 * 를 읽으면 L → * . R 이 되고, 거기서 R → . L 이 나오고, 거기서 다시 L → . * R , L → . \[id] 가 나오는 재귀가 발생. 단, 이미 추가된 item은 중복 추가하지 않으므로 종료 보장. = lookahead 라인과 $ lookahead 라인이 병렬로 전개되면서 일부 transition에서 같은 상태로 합류 — 이것이 최종 LR(1) DFA의 구조. ![[스크린샷 2026-04-29 오전 12.19.41.png]] LR(0)과 비교해보자. ![[스크린샷 2026-04-29 오전 12.20.04.png]] ### 8.4.3 LR(1) Item 구하는 알고리즘 ```pseudo 입력: (T, NT, S, P) // Grammar FIRST // Map<T ∪ NT, Set<T>> 변수: Items ← {} // Set<LR1Item> 결과값 WorkList ← {(S' → S, $)} // Set<P × T> // Production, Terminal pairs ← {(S' → S, $)} // Set<P × T> // Production, Terminal // 1단계: 유효한 (production, lookahead) 쌍 수집 while WorkList ≠ ∅ do WorkList, (prod, t) ← pop(WorkList) // prod = A → γ₁γ₂…γₙ for X ∈ {γ₁, …, γₙ} ∩ NT do // X = γᵢ , β = γᵢ₊₁…γₙ for prod' ∈ P[X], a ∈ FIRST(β · t) do if (prod', a) ∉ pairs then pairs ← pairs ∪ {(prod', a)} WorkList ← WorkList ∪ {(prod', a)} // 2단계: 전체 LR(1) item 생성 for (prod, t) ∈ pairs do // prod = A → γ₁…γₙ for item ∈ LR0(A → γ₁…γₙ) do Items ← Items ∪ {(item, t)} ``` ## 8.5 LR(1) Parsing Table > §8.4에서 LR(1) DFA를 구성했다. > §8.5에서는 이를 Parsing Table로 변환하고, > LR(0) 기반 table과 비교하여 conflict가 어떻게 해소되는지 확인한다. ### 8.5.1 LR(1) Parsing Table (대입 문법) > 현재 상태 + 다음 token을 보고 어떤 action을 취할지 | State | Items (요약) | S | L | R | = | * | [id] | | ----- | ---------------------- | --- | --- | --- | --- | --- | ---- | | 0 | S'→.S,$, S→.L=R,$, ... | 1 | 2 | 3 | | 4 | 5 | | 1 | S'→S.,$ | | | | | | | | 2 | S→L.=R,$, **R→L.,$** | | | | 6 | | | | 3 | S→R.,$ | | | | | | | | 4 | L→_.R,=, L→_.R,$, ... | | 7 | 8 | | 4 | 5 | | 5 | L→[id].,=, L→[id].,$ | | | | | | | | 6 | S→L=.R,$, R→.L,$, ... | | 9 | 10 | | 11 | 12 | | 7 | R→L.,=, R→L.,$ | | | | | | | | 8 | L→*R.,=, L→*R.,$ | | | | | | | | 9 | R→L.,$ | | | | | | | | 10 | S→L=R.,$ | | | | | | | | 11 | L→*.R,$, R→.L,$, ... | | 9 | 13 | | 11 | 12 | | 12 | L→[id].,$ | | | | | | | | 13 | L→*R.,$ | | | | | | | - `S`, `L`, `R` : - reduce 후 어느 state로 이동할지 (state transition) - `=`, `*`, `[id]` : shift할 state 번호 (숫자만 있으면 Shift N) **State 2에서 `=` 입력 시: Shift 6만 존재, Reduce 없음 → Conflict 해소.** ### 8.5.2 LR(0)/SLR(1) vs LR(1) 비교 | 항목 | LR(0) / SLR(1) | LR(1) | | ------------- | --------------------- | ------------------ | | Item 형태 | `A → α . β` | `A → α . β , a` | | Reduce 조건 | `a ∈ FOLLOW(A)` (SLR) | lookahead가 정확히 `a` | | 대입문법 상태 수 | 10 | 14 | | 대입문법 Conflict | S6/R5 (State 2) | 없음 | | DFA 변경 여부 | 변경 없음 (SLR은 table만) | **DFA 자체를 새로 설계** | ## 8.6 LR(1)의 장단점과 문법 계층 > §8.5까지 LR(1)이 SLR(1)의 한계를 어떻게 해결하는지 확인했다. > §8.6에서는 그 대가로 생기는 상태 수 폭발 문제와, > LR(1) grammar가 문법 계층에서 어디에 위치하는지를 정리한다. > 상태 수 문제는 §9.5.1에서 LALR(1) heuristic의 동기가 된다. ### 8.6.1 장점 — 더 넓은 문법을 처리 - LR(0) item 하나에 여러 lookahead가 붙어 상태가 분리되므로, **문맥에 따라 다른 reduce 조건**을 표현 가능 - C, C++, Java, Python 등 대부분의 실용 언어가 LR(1) 문법에 속함 ### 8.6.2 단점 — Viable Prefix DFA 상태 수 폭발 - 대입 문법에서 LR(0) 10개 → LR(1) 14개 - 실제 언어 문법에서는 훨씬 심각하게 증가 - 실제 컴파일러에서는 LALR(1) 등 근사 방식 사용 (§9.5.1 참조) ### 8.6.3 Reduce/Reduce Conflict — 문법 모호성의 징후 ``` S' ::= S S ::= A | B A ::= a B ::= a ``` `a`를 읽으면 `A → a .`와 `B → a .` 중 어느 것으로 reduce할지 결정 불가 → **Reduce/Reduce Conflict.** 대부분 **문법 자체가 모호**해서 생기는 경우이며, 문법을 직접 수정하거나 우선순위(priority) 규칙으로 해결해야 한다. ### 8.6.4 LR(1) Grammar의 문법 계층 ``` ┌──────────────────────────────────────────────────────┐ │ Context-Free Grammar │ │ │ │ ┌──────────────────────────┐ ┌──────────────────┐ │ │ │ LR(1) Grammar │ │ Ambiguous │ │ │ │ ┌────────────────────┐ │ │ Grammar │ │ │ │ │ SLR(1) Grammar │ │ │ │ │ │ │ │ ┌──────────────┐ │ │ │ │ │ │ │ │ │ Regular │ │ │ │ │ │ │ │ │ │ Grammar │ │ │ │ │ │ │ │ │ └──────────────┘ │ │ │ │ │ │ │ └────────────────────┘ │ │ │ │ │ └──────────────────────────┘ └──────────────────┘ │ └──────────────────────────────────────────────────────┘ ``` - **LR(1) ⊃ SLR(1) ⊃ Regular** (모두 CFG의 부분집합) - **Ambiguous Grammar**는 LR(1)과 교집합 없음 — LR(1) 파서로 분석 불가 - LR(1) Grammar에 속하지 않는 (하지만 ambiguous하지 않은) CFG도 존재 --- # 9. Parser 마무리 ## 9.1 Parser의 출력 — CST에서 AST로 > §8까지는 Token list를 받아 문법을 검증하는 파서의 동작 원리를 다뤘다. > 그런데 파서의 최종 목적은 파싱 성공/실패만 알리는 것이 아니라, > 이후 의미 분석과 코드 생성에 사용할 AST를 반환하는 것이다. > > §9.1에서는 CST와 AST의 차이를 살펴보고, > §9.2에서는 CST를 경유하지 않고 Token list에서 곧바로 AST를 만드는 방법을 다룬다. ### 9.1.1 지금까지 본 것은 CST-Generator 내부 동작 — Parser의 추상적 정의 재확인 ``` val parser : Token list -> AST Token list ──► [CST-Generator] ──► CST ──► [AST-Generator] ──► AST ``` 개념적으로 두 단계: 1. **CST-Generator**: Token list → CST (Concrete Syntax Tree) 2. **AST-Generator**: CST → AST (Abstract Syntax Tree) ### 9.1.2 CST — 문법 구조를 그대로 반영한 트리 CST는 문법 규칙의 적용을 그대로 트리로 표현한다. `if (x == 1) y = z;`에 대한 CST: ``` CSTStmt(CSTIfStmt) | CSTIfStmt ┌────────┬────────┬────────┬────────┐ IF LPAREN CSTExpr RPAREN CSTStmt(CSTAssignStmt) (CSTRelOp) ┌──┬──┬────────┬────┐ ┌──┬────────┬──┐ ID ASSIGN CSTExpr SEMI CSTExpr CSTRelOp CSTExpr (CSTId) (CSTId) Kind(Eq) (CSTNum) | | | ID EQ NUM ``` ### 9.1.3 AST — 핵심 의미 구조만 추상화 CST에서 제거되는 것들: |제거 대상|이유| |---|---| |LPAREN, RPAREN|괄호는 구조 파악 후 불필요| |SEMI, ASSIGN 등 구두점|노드 타입에 이미 의미가 담김| |Child 1개인 중간 노드|단순 래핑 (예: `CSTExpr → CSTId`) 제거| AST에 남는 것: 중첩 구조, 연산 종류, 식별자/리터럴 값 — 의미 분석·IR 변환에 필요한 것들. 예시 — `1 + 2 + 3 + 4 + 5`의 AST: ``` BinOp(+) / \ BinOp(+) Num(5) / \ Num(1) BinOp(+) / \ Num(2) BinOp(+) / \ Num(3) Num(4) ``` ### 9.1.4 AST 타입 정의 예시 `if (x == 1) y = z;`에 해당하는 AST 구조: ``` ASTStmt(ASTIfStmt) / \ ASTExpr(ASTRelOp) ASTStmt(ASTAssignStmt) / | \ / \ ASTExpr ASTRelOp ASTExpr string(y) ASTExpr(ASTId) (ASTId) Kind(Eq) (ASTNum) string(z) string(x) int(1) ``` ### 9.1.5 CST → AST 변환 규칙 |CST 노드|AST 변환| |---|---| |`CSTIfStmt`|`ASTIfStmt(condition: ASTExpr, body: ASTStmt)`| |`CSTRelOp`|`ASTRelOp(lhs, op: ASTRelOpKind, rhs)`| |`CSTAssignStmt`|`ASTAssignStmt(lhs: string, rhs: ASTExpr)`| |`CSTId`|`ASTId(name: string)` — ID 토큰의 lexeme 추출| |`CSTNum`|`ASTNum(value: int)` — NUM 토큰의 값 추출| |IF, LPAREN, RPAREN, SEMI, ASSIGN 등|**버림**| ## 9.2 Token list → AST 직접 변환 — 두 스택 병행 운용 > §9.1에서 CST를 경유해 AST를 만드는 방식을 보았다. > §9.2에서는 CST를 명시적으로 만들지 않고, > LR 파싱과 동시에 AST를 구성하는 방법을 다룬다. > §9.3의 Yacc 계열 파서 생성기가 이 원리를 활용한다. ### 9.2.1 두 스택 병행 운용 LR 파싱 시 **CST Stack**과 **AST Stack**을 동시에 운용한다: ``` CST Stack │ AST Stack ──────────────────────────┼────────────────────────── CSTIfStmt │ ASTStmt(ASTIfStmt) ... │ ... ``` - **CST Stack**: 기존 LR 파서의 parse stack 역할 (state + CST node) - **AST Stack**: reduce 시 CST node 대신 대응하는 AST node를 push > 슬라이드 보면 직관적으로 이해 가능 ### 9.2.2 변환 과정 단계별 추적 `if (x == 1) y = z;` 파싱 과정 (주요 단계만): ``` 단계 1~2: IF, LPAREN shift CST: [IF, LPAREN] AST: [IF, LPAREN] 단계 3: ID("x") shift → CSTExpr(CSTId) reduce CST: [IF, LPAREN, CSTExpr(CSTId)] AST: [IF, LPAREN, ASTExpr(ASTId("x"))] 단계 4: EQ shift → CSTRelOpKind(CSTEq) reduce CST: [..., CSTExpr(CSTId), CSTRelOpKind(CSTEq)] AST: [..., ASTExpr(ASTId("x")), ASTRelOpKind(ASTEq)] 단계 5: NUM(1) shift → CSTExpr(CSTNum) reduce CST: [..., CSTExpr(CSTId), CSTRelOpKind(CSTEq), CSTExpr(CSTNum)] AST: [..., ASTExpr(ASTId("x")), ASTRelOpKind(ASTEq), ASTExpr(ASTNum(1))] 단계 6: CSTRelOp reduce (위 3개 pop → CSTExpr(CSTRelOp) push) CST: [IF, LPAREN, CSTExpr(CSTRelOp)] AST: [IF, LPAREN, ASTExpr(ASTRelOp(ASTId("x"), ASTEq, ASTNum(1)))] 단계 7: RPAREN — reduce 시 구두점이므로 AST에서는 버림 ... (y = z 처리 후 CSTAssignStmt reduce) CST: [IF, LPAREN, CSTExpr(CSTRelOp), RPAREN, CSTStmt(CSTAssignStmt)] AST: [IF, LPAREN, ASTExpr(...), ASTStmt(ASTAssignStmt("y", ASTId("z")))] 단계 N: CSTIfStmt reduce CST: [CSTIfStmt] AST: [ASTStmt(ASTIfStmt(condition, body))] ``` **핵심**: reduce 동작 시, CST node를 AST node로 변환하는 로직을 각 production rule에 붙여 실행한다. 이것이 §9.3의 Yacc에서 **semantic action**의 역할이다. ## 9.3 자동 파서 생성기 — Yacc와 FsYacc > §9.2까지 파서가 어떻게 AST를 만드는지 이해했다. > §9.3에서는 CFG 명세로부터 파서를 자동 생성하는 Yacc 계열 도구를 소개한다. > §9.4에서 "Yacc를 그냥 쓰면 되지 않냐"는 의문에 답한다. ### 9.3.1 Yacc — Context Free Grammar → Parser 자동 생성 - **Yet Another Compiler-Compiler** (1970년대 Stephen C. Johnson 제안) - 입력: CFG 명세 (`.y` 파일) + 각 production에 대한 semantic action (AST 생성 코드) - 출력: LR 파서 C 코드 - 각 언어마다 다양한 버전: Bison(C), FsYacc(F#), Happy(Haskell) 등 ### 9.3.2 FsYacc — F#을 위한 Yacc ``` Parser.fsy ──[FsYacc]──► Parser.fs ``` `.fsy` 파일에 문법과 semantic action을 작성하면, FsYacc가 LR 파싱 테이블과 AST 생성 코드를 자동으로 F# 코드로 변환한다. ## 9.4 Parsing을 직접 배우는 이유 — Conflict 해결 능력 > §9.3에서 Yacc로 파서를 자동 생성할 수 있음을 보았다. 그러면 Parsing 이론은 몰라도 되지 않을까? §9.4에서는 그렇지 않은 이유를 설명하고, §9.5에서 실무에서 마주치는 구체적인 고민거리들을 정리한다. - **문법을 잘못 디자인하면 Conflict가 발생한다** (LR(1) 문법이 아닌 경우 등) - Yacc 자체 설정(우선순위, 결합성)으로 해결 가능한 경우도 있지만, **문법 구조 자체를 고쳐야 해결되는 경우** 존재 - 문법을 어떻게 수정해야 하는지 판단하려면 Parsing 이론 이해가 필수 ## 9.5 파서 구현의 고민거리들 > §9.4까지 이론과 도구 전반을 다뤘다. > §9.5에서는 실제 파서를 구현할 때 만나게 되는 네 가지 현실적 문제를 정리한다. ### 9.5.1 LR(1) DFA 상태 수 → LALR(1) Heuristic - LR(1) item을 쓰면 상태 수가 너무 많아진다 - **LALR(1)**: 동일한 LR(0) core를 가지는 LR(1) 상태들을 병합하는 heuristic - 상태 수를 대폭 줄이면서 대부분의 실용 문법은 LALR(1)로 처리 가능 - Yacc/Bison이 생성하는 파서가 바로 LALR(1) 파서 ### 9.5.2 여전히 남는 Conflict들 |Parser 종류|발생 가능한 Conflict| |---|---| |LL(1)|First/First, First/Follow| |LR(1)|Shift/Reduce, Reduce/Reduce| 이 conflict들은 문법의 모호성 또는 해당 문법 클래스의 경계를 벗어난 설계에서 비롯된다. ### 9.5.3 언어 설계 시 어떤 문법을 써야 하나? ``` ┌────────────────────────────────────────────────────────┐ │ Context-Free Grammar │ │ │ │ ┌──────────────────────────┐ ┌──────────────────┐ │ │ │ LR(1) Grammar │ │ Ambiguous │ │ │ │ ┌─────────────┐ │ │ Grammar │ │ │ │ │ LL(1) │ │ │ │ │ │ │ │ Grammar │ │ │ │ │ │ │ │ ┌─────────┐ │ │ │ │ │ │ │ │ │ Regular │ │ │ │ │ │ │ │ │ │ Grammar │ │ │ │ │ │ │ │ │ └─────────┘ │ │ │ │ │ │ │ └─────────────┘ │ │ │ │ │ └──────────────────────────┘ └──────────────────┘ │ └────────────────────────────────────────────────────────┘ ``` - LL(1) ⊂ LR(1) ⊂ CFG - Ambiguous Grammar는 LR(1)과 분리 (LR 파서로 분석 불가) - 실용 언어 설계 시 **LR(1) 문법**을 목표로 삼는 것이 일반적 ### 9.5.4 에러 복구 — IDE Linter 구현의 현실 - 중간에 파싱 에러가 발생하면 그 이후 코드는 검사 불가 - IDE의 Linter처럼 에러가 발생해도 파싱을 계속 진행하고 싶은 경우 - **에러 복구(Error Recovery)** 전략 필요: 에러 발생 지점을 건너뛰거나 동기화 토큰(`;`, `}` 등)까지 skip 후 재개