극한의 환경이라고도 알려져 있는 C, 여기서 우리는 간단한 파싱 작업을 할 수 있는 프로그램을 만들고자 합니다.
목표
우리의 목표는 간단한 파서(Parser)를 만드는 것입니다. 다음 파일을 규칙에 맞게 잘 읽어들이는 것입니다.
R 1920 1080
A 0.2 255,255,255
c -50,0,20 0,0,0 70
l -40,0,30 0.7 255,255,255
pl 0,0,0 0,1.0,0 255,0,225
sp 0,0,20 20 255,0,0
sq 0,100,40 0,0,1.0 30 42,42,0
cy 50.0,0.0,20.6 0,0,1.0 14.2 21.42 10,0,255
tr 10,20,10 10,10,20 20,10,10 0,0,255
본 파일은 어떤 그래픽 렌더링 프로그램에 전달할 입력 파일입니다. 이 파일은 렌더링 프로그램의 각종 설정과 렌더링할 도형을 정의합니다. 정의하는 방법은 다음과 같이 정의되어 있습니다.
지시자 | 설명 | 입력할 값 |
---|---|---|
R | 해상도 설정 | 정수(가로 해상도), 정수(세로 해상도) |
A | 주변광 설정 | 밝기(0.0~1.0), 색상(RGB) |
c | 카메라 설정 | 좌표값(x, y, z), 카메라 방향(x, y, z), 카메라 각도(0~180) |
l | 빛 설정 | 좌표값(x, y, z), 빛의 세기(0.0~1.0), 색상(RGB) |
pl | 평면 추가 | 좌표값(x, y, z), 방향(x, y, z) |
sp | 구 추가 | 좌표값(x, y, z), 반지름, 색상(RGB) |
sq | 정사각형 추가 | 좌표값(x, y, z), 방향(x, y, z), 한 변의 길이, 색상(RGB) |
cy | 원기둥 추가 | 좌표값(x, y, z), 방향(x, y, z), 반지름, 높이, 색상(RGB) |
tr | 삼각형 추가 | 좌표값1(x, y, z), 좌표값2(x, y, z), 좌표값3(x, y, z), 색상(RGB) |
세부 목표는 다음과 같습니다.
- 입력 파일을 읽어서 어떤 구조체 안에 데이터를 전부 담아 놓으십시오. 데이터는 외부에서 가져다 쓰기 편하도록 구조화되어 있어야 합니다.
- 입력 파일의 규칙이 잘못 되었을 때, 왜 잘못되었는지, 어디서 잘못되었는지 등의 에러 정보를 출력하고 프로그램을 즉시 종료하세요.
해당 과제는 다음과 같은 조건 아래에 진행됩니다.
- 주어진 함수 외의 함수는 사용이 불가합니다. 주어진 함수는 다음과 같습니다:
malloc
,free
,exit
,write
,open
,read
,close
. - 전역 변수는 사용이 불가합니다.
- 아주 큰 파일이 들어올 수 있으므로, 버퍼사이즈가 지정되고, 버퍼사이즈만큼 파일을 읽어야 합니다.
- 컴파일은 gcc 또는 clang 으로 진행합니다.
누가 왜 저런 조건을 만들었는가는 알 수 없지만, 해당 조건을 만족시키기 위하여 몇 가지 기능들을 먼저 모듈화한 후 진행하도록 하겠습니다. 우선 무언가를 출력하려면 <unistd.h>
헤더에 있는 write
함수를 사용해야 합니다. printf
조차 사용할 수 없기에, 출력하는 부분을 모듈화하여 사용하기 (그나마) 편하도록 진행할 예정입니다. 그리고 전역 함수를 사용하지 못하므로 static 함수를 이용하여 전역 변수처럼 사용할 예정입니다.
우리가 지금 만드는 프로그램은 렌더링 프로그램이 아닙니다. 해당 파일을 잘 읽어들이는 파서 프로그램입니다!
본 글에서는 헤더 파일에 프로토타입을 일일히 기록하지 않습니다. 만약 직접 따라하면서 실습한다면, 헤더 파일에 함수 프로토타입을 모두 넣어주시기 바랍니다.
파싱과 토큰
파서를 본격적으로 만들어보기 전에 먼저 개념을 정리해봅시다. 파서를 한국어로 좀 더 부드럽게 옮긴다면 구문 분석기라고 말할 수 있습니다.
파싱은 일련의 문자열을 의미있는 토큰(token)으로 분해하고 이들로 이루어진 파스 트리(parse tree)를 만드는 과정을 말한다.
위키백과
토큰은 파싱 목적을 위해 분류화를 명시적으로 지시하는 어휘소를 표현하는 구조의 하나이다.
역시 위키백과
대~충 개념적이므로 이 글에서는 파서(파싱)과 토큰을 다음과 같이 특수하게 정의내리도록 하겠습니다.
- 파싱: 입력된 문자열로부터 토큰을 뽑아내고, 문법에 맞게 토큰을 배열/결합/변형하여 의미있는 값과 사용하기 간편한 구조를 만들어내는 것.
- 토큰: 최소의 의미를 가지는 문자열(혹은 문자)의 묶음. 공백(=단순 토큰 구분용 문자)을 제외한 모든 문자 데이터가 살아있어야 함.
예를 들어 아래 한 줄을 가지고 설명해보도록 하겠습니다. 이 한 줄을 토큰으로 바꿔보고자 합니다.
l -40,0,30 0.7 255,255,255
토큰은 단순한 묶음이므로 그 순서도 입력 문자열의 순서와 같습니다. 해당 순서가 옳은 건지 아닌지는 파서가 판단할 일입니다.
[type:name, value:l]
[type:number, value:-40]
[type:comma, value:,]
[type:number, value:0]
[type:comma, value:,]
[type:number, value:30]
[type:number, value:0.7]
[type:integer, value:255]
[type:comma, value:,]
[type:integer, value:255]
[type:comma, value:,]
[type:integer, value:255]
[type:newline, value:\n]
그렇다면 문자열에서 토큰을 생성하는 과정에서 일어나는 에러란 무엇일까요? 바로 지원하지 않는 문자가 있을 때 에러가 발생합니다. 예를 들어 위 입력 파일에서 우리는 +
혹은 *
과 같은 기호를 사용하지 않습니다. 사용할 일이 없습니다. 이 문자는 아무런 의미를 가질 수 없으므로 바로 에러를 내는 것이 합당합니다. 또한 토큰을 생성하는 과정에서 예상하지 못한 문자가 나왔을 때에도 에러가 발생할 수 있습니다. 예를 들어 .
은 숫자 안에 포함이 됩니다. 그러나 123.456.789
와 같은 입력이 들어가게 되면 123.456
까지는 숫자로 인식할 수 있지만 그 뒤에 바로 .
이 등장하므로 기대하지 않은 토큰 값이라고 처리할 수 있습니다.
그렇다면 만약 파싱까지 완료한다면 어떤 구조가 생길까요? 의사 코드로 나타내보도록 하겠습니다.
CreateLight {
light: Coordinate {
x: -40
y: 0
z: 30
}
strength: 0.7
color: RGB {
r: 255
g: 255
b: 255
}
}
그렇다면 이제 create_light.light.x
등으로 값에 바로 접근해볼 수도 있겠습니다! 만약 파싱 과정에서 에러가 일어난다면, 예상하는 토큰 타입이 어긋날 때 발생합니다. 예를 들어 색상 값을 받아와야 하는데 255,255,
와 같이 입력이 들어온다면, 마지막 숫자가 존재하지 않으므로 에러가 발생합니다. 토큰 단계에서는 에러가 나지 않습니다. 왜냐하면 지원하지 않는 문자열도 없고, 기대하지 않은 토큰 값도 없기 때문이지요.
역할을 나누어봅시다. 이 글에서 파서는 크게 세 가지 역할로 나눌 수 있으며, 서로의 역할을 다하기 위해 해당 기능을 구현하여야 합니다.
역할 이름 | 역할 설명 | 기능 |
---|---|---|
FileReader | 파일을 읽습니다. | – fr_read : 파일로부터 하나의 글자를 읽습니다. 문자들을 읽을 때의 버퍼 관리를 모두 책임집니다. 만약 파일의 끝에 다다랐을 경우 FileReader 의 상태를 변경하거나 '\0' 를 리턴합니다.– 위치값 저장 : 마지막으로 읽은 문자의 위치를 저장합니다. |
Tokenizer | 토큰을 생성합니다. | – consume : 현재 가리키고 있는 토큰을 소모하고 다음 토큰을 구합니다. 토큰이 생성되는 규칙에 대한 것을 전담합니다.– lookahead : 현재 가리키고 있는 토큰의 정보를 보여줍니다. 이 함수는 보여주기만 할 뿐입니다.– FileReader 로부터 읽은 문자를 임시 저장 : 한번 fr_read 하게 되면 되돌릴 수 없으므로, 계산하고자 하는 문자열이 어떠한 토큰으로 판명날 때까지 데이터를 모두 지니고 있어야 합니다. – Token 의 위치 저장 : 추후 문법적으로 예상치 못한 토큰일 때 해당 토큰의 위치를 알려주면서 에러 메시지를 띄워야 하므로 토큰의 시작 위치 등을 저장해놓습니다. |
Parser | 파싱을 수행합니다. | – parse : 파싱을 수행합니다. 성공적으로 파싱된다면 파싱된 구조체가 나올 것이고, 실패했다면 에러를 출력하고 프로그램이 종료됩니다.– Tokenizer 로부터 얻은 token 을 임시 저장: 한번 consume 하게 되면 이전 토큰을 얻을 수 없으므로, 토큰들이 문법에 맞는지 판단할 수 있을 때까지 데이터를 모두 지니고 있어야 합니다. |
프로젝트 세팅
본 프로젝트는 gcc 또는 clang 컴파일러를 사용하고, make 로 빌드 과정을 간소화합니다. mac이나 linux 라면 기본 컴파일러와 make 가 이미 설치되어 있지만, Windows 라면 아마 직접 설치하셔야 할 것입니다. 설치 법은 본 블로그에서는 소개드리지 않습니다.
폴더 구조는 다음과 같습니다.
.
├── input.txt
├── Makefile
├── src
│ ├── parser.h
│ └── 각종 소스파일들.c
└── test.c # main 함수
input.txt
는 상단에 우리가 읽어야 할 입력 파일입니다. 우선 Makefile
먼저 간단하게 봅시다.
# Makefile
SRCS = $(wildcard src/*.c)
OBJS = $(SRCS:.c=.o)
INC = -I./src
LIB = -lm
ARGS = -g
test : $(OBJS)
gcc $(ARGS) $(INC) $^ test.c -o do_test $(LIB)
./do_test
$(OBJS) : %.o: %.c
gcc $(ARGS) $(INC) -c $^ -o $@
clean:
rm -rf $(OBJS)
re: clean test
valgrind: test
valgrind --leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
--verbose \
./do_test
.PHONY: test clean re valgrind
Makefile
에 의해서, make
, make test
, make re
명령어를 쳐서 테스트를 수행할 수 있습니다.
다음은 test.c
파일입니다. 여기에 main
함수가 있습니다.
// test.c
#include <stdio.h>
#include "parser.h"
int main(void)
{
// 내용
return (0);
}
전역처럼 쓸 수 있는 static 구조체 만들기
// src/parser.h
typedef struct s_parser
{
// something
} t_parser;
// src/parser.c
t_parser *parser()
{
static t_parser p;
return &p;
}
이렇게 만들어두면 언제 어디에서든지 parser()
를 호출하기만 해도 같은 p
에 접근할 수 있게 됩니다.
매크로 정의
미리 필요한 매크로를 아래와 같이 정의해 놓읍시다.
// src/parser.h
# define TOKEN_NOT_SPECIFIED 0
# define TOKEN_INT 1
# define TOKEN_DOUBLE 2
# define TOKEN_NAME 3
# define TOKEN_NEWLINE 5
# define TOKEN_COMMA 6
# define TOKEN_END 100
# define ERR_READ 1
# define ERR_FILE_OPEN 2
# define ERR_PARSE 3
# define ERR_STR_ALLOC 4
# define ERR_STR_PUSH_CHAR_ALLOC 5
# define ERR_TOKEN_CONSUME 6
# define BUF_SIZE 100
파일 읽기
과제의 조건을 충족시키기 위해 우선 기능들을 모듈화 해놓도록 하겠습니다. 파일을 읽기 위해서는 버퍼를 활용해야 합니다.
// src/parser.h
+typedef struct s_filereader
+{
+ const char *path;
+ int fd;
+ char buf[BUF_SIZE];
+ int c_col;
+ int c_row;
+} t_filereader;
typedef struct s_parser
{
+ t_filereader fr;
} t_parser;
path
: 파일 경로를 저장합니다.fd
: 파일을 open 하고 난 후 파일 디스크립션을 저장합니다.buf[BUF_SIZE]
: 버퍼입니다.c_col
,c_row
: 현재 읽고 있는 위치를 저장합니다.
자, 이제 파일을 열어봅시다.
// src/file_reader.c
#include <fcntl.h>
#include "parser.h"
int file_open(int *fd, const char *path)
{
*fd = open(path, O_RDONLY);
if (*fd == -1)
return (0);
return (1);
}
t_filereader fr(const char *path)
{
t_filereader fr;
int fd;
if(!file_open(&fd, path))
exit(ERR_FILE_OPEN);
fr.fd = fd;
fr.path = path;
fr.c_row = 1;
fr.c_col = 0;
return (fr);
}
// src/parser.c
+void init_parser(const char *path)
+{
+ t_parser *p;
+
+ p = parser();
+ p->fr = fr(path);
+}
// test.c
#include <stdio.h>
#include "parser.h"
int main(void)
{
+ init_parser("./input.txt");
return (0);
}
이제 fr
함수를 통해서 t_filereader
구조체를 생성할 수 있게 되었습니다. 여기 내부에서는 file_open
함수를 부릅니다. 파일을 열 때는 읽기 전용 모드로 열고 있습니다. fr
함수를 init_parser
함수에서 호출하고 있고, 이 init_parser
함수는 main
에서 호출되고 있습니다. init_parser
를 호출할 때 읽을 파일 이름도 설정하고 있음을 보실 수 있습니다.
이제 파일의 내용을 읽으려면 어떻게 해야 할까요? 우선 read
함수를 써서 buf
에 데이터들을 넣어놓은 다음, buf
에서 한 글자씩 읽어오면 되겠지요. 우리는 하나의 글자만 읽어서 보내주는 char fr_read()
함수를 구현하면 됩니다. 우선 한 가지 생각이 듭니다. fr_read
함수는 한번 호출할 때마다 한 글자씩 내보내지만, 우리가 실제로 read
함수를 쓸 때에는 내용을 왕창 읽어옵니다. 그렇다면, 호출 빈도로 따진다면 fr_read
가 read
보다 훨씬 자주 호출될 것 같습니다.
그렇다면 일단 가정을 해 봅시다. buf
에 read
한 값들이 온전히 잘 들어가있다고 가정합시다. 그렇게 되었을 때, 우리는 한 글자씩 뽑아내야 하기 때문에 뽑아낼 위치 정보를 기억하고 있어야 합니다.
// src/parser.h
typedef struct s_filereader
{
const char *path;
int fd;
+ int pos;
char buf[BUF_SIZE];
int c_col;
int c_row;
} t_filereader;
// src/file_reader.c
char fr_read()
{
t_filereader *fr;
char c;
fr = &parser()->fr;
c = fr->buf[fr->pos];
fr->pos++;
fr->c_col++;
if (c == '\n')
{
fr->c_row ++;
fr->c_col = 0;
}
return (c);
}
c = fr->buf[fr->pos]
에서 pos
의 값에 의거하여 문자를 buf
에서 뽑아내고 있고, 그 다음 fr->pos++
를 통해 pos
의 값을 갱신시켜주고 있습니다. 뒤의 코드들은 위치 값을 갱신시켜주고 있습니다.
자, 그렇다면 이제 pos
가 유효한 값을 가리키도록 보장만 해주면 본 함수는 끝이 날 것 같습니다!
// src/parser.h
typedef struct s_filereader
{
const char *path;
int fd;
int pos;
char buf[BUF_SIZE];
+ int eof_reached;
+ int eof_reached_len;
int c_col;
int c_row;
} t_filereader;
// src/file_reader.c
t_filereader fr(const char *path)
{
t_filereader fr;
int fd;
if(!file_open(&fd, path))
exit(ERR_FILE_OPEN);
fr.fd = fd;
fr.path = path;
+ fr.eof_reached = 0;
+ fr.eof_reached_len = 0;
fr.c_row = 1;
fr.c_col = 0;
return (fr);
}
+int make_frpos_safe(t_filereader *fr)
+{
+ int border;
+
+ if (fr->eof_reached)
+ border = fr->eof_reached_len;
+ else
+ border = BUF_SIZE;
+ if (fr->eof_reached && fr->pos >= border)
+ return (0);
+ if (fr->pos >= border)
+ // 여기서 뭔가를 해야 함!
+ return (1);
+}
char fr_read()
{
t_filereader *fr;
char c;
fr = &parser()->fr;
+ if (!make_frpos_safe(fr))
+ return ('\0');
c = fr->buf[fr->pos];
fr->pos++;
fr->c_col++;
if (c == '\n')
{
fr->c_row ++;
fr->c_col = 0;
}
return (c);
}
우선 eof_reached
와 eof_reached_len
를 추가해줬습니다. eof 일 때와 그렇지 않을 때 pos
가 안전한 구간이 다르므로 그 구간까지 계산하기 위해 새로운 변수를 추가하였습니다. eof_reached
는 현재 eof 에 도달했는지의 여부를 알려주고 (eof 여도 여전히 버퍼에 읽을 것이 남아있다면 계속해서 읽어야 합니다.) eof_reached_len
은 eof에 다다른 시점에 읽었던 버퍼의 길이입니다. eof 가 아니라면 그 경계(border
)는 단순히 BUF_SIZE
가 되겠지만, 이미 eof 에 도달했다면 그만큼 읽은 사이즈(eof_reached_len
)가 경계가 됩니다. 만약 eof에 도달했고 buf
에 남은 데이터도 모조리 읽었다면, 이제 fr_read
는 더이상 read
함수를 호출할 필요가 없어집니다. 그럴 때에는 단순히 '\0'
를 리턴합니다.
eof에 도달하지 않은 채로 그냥 경계(border
) 를 넘었다면, 새롭게 버퍼에 데이터를 채워주면서 pos
를 초기화시켜주면 됩니다. 아래에서 봅시다.
// src/file_reader.c
+void read_to_buffer(t_filereader *fr)
+{
+ int n_read;
+ int i;
+
+ fr->pos = 0;
+ n_read = read(fr->fd, fr->buf, BUF_SIZE);
+ if (n_read == -1)
+ exit(ERR_READ);
+ if (n_read < BUF_SIZE)
+ {
+ fr->eof_reached = 1;
+ fr->eof_reached_len = n_read;
+ i = n_read - 1;
+ while (++i < BUF_SIZE)
+ fr->buf[i] = '\0';
+ }
+}
int make_frpos_safe(t_filereader *fr)
{
int border;
if (fr->eof_reached)
border = fr->eof_reached_len;
else
border = BUF_SIZE;
if (fr->eof_reached && fr->pos >= border)
return (0);
if (fr->pos >= border)
+ read_to_buffer(fr);
return (1);
}
read_to_buffer
함수에서 예외 처리, 초기화, eof 감지 등을 합니다. 만약 eof 일 시 buf
에 남은 데이터를 재사용하지 않도록 나머지 값을 while
문을 활용해 '\0'
으로 초기화시킵니다. 이제 간단한 테스트를 수행해봅시다.
// test.c
+void test_filereader()
+{
+ char c;
+
+ init_parser("./input.txt");
+ c = 1;
+ while(c)
+ {
+ c = fr_read();
+ printf("%c", c);
+ }
+}
int main(void)
{
+ test_filereader();
return (0);
}
테스트를 수행하면 아주 결과가 잘 나온다는 걸 확인하실 수 있습니다. 우리는 테스트를 하기 위한 목적으로만 printf
를 사용할 것입니다!
string 구조체
c 는 문자열 관련 기능이 빈약합니다. <string.h>
헤더에서 많은 것을 지원해주지만, 어차피 이 표준 라이브러리에는 길이가 정해지지 않은 문자열을 다루는 데 상당한 애로사항이 있습니다. 우리는 토큰 하나가 어느 정도의 길이로 들어올 지 모릅니다. 그러므로 충분한 길이의 문자열 (예: char str[1000])
등으로 처리하려고 해도 길이가 모자랄 지 모릅니다. 물론 평균적인 토큰의 길이를 생각한다면 1000 칸은 너무 과도한 것 같기도 합니다. 고정 길이는 이렇게 상황에 유연하게 대처하지 못하는 문제가 있으므로 가변적으로 크기를 할당하여 사용하는 t_str
구조체를 만들어서 활용해보도록 합시다.
아이디어는 이렇습니다. 처음에 조그맣게 공간을 할당해놓습니다. 그러고 문자열에 공간을 채워넣어갑니다. 만약 전체 할당된 공간(max
)의 1/2 에 다다랐다면, max
를 두 배 확장하고 크기도 두 배로 다시 할당합니다. 항상 문자열의 길이를 즉시 알 수 있도록 길이 값(len
)을 저장해놓습니다.
// src/parser.h
# define INITIAL_STR_LEN 10
typedef unsigned long t_ul;
typedef struct s_str
{
t_ul len;
t_ul max;
char *ptr;
} t_str;
// src/string.c
t_str create_str()
{
t_str str;
str.len = 0;
str.max = INITIAL_STR_LEN;
str.ptr = (char *)malloc(sizeof(char) * INITIAL_STR_LEN);
if (!str.ptr)
exit(ERR_STR_ALLOC);
return (str);
}
void destroy_str(t_str *str)
{
free(str->ptr);
str->ptr = 0;
str->len = 0;
str->max = 0;
}
len
: 항상 문자열의 길이를 나타냅니다.max
: 문자열이 할당된 길이를 나타냅니다.ptr
: 할당한 공간의 포인터입니다.
일단 문자열의 초기 최대치(max
)는 10으로 설정해놓았습니다. 생성(create_str
)하며 파괴(destroy_str
)하는 함수도 만들었습니다. (malloc
하나에 free
하나가 대응되는 것처럼 create 하나와 destroy 하나가 대응되도록 설계합니다.) 그렇다면 이제 이 t_str 에 문자열을 추가할 수 있는 방법을 주어야겠지요?
// src/string.c
void str_push_char(t_str *str, char c)
{
str->ptr[str->len] = c;
str->len += 1;
if (str->len > str->max / 2)
{
// str->ptr 의 크기를 확장하자!
}
}
len
은 항상 문자열의 길이를 나타내므로, ptr[len]
은 항상 마지막 문자의 다음 위치를 가리킵니다. 즉 새롭게 추가할 문자가 들어갈 위치를 가리키고 있다고 말할 수 있습니다. 여기에 문자를 대입하고 len
을 1 증가시켜줍니다. 대입을 마친 뒤에 len
이 max / 2
보다 길어졌을 경우에는 문자열을 새롭게 할당하도록 합니다.
// src/string.c
void str_push_char(t_str *str, char c)
{
+ t_ul i;
+ char *new_ptr;
str->ptr[str->len] = c;
str->len += 1;
if (str->len > str->max / 2)
{
+ str->max *= 2;
+ new_ptr = (char *)malloc(sizeof(char) * str->max);
+ if (!new_ptr)
+ exit(ERR_STR_PUSH_CHAR_ALLOC);
+ i = 0;
+ while (i < str->len)
+ {
+ new_ptr[i] = str->ptr[i];
+ i++;
+ }
+ free(str->ptr);
+ str->ptr = new_ptr;
}
}
새롭게 할당하는 방법은 간단합니다. 공간만 2배로 키워서 새롭게 할당한 다음, 모든 내용을 복사하고, ptr
를 교체합니다.
이제 문자열에 접근하는 방법을 제공해주도록 합시다.
// src/string.c
const char *raw(t_str str)
{
str.ptr[str.len] = '\0';
return (str.ptr);
}
그냥 직접 str.ptr
에 바로 접근하면 되지 않느냐고 생각하실 수 있지만, 그래도 상관없습니다. 하지만 좀 더 명확한 쓰임새를 보여줄 수 있습니다. raw 를 통해 불러오는 문자열 포인터는 c-style 문자열에 호환되도록 보장해줄 수 있습니다. 또한 const char *
로 리턴해줌으로써 직접 포인터로 수정할 수 없도록 합니다.
우리는 기본적으로 문자열을 수정하는 방법을 제공하지 않을 것입니다. 왜냐하면 굳이 필요가 없기 때문이지요. 토큰을 읽어드리는 대로 그대로 받아들이면 될 뿐입니다. 만약 읽고 있었던 토큰이 잘못되어서 수정해야 한다면, 그냥 기존 것을 폐기한 다음 새롭게 만드는 것이 더 쉽습니다. 우리가 만드는 프로그램에서 t_str
의 기능은 이정도 기능만으로도 충분합니다.
앞으로 필요한 기능이 생긴다면 그때 그때 추가하도록 하겠습니다.
Tokenizer 만들기
우선 토큰과 Tokenizer 를 먼저 정의해보도록 합시다.
// src/parser.h
typedef int t_token_type;
typedef struct s_token
{
int file_row;
int file_col;
t_str str;
t_token_type type;
} t_token;
typedef struct s_tokenizer
{
t_token current_token;
t_str reading;
} t_tokenizer;
t_token::file_row
: 해당 토큰의 줄 정보입니다.t_token::file_col
: 해당 토큰의 행 정보입니다.t_token::str
: 해당 토큰의 실제 문자열입니다.t_token::type
: 해당 토큰의 종류입니다.t_tokenizer::current_token
: 현재 가리키고 있는 토큰을 의미합니다.t_tokenizer::reading
: 다음 토큰을 읽을 때 문자열들을 임시 저장할 문자열입니다.
// src/parser.h
typedef struct s_parser
{
t_filereader fr;
+ t_tokenizer tknzr;
} t_parser;
// src/parser.c
void init_parser(const char *path)
{
t_parser *p;
p = parser();
+ p->tknzr = create_tokenizer();
p->fr = fr(path);
}
// src/tokenizer.c
t_tokenizer create_tokenizer()
{
t_tokenizer tknzr;
tknzr.current_token = create_token();
tknzr.reading = create_str();
return (tknzr);
}
t_token create_token()
{
t_token tk;
tk.str = create_str();
tk.type = TOKEN_NOT_SPECIFIED;
tk.file_col = 0;
tk.file_row = 0;
return (tk);
}
t_token token_lookahead()
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
if (tknzr->current_token.type == TOKEN_NOT_SPECIFIED)
token_consume();
return (tknzr->current_token);
}
void token_consume()
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
if (tknzr->current_token.type == TOKEN_END)
return ;
// 뭔가 한다!
// 만약 성공했을 시 return
// 만약 실패했을 시 exit
}
이제 토큰과 Tokenizer 를 생성할 수 있습니다.그리고 token_lookahead
함수와 token_consume
함수의 기본적인 형태를 만들었습니다. token_lookahead
함수는 간단히 현재 토큰을 리턴하는 함수입니다. 초기화 상태(TOKEN_NOT_SPECIFIED
)일 때에만 token_consume
함수를 호출해서 토큰을 갱신합니다. token_consume
함수는 토큰을 읽어들이는 핵심 부분입니다. 지금부터 본격적으로 작성해볼 예정입니다.
이제 간단하게 숫자만을 받아들이는 Tokenizer 를 만들어봅시다.
// src/is.c
int is_digit(char c)
{
return (c >= '0' && c <= '9');
}
// src/tokenizer.c
void token_consume()
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
if (tknzr->current_token.type == TOKEN_END)
return ;
+ if (try_token_int())
+ return ;
+ exit(ERR_TOKEN_CONSUME);
}
// src/tokenizer_try.c
int try_token_int()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = fr_read();
str_push_char(&tknzr->reading, c);
if (is_digit(c))
{
while (1)
{
c = fr_read();
if (!is_digit(c))
break ;
str_push_char(&tknzr->reading, c);
}
tknzr->current_token.type = TOKEN_INT;
tknzr->current_token.str = tknzr->reading;
return (1);
}
return (0);
}
try_token_int
함수를 새롭게 만들었습니다. 우리는 이제 앞으로 try_token_...
형태의 함수를 여러 개 만들텐데, 이 함수들의 목표는 다음과 같습니다.
- fr_read 를 적절하게 호출해서 다음 문자를 읽어옴
- 읽어온 문자들을 날리지 않도록 reading 에 차곡히 저장
- 만약 성공했으면 current_token 에 적절한 데이터를 저장한 후
1
을 리턴함. - 만약 실패했다면
0
을 리턴함.
우선 fr_read
로 첫 번째 문자를 불러온 후에 이것이 숫자라면 반복문을 시작합니다. 반복문에서는 숫자가 아닐 때까지 반복되며, 숫자라고 판명난 문자들은 모두 reading
에 저장됩니다. 반복문이 끝나고 데이터 정보를 current_token.str
에 기록합니다.
try_token...
에서 성공과 실패를 1
과 0
으로 리턴함으로써 token_consume
에서 분기를 조정할 수 있습니다. 하나라도 성공하게 되었을 때 즉시 return
하여 1회 token_consume
을 성공적으로 마무리시킬 수 있고, 모든 시도가 실패하게 되었을 때 이윽고 마지막 exit
함수가 호출되면서 프로그램이 강제로 종료되도록 합니다.
자 그럼 제대로 동작하는지 테스트해봅시다. input.txt
파일에는 그냥 단순히 13245
라는 숫자 하나만 넣어두도록 합니다.
// test.c
+void test_first_token()
+{
+ t_token token;
+ init_parser("./input.txt");
+ token_consume();
+ token = token_lookahead();
+ printf("%s, %d\n", raw(token.str), token.type);
+}
int main(void)
{
- test_filereader();
+ // test_filereader();
+ test_first_token();
return (0);
}
make re
등으로 테스트를 수행시키면 다음과 같이 제대로 출력이 됩니다.
13245, 1
token.type
부분에서 1
이 출력된 이유는 우리가 #define TOKEN_INT 1
과 같이 설정했기 때문입니다.
assign_str
구현
지금 읽은 데이터를 토큰에 저장하는 과정에서 단순한 대입문이 사용되고 있습니다. 우리는 current_token.str
에 진작에 할당해놓았던 문자열들을 잃을 생각도 없고, 또한 current_token.str
과 reading
이 같은 문자열을 가리키게 할 생각도 없으므로 t_str
의 기능을 확장하여 코드를 잠깐 손을 봅시다.
// src/string.c
t_str duplicate_str(t_str str)
{
t_str new_str;
t_ul i;
new_str.max = str.max;
new_str.len = str.len;
new_str.ptr = (char *)malloc(sizeof(char) * str.max);
i = 0;
while (i < str.len)
{
(new_str.ptr)[i] = (str.ptr)[i];
i++;
}
return (new_str);
}
void assign_str(t_str *str, t_str other)
{
t_str temp;
temp = duplicate_str(other);
destroy_str(str);
*str = temp;
}
// src/tokenizer_try.c
int try_token_int()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = fr_read();
str_push_char(&tknzr->reading, c);
if (is_digit(c))
{
while (1)
{
c = fr_read();
if (!is_digit(c))
break ;
str_push_char(&tknzr->reading, c);
}
tknzr->current_token.type = TOKEN_INT;
- tknzr->current_token.str = tknzr->reading;
+ assign_str(&tknzr->current_token.str, tknzr->reading);
return (1);
}
return (0);
}
이제 이렇게 하면, 일단 reading
의 복제본을 가지고 있다가, current_token.str
의 메모리를 해제한 후, 그 자리에 복제본을 집어넣습니다. 이 과정에서 reading
은 아무런 영향을 받지 않고, current_token.str
은 파괴되었다가 다시 할당된 것이 생깁니다. 결과적으로 할당된 t_str
의 개수는 변하지 않습니다. 어때요, current_token.str
을 직접 하나하나 수정하는 것 보다는 훨씬 간편하지 않나요?
매번 reading
갱신해놓기
일단 우리는 1회 토큰화시키기를 성공했습니다! 이제 반복적으로 동작하여야 합니다. 더 세부적인 구현을 하기 전에 한 가지 생각해봅시다. 우리가 프로그램의 중간부터 숫자 토큰을 읽고 있는 상황이라고 가정합니다. 우리는 한 번에 하나의 문자를 가져올 수 있는 fr_read
함수를 자유롭게 사용할 수 있습니다. 그렇다면 어디까지가 숫자인지 어떻게 판단할 수 있을까요?
abc,def,1234????????
^ ^
a b
우리는 a
지점부터 읽기 시작했습니다. a
지점은 1
이니까 숫자가 맞고, b
지점까지도 숫자가 명확합니다. 하지만 b
지점이 숫자의 끝일까요? 그건 아무도 모릅니다. b
지점 이후를 읽어보아야 알 수 있습니다.
abc,def,1234567??????
^ ^
a b
아직까지 숫자입니다.
abc,def,1234567,?????
^ ^
a b
좋습니다! 쉼표가 등장했습니다. 이제 1234567
이 완전한 숫자임을 확신할 수 있습니다. 이제 다음 토큰으로 넘어가도록 합시다. 하지만 우리는 이미 ,
를 읽은 상태이므로, fr_read
를 수행하면 안 됩니다. 만약 수행하게 된다면 ,
를 건너뛰게 될 것입니다. 이미 읽은 문자이지만, 해당 문자가 어떤 토큰인지 아직 모르는 상태에 있는 문자열들을 우리는 reading
에 저장하고 있었습니다. 토크나이저가 1234567
에 대한 토큰 검사가 끝나는 즉시 reading
은 ","
여야 합니다. 그리고 다음 토큰을 검사할 때 무작정 fr_read
하는 것이 아니라 reading
에 담겨져있는 첫번째 문자인 ,
부터 적절하게 처리해야 합니다.
// src/tokenizer_try.c
int try_token_int()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
- c = fr_read();
- str_push_char(&tknzr->reading, c);
+ c = raw(tknzr->reading)[0];
if (is_digit(c))
{
while (1)
{
c = fr_read();
if (!is_digit(c))
break ;
str_push_char(&tknzr->reading, c);
}
tknzr->current_token.type = TOKEN_INT;
assign_str(&tknzr->current_token.str, tknzr->reading);
+ destroy_str(&tknzr->reading);
+ tknzr->reading = create_str();
+ str_push_char(&tknzr->reading, c);
return (1);
}
return (0);
}
하지만 이 상태로 테스트를 그대로 실행시킨다면 에러가 발생합니다!
make: *** [Makefile:10: test] Error 6
왜일까요? 바로 제일 첫번째 try_token_int
시도에는 raw(tknzr->reading)[0]
는 '\0'
이기 때문입니다. 이전 단계에서 미리 읽어놓았던 문자가 단 하나도 없으므로 아무것도 가리킬 게 없습니다. 그러므로 제일 첫번째에는 raw(tknzr->reading)[0]
이 첫 번째 문자를 가리킬 수 있도록 초기화를 시켜줘야 합니다.
// src/parser.c
void init_parser(const char *path)
{
t_parser *p;
p = parser();
p->fr = fr(path);
p->tknzr = create_tokenizer();
+ init_tokenizer();
}
// src/tokenizer.c
void init_tokenizer()
{
char c;
c = fr_read();
str_push_char(&parser()->tknzr.reading, c);
}
이렇게 하면 다시 결과는 잘 나온다는 걸 확인할 수 있습니다.
공백 패스하기
위에서도 잠깐 이야기했지만, 정말로 아무런 뜻이 없는 (오직 토큰 구분자용으로만 사용되는) 토큰은 굳이 토큰화시키지 않아도 됩니다. 그래서 우리는 공백을 패스해보도록 하겠습니다.
// src/tokenizer.c
void skip_blank()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = raw(parser()->tknzr.reading)[0];
if (c == ' ')
{
while (1)
{
c = fr_read();
if (c != ' ')
break;
}
}
destroy_str(&tknzr->reading);
tknzr->reading = create_str();
str_push_char(&tknzr->reading, c);
}
// src/tokenizer.c
void token_consume()
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
if (tknzr->current_token.type == TOKEN_END)
return ;
+ skip_blank();
if (try_token_int())
return ;
exit(ERR_TOKEN_CONSUME);
}
공백은 적당히 공백만큼 읽어들인 다음 공백이 아닌 문자가 등장했을 때 그것을 reading
에 채워넣어주면 됩니다. try_token_int
함수와 비슷한 점은 reading
에 마지막 글자를 채워넣는다는 행동이지만, 다른 점은 current_token
의 데이터를 전혀 만지지 않는다는 점입니다. 어찌보면 합당합니다. 공백은 토큰조차 되지 못하는 신세이니까요.. ㅠㅠ
편의성을 위해 다음 next_token
함수를 먼저 추가해주세요.
// src/parser.c
t_token next_token()
{
token_consume();
return (token_lookahead());
}
그 다음 아래 내용을 input.txt
에 집어 넣고
13245 12837 2232
아래 테스트 코드로 제대로 동작하는지 확인해봅시다.
// text.c
+void test_several_int()
+{
+ t_token token;
+ init_parser("./input.txt");
+ token = next_token();
+ printf("%s, %d\n", raw(token.str), token.type);
+ token = next_token();
+ printf("%s, %d\n", raw(token.str), token.type);
+ token = next_token();
+ printf("%s, %d\n", raw(token.str), token.type);
+ token = next_token();
+ printf("%s, %d\n", raw(token.str), token.type);
+}
int main(void)
{
// test_filereader();
- test_first_token();
+ // test_first_token();
+ test_several_int();
return (0);
}
아래는 그 결과입니다.
13245, 1
12837, 1
2232, 1
make: *** [Makefile:10: test] Error 6
네 번째에서 에러가 일어난다는 것은 지극히 자연스럽습니다. 왜냐하면 next_token()
은 4번 실행되었던 데에 반해 실제 숫자는 3개밖에 없기 때문입니다.
reading
초기화하는 부분을 함수로 묶기
skip_blank
함수와 try_token_int
함수의 반복되는 부분을 없애보도록 합시다.
// src/tokenizer.c
+int init_reading(char c)
+{
+ t_tokenizer *tknzr;
+
+ tknzr = &parser()->tknzr;
+ destroy_str(&tknzr->reading);
+ tknzr->reading = create_str();
+ str_push_char(&tknzr->reading, c);
+ return (1);
+}
void skip_blank()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = raw(parser()->tknzr.reading)[0];
if (c == ' ')
{
while (1)
{
c = fr_read();
if (c != ' ')
break;
}
}
- destroy_str(&tknzr->reading);
- tknzr->reading = create_str();
- str_push_char(&tknzr->reading, c);
+ init_reading(c);
}
// src/tokenizer_try.c
int try_token_int()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = raw(tknzr->reading)[0];
if (is_digit(c))
{
while (1)
{
c = fr_read();
if (!is_digit(c))
break ;
str_push_char(&tknzr->reading, c);
}
tknzr->current_token.type = TOKEN_INT;
assign_str(&tknzr->current_token.str, tknzr->reading);
- destroy_str(&tknzr->reading);
- tknzr->reading = create_str();
- str_push_char(&tknzr->reading, c);
- return (1);
+ return (init_reading(c));
}
return (0);
}
계속해서 비슷한 역할을 하는 코드들은 적극적으로 묶을 것입니다.
try_token_end
구현하고 반복문으로 모든 토큰 돌려보기
이제 토큰의 끝을 의미하는 TOKEN_END
도 구현해보도록 합시다.
// src/tokenizer_try.c
int try_token_end()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = raw(tknzr->reading)[0];
if (c == '\0')
{
tknzr->current_token.type = TOKEN_END;
assign_str(&tknzr->current_token.str, tknzr->reading);
c = fr_read();
return (init_reading(c));
}
return (0);
}
fr_read
를 했을 적에 더이상 읽을 문자가 없다면 그냥 '\0'
이 리턴되었던 걸 기억하시나요? 그래서 그냥 raw(tknzr->reading)[0]
이 '\0'
라면 그냥 끝났다고 판단하면 됩니다. 여기서는 하나의 글자만 파악하면 되기 때문에 별 다른 반복문이 필요없습니다. 바로 fr_read
를 한 번만 호출하고 init_reading
을 수행합니다. (사실 다 끝난 마당에 fr_read
도 필요없고, init_reading
도 필요없지만, 문자 하나만을 받는 다른 try_token_...
과 비슷한 구조를 만들기 위함이라는 점을 알립니다.)
// src/tokenizer.c
void token_consume()
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
if (tknzr->current_token.type == TOKEN_END)
return ;
skip_blank();
if (try_token_int())
return ;
+ if (try_token_end())
+ return ;
exit(ERR_TOKEN_CONSUME);
}
try_token_end
함수도 token_consume
함수에 추가시켜줍니다. 그리고 테스트를 좀 더 깔끔하게 만들어봅시다.
// test.c
+void test_until_end()
+{
+ t_token token;
+ init_parser("./input.txt");
+ token.type = TOKEN_NOT_SPECIFIED;
+ while (token.type != TOKEN_END)
+ {
+ token = next_token();
+ printf("%s, %d\n", raw(token.str), token.type);
+ }
+}
int main(void)
{
// test_filereader();
// test_first_token();
+ // test_several_int();
- test_several_int();
+ test_until_end();
return (0);
}
13245, 1
12837, 1
2232, 1
, 100
while
문이 무사히 종료되었습니다!
가끔 에디터에 따라 파일을 저장할 때 파일의 가장 끝부분에 줄바꿈 문자(
\n
)를 삽입하는 경우가 있습니다. 아직 우리의 코드는 줄바꿈 문자를 인식할 수 없으므로input.txt
파일의 마지막에 줄바꿈 문자가 포함되게 된다면 에러가 발생합니다. 추후 줄바꿈 문자도 인식하도록 할테지만, 테스트를 빠르게 해보고 싶다면 아래 명령어를 실행한 후 테스트를 시도해보세요.
echo -n "13245 12837 2232" > input.txt
current_token
설정하는 부분을 묶기
// src/tokenizer.c
int confirm_trial(t_token_type type, char next)
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
tknzr->current_token.type = type;
assign_str(&tknzr->current_token.str, tknzr->reading);
return (init_reading(next));
}
int confirm_trial_init_next(t_token_type type)
{
return (confirm_trial(type, fr_read()));
}
current_token
을 설정하고 init_reading
하는 부분이 동일하므로 최대한 묶어줍니다.
// src/tokenizer_try.c
int try_token_int()
{
char c;
- t_tokenizer *tknzr;
- tknzr = &parser()->tknzr;
- c = raw(tknzr->reading)[0];
+ c = raw(parser()->tknzr.reading)[0];
if (is_digit(c))
{
while (1)
{
c = fr_read();
if (!is_digit(c))
break;
- str_push_char(&tknzr->reading, c);
+ str_push_char(&parser()->tknzr.reading, c);
}
- tknzr->current_token.type = TOKEN_INT;
- tknzr->current_token.str = tknzr->reading;
- return (1);
+ return (confirm_trial(TOKEN_INT, c));
}
return (0);
}
int try_token_end()
{
char c;
- t_tokenizer *tknzr;
- tknzr = &parser()->tknzr;
- c = raw(tknzr->reading)[0];
+ c = raw(parser()->tknzr.reading)[0];
if (c == '\0')
- {
- tknzr->current_token.type = TOKEN_END;
- assign_str(&tknzr->current_token.str, tknzr->reading);
- c = fr_read();
- return (init_reading(c));
- }
+ return (confirm_trial_init_next(TOKEN_END));
return (0);
}
자 이제 구조가 훨씬 단순해졌습니다!
토큰에 위치 정보를 삽입하여 Unexpected Token 에러 출력시키기
그렇습니다. fr_read
할 때마다 위치를 갱신시키고, 예상치 못한 토큰이 있을 때 unexpected token 에러를 출력해보도록 합시다.
// src/parser.h
typedef struct s_tokenizer
{
t_token current_token;
t_str reading;
+ t_ul unresolved_row;
+ t_ul unresolved_col;
+ char unresolved_char;
} t_tokenizer;
우선 문자열을 분석하고 있는 Tokenizer 입장에서 아직까지 토큰화되지 않은 문자 정보를 저장할 수 있도록 공간을 만들어둡니다.
// src/tokenizer.c
char fr_read_track()
{
char result;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
result = fr_read();
tknzr->unresolved_row = parser()->fr.c_row;
tknzr->unresolved_col = parser()->fr.c_col;
tknzr->unresolved_char = result;
return (result);
}
기존의 fr_read
와 기능적으로 똑같이 동작하되, 위치 정보를 Tokenizer 에 기록하는 래퍼 함수 fr_read_track
함수를 만듭니다.
그래서 기존에 사용하고 있었던 fr_read
모두 대체하도록 합니다.
// src/tokenizer_try.c
int try_token_int()
{
char c;
c = raw(parser()->tknzr.reading)[0];
if (is_digit(c))
{
while (1)
{
- c = fr_read();
+ c = fr_read_track();
if (!is_digit(c))
break;
str_push_char(&parser()->tknzr.reading, c);
}
return (confirm_trial(TOKEN_INT, c));
}
return (0);
}
// src/tokenizer.c
void init_tokenizer()
{
char c;
- c = fr_read();
+ c = fr_read_track();
str_push_char(&parser()->tknzr.reading, c);
}
int confirm_trial_init_next(t_token_type type)
{
- return (confirm_trial(type, fr_read()));
+ return (confirm_trial(type, fr_read_track()));
}
void skip_blank()
{
char c;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
c = raw(parser()->tknzr.reading)[0];
if (c == ' ')
{
while (1)
{
- c = fr_read();
+ c = fr_read_track();
if (c != ' ')
break;
}
}
init_reading(c);
}
그 다음 에러 메시지를 만들기 위해 t_str
를 손 좀 보도록 합시다.
// src/string.c
#include <unistd.h>
void str_push_chars(t_str *str, const char *s)
{
t_ul i;
i = 0;
while (s[i])
{
str_push_char(str, s[i]);
i++;
}
}
void str_push_long(t_str *str, long num)
{
if (num < 0)
{
str_push_char(str, '-');
num *= -1;
}
if (num >= 10)
str_push_long(str, num / 10);
str_push_char(str, (num % 10) + '0');
}
void write_str(t_str str)
{
write(1, raw(str), str.len);
}
총 3가지의 함수를 추가했습니다.
str_push_chars
: 기존의t_str
내용에 c-style 문자열 내용을 덧붙입니다.str_push_long
: 기존의t_str
내용에 숫자를 문자화하여 내용을 덧붙입니다. 재귀적으로 구성되었습니다. 간단하게 구현한 특성상num
이long
의 최소값일 경우 이상하게 작동합니다.write_str
: 표준 출력으로t_str
의 내용을 출력합니다.
그 다음 에러 메시지를 출력하고 종료하는 abort_tokenizer
함수를 만들어줍니다.
// src/abort.c
void abort_tokenizer()
{
t_str str;
t_tokenizer tknzr;
tknzr = parser()->tknzr;
str = create_str();
str_push_chars(&str, "Unexpected Token '");
str_push_char(&str, tknzr.unresolved_char);
str_push_chars(&str, "' at ");
str_push_chars(&str, parser()->fr.path);
str_push_char(&str, ':');
str_push_long(&str, tknzr.unresolved_row);
str_push_char(&str, ':');
str_push_long(&str, tknzr.unresolved_col);
str_push_char(&str, '\n');
write_str(str);
destroy_str(&str);
exit(ERR_TOKEN_CONSUME);
}
이 함수를 consume
에서 exit
대신 넣어줍니다.
// src/tokenizer.c
void token_consume()
{
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
if (tknzr->current_token.type == TOKEN_END)
return ;
skip_blank();
if (try_token_int())
return ;
if (try_token_end())
return ;
- exit(ERR_TOKEN_CONSUME);
+ abort_tokenizer();
}
이제 123 456 7+9
이렇게 input.txt
에 넣고 테스트를 해봅시다. 테스트 코드는 이전과 동일하게 test_until_end
함수로 진행합니다.
123, 1
456, 1
7, 1
Unexpected Token '+' at ./input.txt:1:10
make: *** [Makefile:10: test] Error 6
와 정말 멋잇지 않나요!!
newline(줄바꿈), comma(쉼표), name(식별자)토큰 지원하기
줄바꿈 문자를 지원하는 건 파일의 끝을 인식하는 것과 거의 동일하게 진행하면 됩니다. 아래가 그 코드입니다.
// src/tokenizer_try.c
int try_token_newline()
{
char c;
c = raw(parser()->tknzr.reading)[0];
if (c == '\n')
return (confirm_trial_init_next(TOKEN_NEWLINE));
return (0);
}
int try_token_comma()
{
char c;
c = raw(parser()->tknzr.reading)[0];
if (c == ',')
return (confirm_trial_init_next(TOKEN_COMMA));
return (0);
}
name(이름)을 불러오는 것도 쉬운데요, 여기서 말하는 이름이란 우리가 흔히 변수명으로 쓰는 것처럼 어떤 이름이라고 생각하면 됩니다. 이것도 꽤 쉽게 구현할 수 있습니다.
// src/is.c
int is_alpha(char c)
{
return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
}
int one_of(char c, const char *s)
{
int i;
i = 0;
while (s[i] != '\0')
{
if (s[i] == c)
return (1);
i++;
}
return (0);
}
int is_c_name(char c)
{
return (is_alpha(c) || one_of(c, "_"));
}
is_alpha 함수는 c
라는 글자가 알파벳인지 아닌지 판단합니다. one_of
함수는 c 라는 문자 하나가 s
라는 문자열에 포함되어 있는지 아닌지를 판단합니다. 이 두개를 조합하여 is_c_name
을 만듭니다. is_c_name
함수는 해당 글자가 알파벳이거나 _
라면 TRUE
(1) 를 리턴하고, 그렇지 않다면 FALSE
(0)를 리턴합니다. C 를 작성할 때 숫자도 변수명에 포함될 수 있지만, 일단 우리가 만드는 파서는 일단 지원하지 않는다고 가정합시다.
그런 다음 try_token_name
함수를 작성합니다.
int try_token_name()
{
char c;
c = raw(parser()->tknzr.reading)[0];
if (is_c_name(c))
{
while (1)
{
c = fr_read_track();
if (!is_c_name(c))
break ;
reading_push_char(c);
}
return (confirm_trial(TOKEN_NAME, c));
}
return (0);
}
우선 첫번째 글자를 읽어와서, name(이름) 에 해당하는 글자라면 계속 읽어나갑니다. 이윽고 name 에 해당하지 않는 글자가 나온다면, break
로 while
문을 빠져간 후 함수를 종료합니다.
정수를 더 정확하게 읽어오기
우리의 정수는 아직까지 미완성입니다. 왜냐하면 일단 음수를 받아들이지 못하고, 그 다음 00001234
와 같이 숫자 형식에 좀 벗어난 것도 일단 숫자라고 알아보기 때문입니다. 이러한 조건을 더 세세하게 들어가보겠습니다. 지금은 이면지나 아이패드를 준비해주세요. 여러가지 숫자일 가능성이 있는 문자의 나열을 끄적여봅시다. 0
, -2
, 234
, 100
, -235
, --123
, 0042
, -0
. 기타 등등등..
이 중에서 --123
이나 0042
같은 건 틀리다고 가정하겠습니다. 다만 -0
같은 경우는 일단 옳다고 가정하겠습니다. 이 정수 판별기를 가지고 추후 소수점 읽을 때도 사용할 거라서 말입니다.. 엄밀한 규칙은 이제 숙제로 남겨놓도록 하겠습니다. 이럼 이제 분류가 되었으니 좀 정리를 해볼까요?
- 처음에
-
기호 하나가 나오거나 나오지 않아야 합니다. (선택) 여러 번 나올 수 없습니다. - 그 다음 0이 나오거나, 0이 아닌 숫자가 나옵니다.
- 0이 나온다면, 0으로, 정수로 처리해 버리고 바로 종료합니다. 0은 두 번 이상 연속으로 나올 수 없습니다.
- 0이 아닌 숫자가 나온다면, 그 다음부터 연속적으로 나오는 0~9의 모든 숫자까지 정수로 처리합니다.
위 논리에 따르면 --123
같은 경우는 두 번째 -
에서 2번 규칙에 걸리게 되므로 올바른 토큰이 될 수 없습니다. 0042
같은 경우는 0을 만나면 바로 종료하는 조건때문에 토큰이 3개가 생깁니다. 0
, 0
, 42
이렇게요. 논리적인 결함이 있는 듯 하지만, 앞으로 만들 파서에 큰 걸림돌이 되는 것은 아니므로 가볍게 넘어가도록 하겠습니다.
그렇다면 바로 코딩해봅시다.
// src/is.c
int is_no_zero_digit(char c)
{
return (c >= '1' && c <= '9');
}
// src/tokenizer_try.c
int try_token_int()
{
char c;
c = raw(parser()->tknzr.reading)[0];
if (c == '-')
{
c = fr_read_track();
reading_push_char(c);
}
if (c == '0')
return (confirm_trial_init_next(TOKEN_INT));
if (is_no_zero_digit(c))
{
while (1)
{
c = fr_read_track();
if (!is_digit(c))
break ;
reading_push_char(c);
}
return (confirm_trial(TOKEN_INT, c));
}
return (0);
}
이제 아래 내용을 입력으로 설정하고 테스트를 수행해봅시다.
123 456 79 -123 -0 0 05
그렇다면 이제 아래와 같은 결과가 나옵니다.
123, 1
456, 1
79, 1
-123, 1
-0, 1
0, 1
0, 1
5, 1
234, 1
좋습니다. 적당히 잘 동작합니다.
소수점이 있는 숫자 읽어오기
앞서 만든 정수 try
함수를 이용하여 소수점이 있는 숫자(이하 실수)까지 불러와보도록 하겠습니다. 실수은 알고보면 정수를 포함합니다. 그러니까 1
, 2
, 3
과 같은 정수는 number 이 될 수 있지만, 1.2
, 2.5
와 같은 number 은 정수가 될 수 없지요. 그리고 실수와 정수를 가르는 가장 큰 기준점은 점(.
) 입니다. 그렇다면 순서를 한번 또 정리해봅시다.
- 일단 실수의 앞에는 정수가 나와야 합니다.
- 그 다음 점이 올 수도 있고, 안 올수도 있습니다. 온다면 실수부 검색을 들어갈 준비를 해야 하고, 안온다면 앞에까지를 그냥 정수로 성공 처리합니다.
- 점 다음에 숫자들이 온다면, 숫자들을 기존 정수 검사 결과에 덧붙입니다.
- 점 다음에 숫자가 오지 않는다면, 잘못된
// src/tokenizer_try.c
int try_token_real()
{
char c;
if (!try_token_int())
return (0);
c = raw(parser()->tknzr.reading)[0];
if (c != '.')
return (init_reading(c));
c = fr_read_track();
if (is_digit(c))
return (iterate_dot_right(c));
else
return (0);
}
int iterate_dot_right(char c)
{
t_str temp;
t_tokenizer *tknzr;
tknzr = &parser()->tknzr;
reading_push_char(c);
while (1)
{
c = fr_read_track();
if (!is_digit(c))
break;
reading_push_char(c);
}
temp = duplicate_str(tknzr->current_token.str);
concat_str(&temp, tknzr->reading);
confirm_trial_value(TOKEN_DOUBLE, c, temp);
destroy_str(&temp);
return (1);
}
// src/string.c
void concat_str(t_str *target, t_ststr source)
{
t_ul i;
const char *raw_str;
raw_str = raw(source);
i = 0;
while (i < source.len)
{
str_push_char(target, raw_str[i]);
i++;
}
}
Parser 만들기
// test.c
#include <stdio.h>
#include "parser.h"
int main(void)
{
init_parser("./input");
parse();
return (0);
}
설치 법은 본 블로그에서는 소개드리지 않습니다. -> 설치법은
그 다음 점이 올 수도 있고, 안 올수도 있습니다. -> 안 올 수도
기타 등등등.. | 말입니다.. -> 말줄임표(…)
토큰을 읽어드리는 대로 그대로 받아들이면 될 뿐입니다. -> 읽어들이는 대로
좋은 글입니다.