CHAPTER 5

Parsing in Practice — 실전 파싱

Douglas Thain, Introduction to Compilers and Language Design (2nd ed., 2023) · pp.69–84

📌단원 개요

앞에서 배운 LL(1)·LR(1) grammar 이론을 실제로 적용해, 간단한 expression language용 동작하는 parser를 만든다. 이 장이 다음 장의 B-Minor parser 작성의 토대가 된다.

💡 이 장의 큰 그림
  • LL(1) parser → 보통 손으로(by hand) 작성한다.
  • LR(1) parser → 너무 복잡(cumbersome)해서 손으로 못 만든다 → parser generator로 자동 생성한다.
  • 이 장에서는 C 계열용 parser generator인 Bison으로 LALR grammar(algebraic expression)를 정의하고 활용한다.

Bison으로 만드는 3가지 프로그램 유형

유형하는 일핵심
Validator 입력이 grammar에 맞는 valid sentence인지 여부만 알려줌 표준 준수(standards compliant) 여부 확인용
Interpreter 프로그램을 실제로 실행해 결과 산출 ① 파싱하며 즉시 계산 / ② AST로 만든 뒤 실행
Translator 프로그램을 AST로 파싱 후, 순회하며 다른 형식의 동등한 프로그램 생성 형식 변환

5.1The Bison Parser Generator

LALR parser는 손으로 구현하기 비현실적이므로, grammar 명세로부터 table과 code를 자동 생성하는 도구를 쓴다.

🛠 도구의 계보

  • YACC (Yet Another Compiler Compiler) — Unix의 대표 parser generator
  • GNU Bison — YACC를 대체, 대체로 호환(compatible)
  • Bison은 필요시 Flex(scanner)를 자동 호출 → 둘을 쉽게 결합

📄 Bison 파일 구조 (Flex와 유사)

%{
    (C preamble code)
%}
    (declarations)
%%
    (grammar rules)
%%
    (C postamble code)
💡 각 섹션의 역할
  • 1st (preamble): 임의의 C code — 주로 #include, 전역 선언
  • 2nd (declarations): Bison 전용 선언 — %token 키워드로 모든 terminal 선언
  • 3rd (rules): non-terminal : ...rule... ; 형태의 규칙들

문법 규칙(rule)의 형태

expr : expr TOKEN_ADD expr
     | TOKEN_INT
     ;

non-terminal exprexpr TOKEN_ADD expr 또는 단일 terminal TOKEN_INT를 생성할 수 있다.

⭐ 시험 포인트 — naming convention이 거꾸로!
  • C에서 대문자는 관습적으로 상수(constant)에 쓰임 → 그래서 terminal = 대문자 (예: TOKEN_INT)
  • non-terminal = 소문자 (예: expr, term, factor)
  • White space(공백)는 의미 없음 → 가독성 위해 자유롭게 배치 가능

생성되는 함수: yyparse() · yylex()

요소설명
yyparse()parser의 핵심 함수. 정수 반환
반환값 0parse 성공 (successful)
반환값 1parse error
반환값 2내부 문제 (예: memory exhaustion)
yylex()yyparse가 호출하는 함수. 정수 token type 반환. 손으로 쓰거나 Flex로 자동 생성
yyinfile pointer. 입력 소스를 바꿀 때 수정

예시 grammar (Figure 5.1 — Validator용)

parser.bison

%token TOKEN_INT  TOKEN_PLUS TOKEN_MINUS TOKEN_MUL TOKEN_DIV
%token TOKEN_LPAREN TOKEN_RPAREN TOKEN_SEMI TOKEN_ERROR
%%
program : expr TOKEN_SEMI ;

expr : expr TOKEN_PLUS  term
     | expr TOKEN_MINUS term
     | term ;

term : term TOKEN_MUL factor
     | term TOKEN_DIV factor
     | factor ;

factor: TOKEN_MINUS factor
      | TOKEN_LPAREN expr TOKEN_RPAREN
      | TOKEN_INT ;
%%
💡 왜 expr / term / factor 3계층?
  • operator precedence를 grammar 구조로 표현: + - < * / < parentheses · unary minus · INT
  • Bison은 LR(1) grammar를 받으므로 규칙 안의 left recursion이 허용된다. (예: expr : expr TOKEN_PLUS term)

빌드 절차 (Figure 5.3)

parser.bisonBisonparser.c + token.h
scanner.flexFlexscanner.c
parser.c / scanner.c / main.c→ Compile →.o→ Linker →compiler.exe
bison --defines=token.h --output=parser.c parser.bison
  • --defines=token.h : %token 선언으로부터 token.h 자동 생성 → parser와 scanner가 같은 정보 공유
  • --output=parser.c : 알아보기 힘든 기본 파일명 yy.tab.c 대신 parser.c로 출력
⚠️ 매우 중요 — Conflict를 항상 확인하라!
  • bison -v → LALR automaton을 parser.output 파일로 출력. 각 state의 item을 dot(.)으로 parser 위치 표시
  • grammar가 ambiguous해지면(예: expr : expr TOKEN_PLUS expr) → shift-reduce conflict 발생
  • conflict 시 Bison은 일부 action을 억제(suppress)하고 [대괄호]로 표시 → 그래도 동작하는 코드를 그냥 출력한다!
  • 단순 입력엔 맞는 듯 보여도 전체 프로그램에선 예기치 못한 결과 → 진행 전 항상 conflict 검사
state 9
    2 expr: expr . TOKEN_PLUS expr
    2     | expr TOKEN_PLUS expr .

  TOKEN_PLUS   shift, and go to state 7
  TOKEN_PLUS   [reduce using rule 2 (expr)]   ← 억제됨
  $default     reduce using rule 2 (expr)

5.2Expression Validator

Figure 5.1의 명세는 입력이 원하는 grammar와 일치하는지만 평가한다 → 이런 프로그램이 validator.

main.c (Figure 5.2)

extern int yyparse();
int main() {
    if(yyparse()==0) printf("Parse successful!\n");
    else             printf("Parse failed.\n");
}
💡 핵심
  • yyparse()0이면 성공, 그 외는 실패만 판정 (실행·계산은 안 함)
  • 실제 활용 예: HTML(validator.w3.org), CSS(css-validator.org), JSON(jsonlint.com)
  • 장점: 구현과 분리된 엄격한 language definition → standards compliance = portability 판단이 쉬움

5.3Expression Interpreter

단순 검증을 넘어서려면 grammar 안에 semantic action을 심어야 한다.

💡 Semantic action & Semantic value
  • production rule 우변 뒤 중괄호 { } 안에 임의의 C code 삽입 = semantic action
  • $기호 = semantic value (이미 계산된 non-terminal의 값)
  • $1, $2, $3 ... = 우변에서 해당 위치 심볼의 값
  • $$ = 현재 rule(좌변)의 semantic value
규칙의미
expr : expr TOKEN_PLUS term { $$ = $1 + $3; }좌측값($1) + 우측값($3) → 덧셈
factor : TOKEN_INT { $$ = atoi(yytext); }정수 token의 값 = token 텍스트를 정수로 변환
term : factor { $$ = $1; }non-terminal이 단일 non-terminal로 확장 → 값 그대로 전달
⚠️ 주의

yytext 배열(scanner의 token 텍스트)을 쓰는 것은, 규칙 우변에 terminal이 단 하나일 때만 가능하다.

⭐ 시험 포인트 — Bison은 Bottom-up parser

Bison은 bottom-up parser이므로 parse tree의 leaf node 값을 먼저 결정하고, 그 값을 위(interior node)로 전달하며, 최종적으로 start symbol에 결과가 도달한다.

Figure 5.4 = 완전한 interpreter. mainyyparse()를 호출하고, 성공 시 결과가 전역변수 parser_result에 저장된다.

Figure 5.4 — Interpreter

prog : expr TOKEN_SEMI          { parser_result = $1; } ;

expr : expr TOKEN_PLUS  term    { $$ = $1 + $3; }
     | expr TOKEN_MINUS term    { $$ = $1 - $3; }
     | term                     { $$ = $1; } ;

term : term TOKEN_MUL factor    { $$ = $1 * $3; }
     | term TOKEN_DIV factor    { $$ = $1 / $3; }
     | factor                   { $$ = $1; } ;

factor : TOKEN_MINUS factor            { $$ = -$2; }
       | TOKEN_LPAREN expr TOKEN_RPAREN { $$ = $2; }
       | TOKEN_INT                     { $$ = atoi(yytext); } ;

5.4Expression Trees (AST)

파싱 도중 즉시 계산하는 방식의 한계를 해결하기 위해, 값을 바로 계산하지 않고 abstract syntax tree(AST) 자료구조를 만든다.

💡 왜 AST를 쓰는가? (즉시 계산의 단점)
  • 즉시 계산은 많은 연산을 한 뒤에야 뒤늦게 parse error를 발견할 수 있음
  • 일반적으로 실행 전에 모든 parse error를 먼저 찾는 것이 바람직
  • AST를 만들어 두면 → 트리를 순회하며 검사(check)·실행(execute)·번역(translate)을 필요에 따라 수행 가능

AST 자료구조 (Figure 5.5)

expr.h

typedef enum {
    EXPR_ADD,
    EXPR_SUBTRACT,
    EXPR_DIVIDE,
    EXPR_MULTIPLY,
    EXPR_VALUE
} expr_t;

struct expr {
    expr_t kind;
    struct expr *left;
    struct expr *right;
    int value;   // leaf용
};

expr.c — 생성 함수

// 모든 종류의 노드 생성
struct expr * expr_create(
    expr_t kind,
    struct expr *left,
    struct expr *right);

// 특별히 EXPR_VALUE leaf 노드 생성
struct expr * expr_create_value(int value);
⭐ 시험 포인트 — 용어 "kind" vs "type"

노드 종류를 type이 아니라 kind라고 부른다. type은 나중에(semantic analysis 단계) 아주 구체적인 의미로 따로 쓰이기 때문이다.

AST 손으로 만들기 — 예: (10+20)*30

struct expr *e =
  expr_create(EXPR_MULTIPLY,
    expr_create(EXPR_ADD,
      expr_create_value(10),
      expr_create_value(20)),
    expr_create_value(30));
MULTIPLY / \ ADD VALUE:30 / \ VALUE:10 VALUE:20

Bison으로 AST 자동 생성 (Figure 5.6)

각 element가 인식될 때마다 새 노드를 만들고, 위로 전달(pass up)해 알맞은 자리에 연결한다. bottom-up이므로 leaf 먼저 생성 후 parent에 연결.

%{
#include "expr.h"
#define YYSTYPE struct expr *      // ★ semantic type 지정
struct expr * parser_result = 0;
%}

prog : expr TOKEN_SEMI   { parser_result = $1; } ;

expr : expr TOKEN_PLUS  term { $$ = expr_create(EXPR_ADD,$1,$3); }
     | expr TOKEN_MINUS term { $$ = expr_create(EXPR_SUBTRACT,$1,$3); }
     | term                  { $$ = $1; } ;

term : term TOKEN_MUL factor { $$ = expr_create(EXPR_MULTIPLY,$1,$3); }
     | term TOKEN_DIV factor { $$ = expr_create(EXPR_DIVIDE,$1,$3); }
     | factor                { $$ = $1; } ;

factor : TOKEN_MINUS factor
           { $$ = expr_create(EXPR_SUBTRACT, expr_create_value(0), $2); }
       | TOKEN_LPAREN expr TOKEN_RPAREN { $$ = $2; }
       | TOKEN_INT { $$ = expr_create_value(atoi(yytext)); } ;
⭐ 시험 포인트 — Figure 5.6에서 꼭 기억할 3가지
  1. YYSTYPE 매크로struct expr *로 정의해야 함. 그래야 $$, $1 등 모든 semantic value의 내부 type이 struct expr *가 됨. (최종 parser_result도 같은 type)
  2. AST ≠ parse tree (항상 직접 대응하지 않음). expr → factor처럼 단순 통과는 포인터만 전달($$=$1).
  3. Unary minus0 - expr 형태의 subtree로 구현 → 왼쪽 VALUE:0, 오른쪽이 실제 expr인 subtraction.

-20 의 AST

SUB / \ VALUE:0 VALUE:20
💡 Parentheses

괄호는 AST에 직접 표현되지 않는다. 대신 노드의 순서를 정해 원하는 평가 순서(evaluation order)를 만든다.

(5+6)/7

DIV / \ ADD VALUE:7 / \ V:5 V:6

5+(6/7)

ADD / \ V:5 DIV / \ V:6 V:7

5.4+AST 순회: 평가 & 출력

AST를 만든 뒤에는 이를 계산·출력 등 여러 연산의 기반으로 쓴다.

expr_evaluate() — Figure 5.7

Post-order traversal: 왼쪽·오른쪽 자식을 먼저 재귀 평가 후 현재 노드 계산.

int expr_evaluate(struct expr *e){
  if(!e) return 0;            // ★ null 체크
  int l = expr_evaluate(e->left);
  int r = expr_evaluate(e->right);
  switch(e->kind){
    case EXPR_VALUE:    return e->value;
    case EXPR_ADD:      return l+r;
    case EXPR_SUBTRACT: return l-r;
    case EXPR_MULTIPLY: return l*r;
    case EXPR_DIVIDE:
      if(r==0){ printf("error: divide by zero\n");
                exit(1); }   // ★ divide-by-zero 명시 체크
      return l/r;
  }
  return 0;
}

expr_print() — Figure 5.8

In-order traversal: 왼쪽 출력 → 현재 노드 → 오른쪽 출력. 텍스트로 역변환.

void expr_print(struct expr *e){
  if(!e) return;             // ★ null 체크
  printf("(");
  expr_print(e->left);
  switch(e->kind){
    case EXPR_VALUE:    printf("%d",e->value); break;
    case EXPR_ADD:      printf("+"); break;
    case EXPR_SUBTRACT: printf("-"); break;
    case EXPR_MULTIPLY: printf("*"); break;
    case EXPR_DIVIDE:   printf("/"); break;
  }
  expr_print(e->right);
  printf(")");
}
⭐ 시험 포인트 — 순회(traversal) 종류 구분
함수순회 방식순서
expr_evaluatepost-orderleft → right → node
expr_printin-orderleft → node → right
  • 두 함수 모두 시작 시 null 포인터 검사 필수.
  • evaluatedivide-by-zero를 명시적으로 검사(안 하면 r=0일 때 crash).
  • print는 보수적으로 모든 값에 괄호를 둘러 출력 → 괄호가 너무 많아짐. 더 나은 방법: subtree가 더 낮은 우선순위 연산자를 포함할 때만 괄호 출력.

5.5Exercises (연습문제)

  1. Bison manual을 참고해 grammar로부터 LALR automaton 그래프를 자동 생성하고, 손으로 그린 것과 비교하라.
  2. expr_evaluate()(외 필요한 부분)를 정수 대신 floating point 값을 다루도록 수정하라.
  3. expr_print()가 정확성에 필요한 최소한의 괄호만 표시하도록 수정하라.
  4. parser와 interpreter를 확장해 sin(x), sqrt(x)내장 수학 함수 호출을 허용하라. (함수 이름을 keyword로? 아니면 identifier로 두고 evaluate에서 검사?)
  5. parser/interpreter를 확장해 변수 할당·사용을 허용하라. 예: g=9.8; t=5; g*t*t - 7*t + 10;

5.6Further Reading

YACC는 최초의 컴파일러 도구는 아니지만 지금도 널리 쓰이며, 다양한 언어·문법 클래스를 다루는 유사 도구들을 파생시켰다.

도구 / 논문특징
YACC (S.C. Johnson, 1975)Yet Another Compiler-Compiler
LL(1) generator (Grune & Jacobs)programmer-friendly LL(1) parser generator
ANTLR (Parr & Quong, 1995)predicated LL(k) parser generator
Elkhound (McPeak & Necula, 2004)Fast, Practical GLR parser generator

핵심 정리 / 시험 포인트

🎯 반드시 외울 핵심 명제
  • LL(1)은 손으로, LR(1)/LALRgenerator(Bison)로 만든다.
  • Bison은 LR(1) grammar를 받음 → left recursion 허용.
  • Bison은 bottom-up parser → leaf 값 먼저 → 위로 전달 → start symbol에 결과 도달.
  • terminal = 대문자, non-terminal = 소문자 (C 관습 때문에 거꾸로).
  • yyparse(): 0=성공, 1=parse error, 2=내부 문제(memory).
  • Validator / Interpreter / Translator 3가지 프로그램 유형 구분.
  • AST는 parse tree와 다르다. parentheses는 AST에 표현되지 않고 순서로만 반영. unary minus = 0 - x subtree.
  • YYSTYPE 매크로로 semantic value의 type 지정.
  • 순회: evaluate=post-order, print=in-order.
  • Shift-reduce / reduce-reduce conflict는 반드시 확인(Bison은 conflict를 억제하고 동작하는 code를 그냥 출력!).
💡 핵심 기호(semantic value)
$$현재 rule(좌변)의 값
$1, $3 …우변 n번째 심볼의 값
yytextscanner의 token 텍스트(단일 terminal일 때만)
YYSTYPEsemantic value의 type 정의
💡 grammar 3계층 = 우선순위
  • expr+ , - (가장 낮음)
  • term* , /
  • factor → unary -, parentheses ( ), INT (가장 높음)
❓ 예상 서술형/단답
  • Q. 즉시 계산 방식의 단점과 AST 도입 이유는? → 실행 후에야 늦게 parse error 발견 / 실행 전 모든 error 검출, 검사·실행·번역 재사용.
  • Q. 왜 LR parser는 손으로 안 만드나? → 너무 cumbersome(복잡) → table을 generator가 자동 생성.
  • Q. -20의 AST를 그려라 → SUB(VALUE:0, VALUE:20).
  • Q. Bison에서 token.h는 어떻게 만들어지나? → bison --defines=token.h%token 선언에서 자동 생성(scanner와 공유).

📋한눈에 보기 — Cheat Sheet

키워드 / 명령의미
Bison / YACCLR/LALR parser generator (C 계열)
Flexscanner generator. Bison이 자동 호출
%token모든 terminal 선언 (declarations 섹션)
%{ … %}C preamble (include, 전역 선언)
%% … %%grammar rules 구역 구분자
yyparse()parser 본체. 0=성공 / 1=error / 2=내부문제
yylex()token type 반환. Flex가 생성
yyin / yytext입력 file pointer / 현재 token 텍스트
$$ / $1,$3좌변 값 / 우변 n번째 심볼 값
YYSTYPEsemantic value의 type 매크로
expr / term / factor+− / ×÷ / unary − · parentheses · INT (precedence 계층)
ASTabstract syntax tree. 검사·실행·번역 기반
expr_create / _value일반 노드 / VALUE leaf 노드 생성
kind (≠ type)노드 종류 enum (EXPR_ADD 등)
expr_evaluatepost-order 평가, divide-by-zero 체크
expr_printin-order 출력(모든 값에 괄호)
shift-reduce conflictambiguous grammar 신호. 반드시 확인
bison -vparser.output에 LALR automaton·state 출력(dot 표기)
--defines=token.h%token에서 token.h 자동 생성