Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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

어휘 분석(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

토큰 스트림:

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

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

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)

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

식별자와 키워드 인식

시작 --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;"

토큰 스트림:

9. 에러 처리

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

1. **패닉 모드(Panic Mode)**: 매치되는 패턴을 찾을 때까지 문자를 건너뜁니다.

2. **문자 삭제**: 잘못된 문자를 삭제하고 계속 진행합니다.

3. **문자 삽입**: 빠진 문자를 삽입합니다 (예: 따옴표 짝 맞추기).

4. **문자 교체**: 잘못된 문자를 올바른 문자로 교체합니다.

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

정리

| 개념 | 핵심 내용 |

| --------------- | ------------------------------------------- |

| 토큰 | 어휘 단위의 추상적 이름 (id, num, relop 등) |

| 패턴 | 토큰을 구성하는 문자열의 형태 (정규 표현식) |

| 렉심 | 패턴에 매치되는 실제 문자열 |

| 정규 표현식 | 합집합, 접합, 클로저의 3가지 기본 연산 |

| 정규 정의 | 정규 표현식에 이름을 붙여 재사용 |

| 입력 버퍼링 | 이중 버퍼와 센티넬로 효율적 I/O |

| 전이 다이어그램 | 토큰 인식 과정을 상태 전이로 표현 |

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

현재 단락 (1/244)

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

작성 글자: 0원문 글자: 6,291작성 단락: 0/244