본 문서에 건축과 관련된 내용은 없습니다! 제목은 어그로만 잘 끌면 그만. 사실 이제 건축학도도 아님
42에 들어온 이상 우리는 norminette을 벗어날 수 없습니다. 이 놈(norm)의 모든 부분이 거슬리지만 가장 골치아픈 부분은 한 파일에 함수는 5개까지만 만들 수 있는 것, 함수는 25줄을 넘을 수 없는 것이죠. 설상가상으로 gnl은 .c파일도 두개로 제한했습니다. 이 글에서는 이런 골치아픈 제한이 있는 gnl의 구현예시를 통해 효율적이고 파악하기 쉬운 구조를 위한 제 원칙들이 무엇인지 이야기해보려 합니다.
언제나 그렇듯 정답은 없습니다. 저보다 더 좋은 구조로 하신 분들도 계실테죠. 앞으로 설명할 예시들은 어디까지나 참고용입니다. 글의 목적과 무관한 각 함수의 구현 관련된 코드는 최대한 생략했습니다. 치팅의 유혹에서 여러분을 지켜드리기 위해
두개의 c파일
gnl subject에서 정의한 제출 파일은 두개의 c파일, 한개의 헤더파일입니다. 각 파일의 이름은 get_next_line.c와 get_next_line_utils.c입니다. 이름만 봐도 앞의 것은 get_next_line함수가 들어있는 메인 모듈일테고, 뒤에 있는 파일은 어떤 용도일까요? subject에는 다음과 같이 설명하고 있습니다.
Add all the helper functions you need in the get_next_line_utils.c file.
헬퍼함수를 담으라네요. 흠… 어떤 것들이 들어갈 수 있을지는 아직 잘 모르겠습니다. 그래도 단서는 주어졌으니 이를 토대로 어떤 파일에 어떤 함수를 담을지에 대한 원칙을 세워볼까요?
1.
첫번째 파일에는 get_next_line을 수행하는 메인로직과 관련된 함수들이 들어간다.
2.
두번째 파일에는 메인로직의 여기저기에 쓰일 수 있으, 메인로직이외의 헬퍼 함수들이 들어간다.
메인로직? 메인로직은 무엇일까요? 도메인 로직, 비즈니스 로직이라는 용어로 익숙하신 분들도 계실텐데 어디까지를 메인로직으로 봐야하며, 어디부터 메인로직 범위를 벗어났다고 봐야할까요?
과제에서 요구하는 gnl의 핵심사항은 파일을 읽어 다음 개행까지 묶어 반환하는 일입니다. 이를 토대로 메인함수인 get_next_line함수의 작동방식을 순서도로 나타내봅시다.(순서도가 규칙과 다를 수 있지만 그런갑다 해주십쇼)
간단하네요? 물론 실제로 구현하려면 쉽지 않을 수 있습니다. 다만 메인 로직을 뽑아내기 위해 세세한 구현이나 조건들을 떼어내고 핵심 기능만 뽑아내어 추상화 과정을 거쳤습니다. 그렇다면 이제 gnl함수의 메인로직은?
1.
파일을 읽는다.
2.
개행이 있는지 판별한다.
3.
개행까지 문자열을 할당해 반환한다.
3가지로 볼 수가 있겠군요? 이를 통해 저는 다음과 같이 4개의 함수가 get_next_line.c에 들어갔습니다.
char *get_next_line(int fd); // 메인함수
int has_newline(const char *str); // 개행이 있는지 판별
char *get_head(char **str); // 개행까지 문자열을 떼어 반환
ssize_t read_file(int fd, char **backup); // 파일 읽기
C
복사
그렇다면 메인로직 범위를 벗어나는 헬퍼함수들은 어떤 것들이 있을까요? 이는 여러분의 구현 방식에 따라 달라질 수 있습니다. 하지만 gnl함수의 반환타입이 char *, 문자열인 만큼, 문자열을 다루는 함수들을 헬퍼함수라고 볼 수 있겠죠. 문자열을 다루는건 말 그대로 앞의 세 로직을 도와줄 뿐이지 기능의 핵심사항은 아니잖아요? 또는 메모리를 할당, 해제하는데 관여하는 함수들도 포함될 수 있겠습니다. 헬퍼함수에 대해서는 잠시 후에 살펴보도록 하겠습니다.
25줄의 함수
피신 때부터 지긋지긋하게 따라오는 25줄의 제한. 42는 왜 이런 제한을 걸었을까요? 함수를 25줄 내에서 구현하기 위해 여러분은 어떤 노력을 하나요? 우선 최대한 간결하게 구현하려 노력하고, 줄 수를 넘긴다면 while문을 쓰는 대신 재귀를 돌린다거나, 연산자를 이용해 if문을 없애는 꼼수를 부린다거나 하는 노력들을 하셨을 겁니다. 하지만 이렇게 해도 25줄을 넘긴다면? 이제 우리는 함수를 나눌 궁리를 하게 됩니다. 이 함수를 어떻게 나눌 것인가, 어느 부분을 떼어 새로운 함수를 만들것인가, 와 같은 질문들이 꼬리를 물겠죠. 여러분은 자연스레 함수의 책임범위에 대해 고민하고 계셨습니다. 흔히 OOP의 원칙이라고 이야기하는 SOLID원칙. 그 중 첫번째 S는 Single responsibility principle. 즉, 단일 책임 원칙입니다. 물론 OOP의 S는 객체의 책임범위를 이야기합니다만, 함수라고 책임에서 자유로울 수 있을까요? 위에서 뽑아낸 각 로직의 책임은 어떻게 지워야 할까요?
메인 로직
먼저, gnl의 핵심 기능 중 저희가 각 로직에 기대하는 역할에 대해 생각해봅시다.
1.
파일을 읽는다.
무엇보다 read함수를 이용해 파일을 읽는게 핵심입니다. 버퍼 사이즈가 주어지는 만큼 파일을 읽을테고 파일을 읽기만 하면 앞서 읽은 데이터가 날아가버릴테니 읽은 데이터를 저장할 무언가가 필요할겁니다. 이 저장소에 읽은 데이터를 저장하는 역할을 수행해야겠죠.
2.
개행이 있는지 판별한다.
읽어온 데이터에 개행이 있는지를 판별해야합니다. 데이터 끝까지 순회하며 해당 문자가 개행인지 판별하고, 그 결과를 반환해야합니다.
3.
개행까지 반환한다.
저장된 데이터에 개행이 있다면, 혹은 파일이 끝났다면 문자열을 묶어 반환해야합니다. 묶는다는 건, 새로운 메모리를 할당하고 문자열을 채운다는 이야기겠죠? 문자열을 반환했다면, 저장된 데이터는 어떻게 해야할까요? 그대로 둔다면, gnl이 또 실행됐을 때 같은 결과를 출력할테니, 반환한 데이터는 저장소에서 삭제시켜야합니다.
이렇게 풀어놓고 보니 각 로직이 수행하는 역할의 특징이 보이시나요? 파일을 읽는 로직은 파일을 읽고, 읽어온 데이터를 저장합니다. 개행 판별 로직은, 개행이 있는지를 판별하기만 합니다. 문자열을 순회하기 위한 인덱스를 제외하고는 값을 변화시킬 일이 없습니다. 개행까지 반환하는 로직은, 데이터를 사용하고 삭제 시키는 역할을 수행합니다. 파일을 읽는 로직과 개행까지 반환하는 로직이 각각 저장된 데이터의 생성과 소멸을 담당하고 있군요? 로직을 좀 더 간결하게 정리해봅시다.
1.
파일을 읽는다.
•
파일 읽기
•
읽은 데이터 저장하기(생성)
2.
개행이 있는지 판별한다.
•
개행이 있는지 판별하기
•
값을 변경해서는 안됨
3.
개행까지 반환한다.
•
개행, 혹은 파일의 끝까지 반환
•
반환한 데이터를 저장소에서 삭제(소멸)
여기에 몇가지를 더해줄 수도 있겠습니다. 물론 위의 조건들을 어기지 않는 한에서요. 저는 파일을 읽는 로직에 저장소가 아직 없다면 저장소를 생성하는 기능, 개행이 나올때까지 계속해서 읽는 기능 두가지를 추가했습니다. 반환하는 로직에서는 파일이 끝났다면 더이상 필요없는 저장소를 없애는 기능을 추가했습니다. 생성과 소멸의 책임을 두 로직에 완전히 몰아줘 버린 것이죠. 이로써 gnl함수는 데이터를 생성하거나 소멸할 필요가 없습니다. 각 로직에서 알아서 처리할테니까요.
헬퍼함수
이제 헬퍼함수에 대해 생각해볼까요? 저는 저장소의 기능을 수행하는 데에 하나의 문자열 변수를 사용했습니다. 자연스레 헬퍼함수는 문자열을 조작하는데에 필요한 기능들이 되었습니다. 메인 로직에서 필요로 하는 기능들은 어떤 것들이 있는지 살펴보도록 하겠습니다.
1.
파일을 읽는 로직은 버퍼에서 저장소로 데이터를 옮겨야합니다. 이미 저장된 데이터가 있다면 그 뒤에 이어붙여야겠죠. 문자열을 이어붙이는 기능이 필요합니다. 아직 할당된 문자열이 없다면 버퍼의 데이터를 복제해 문자열을 할당해줘야겠죠. 문자열 복제 기능도 필요합니다.
2.
개행이 있는지 판별하는 기능은 별다른 도움이 필요없습니다. 문자열을 순회하며 조건을 판별하기만 하면 되니까요.
3.
개행까지 반환하는 로직 역시 반환할 문자열을 만들어야하기 때문에 문자열 복제 기능을 필요로 합니다. 다만, 문자열 전체 복제를 하는게 아니라, 앞의 특정 부분까지만 복제를 해야합니다. 복제한 부분은 삭제해야겠죠. 특정 부분까지 문자열을 잘라내는 기능이 필요합니다. 필요 없는 저장소를 삭제(해제)하는 기능도 필요하겠네요.
음.. 복제하는 기능이 두가지나 필요하네요. 문자열 복제를 길이단위로 하고 전체를 복제할 때는 전체 길이를 넣어주면 되지 않을까요? 합쳐줍시다. 또 의아한 부분이 있네요? 문자열 해제하는 기능은 free를 쓰면 되지 않을까요? 저는 메모리를 해제하고 해당 포인터 변수에 NULL값을 넣어줘 댕글링 포인터를 방지하는 기능을 즐겨쓰기 때문에 굳이 넣어줬습니다. 이렇게 뽑아낸 헬퍼 함수를 정리해보면 다음과 같습니다.
1.
문자열 이어붙이기
2.
문자열 특정 부분까지만 복제하기
3.
특정 부분까지 문자열 잘라내기
4.
할당된 문자열 해제하기
이 함수들의 책임범위는 어떻게 설정할 수 있을까요? 이 부분은 여러분이 직접 고민해보시기 바랍니다. 구현 방식에 따라 다를 수 있는 부분이기도 하고, 다 풀면 이야기가 너무 길어져 지루해질 수도 있으니까요(이미 생각보다 길어진 것 같긴 해요..)
저는 다음과 같이 5개의 함수를 헬퍼함수로 사용했습니다.
char *gnl_strcat(char *dest, char *src, ssize_t size); // 문자열 이어붙이기
unsigned int gnl_strlen(char *str); // 문자열 길이재기
char *gnl_strndup(char *src, unsigned int n); // 문자열 길이 n만큼 복제
char *gnl_substr(char *s, unsigned int start); // 문자열 쪼개기
char *free_backup(char **backup); // 문자열에 할당된 메모리 해제
C
복사
25줄…?
gnl을 푸느라 끙끙거리고 계신다면 의문이 들 수 있습니다. 이게 25줄 지키는 방법? 이게 25줄로 돼? 이야기 삼천포로 빠진 거 아니야? 네, 저도 코드 여러번 갈아엎고 열심히 열심히 고민해서 하다보니 이렇게 되었습니다. 42가 25줄 제한을 건데에는 이렇게 책임범위를 명확히 하는 것 외에도 함수가 최소한의 기능만을 수행하도록 하는 것도 있을겁니다. 지금까지 살펴본 방법에는 책임범위만 있는게 아닙니다. 함수가 어떤 기능을 수행할 것인가 하는 고민도 같이 했습니다. 지금까지 살펴본 함수들은 어떤가요. 딱 필요한 기능만 수행하는 것 같나요? 아니라면 왜 그럴까요? 저는 왜 이렇게 구현해야 했을까요? gnl을 norm에 끼워맞춘 여러분은 알고 계실겁니다. 제일 큰 문제는 25 * 5 * 2 = 250줄이라는 제한된 자원을 최대한 효율적으로 아껴써야한다는 것…
파일당 함수 5개
자! 이제 Mandatory의 설계는 끝났습니다. 문제는 bonus겠죠. 제가 구현한 방법에서 저장소로 활용한 변수만 1차원 문자배열에서 2차원 배열로 바꿔주면 생각보다 쉽게 끝납니다. 다만, 아는 분들은 아실만한 OPEN_MAX의 디펜스가 귀찮기도 하고, 제 기준에서도 맞지 않는 방법이라고 생각해 저는 연결리스트로 구현했습니다. 연결리스트로 구현하려니, 이 조건이 저를 괴롭게 하더군요.
일단 아직까지 제가 경험해온 이 이상한 나라에는 객체나 모듈이 없는 듯 합니다. 저희가 만든 함수들을 묶어주는건 파일입니다. 파일을 하나의 모듈로 본다면, 아마 한 파일에 5개의 함수는 모듈의 책임과 관련된 부분일 겁니다. 그리고 함수가 그렇듯, 모듈의 이름 역시 모듈에 속한 함수들의 기능을 함의해야할 것이고, 모듈의 책임범위가 있을 것입니다. 다른 과제들(일단 아직까지 제가 해본건 ft_printf까지)에서는 큰 문제가 안됩니다. 파일이야 얼마든 만들면 되니까. 다만 gnl은 두개의 파일 이름이 주어지고, 책임범위가 명확합니다. 앞서 보았듯 메인 로직과 헬퍼함수. 이미 제 헬퍼함수는 5개의 함수가 사용되고 있습니다. 여기서 연결리스트를 관리해줄 함수가 더 필요합니다. 어떻게 해결했는지 알아보기에 앞서 제 보너스 함수들을 살펴볼까요?
// get_next_line_bonus.c
char *get_next_line(int fd);
char *get_head(t_list **list, int fd);
int has_newline(const char *str);
ssize_t read_file(t_list *list, int fd);
// get_next_line_utils_bonus.c
char *gnl_strcat(char *dest, char *src, ssize_t size);
char *gnl_strndup(char *src, unsigned int n);
char *gnl_substr(char *s, unsigned int start);
t_list *gnl_getlist(t_list **list, int fd);
char *gnl_removelist(t_list **list, int fd);
C
복사
다행히, 헬퍼함수가 메인모듈에 섞이는 일은 일어나지 않았습니다. 그만큼 헬퍼함수들은 더 많은 기능과 책임을 수행해야 했지만요. 문자열 길이를 재던 함수는 다행히 다른 함수들의 줄 수에 여유가 있어 각 함수로 밀어 넣었고, 문자열을 해제해주는 함수는 없앴습니다. 그리고 연결리스트를 관리할 두 함수가 생겼습니다. getlist함수는 해당 fd의 노드가 있으면 가져오고 없으면 만듭니다. removelist는 연결리스트의 노드를 삭제시켜줍니다. 음? 생각보다 간단하죠? 이 때, 앞서 여러분이 생각했던 다른 헬퍼함수, 혹은 메인함수의 기능이나 책임에는 어떤 변화가 생겼나요?
끝?
네! 끝났습니다. 어떤가요, 별 거 없나요? 여러분의 방법이 더 좋은 것 같나요? 코딩은 작문이고, 가독성 높은 코드는 변수나 함수명에 적절한 어휘선택을 하는 것도 중요하겠지만 구조를 쉽게 파악할 수 있도록 하는 것도 중요하다고 생각합니다. 여러분이 파일이나 함수의 구조에 대해 한 번이라도 더 생각해보게 했다면 이 글의 목적은 달성했습니다. 글을 통해 살펴본 제가 사용하는 원칙은 추상화, 함수는 최소한의 기능만 수행할 것, 각 함수의 책임범위를 구분하고 명확히 할 것. 어디서 많이 들어본 얘기들이죠? 저만의 원칙은 아닙니다. 이미 많은 분들이 공개하고 사용해온 방법들이죠. 다만 “저는 이런 원칙을 이렇게 썼습니다” 정도의 이야기입니다. 보셨던 것 처럼 저도 아직 부족한 부분들이 많고 이 원칙들을 완벽하게 지키지는 못했습니다. 긴 글 읽어주셔서 감사합니다.