제목 : C, 그리고 fgets 라인 입력만으로 입력 토큰화 메서드 제작하기
이 글을 작성하는 이유
물론, C 에서의 특정 기본 라이브러리나,
C++ 의 특정 기본 라이브러리를 가져와서 하나의 문자열을 토큰화 할 수 있다.
그러나, 나는 내가 가진 기존의 개발자 역량에서, 엔지니어 역량으로 이끌기 위해 여러 제약을 걸었다.
(알고리즘 문제에 제한해서.)
stdio.h라이브러리만 사용한다.- 동적 메모리 할당 메서드만
extern키워드로 가져온다. - 입출력은 모두
fgetsfgetcfputs와 같이,
'\0'을 참조하는 메서드로 해결한다. - 필요한 모든 유틸리티 메서드와 구조체를 "직접" 작성하여 해결한다.
- 작성한 코드는 헤더 파일로 만들어 재사용하지 않으며, 하나의 문제마다 모두 재작성하여 사용한다.
이러한 제약을 스스로 걸어, 나 스스로 포인터라는 개념 그 자체를 직관적으로 이해했다.
현재까지 70 문제쯤을 이러한 제약 속에서 해결했으며,
1 줄의 코드로 해결할 수 있는 문제도 300 줄이 나오는 경우가 대부분이었다.
그러나, 역설적으로 편의성 라이브러리들이 왜 존재하는지 진정으로 이해할 수 있었다.
내가 이 글을 작성하는 이유는 궁극적으로,
이 과정을 설명함으로서 "이해했다" 라는 결과에 도달하기 위함이다.
또한, 프로그래밍 언어가 자연어에 가까울 수록 개발자는 그 역할을 잃어가는 AI 세상에서,
마지막까지 컴퓨터 코드 엔지니어로서 남기 위한 최후의 발악이기도 하다.
그리고 이 기능은, 내가 Java 에서 주로 사용하던 클래스인 StringTokenizer 에서
영감을 받아 비슷하게 제작했다.
"토큰화" 란?
컴퓨터 도메인에서의 토큰화란, 이런 의미를 가진다.
연속된 데이터가 존재할 때,
지정된 특정 데이터 패턴을 기준삼아 연속된 데이터를 원하는 형태로 분리하는 것을 의미한다.
바로 프로그램으로 들어가지 않고, 예제를 상상 해 보자.
만약에 "2025-08-12" 라는 데이터가 존재한다.
물론, 이 데이터는 누가 보더라도 지구의 연도를 말한다는 것을 안다.
특히나, 날짜를 관리하는 메서드가 많다는 것을 고려했을 때,
{"2025", "08", "12"} 로 인식하기보다는,
2025:getYear과 비슷한 메서드로 가져올 수 있다.08:getMonth와 비슷한 메서드로 가져 올 수 있다.12:getDay와 비슷한 메서드로 가져 올 수 있다.
이렇게 먼저 인식하는 개발자들도 굉장히 많을 것이다.
왜냐하면, 날짜 그 자체는 각각 다른 의미를 가지기 때문에,
이를 배열로 만들어 가져오기보다는, 기본 유틸리티로 가져오는 것이 편리하기 때문이다.
그렇다면, 기존 유틸리티는 "2025-08-12" 라는 데이터를,
특정 메서드로 추론한다는 생각을 가지고 있을까?
절대 아닐 것이다. 특히나 나라별 순서 또한 다르기 때문에,
로컬(지역) 을 요구하는 게 이해가 될 것이다.
데이터를 쉽게 제공하는 유틸리티 함수나 클래스, 혹은 구조체는,
이 데이터를 "토큰화" 한 뒤 유저에게 제공한다.
즉, 이 데이터를 "토큰화" 했다고 가정 할 때,
{"2025", "08", "12"} 로 인식한다.
토크나이저 기능의 유용함. (Tokenizer)
대부분의 프로그래밍 언어들은 토큰화 기능을 제공하는 유틸리티 메서드 혹은 클래스를 제공한다.
주로 토큰화는 문자열 그 자체를 Target 으로 삼는다.
특정 조직이나 개인 이렇게 각자 다른 목적을 가진 프로그래머들은 저마다의 프로그램마다
데이터에 있어 특정 패턴이 "분리" 조건을 의미할 수 있다.
그것은 단순한 빈 칸 ' ' 일 수도 있고, '-' 와 같은 하나의 문자 일 수 있으며,
혹은 문자열 "--" 가 될 수도 있다.
조직과 단체, 혹은 개인까지, 원하는 대로 어떤 기준으로 토큰화 할 것인지 지정할 수 있다.
토큰화의 기준, Delimeter
보통 한글로 델림, 혹은 델리미터로 부르는 이것은,
토큰화를 수행 할 때, 연속된 데이터를 나눌 특정한 "기준" 을 의미한다.
개인적으로 생각했을 때,
아주 포괄적으로 말했을 때에는 "연속된 데이터를 나눌 특정한 데이터 패턴" 을 말할 수 있으며,
알고리즘으로 말했을 때에는, "문자열 데이터를 나누는 모든 특정 문자들의 집합" 을 말할 수 있다.
이러한 Delimeter 는, 하나의 문자가 될 수도 있으며, "문자열" 이 될 수도 있다.
대부분의 프로그래밍 언어들에서는 기본 delim 으로 빈 칸 (' ') 으로 세팅할 수도 있으며,
자신이 원하는 delim 을 추가적인 인자로 넣어서 연속 데이터를 나눌 수도 있다.
뭐, 위에서 제시한 날짜 데이터의 경우, '-' 를 사용 할 수도 있겠다.
문자열에서의 token 을 어떻게 만들까?
먼저, 이 글은 c++ 도 아니고, c 라는 언어를 사용했으므로,
그 외의 프로그래밍 언어를 사용하는 사람들은 기본 유틸리티 메서드를 사용하는 것이
성능 면에서도, 오류 면에서도, 코드의 절감 면에서도 우월하다.
따라서, 내가 작성하는 프로그램을 이해 할 필요는 없을 것이다.
그러나, "어떻게 데이터를 나누고, 분리된 데이터를 반환할 것인가?" 에 대해서는
확실히 이해할 수 있으리라 생각한다.
토큰화 자체의 의미. 연속된 데이터를 주어진, 혹은 특정한 데이터 패턴에 따라 "나눈다" 라는 것을
깊이 숙지하고 생각해야 한다.
문자열이라는 연속된 데이터를 어떻게 분리된 문자열 데이터로 만드나?
나는 이미 이에 대한 알고리즘을 구축하고 수십 번을 작성했기 때문에,
초창기 논리 로직으로 이해했던 것을 지금은 직관으로 받아들인 상태다.
현재는 직관으로 이해한 논리를 다시 논리 로직으로 풀어가게 될 것 같다.
"분리된" 데이터란?
이 용어는 토큰화 과정을 이 글을 읽는 사람들이 이해하기 위해 사용한 용어다.
정해져 있는 것은 아니고,
정말 말 그대로 어떻게? 연속된 데이터를 분리 할 것인지에 대해서 묻는 것이다.
특정 프로그래밍 언어를 사용한다면 문자열을 String 과 같이 이해할 것이므로,
문자열이 아닌, 다른 타입으로 이해 해 보자.
만약,
{1, 2, 3, 0, 9, 8, 7}
위와 같은 데이터 패턴이 주어지고, Delimeter 로는 0 을 선택했다.
그렇다면, 위의 배열은 토큰화(Tokenizer) 과정을 거치고 난 후, 이러한 양상을 띄게 된다.
{1, 2, 3}
{9, 8, 7}
위의 결과가 나오기 위해서는, 이러한 데이터 패턴도 주어질 수 있다.
{0, 1, 2, 3, 0, 9, 8, 7}
or
{1, 2, 3, 0, 0, 9, 8, 7}
or
{1, 2, 3, 0, 9, 8, 7, 0}
토큰화의 결과는 주어진 데이터보다 차원이 높아진다.
정말 말 그대로, 데이터 표현 상 1 차원이 높아진다.
{1, 2, 3, 0, 9, 8, 7}
위의 데이터는,
{1, 2, 3}
{9, 8, 7}
이라는 결과를 낳는다.
그리고, 만약 {1, 2, 0, 3, 0, 9, 8, 7} 이라는 데이터 입력이 주어졌을 때,
{1, 2}
{3}
{9, 8, 7}
이라는 토큰화 결과를 낳는다.
즉, 주어진 입력에 대한 데이터의 결과는, "데이터 상에서의 차원을 한 단계 올린다."
직관적으로 말했을 때, 우리는 "연속된 데이터를 분리했기 때문이다."
데이터의 차원이 높아진다?
즉, 여타 대부분의 프로그래밍 언어로 표현했을 때,
정수의 배열은 Integer[] 로 표현할 수 있다.
그렇다면, 토큰화로 인해 나오는 결과는 Integer[][] 라는 것이다.
만약 문자열을 String 이라고 칭한다면, 결과물은 String[] 이 될 것이다.
C 언어에서는 문자열을 char* 로 지정한다.
char: 하나의 문자char*: 문자들을 담고 있는 배열의 주소 or 문자가 연속됨을 알 수 있음.char**: 문자들을 담고 있는 배열의 주소를 담는 배열
이렇게 구성된다.
즉, 입력된 데이터는 "문자열" 이므로, char* 데이터 형태를 띄고 있다.
그렇다면, 나오는 토큰화 결과물은 char** 이다.
데이터의 차원이 올라간 것이다. (1차원 --> 2차원)
어떻게 하나의 토큰을 만들까?
주어지는 데이터 구성은 이러하다.
- 연속된 데이터
- 연속된 데이터를 나눌 기준 or 데이터 패턴
대부분의 경우, 주어지는 연속 데이터는 "문자열" 이다.
그리고 Delim(기준) 은 유저가 선택하는 편이다.
예제를 위해 확실한 기준들을 세워보자.
- 연속된 데이터 == (문자열)
- 연속 데이터를 나눌 기준 or 데이터 패턴 == (개행문자들 || 빈 칸 || 종료문자)
만약에, "abc def" 가 주어진다면, "abc", "def" 로 나누어 질 것이다.
그래서, "어떻게 나누어야 하는가?"
신경써야 할 카테고리를 나누어 보자면,
- 인덱스
- 메모리 생성
으로 정말정말 추상적으로 나눌 수 있다.
메모리 생성은 여타 다른 언어들에서는 쉬울 수 있으나, C 에서는 상대적으로 고려해야 할 부분이 좀 많다.
과정도 추상화 해서 나누어 보자면,
- 토큰화 결과물을 저장할 메모리 배열들 생성
- 인덱스 탐색으로 시작 지점과 종료 지점을 선택한다.
- 그 구간을 순회하여 새로운 배열에 넣는다. (1 번 배열이 아님.)
- 입력 데이터가 마무리되었을 때, 저장된 토큰화 결과물을 반환한다.
토큰을 만들기 위한 인덱스 탐색을 알아보자.
참고로 "인덱스 탐색" 은 통용되는 단어가 아니다.
토큰화 과정 중 하나의 토큰을 추출하기 위해,
토큰의 "시작 인덱스", "마지막 인덱스" 를 탐색해야 한다.
이 과정을 직관적으로 "인덱스 탐색" 이라고 말한 것이다.
즉, 하나의 토큰을 만들기 위해서는 "토큰 시작 인덱스", "토큰 마지막 인덱스"
이 두 개의 인덱스를 과정 중에 관리해야 한다.
이는 한 번의 순회(loop) 만에 토큰화를 완수하기 위함이다.
(한 번의 순회로 토큰화를 완수할 수 있는 다른 방식이 있다면, 그것도 옳은 방식이라고 말할 수 있다.)
하나의 토큰을 만드는 것은 slice 메서드와 거의 동일하다.
혹시 특정 프로그래밍 언어만을 숙달하여 이러한 메서드를 접해보지 못했을 수도 있는데,
이 메서드는 예를 들어서, (JavaScript 에서.)
let str = "abcdefg" 라는 문자열이 존재 할 때,
str.slice(1, 4) == "bcd" 를 의미한다. (인덱스 1 부터, 4 까지. 1 ~ 3 을 포함.)
여기서 1 == 토큰 시작 인덱스 이며, 4 == 토큰 종료 인덱스 를 의미한다.
그렇다면, 한번 표로 살펴보자.
밑의 표는 ab cd e 를 입력하고 "엔터" 를 입력했을 때,
fgets 가 어떻게 문자열을 나열하는지 보여준다.
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 0 | ||||||||
| End Index | 0 |
토큰 시작 인덱스와 종료 인덱스는 시작과 함께 처음 인덱스인 0 에서 시작한다.
- Start Index : 하나의 토큰이 시작되는 인덱스.
- End Index : 토큰이 끝나는 인덱스 or "델리미터(Delim)" 에 해당하는 인덱스.
이를 요약하자면,
Start Index 는 지정한 Delim 이 아니라면 멈춰서 기다리고,
End Index 는 지정된 Delim 에 해당하는 문자가 나올 때 까지 탐색한다.(이동한다.)
현재 상태는,
완전한 초기 상태로, Start Index 와 End Index 가 아직
Slice 하기 위한 위치에 안착하지 않은 상태라고 인식해야 한다.
('a' 대신에 ' ' 가 올 수도 있으므로.)
알고리즘을 위한 델리미터로,
- 빈 칸(
' ') - 개행문자(
'\n') - 종료문자(
'\0') - 등등..
을 Delimeter 로 지정했다고 생각해야 한다.
정확히는 ch == 32 || ch == 0 || 9 <= ch <= 13
이라는 유니코드에 해당하면 전부 Delimeter 로 간주한다. (이거 매우 유용)
그렇다면, 위에서 입력된 문자 리스트에서,
' ', '\n', '\0' 문자는 전부 Delimeter 에 해당하는 것이다. 이를 염두해 두자.
Input String 에 따라 문자 하나씩 순회한다고 가정 할 때,
Start Index 와 End Index 가 어떻게 움직이는지 직접 만든 예제로 함께 보자.
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 0 | ||||||||
| End Index | 1 |
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 0 | ||||||||
| End Index | 2 |
현재 상태는, Start Index 와 End Index 가 하나의 토큰을 추출(Slicing) 하기 위한
위치에 정확히 안착해 있는 상황이다.
(Start Index != 델리미터) && (End Index == 델리미터)
이 상황에서 Start Index 와, End Index 를 사용하여 주어진 문자열에서 새로운 문자열을 생성한다.
그리고, 미리 만들어 둔 문자열 주소 배열(char**)에 등록한다.
그래서, 생각 해 보자.
현재 표로서, 우리는 토큰 시작 인덱스가 0 이며, 끝이 2 라는 것을 알게 되었다.
그렇다면, 이 부분을 따로 저장하면 된다.
만약에 유틸리티 메서드를 사용한다면, strcmp 를 사용해도 되고,
나처럼 유틸리티 메서드를 전혀 사용하지 않고 전부 구현한다면, 이를 복사하는 과정을 만들어야 한다.
현재 위의 상황은 Start Index 가 "토큰 시작 인덱스" 에 정확히 안착했으며,
End Index 는 "토큰 종료 인덱스" 에 정확히 안착했다. 이 조건 하에서 문자를 복사하여 저장한다.
지정된 영역의 토큰을 저장했으므로, Start Index 와 End Index 는 "그 다음 인덱스" 로 이동한다.
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 3 | ||||||||
| End Index | 3 |
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 3 | ||||||||
| End Index | 4 |
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 3 | ||||||||
| End Index | 5 |
이 상황에서 다시 토큰화를 수행하여 저장한다.
결국 이러한 과정은
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Input String | 'a' |
'b' |
' ' |
'c' |
'd' |
' ' |
'e' |
'\n' |
'\0' |
| Start Index | 8 | ||||||||
| End Index | 8 |
문자열의 마지막을 의미하는 '\0' == 0 데이터에 도달하고 나서야 종료된다.
최종적으로 하나의 문자열을 토큰화 한 뒤 나온 결과물은,
"ab""cd""e"
가 된다.
위에서 제시한 예제는, 이러한 트리거를 가진다.
1. 만약에 Start, End 인덱스가 Delim 에 해당하지 않을 경우
이는 Start, End 가 각각 'a', 'b' 라는 예시를 들어도 될 것 같다.
이 상황에서는 아직 End 인덱스가 종료 인덱스로 정확히 안착하지 않은 상황이다.
따라서, End 인덱스만 한 칸 이동시킨다.
2. 만약에 Start, End 인덱스가 Delim 에 전부 해당 될 경우
이 경우, 애초에 토큰을 시작할 인덱스를 Start 가 찾지도 못한 상황이기 때문에,
Start 와 End 둘 다 하나씩 더한다.
참고로, 곧 작성하게 될 코드에서, 이 경우 Start, End 는 각각 인덱스가 동일하다.
3. 만약에 Start 인덱스는 Delim 이 아니며, End 인덱스는 Delim 에 해당 할 경우
이 경우는 정확하게 Start 인덱스는 토큰의 시작 인덱스에 안착했으며,
End 인덱스는 정확하게 토큰 종료 인덱스에 안착 한 상황이다.
이 조건 하에 저장할 토큰을 생성하여 저장하고, 반환될 토큰 배열에 추가한다.
위의 세 가지 조건이 맞물려서 Tokenizer 가 만들어진다.
Tokenizer 구현하기
사실상 유틸리티를 내가 직접 구현했기 때문에, 다른 사람의 눈으로 보기에는
해석이 조금 힘들 수 있으므로, 먼저 구현하게 될 내용을 작성 해 보자면,
- "주소" 를 저장할 유연한 배열을 만든다. (
char**) - 주어진 문자열을 순회할 포인터를 생성한다.
- 시작 인덱스와 종료 인덱스를 생성한다.
- 순회 포인터를 통해 문자열을 탐색한다. (1 글자씩.)
- 위의 3 가지 조건에 따라 시작 인덱스와 종료 인덱스를 이동시킨다.
- 토큰화에 적합한 인덱스에 머물렀을 때, 새로운 "문자열 장소"를 생성하며 그 안에 저장한다.
- 만약에 1 번에서 생성한 "주소" 저장 배열이 꽉 찼을 경우, 용량을 2 배로 늘린다. (
realloc) - 4 로 이동한다.
- 토큰화가 마무리 되었을 경우, 현재 "주소" 포인터에 무조건적으로
NULL을 넣어준다. - 이 토큰들을 반환한다.
참고로, 나의 제약은 stdio.h 라이브러리만 사용하며, malloc, free, realloc, calloc
만 사용한다. 그리고, scanf, printf 는 사용하지 않는다.
코드
// 필요 라이브러리
#include<stdio.h>
extern void* malloc(size_t byte);
extern void free(void* memory);
extern void* realloc(void* memory, size_t byte);
// 토큰화를 위한 메서드들
char** tokenizer(const char* input);
void freeTokens(char** tokens);
_Bool isBlank(char ch);
// 컴파일 후 즉시 실행될 메인 로직
int main(void) {
char input[255];
// sizeof(input) == 255 이며, input 이 동적 배열일 경우, 사이즈를 직접 넣어줘야 함.
fgets(input, sizeof(input), stdin);
char** tokens = (char**)tokenizer(input);
char** tokensPtr = tokens;
while(*tokensPtr) {
fputs(*tokensPtr++, stdout); fputc('\n', stdout);
}
freeTokens(tokens);
return 0;
}
// 입력된 문자열을 토큰화하여 반환한다.
// const 로 지정한 이유는 input 자체의 주소를 변경하지 않겠다는 일종의 표식으로 남겨둠.
char** tokenizer(const char* input) {
// 처음 만들 "주소 배열" 의 사이즈는 5 로 지정한다.
int size = 5;
// 현재 "주소 배열" 의 저장 현황. 초기화로 0 으로 지정한다.
int currSize = 0;
// 생성될 토큰의 주소를 보관할 메모리 공간을 창출한다.
char** tokens = (char**)malloc(sizeof(char*) * (size + 1));
// 생성된 토큰을 저장할 순회 포인터를 생성한다.
char** tokensPtr = (char**)tokens;
// 입력된 문자열을 순회할 포인터를 생성한다.
// 애초에 const 로 선언된 포인터는 건드리지 않는 것이 좋은데,
// char* 에 해당하도록 알맞게 바꾸고 싶다면 앞에 (char*) 을 붙여야 한다.
char* inputPtr = (char*)input;
// 토큰 시작 인덱스, 종료 인덱스를 지정한다.
// 나는 tknIdx, currIdx 로 사용하는데, 혹시 이 코드를 보는 분을 위해서 명확히 작명했다.
int start = 0;
int end = 0;
// 입력된 문자열이 '\0' == 0 에 도달 할 때 까지 순회한다. (끝까지 순회한다.)
while(*inputPtr) {
// 현재 문자를 추출하고, 입력 포인터를 동시에 한 칸 옮긴다.
char ch = *inputPtr++;
// 만약에, 현재 순회 문자가 Delimeter 에 해당한다면.
if(isBlank(ch) == 1) {
// 이 경우, 시작 인덱스와 종료 인덱스 둘 다 안착하지 못한 상황.
if(start == end) {
// 나의 경우, 전위 연산자를 이용하여 둘 다 이런 식으로 이동시킨다.
start = ++end;
// 곧바로 다시 다음 문자 탐색으로 넘어간다.
// char ch = *inputPtr++; 로 이동.
continue;
}
// 이 분기에 들어왔다면, 시작 인덱스와 종료 인덱스 둘 다 성공적으로 안착.
// 토큰의 크기는 정확히 "종료 인덱스 - 시작 인덱스" 가 된다.
int tknSize = end - start;
// 새로운 토큰을 저장하기 위해 문자열 메모리를 생성한다.
char* newTkn = (char*)malloc(sizeof(char) * (tknSize + 1));
// 토큰을 이후에 정확하게 사용하기 위해서는,(기본 라이브러리까지 포함)
// 문자열의 마지막을 정확하게 0 이라는 데이터로 넣어주어야 한다.
*(newTkn + tknSize) = 0;
// 생성된 토큰 문자열을 순회하기 위한 포인터 생성
char* newTknPtr = newTkn;
// 토큰 시작 인덱스 문자부터, 종료 직전까지 문자들을 추출하여 저장한다.
// 따로 인덱스를 생성해서 순회해도 되는데, 나는 좀 직관적으로 start 를 이용했다.
while(start != end) {
// 입력된 문자열을 인덱스로 접근하여 추출하고, 생성된 문자열 메모리에 삽입한다.
// 코드가 길어질 것 같아서, 둘 다 후위 연산자를 이용했다.
// 포인터는 가르키는 주소 자체가 이동하고, 정수는 +1 을 의미한다.
*newTknPtr++ = input[start++];
}
// 복사를 완성한 토큰 문자열은 곧 "주소" 이므로,
// 현재 가르키는 토큰 주소에 삽입하고,
// 현재 저장된 토큰의 사이즈를 더한다.
*tokensPtr++ = newTkn; currSize++;
// 종료 인덱스를 먼저 더하고, 그 값을 start 에 할당한다.
// 위에서 제시했던 예제를 보면 알겠지만, 이러한 표현식이 정확하게 일치할 것이다.
// 이건 내가 사용하는 방식이고, 다른 방식으로 개조해도 상관이 없다.
start = ++end;
// 만약에 우리가 맨 위에서 생성했던 주소 배열의 최대치에 도달했다면,
// realloc 을 통해 데이터를 보존 한 채로 2 배로 늘린다.
if(size == currSize) {
size *= 2;
tokens = (char**)realloc(tokens, sizeof(char*) * (size + 1));
// 새로운 토큰 주소 배열이 할당되었으므로, 기존의 tokensPtr 은 잘못됨.
// 따라서, 용량이 늘어난 주소를 바탕으로 현재 사이즈를 더해 포인터를 복구.
tokensPtr = (char**)(tokens + currSize);
}
// 그렇지 않다면, 미리 선언한 조건에 따라 토큰 종료 인덱스를 한 칸 옮긴다.
} else { // 이 경우, 시작 인덱스는 안착에 성공했으며, 종료 인덱스가 안착하지 못한 상황.
end++;
}
}
*tokensPtr = NULL;
return tokens;
}
// tokenizer 로 생성된 모든 메모리를 해제하기 위한 메서드.
void freeTokens(char** tokens) {
// tokenizer 로 인해 생성된 모든 토큰들을 가져와서, 시작 주소를 tokensPtr 에 할당한다.
char** tokensPtr = (char**)tokens;
// 우리가 지정한 NULL 값이 나올 때 까지 순회한다.
while(*tokensPtr) {
// 해당 문자열 메모리 해제
free(*tokensPtr++);
}
// 주소값들을 저장하는 배열을 해제한다.
free(tokens);
// 종료.
return;
}
// Delimeter 에 해당하면 1 을 반환하며, 그렇지 않다면 0 을 반환한다.
// 알고리즘 전용으로 만들었으므로, 개행문자, 빈 칸, 혹은 문자열 종료 문자를 포함하도록 만들었다.
_Bool isBlank(char ch) {
// ' ' 혹은 '\0' 일 경우.
if(ch == 32 || ch == 0) {
return 1;
// 그 외 개행문자, 혹은 `\t` 와 같은 특수 공백문자를 의미하는 경우.
} else if(ch >= 9 && ch <= 13) {
return 1;
// 그렇지 않다면, 특수기호나 알파벳 숫자 등등에 해당됨.
} else {
return 0;
}
}
$ gcc 5-tokenizer.c -Wall -Wextra -pedantic -O2 -o 5-tokenizer
$ ./5-tokenizer
ab c def g
ab
c
def
g
위의 코드에 주석을 촘촘히 넣어놨는데,
tokenizer 메서드는 중간에 따로 메서드로 뺄 부분도 존재한다.
예를 들어, 지정된 두 인덱스 사이의 값을 배열로 반환하는 과정이다.
그러나, 나는 내 코드라서 전부 읽히기 때문에 작성했지만,
이러한 코드 또한 자신만의 Clean Code 로서 만들 수 있다.
아마, 굳이 이러한 제약을 두지 않고 로직을 작성한다면, 훨씬 깔끔하고 적게 작성할 수 있다.
마무리
솔직히 누가 토큰화 과정을 이해하고 싶어할까? 생각이 들기도 한다.
실제로, 나도 나만의 방식과 계기로 토큰화 유틸리티를 제작하기 전 까지는
이해할 필요를 상상조차 하지 못했다.
그러나, 이 과정은 "특정 데이터 패턴" 에 따라, 데이터를 분리한다.
또한, 메모리 생성, 그리고 해제. 유연한 주소 배열 관리라는 항목이 들어가 있다.
인덱스에 대한 철저한 관리와, 포인터를 통한 내부 데이터 조회와 삽입이라는 과정도 겪는다.
처음에는 정말 어려운데, 지속적으로 작성하고 관리하다 보면,
이러한 과정을 머릿속으로 이해 할 뿐만 아니라, 적용이 가능한 특수 상황에서도 떠올릴 수 있을 것이다.
그 예로, 알고리즘 문제를 풀 때, 대다수가 사용하는 라이브러리를 사용하지 못하므로,
해당 라이브러리를 직접 구현하여 사용하거나, 최소화 혹은 최적화하여 사용한다.
예를 들면, Queue, Stack 이 존재한다.
(구조체를 작성하고 적용하기 위해선 수백 줄이 더 작성되므로 최소화 방안을 찾게 된다....)
참조
없뜸
'Algorithm' 카테고리의 다른 글
| C 프로그램에서 정수, 문자열 상호변환 메서드 만들기 (4) | 2025.08.04 |
|---|---|
| C 알고리즘 문제 scanf, printf 없이 입출력 수행하기 - (fgets, fputs) (1) | 2025.08.01 |
| C 언어와 stdio.h 라이브러리만으로 백준 풀며 포텐셜 올리기 (3) | 2025.07.26 |