Skip to content
Published on

[컴파일러] 04. 어휘 분석 - 토큰, 패턴, 정규 표현식

Authors

어휘 분석 - 토큰, 패턴, 정규 표현식

어휘 분석(Lexical Analysis)은 컴파일의 첫 번째 단계입니다. 소스 코드의 문자 스트림을 읽어 의미 있는 단위인 토큰으로 분리하는 과정을 살펴보겠습니다.


1. 어휘 분석기의 역할

어휘 분석기(Lexer, Scanner)는 다음과 같은 일을 합니다.

소스 프로그램 (문자 스트림)
        |
        v
  +------------------+
  |  어휘 분석기       |  <---> 심볼 테이블
  +------------------+
        |
        v
  토큰 스트림 (파서로 전달)

주요 기능

  1. 토큰 인식: 문자들을 토큰 단위로 묶습니다.
  2. 공백/주석 제거: 파서에게 불필요한 문자를 걸러냅니다.
  3. 소스 위치 추적: 에러 메시지를 위한 행 번호와 열 번호를 기록합니다.
  4. 심볼 테이블 관리: 식별자를 심볼 테이블에 등록합니다.

어휘 분석기를 별도로 분리하는 이유

  • 설계의 단순화: 파서가 공백과 주석을 신경 쓸 필요가 없습니다.
  • 이식성 향상: 입력 장치 종속적인 처리를 어휘 분석기에 격리합니다.
  • 효율성: 토큰 인식에 특화된 기법(유한 오토마타)을 적용할 수 있습니다.

2. 토큰, 패턴, 렉심

이 세 가지 개념은 어휘 분석의 핵심입니다.

토큰(Token)

토큰은 프로그래밍 언어의 어휘 단위를 나타내는 추상적인 이름입니다.

패턴(Pattern)

패턴은 토큰을 구성할 수 있는 문자열의 형태를 기술합니다.

렉심(Lexeme)

렉심은 소스 코드에서 패턴에 매치되는 실제 문자열입니다.

예시

C 소스 코드: printf("Total = %d\n", score);

+----------+--------------------------+---------------------+
| 토큰      | 패턴                      | 렉심 예시            |
+----------+--------------------------+---------------------+
| id       | 문자로 시작, 문자/숫자 조합  | printf, score       |
| num      | 숫자 하나 이상             | 42, 3, 100          |
| literal  | " 사이의 문자열            | "Total = %d\n"      |
| lparen   | (                        | (                   |
| rparen   | )                        | )                   |
| comma    | ,                        | ,                   |
| semi     | ;                        | ;                   |
+----------+--------------------------+---------------------+

토큰의 종류

1. 키워드(Keyword):    if, else, while, return, ...
2. 식별자(Identifier): 사용자 정의 이름
3. 상수(Constant):     정수, 실수, 문자열 리터럴
4. 연산자(Operator):   +, -, *, /, =, ==, !=, ...
5. 구분자(Delimiter):  (, ), {, }, ;, ,, ...

3. 토큰의 속성(Attribute)

하나의 토큰 이름에 여러 렉심이 매치될 수 있으므로, 추가 정보가 필요합니다.

소스 코드: position = initial + rate * 60

토큰 스트림:
  <id, "position">
  <assign_op, >
  <id, "initial">
  <add_op, >
  <id, "rate">
  <mul_op, >
  <num, 60>

여기서 두 번째 요소가 속성입니다.

식별자의 속성은 보통 심볼 테이블 엔트리에 대한 포인터입니다. 숫자의 속성은 실제 값이 됩니다.


4. 정규 표현식(Regular Expression)

토큰의 패턴을 정의하는 데 **정규 표현식(regex)**을 사용합니다.

기본 연산

알파벳 Sigma 위에서 정의되는 정규 표현식의 기본 연산은 다음 세 가지입니다.

1. 합집합 (Union):       r | s
   - r 또는 s에 매치되는 문자열

2. 접합 (Concatenation):  rs
   - r에 매치되는 문자열 뒤에 s에 매치되는 문자열

3. 클로저 (Closure):      r*
   - r에 매치되는 문자열의 0번 이상 반복

기본 정규 표현식

표현식        설명                    매치 예시
-------------------------------------------------
a             문자 'a' 그 자체        "a"
epsilon       빈 문자열               ""
r | s         r 또는 s               r이 "a", s가 "b"면 "a" 또는 "b"
rs            r 뒤에 s               "ab"
r*            r의 0번 이상 반복        "", "a", "aa", "aaa", ...

확장 표기법

편의를 위한 약어들이 있습니다.

r+      = r r*            (1번 이상 반복)
r?      = r | epsilon     (0번 또는 1번)
[a-z]   = a | b | ... | z (문자 클래스)
[^a-z]  = a-z 제외한 문자  (부정 문자 클래스)
.       = 임의의 한 문자

정규 표현식 예제

// 정수 리터럴
digit  = [0-9]
digits = digit+
integer = digits

// 부동소수점 리터럴
fraction = . digits | epsilon
exponent = (E | e)(+ | - | epsilon) digits | epsilon
float_num = digits fraction exponent

// 식별자
letter = [a-zA-Z_]
identifier = letter (letter | digit)*

// 문자열 리터럴 (큰따옴표 내부에 큰따옴표 제외 문자)
string_lit = " [^"]* "

5. 정규 정의(Regular Definition)

복잡한 정규 표현식을 간결하게 표현하기 위해, 정규 표현식에 이름을 붙여 사용합니다.

// C 언어 식별자의 정규 정의
letter_  -> [a-zA-Z_]
digit    -> [0-9]
id       -> letter_ (letter_ | digit)*

// C 언어 정수 상수의 정규 정의
digit      -> [0-9]
nonzero    -> [1-9]
decimal    -> nonzero digit*
octal_dig  -> [0-7]
octal      -> 0 octal_dig*
hex_dig    -> [0-9a-fA-F]
hex        -> 0 (x|X) hex_dig+
integer    -> decimal | octal | hex

주의: 정규 정의에서는 순환 참조가 허용되지 않습니다. 각 이름은 이전에 정의된 이름만 참조할 수 있습니다.


6. 입력 버퍼링(Input Buffering)

어휘 분석기는 소스 코드를 한 문자씩 읽어야 하는데, I/O 비용을 줄이기 위해 버퍼링 기법을 사용합니다.

이중 버퍼(Double Buffering)

+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| E | = | M | * | C | * | * | 2 |eof| . | . | . | . | . | . | . |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
  ^                               ^
  |                               |
lexemeBegin                    forward

          Buffer 1                          Buffer 2
  • 두 개의 버퍼를 번갈아 사용하여 연속적인 읽기를 보장합니다.
  • lexemeBegin: 현재 렉심의 시작 위치
  • forward: 현재 검사 중인 문자 위치
  • 각 버퍼 끝에 센티넬(sentinel) 문자 eof를 놓아 경계 검사를 단순화합니다.

센티넬을 사용한 forward 포인터 이동

// 센티넬 없는 코드 (매번 2번의 검사)
forward++;
if (forward >= bufferEnd) {
    // 다음 버퍼 로드
    reloadBuffer();
}
if (*forward == EOF) {
    // 입력 끝 처리
    terminate();
}

// 센티넬 있는 코드 (보통 1번의 검사)
forward++;
if (*forward == EOF) {
    if (forward == bufferEnd) {
        reloadBuffer();  // 버퍼 끝의 센티넬
        forward = bufferStart;
    } else {
        terminate();     // 실제 입력 끝
    }
}

7. 토큰 인식 예제

간단한 언어의 토큰 인식 과정을 살펴보겠습니다.

관계 연산자 인식

relop -> <  |  <=  |  <>  |  >  |  >=  |  =

이를 인식하는 과정을 **전이 다이어그램(Transition Diagram)**으로 표현합니다.

시작 --'<'--> s1 --'='--> [return LE]
              |
              +--'>'--> [return NE]
              |
              +--other--> [return LT] (retract)

시작 --'='--> [return EQ]

시작 --'>'--> s2 --'='--> [return GE]
              |
              +--other--> [return GT] (retract)

retractforward 포인터를 한 칸 되돌리는 것을 의미합니다. 예를 들어 < 다음에 =>도 아닌 문자가 오면, 그 문자를 다음 토큰의 일부로 처리해야 합니다.

식별자와 키워드 인식

시작 --letter--> s1 --letter|digit--> s1
                  |
                  +--other--> [키워드 테이블 확인 후 return]

식별자를 인식한 후 키워드 테이블을 검색하여 키워드인지 확인합니다. if, else, while 같은 예약어는 식별자와 형태가 같으므로, 별도 처리가 필요합니다.

// 키워드 테이블 초기화
Map<String, TokenType> keywords = new HashMap<>();
keywords.put("if", TokenType.IF);
keywords.put("else", TokenType.ELSE);
keywords.put("while", TokenType.WHILE);
keywords.put("return", TokenType.RETURN);

// 식별자 인식 후
String lexeme = getLexeme();
if (keywords.containsKey(lexeme)) {
    return new Token(keywords.get(lexeme), lexeme);
} else {
    return new Token(TokenType.ID, lexeme);
}

8. 간단한 어휘 분석기 구현

Java로 간단한 어휘 분석기를 구현해 보겠습니다.

public class SimpleLexer {
    private String input;
    private int pos;

    public SimpleLexer(String input) {
        this.input = input;
        this.pos = 0;
    }

    public Token nextToken() {
        skipWhitespace();

        if (pos >= input.length()) {
            return new Token(TokenType.EOF, "");
        }

        char ch = input.charAt(pos);

        // 숫자: 정수 리터럴 인식
        if (Character.isDigit(ch)) {
            return readNumber();
        }

        // 문자: 식별자 또는 키워드 인식
        if (Character.isLetter(ch) || ch == '_') {
            return readIdentifier();
        }

        // 연산자 및 구분자
        switch (ch) {
            case '+': pos++; return new Token(TokenType.PLUS, "+");
            case '-': pos++; return new Token(TokenType.MINUS, "-");
            case '*': pos++; return new Token(TokenType.STAR, "*");
            case '/': pos++; return new Token(TokenType.SLASH, "/");
            case '=':
                if (peek() == '=') {
                    pos += 2;
                    return new Token(TokenType.EQ, "==");
                }
                pos++;
                return new Token(TokenType.ASSIGN, "=");
            case '(':  pos++; return new Token(TokenType.LPAREN, "(");
            case ')':  pos++; return new Token(TokenType.RPAREN, ")");
            case ';':  pos++; return new Token(TokenType.SEMI, ";");
            default:
                throw new RuntimeException(
                    "Unexpected character: " + ch + " at position " + pos
                );
        }
    }

    private Token readNumber() {
        int start = pos;
        while (pos < input.length() && Character.isDigit(input.charAt(pos))) {
            pos++;
        }
        return new Token(TokenType.NUM, input.substring(start, pos));
    }

    private Token readIdentifier() {
        int start = pos;
        while (pos < input.length() &&
               (Character.isLetterOrDigit(input.charAt(pos)) ||
                input.charAt(pos) == '_')) {
            pos++;
        }
        String lexeme = input.substring(start, pos);
        TokenType type = Keywords.lookup(lexeme);
        return new Token(type != null ? type : TokenType.ID, lexeme);
    }

    private void skipWhitespace() {
        while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) {
            pos++;
        }
    }

    private char peek() {
        return (pos + 1 < input.length()) ? input.charAt(pos + 1) : '\0';
    }
}

실행 예시

입력: "x = 10 + y;"

토큰 스트림:
  <ID, "x">
  <ASSIGN, "=">
  <NUM, "10">
  <PLUS, "+">
  <ID, "y">
  <SEMI, ";">
  <EOF, "">

9. 에러 처리

어휘 분석기가 어떤 패턴에도 매치되지 않는 문자를 만났을 때의 에러 처리 전략입니다.

  1. 패닉 모드(Panic Mode): 매치되는 패턴을 찾을 때까지 문자를 건너뜁니다.
  2. 문자 삭제: 잘못된 문자를 삭제하고 계속 진행합니다.
  3. 문자 삽입: 빠진 문자를 삽입합니다 (예: 따옴표 짝 맞추기).
  4. 문자 교체: 잘못된 문자를 올바른 문자로 교체합니다.

실제 컴파일러에서는 가능한 많은 에러를 한 번에 보고하기 위해 복구 후 분석을 계속합니다.


정리

개념핵심 내용
토큰어휘 단위의 추상적 이름 (id, num, relop 등)
패턴토큰을 구성하는 문자열의 형태 (정규 표현식)
렉심패턴에 매치되는 실제 문자열
정규 표현식합집합, 접합, 클로저의 3가지 기본 연산
정규 정의정규 표현식에 이름을 붙여 재사용
입력 버퍼링이중 버퍼와 센티넬로 효율적 I/O
전이 다이어그램토큰 인식 과정을 상태 전이로 표현

어휘 분석은 컴파일의 가장 기초적인 단계이지만, 전체 컴파일 시간의 상당 부분을 차지하므로 효율적인 구현이 중요합니다. 다음 글에서는 정규 표현식을 기반으로 어휘 분석기를 자동 생성하는 유한 오토마타를 다루겠습니다.