극한의 환경이라고도 알려져 있는 C, 여기서 우리는 간단한 파싱 작업을 할 수 있는 프로그램을 만들고자 합니다.
목표
우리의 목표는 간단한 파서(Parser)를 만드는 것입니다. 다음 파일을 규칙에 맞게 잘 읽어들이는 것입니다.
본 파일은 어떤 그래픽 렌더링 프로그램에 전달할 입력 파일입니다. 이 파일은 렌더링 프로그램의 각종 설정과 렌더링할 도형을 정의합니다. 정의하는 방법은 다음과 같이 정의되어 있습니다.
지시자 | 설명 | 입력할 값 |
---|---|---|
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)를 만드는 과정을 말한다.
위키백과
토큰은 파싱 목적을 위해 분류화를 명시적으로 지시하는 어휘소를 표현하는 구조의 하나이다.
역시 위키백과
대~충 개념적이므로 이 글에서는 파서(파싱)과 토큰을 다음과 같이 특수하게 정의내리도록 하겠습니다.
- 파싱: 입력된 문자열로부터 토큰을 뽑아내고, 문법에 맞게 토큰을 배열/결합/변형하여 의미있는 값과 사용하기 간편한 구조를 만들어내는 것.
- 토큰: 최소의 의미를 가지는 문자열(혹은 문자)의 묶음. 공백(=단순 토큰 구분용 문자)을 제외한 모든 문자 데이터가 살아있어야 함.
예를 들어 아래 한 줄을 가지고 설명해보도록 하겠습니다. 이 한 줄을 토큰으로 바꿔보고자 합니다.
토큰은 단순한 묶음이므로 그 순서도 입력 문자열의 순서와 같습니다. 해당 순서가 옳은 건지 아닌지는 파서가 판단할 일입니다.
그렇다면 문자열에서 토큰을 생성하는 과정에서 일어나는 에러란 무엇일까요? 바로 지원하지 않는 문자가 있을 때 에러가 발생합니다. 예를 들어 위 입력 파일에서 우리는 +
혹은 *
과 같은 기호를 사용하지 않습니다. 사용할 일이 없습니다. 이 문자는 아무런 의미를 가질 수 없으므로 바로 에러를 내는 것이 합당합니다. 또한 토큰을 생성하는 과정에서 예상하지 못한 문자가 나왔을 때에도 에러가 발생할 수 있습니다. 예를 들어 .
은 숫자 안에 포함이 됩니다. 그러나 123.456.789
와 같은 입력이 들어가게 되면 123.456
까지는 숫자로 인식할 수 있지만 그 뒤에 바로 .
이 등장하므로 기대하지 않은 토큰 값이라고 처리할 수 있습니다.
그렇다면 만약 파싱까지 완료한다면 어떤 구조가 생길까요? 의사 코드로 나타내보도록 하겠습니다.
그렇다면 이제 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
먼저 간단하게 봅시다.
Makefile
에 의해서, make
, make test
, make re
명령어를 쳐서 테스트를 수행할 수 있습니다.
다음은 test.c
파일입니다. 여기에 main
함수가 있습니다.
전역처럼 쓸 수 있는 static 구조체 만들기
이렇게 만들어두면 언제 어디에서든지 parser()
를 호출하기만 해도 같은 p
에 접근할 수 있게 됩니다.
매크로 정의
미리 필요한 매크로를 아래와 같이 정의해 놓읍시다.
파일 읽기
과제의 조건을 충족시키기 위해 우선 기능들을 모듈화 해놓도록 하겠습니다. 파일을 읽기 위해서는 버퍼를 활용해야 합니다.
path
: 파일 경로를 저장합니다.fd
: 파일을 open 하고 난 후 파일 디스크립션을 저장합니다.buf[BUF_SIZE]
: 버퍼입니다.c_col
,c_row
: 현재 읽고 있는 위치를 저장합니다.
자, 이제 파일을 열어봅시다.
이제 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
한 값들이 온전히 잘 들어가있다고 가정합시다. 그렇게 되었을 때, 우리는 한 글자씩 뽑아내야 하기 때문에 뽑아낼 위치 정보를 기억하고 있어야 합니다.
c = fr->buf[fr->pos]
에서 pos
의 값에 의거하여 문자를 buf
에서 뽑아내고 있고, 그 다음 fr->pos++
를 통해 pos
의 값을 갱신시켜주고 있습니다. 뒤의 코드들은 위치 값을 갱신시켜주고 있습니다.
자, 그렇다면 이제 pos
가 유효한 값을 가리키도록 보장만 해주면 본 함수는 끝이 날 것 같습니다!
우선 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
를 초기화시켜주면 됩니다. 아래에서 봅시다.
read_to_buffer
함수에서 예외 처리, 초기화, eof 감지 등을 합니다. 만약 eof 일 시 buf
에 남은 데이터를 재사용하지 않도록 나머지 값을 while
문을 활용해 '\0'
으로 초기화시킵니다. 이제 간단한 테스트를 수행해봅시다.
테스트를 수행하면 아주 결과가 잘 나온다는 걸 확인하실 수 있습니다. 우리는 테스트를 하기 위한 목적으로만 printf
를 사용할 것입니다!
string 구조체
c 는 문자열 관련 기능이 빈약합니다. <string.h>
헤더에서 많은 것을 지원해주지만, 어차피 이 표준 라이브러리에는 길이가 정해지지 않은 문자열을 다루는 데 상당한 애로사항이 있습니다. 우리는 토큰 하나가 어느 정도의 길이로 들어올 지 모릅니다. 그러므로 충분한 길이의 문자열 (예: char str[1000])
등으로 처리하려고 해도 길이가 모자랄 지 모릅니다. 물론 평균적인 토큰의 길이를 생각한다면 1000 칸은 너무 과도한 것 같기도 합니다. 고정 길이는 이렇게 상황에 유연하게 대처하지 못하는 문제가 있으므로 가변적으로 크기를 할당하여 사용하는 t_str
구조체를 만들어서 활용해보도록 합시다.
아이디어는 이렇습니다. 처음에 조그맣게 공간을 할당해놓습니다. 그러고 문자열에 공간을 채워넣어갑니다. 만약 전체 할당된 공간(max
)의 1/2 에 다다랐다면, max
를 두 배 확장하고 크기도 두 배로 다시 할당합니다. 항상 문자열의 길이를 즉시 알 수 있도록 길이 값(len
)을 저장해놓습니다.
len
: 항상 문자열의 길이를 나타냅니다.max
: 문자열이 할당된 길이를 나타냅니다.ptr
: 할당한 공간의 포인터입니다.
일단 문자열의 초기 최대치(max
)는 10으로 설정해놓았습니다. 생성(create_str
)하며 파괴(destroy_str
)하는 함수도 만들었습니다. (malloc
하나에 free
하나가 대응되는 것처럼 create 하나와 destroy 하나가 대응되도록 설계합니다.) 그렇다면 이제 이 t_str 에 문자열을 추가할 수 있는 방법을 주어야겠지요?
len
은 항상 문자열의 길이를 나타내므로, ptr[len]
은 항상 마지막 문자의 다음 위치를 가리킵니다. 즉 새롭게 추가할 문자가 들어갈 위치를 가리키고 있다고 말할 수 있습니다. 여기에 문자를 대입하고 len
을 1 증가시켜줍니다. 대입을 마친 뒤에 len
이 max / 2
보다 길어졌을 경우에는 문자열을 새롭게 할당하도록 합니다.
새롭게 할당하는 방법은 간단합니다. 공간만 2배로 키워서 새롭게 할당한 다음, 모든 내용을 복사하고, ptr
를 교체합니다.
이제 문자열에 접근하는 방법을 제공해주도록 합시다.
그냥 직접 str.ptr
에 바로 접근하면 되지 않느냐고 생각하실 수 있지만, 그래도 상관없습니다. 하지만 좀 더 명확한 쓰임새를 보여줄 수 있습니다. raw 를 통해 불러오는 문자열 포인터는 c-style 문자열에 호환되도록 보장해줄 수 있습니다. 또한 const char *
로 리턴해줌으로써 직접 포인터로 수정할 수 없도록 합니다.
우리는 기본적으로 문자열을 수정하는 방법을 제공하지 않을 것입니다. 왜냐하면 굳이 필요가 없기 때문이지요. 토큰을 읽어드리는 대로 그대로 받아들이면 될 뿐입니다. 만약 읽고 있었던 토큰이 잘못되어서 수정해야 한다면, 그냥 기존 것을 폐기한 다음 새롭게 만드는 것이 더 쉽습니다. 우리가 만드는 프로그램에서 t_str
의 기능은 이정도 기능만으로도 충분합니다.
앞으로 필요한 기능이 생긴다면 그때 그때 추가하도록 하겠습니다.
Tokenizer 만들기
우선 토큰과 Tokenizer 를 먼저 정의해보도록 합시다.
t_token::file_row
: 해당 토큰의 줄 정보입니다.t_token::file_col
: 해당 토큰의 행 정보입니다.t_token::str
: 해당 토큰의 실제 문자열입니다.t_token::type
: 해당 토큰의 종류입니다.t_tokenizer::current_token
: 현재 가리키고 있는 토큰을 의미합니다.t_tokenizer::reading
: 다음 토큰을 읽을 때 문자열들을 임시 저장할 문자열입니다.
이제 토큰과 Tokenizer 를 생성할 수 있습니다.그리고 token_lookahead
함수와 token_consume
함수의 기본적인 형태를 만들었습니다. token_lookahead
함수는 간단히 현재 토큰을 리턴하는 함수입니다. 초기화 상태(TOKEN_NOT_SPECIFIED
)일 때에만 token_consume
함수를 호출해서 토큰을 갱신합니다. token_consume
함수는 토큰을 읽어들이는 핵심 부분입니다. 지금부터 본격적으로 작성해볼 예정입니다.
이제 간단하게 숫자만을 받아들이는 Tokenizer 를 만들어봅시다.
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
라는 숫자 하나만 넣어두도록 합니다.
make re
등으로 테스트를 수행시키면 다음과 같이 제대로 출력이 됩니다.
token.type
부분에서 1
이 출력된 이유는 우리가 #define TOKEN_INT 1
과 같이 설정했기 때문입니다.
assign_str
구현
지금 읽은 데이터를 토큰에 저장하는 과정에서 단순한 대입문이 사용되고 있습니다. 우리는 current_token.str
에 진작에 할당해놓았던 문자열들을 잃을 생각도 없고, 또한 current_token.str
과 reading
이 같은 문자열을 가리키게 할 생각도 없으므로 t_str
의 기능을 확장하여 코드를 잠깐 손을 봅시다.
이제 이렇게 하면, 일단 reading
의 복제본을 가지고 있다가, current_token.str
의 메모리를 해제한 후, 그 자리에 복제본을 집어넣습니다. 이 과정에서 reading
은 아무런 영향을 받지 않고, current_token.str
은 파괴되었다가 다시 할당된 것이 생깁니다. 결과적으로 할당된 t_str
의 개수는 변하지 않습니다. 어때요, current_token.str
을 직접 하나하나 수정하는 것 보다는 훨씬 간편하지 않나요?
매번 reading
갱신해놓기
일단 우리는 1회 토큰화시키기를 성공했습니다! 이제 반복적으로 동작하여야 합니다. 더 세부적인 구현을 하기 전에 한 가지 생각해봅시다. 우리가 프로그램의 중간부터 숫자 토큰을 읽고 있는 상황이라고 가정합니다. 우리는 한 번에 하나의 문자를 가져올 수 있는 fr_read
함수를 자유롭게 사용할 수 있습니다. 그렇다면 어디까지가 숫자인지 어떻게 판단할 수 있을까요?
우리는 a
지점부터 읽기 시작했습니다. a
지점은 1
이니까 숫자가 맞고, b
지점까지도 숫자가 명확합니다. 하지만 b
지점이 숫자의 끝일까요? 그건 아무도 모릅니다. b
지점 이후를 읽어보아야 알 수 있습니다.
아직까지 숫자입니다.
좋습니다! 쉼표가 등장했습니다. 이제 1234567
이 완전한 숫자임을 확신할 수 있습니다. 이제 다음 토큰으로 넘어가도록 합시다. 하지만 우리는 이미 ,
를 읽은 상태이므로, fr_read
를 수행하면 안 됩니다. 만약 수행하게 된다면 ,
를 건너뛰게 될 것입니다. 이미 읽은 문자이지만, 해당 문자가 어떤 토큰인지 아직 모르는 상태에 있는 문자열들을 우리는 reading
에 저장하고 있었습니다. 토크나이저가 1234567
에 대한 토큰 검사가 끝나는 즉시 reading
은 ","
여야 합니다. 그리고 다음 토큰을 검사할 때 무작정 fr_read
하는 것이 아니라 reading
에 담겨져있는 첫번째 문자인 ,
부터 적절하게 처리해야 합니다.
하지만 이 상태로 테스트를 그대로 실행시킨다면 에러가 발생합니다!
왜일까요? 바로 제일 첫번째 try_token_int
시도에는 raw(tknzr->reading)[0]
는 '\0'
이기 때문입니다. 이전 단계에서 미리 읽어놓았던 문자가 단 하나도 없으므로 아무것도 가리킬 게 없습니다. 그러므로 제일 첫번째에는 raw(tknzr->reading)[0]
이 첫 번째 문자를 가리킬 수 있도록 초기화를 시켜줘야 합니다.
이렇게 하면 다시 결과는 잘 나온다는 걸 확인할 수 있습니다.
공백 패스하기
위에서도 잠깐 이야기했지만, 정말로 아무런 뜻이 없는 (오직 토큰 구분자용으로만 사용되는) 토큰은 굳이 토큰화시키지 않아도 됩니다. 그래서 우리는 공백을 패스해보도록 하겠습니다.
공백은 적당히 공백만큼 읽어들인 다음 공백이 아닌 문자가 등장했을 때 그것을 reading
에 채워넣어주면 됩니다. try_token_int
함수와 비슷한 점은 reading
에 마지막 글자를 채워넣는다는 행동이지만, 다른 점은 current_token
의 데이터를 전혀 만지지 않는다는 점입니다. 어찌보면 합당합니다. 공백은 토큰조차 되지 못하는 신세이니까요.. ㅠㅠ
편의성을 위해 다음 next_token
함수를 먼저 추가해주세요.
그 다음 아래 내용을 input.txt
에 집어 넣고
아래 테스트 코드로 제대로 동작하는지 확인해봅시다.
아래는 그 결과입니다.
네 번째에서 에러가 일어난다는 것은 지극히 자연스럽습니다. 왜냐하면 next_token()
은 4번 실행되었던 데에 반해 실제 숫자는 3개밖에 없기 때문입니다.
reading
초기화하는 부분을 함수로 묶기
skip_blank
함수와 try_token_int
함수의 반복되는 부분을 없애보도록 합시다.
계속해서 비슷한 역할을 하는 코드들은 적극적으로 묶을 것입니다.
try_token_end
구현하고 반복문으로 모든 토큰 돌려보기
이제 토큰의 끝을 의미하는 TOKEN_END
도 구현해보도록 합시다.
fr_read
를 했을 적에 더이상 읽을 문자가 없다면 그냥 '\0'
이 리턴되었던 걸 기억하시나요? 그래서 그냥 raw(tknzr->reading)[0]
이 '\0'
라면 그냥 끝났다고 판단하면 됩니다. 여기서는 하나의 글자만 파악하면 되기 때문에 별 다른 반복문이 필요없습니다. 바로 fr_read
를 한 번만 호출하고 init_reading
을 수행합니다. (사실 다 끝난 마당에 fr_read
도 필요없고, init_reading
도 필요없지만, 문자 하나만을 받는 다른 try_token_...
과 비슷한 구조를 만들기 위함이라는 점을 알립니다.)
try_token_end
함수도 token_consume
함수에 추가시켜줍니다. 그리고 테스트를 좀 더 깔끔하게 만들어봅시다.
while
문이 무사히 종료되었습니다!
가끔 에디터에 따라 파일을 저장할 때 파일의 가장 끝부분에 줄바꿈 문자(
\n
)를 삽입하는 경우가 있습니다. 아직 우리의 코드는 줄바꿈 문자를 인식할 수 없으므로input.txt
파일의 마지막에 줄바꿈 문자가 포함되게 된다면 에러가 발생합니다. 추후 줄바꿈 문자도 인식하도록 할테지만, 테스트를 빠르게 해보고 싶다면 아래 명령어를 실행한 후 테스트를 시도해보세요.
echo -n "13245 12837 2232" > input.txt
current_token
설정하는 부분을 묶기
current_token
을 설정하고 init_reading
하는 부분이 동일하므로 최대한 묶어줍니다.
자 이제 구조가 훨씬 단순해졌습니다!
토큰에 위치 정보를 삽입하여 Unexpected Token 에러 출력시키기
그렇습니다. fr_read
할 때마다 위치를 갱신시키고, 예상치 못한 토큰이 있을 때 unexpected token 에러를 출력해보도록 합시다.
우선 문자열을 분석하고 있는 Tokenizer 입장에서 아직까지 토큰화되지 않은 문자 정보를 저장할 수 있도록 공간을 만들어둡니다.
기존의 fr_read
와 기능적으로 똑같이 동작하되, 위치 정보를 Tokenizer 에 기록하는 래퍼 함수 fr_read_track
함수를 만듭니다.
그래서 기존에 사용하고 있었던 fr_read
모두 대체하도록 합니다.
그 다음 에러 메시지를 만들기 위해 t_str
를 손 좀 보도록 합시다.
총 3가지의 함수를 추가했습니다.
str_push_chars
: 기존의t_str
내용에 c-style 문자열 내용을 덧붙입니다.str_push_long
: 기존의t_str
내용에 숫자를 문자화하여 내용을 덧붙입니다. 재귀적으로 구성되었습니다. 간단하게 구현한 특성상num
이long
의 최소값일 경우 이상하게 작동합니다.write_str
: 표준 출력으로t_str
의 내용을 출력합니다.
그 다음 에러 메시지를 출력하고 종료하는 abort_tokenizer
함수를 만들어줍니다.
이 함수를 consume
에서 exit
대신 넣어줍니다.
이제 123 456 7+9
이렇게 input.txt
에 넣고 테스트를 해봅시다. 테스트 코드는 이전과 동일하게 test_until_end
함수로 진행합니다.
와 정말 멋잇지 않나요!!
newline(줄바꿈), comma(쉼표), name(식별자)토큰 지원하기
줄바꿈 문자를 지원하는 건 파일의 끝을 인식하는 것과 거의 동일하게 진행하면 됩니다. 아래가 그 코드입니다.
name(이름)을 불러오는 것도 쉬운데요, 여기서 말하는 이름이란 우리가 흔히 변수명으로 쓰는 것처럼 어떤 이름이라고 생각하면 됩니다. 이것도 꽤 쉽게 구현할 수 있습니다.
is_alpha 함수는 c
라는 글자가 알파벳인지 아닌지 판단합니다. one_of
함수는 c 라는 문자 하나가 s
라는 문자열에 포함되어 있는지 아닌지를 판단합니다. 이 두개를 조합하여 is_c_name
을 만듭니다. is_c_name
함수는 해당 글자가 알파벳이거나 _
라면 TRUE
(1) 를 리턴하고, 그렇지 않다면 FALSE
(0)를 리턴합니다. C 를 작성할 때 숫자도 변수명에 포함될 수 있지만, 일단 우리가 만드는 파서는 일단 지원하지 않는다고 가정합시다.
그런 다음 try_token_name
함수를 작성합니다.
우선 첫번째 글자를 읽어와서, 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
이렇게요. 논리적인 결함이 있는 듯 하지만, 앞으로 만들 파서에 큰 걸림돌이 되는 것은 아니므로 가볍게 넘어가도록 하겠습니다.
그렇다면 바로 코딩해봅시다.
이제 아래 내용을 입력으로 설정하고 테스트를 수행해봅시다.
그렇다면 이제 아래와 같은 결과가 나옵니다.
좋습니다. 적당히 잘 동작합니다.
소수점이 있는 숫자 읽어오기
앞서 만든 정수 try
함수를 이용하여 소수점이 있는 숫자(이하 실수)까지 불러와보도록 하겠습니다. 실수은 알고보면 정수를 포함합니다. 그러니까 1
, 2
, 3
과 같은 정수는 number 이 될 수 있지만, 1.2
, 2.5
와 같은 number 은 정수가 될 수 없지요. 그리고 실수와 정수를 가르는 가장 큰 기준점은 점(.
) 입니다. 그렇다면 순서를 한번 또 정리해봅시다.
- 일단 실수의 앞에는 정수가 나와야 합니다.
- 그 다음 점이 올 수도 있고, 안 올수도 있습니다. 온다면 실수부 검색을 들어갈 준비를 해야 하고, 안온다면 앞에까지를 그냥 정수로 성공 처리합니다.
- 점 다음에 숫자들이 온다면, 숫자들을 기존 정수 검사 결과에 덧붙입니다.
- 점 다음에 숫자가 오지 않는다면, 잘못된
설치 법은 본 블로그에서는 소개드리지 않습니다. -> 설치법은
그 다음 점이 올 수도 있고, 안 올수도 있습니다. -> 안 올 수도
기타 등등등.. | 말입니다.. -> 말줄임표(…)
토큰을 읽어드리는 대로 그대로 받아들이면 될 뿐입니다. -> 읽어들이는 대로
좋은 글입니다.