[GET_NEXT_LINE] 구현 예시
혹시나 문제가 된다면 바로 비공개 처리하겠습니다. 지적이나 댓글 환영합니다!
그저께 get_next_line의 구현을 끝내고 어제오늘 평가를 받아, gnl 과제를 보너스 점수 포함 115점으로 통과했다.
코드에 대한 이해가 낮아지기 전에 어서 코드리뷰를 하도록 해야겠다.
우선, get_next_line 과제에서는 3개의 파일을 구현하게끔한다.
get_next_line.c, get_next_line_utils.c, get_next_line.h 를 구현해야한다.
보너스 파트에서는 여러개의 fd를 사용하여도 각 fd의 내용에 대한 스레드가 유지될 수 있도록 해야하는데, 이 경우에는 static 변수를 2차원 배열로 만들면 해결된다. 따라서 보너스 파트와 기본 파트의 함수 구성을 똑같이 하였으므로 보너스에 대한 설명은 굳이 하지 않겠다.
주의해야할 점 !!!
1. 보너스 과제에 대해서는 보너스 헤더를 꼭 get_next_line_bonus.h로 선언하고, 보너스 과제에서 호출하는 헤더를 꼭 모두 이것으로 해주는 것을 주의해야한다. (보너스 과제인데 실수로 get_next_line.h 를 호출할 경우 망하는 거다...)
2. 문장은 개행을 중심으로 구분하여, 개행이 포함되지 않은, 개행의 앞부분을 뜻한다. 따라서 "\n" 1문장을 읽었을 때, line에는 "\0"이 들어가도록 해야한다.
3. 혼자서, 내 앞선 게시글에 나온 main함수로 디버깅 하는 경우에, printf("%d - %s\n", ret, line)을 실행하여, get_next_line()의 리턴값까지 같이 출력해주는 것이 정확히 디버깅 하는 꿀팁이니 참고하자!
참고로, 이번 과제에서는 메모리 누수에 대한 문제가 굉장히 중요하게 다루어진다. 따라서 메모리를 해제하고 널포인터로 만들어주는 과정을 꼼꼼히 구현하였다.
먼저 get_next_line_utils 의 함수들을 리뷰해보겠다.
get_next_line_utils.c 에서는 get_next_line.c에서 쓰이게될 함수들을 구현해놓았다.
(1) get_next_line_utils
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
#include "get_next_line.h"
size_t ft_strlen(char *str)
{
size_t len;
len = 0;
while (str[len])
len++;
return (len);
}
int find_newline(char *str)
{
int i;
i = 0;
while (str[i])
{
if (str[i] == '\n')
return (i);
i++;
}
return (-1);
}
char *ft_strdup(char *s1)
{
char *ptr;
size_t len;
size_t i;
i = 0;
len = ft_strlen(s1);
if (!s1)
return (0);
if (!(ptr = malloc(sizeof(char) * (len + 1))))
return (0);
while (i < len)
{
ptr[i] = s1[i];
i++;
}
ptr[i] = '\0';
return (ptr);
}
char *ft_strjoin(char *s1, char *s2)
{
int i;
int j;
int index;
char *str;
i = 0;
j = 0;
index = 0;
str = malloc(sizeof(char) * (ft_strlen(s1) + ft_strlen(s2) + 1));
if (!(str))
return (0);
while (s1[i])
str[index++] = s1[i++];
while (s2[j])
str[index++] = s2[j++];
str[index] = '\0';
free(s1);
s1 = 0;
return (str);
}
|
cs |
libft에서 구현한 함수들을 그대로, 혹은 수정하여 사용하였다.
- ft_strlen() : 문자열의 길이를 구하는 함수
- find_newline() : 주어진 문자열 내의 개행의 위치를 리턴하는 함수 (개행이 없다면 -1 리턴)
- ft_strdup() : 주어진 문자열을 복사하여 만든 새로운 문자열을 포인터를 리턴하는 함수
- ft_strjoin() : 주어진 두 문자열을 합한 새로운 문자열의 포인터를 리턴하는 함수
여기에서 눈여겨보아야 할 부분은 추가로 ft_strjoin()에서 free(s1); s1 = 0; 처리를 해준 부분이다.
이 부분은 나중에 get_next_line.c에서 이어서 설명하겠다.
(2) get_next_line
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
#include "get_next_line.h"
int error_return(char **backup)
{
if (*backup)
{
free(*backup);
*backup = 0;
}
return (-1);
}
int get_one_line(char **backup, char **line, int nl_idx)
{
char *tmp;
(*backup)[nl_idx] = '\0';
if (!(*line = ft_strdup(*backup)))
return (error_return(backup));
if (!(tmp = ft_strdup(*backup + nl_idx + 1)))
return (error_return(backup));
free(*backup);
*backup = tmp;
tmp = 0;
return (1);
}
int get_all(char **backup, char **line)
{
int nl_idx;
if (*backup && ((nl_idx = find_newline(*backup)) >= 0))
return (get_one_line(backup, line, nl_idx));
else if (*backup)
{
if (!(*line = ft_strdup(*backup)))
return (error_return(backup));
free(*backup);
*backup = 0;
}
return (0);
}
int get_next_line(int fd, char **line)
{
static char *backup[OPEN_MAX];
char buf[BUFFER_SIZE + 1];
int read_size;
int nl_idx;
if (fd < 0 || fd > OPEN_MAX || !line || BUFFER_SIZE <= 0)
return (-1);
if (!backup[fd])
{
if (!(backup[fd] = malloc(sizeof(char))))
return (-1);
backup[fd][0] = '\0';
}
while ((read_size = read(fd, buf, BUFFER_SIZE)) > 0)
{
buf[read_size] = '\0';
if (!(backup[fd] = ft_strjoin(backup[fd], buf)))
return (-1);
if ((nl_idx = find_newline(backup[fd])) >= 0)
return (get_one_line(&backup[fd], line, nl_idx));
}
if (read_size < 0)
return (error_return(&backup[fd]));
return (get_all(&backup[fd], line));
}
|
cs |
- static 변수는 여러개의 fd 를 처리할 수 있도록 2차원으로 선언하였으며, fd의 범위인 0 ~ OPEN_MAX 만큼 배열으로 만들어주었다.
read()에서 값을 받아올 버퍼 또한 buf라는 배열로 BUFFER_SIZE + 1 크기만큼 만들어주었다.
- get_next_line 은 3 개의 리턴값을 갖는다.
- 오류 발생 시 : -1
- EOF 도달 시 : 0
- 1문장 읽음 : 1
1. (- 1) 리턴하는 경우
- 먼저 프로그램이 시작될 때, fd가 범위에서 벗어나거나 line 의 주소가 존재하지 않거나, 버퍼사이즈가 0 이하인 경우에 대해 에러를 리턴하게끔 처리해주었다. (-1) 리턴
이 부분은 프로그램이 처음 시작될 때부터 검사되는 부분이다. 따라서 저 조건식에 걸린다면, get_next_line()을 처음 호출한 경우부터 해당 될 것이다. 그리고 그때는 backup에 메모리가 할당되지 않은 초기 상태일 것이다. 따라서 error_return()의 호출 대신, 단순히 (-1)만 리턴하는 것으로 처리한 것이다.
- malloc()이 사용되는 함수에서는 error_return ()을 호출하여 널 가드로 (-1) 을 리턴하게끔 처리하였다.
할당할 메모리가 부족하면 함수가 정상적으로 실행될 수 없으므로 오류가 발생한 상황이다. 따라서 -1 을 리턴하여 함수를 종료할 필요가 있다. 그러나 backup이라는 정적 변수에 할당된 메모리가 남아있으면 메모리 누수가 발생할 가능성이 있다. 따라서 (-1)을 리턴하기 전에 error_return()을 호출하였다. 이를 통해 backup에 남은 메모리가 있다면 이를 해제해준 후 널포인터로 만들어주는 작업을 하여, 메모리릭을 예방하였다.
- read()를 통해 읽어온 바이트가 음수일 경우는 오류이므로 (-1)을 리턴하게끔 처리하였다.
2. 0 또는 1 리턴하는 경우
- while()문의 조건식을 통해 read를 수행하고, 현재 읽어온 부분을 저번에 읽었던 부분에 합친다. (ft_strjoin() 사용)
read()를 통해 버퍼에 읽어온 문자열과 기존의 backup[fd]의 값을 합쳐 새로운 메모리를 할당한다. 이때 기존의 메모리 주소를 새롭게 합친 문자열의 메모리 주소로 업데이트하는 방식이기 때문에, 기존의 메모리를 free() 해주어야 메모리 누수를 예방할 수 있다. 이때문에 ft_strjoin() 내 에서, 첫번째 인자로 받은 문자열의 주소는 맨마지막에 free() 하도록 수정해 준 것이다.
그렇지만 맨 처음에 backup[fd] 에 메모리가 할당되어 있지 않다면, ft_strjoin()을 실행했을 때 backup[fd]를 free()할 수 없어 오류가 난다. 따라서, while()문 이전에 backup[fd]에 맨 먼저 임의로 1만큼 메모리를 할당하고 그 안에 값으로 '\0'을 넣어주었다.
(참고로, static 배열은 모든 요소가 처음에 0으로 초기화되어있다. 따라서 backup[fd]에 메모리 할당이 되지 않은 경우, backup[fd]의 초기값은 0이 된다는 것을 알아야한다)
- while() 실행 중에, 읽은 문자열 내에 개행이 존재한다면, get_one_line()을 호출하여 1문장만 추출하고 1을 리턴하도록 처리하였다.
- get_one_line 은 개행을 기준으로 개행 앞의 문장은 line에 붙여주고, 그 뒷부분은 static 변수에 복사하여 저장하는 역할을 한다.
이때 null을 만날 때까지 문자열을 복사하는 ft_strdup()를 사용하기 위해, 개행이 있던 부분의 인덱스 값에 먼저 '\0'을 할당해주었다.
개행이 있던 부분의 뒷부분을 static 변수에 새로 복사하고 저장하는 과정에서, 기존의 메모리를 free 해주는 작업을 잊으면 안된다.
- while() 실행 중에, 읽은 문자열 내에 개행이 존재하지 않는다면 계속 반복해서 read()를 수행한다.
이 작업은 read_size 가 0이 될때까지 (EOF)에 도달할 때 혹은 중간에 개행이 나올 때까지 계속된다.
- 더이상 read()를 통해 읽을 값이 없다면 get_all()을 통해 남은 문자열을 처리해준다.
만약에 backup에 남은 문자열이 있다면, 그 안에 개행이 있는지 검사한다.
(버퍼사이즈가 클 경우, 개행이 있는 문장들을 이전에 한번에 여러개 다 읽어왔을 수가 있다)
만약 개행이 존재한다면 get_one_line()을 호출하여 그쪽으로 backup을 보내서 처리를 해준 후 1이 리턴되도록 한다.
(ex : 버퍼 사이즈가 커서 이전에 남은 문장들을 이미 다 읽어왔던 경우, backup에 남아있는 개행이 붙은 문장들이 1개 이상 존재할 수 있다. 예시를 들자면, 개행 8개로만 이루어진 텍스트파일이 있다고 가정하자)
그 안에 개행이 없다면, 아무리 길든 짧든, EOF로 끝나는 마지막 1문장이라는 뜻이다. 따라서 backup에 남은 문자열을 복사하여 line에 붙여준 후, backup의 메모리를 해제하고 이를 널포인터로 만들어준다. 함수가 -1 혹은 0으로 종료되기 전에 backup에 남은 메모리가 없어야 메모리 누수를 예방할 수 있다.
get_all 이 완료되었다면 EOF까지 도달했다는 뜻이므로 0을 리턴한다.
참고) 함수의 매개변수로 backup[fd]의 주소를 넘겨주는 이유는?
코드를 보면, get_one_line(&backup[fd], line, nl_idx) 이런 식으로 backup[fd]의 주소를 넘겨준다.
이렇게 해주는 이유가 무엇일까?
만약 backup[fd]를 그대로 넘겨주게 되면 backup[fd]의 주소가 가르키는, "해당 주소 내부"의 문자열 값에 접근할 수 있게 된다.
그러나 backup[fd]의 주솟값을 바꿀 수는 없다.
그렇지만 get_one_line() 함수는 backup[fd]의 기존 메모리를 free()하고 새로운 문자열을 담은 새로운 메모리를 backup[fd]에 할당해줘야한다. 즉 포인터가 가르키는 메모리의 주솟값이 바뀐다는 이야기다.
따라서 "해당 주소 내부의 값에 접근" 할 수 있게 하는 것과 동시에, "해당 주솟값을 변경"할 수 있게하기 위해서 backup[fd]의 주소를 넘겨준다고 보면된다.
참고) 버퍼사이즈로 큰 값(10000000)를 넣어줄 경우는 어떻게 될까?
segfault가 발생한다. buf[BUFFER_SIZE + 1]은 스택 메모리에 할당된 것이기 때문에 스택 사이즈를 초과하는 크기로 선언할 수 없다.
되도록이면 buf를 동적할당하고 싶었으나, 이럴 경우 리턴 전에 free하는 부분을 추가하느라 함수 구조가 더욱 복잡해지고, norminette 규정으로 정해진 함수의 줄 제한을 넘어가게 되었다. 따라서 어쩔 수 없이 정적인 배열로 선언하게 되었다.
(3) get_next_line 헤더
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
#ifndef GET_NEXT_LINE_H
# define GET_NEXT_LINE_H
# include <unistd.h>
# include <stdlib.h>
# define OPEN_MAX 1024
int get_next_line(int fd, char **line);
size_t ft_strlen(char *str);
int find_newline(char *str);
char *ft_strjoin(char *s1, char *s2);
char *ft_strdup(char *s1);
#endif
|
cs |
fd 의 개수 범위인 OPEN_MAX를 헤더파일을 통해 매크로로 정의해주었다.
OPEN_MAX를 통해 내가 최대한 열고자하는 파일의 개수를 정의해준 것이기 때문에 임의의 상수로 설정해주어도 상관이 없다.
이 외의 방법으로는 limits.h 헤더를 통해 open_max를 받아오는 방법도 있다. 그러나 이 경우는 허용되지 않은 외부 헤더를 호출했다며 디펜스에서 논란이 될 여지가 있다. 따라서 나는 상수로 정의해주는 것을 선택했다.
get_next_line_utils에서 size_t 자료형과 malloc()이 쓰이기 때문에 그에 필요한 헤더 2개 또한 include 해주었다.
그 외에 함수의 프로토타입 선언도 추가해주었다.
버퍼사이즈는 과제에 명시된 대로, 컴파일 단계에서 -D 옵션을 통해 정의해주는 것이다. 어차피 테스터나 뮬리넷에서는 컴파일 옵션으로 정의될 것이기 때문에 굳이 ifndef 으로 기본값을 정의해주지 않아도 될 것으로 생각했다. 따라서 BUFFER_SIZE는 매크로로 정의하지 않았다.
구현 과정에서 어려웠던 부분
1) 메모리 누수 부분을 잡는 것이 힘들었다.
특히 ft_strjoin()을 통해 첫번째 인자로 들어온 기존의 backup 메모리를 해제해주는 데, ft_strjoin()의 내부에서 backup의 주소가 null일 경우를 처리하는 것에서 자꾸 테스터기에서 오답이 났다. ft_strdup()을 사용하거나 여러 방법을 써봤는데도 테스트기를 통과하기 힘들었다.
2) 기존의 남아있는 개행을 가진 문장들이 한번에 backup에 전부 읽혔을 경우(버퍼사이즈가 큰 경우)를 생각하지 못했었다.
왜냐면 개행이 있으면 get_all() 실행이 되지 않고 매번 get_one_line()이 바로 호출되도록 이어진다고 오해했기 때문이다.
그러나 버퍼사이즈가 크면 read() 실행 시 EOF까지 모두 읽어올 수도 있으며, 처리하고 남은 문장들은 backup[fd]에 남는다. 따라서 다음번 호출부터는 새로 read()를 실행하지 않기 때문에, backup 변수에 남아있는 개행이 있는 문장까지 get_all()에서 처리해줘야한다.
따라서 get_all() 함수에서 이를 처리하는 조건문을 추가하는 데 시간이 걸렸다.
어떻게 해결했나?
1) 그냥 ft_strjoin()에 들어가기 전에 backup[fd]에 할당된 메모리가 없는지 체크했다. 메모리 주소가 null 이라면 시작부터 backup[fd]에 메모리를 1칸 할당해주었다. 이 경우에도 오류를 피하지 못했으나, 할당된 메모리의 값으로 '\0' 을 넣어주고나니 그제서야 테스터를 통과할 수 있었다.
2) backup에 개행을 가진 문장들이 남아있을 경우를 고려하여, get_all()에서도 backup의 개행여부를 체크하여 get_one_line()을 호출하는 코드를 추가하였다.
동료평가 하면서 새로 알게 된 지식
1) 정적인 배열은 스택 메모리 영역을 사용하고, 동적인 메모리 할당은 힙 메모리 영역을 사용한다. 그리고 static 자료형은 데이터 영역을 사용한다.
static 자료형은 스택이나 힙이 아닌, 데이터 영역을 사용하여 메모리를 저장하는 것이기 때문에 메모리의 값을 계속 유지할 수가 있는 것이다.
이때, 주의해야할 점을 하나 알 수 있다.
일반 포인터에, static 메모리의 주소를 붙여주면 오류가 날 수 있다.
데이터 영역의 메모리에 static이 아닌 포인터가 접근하는 것이기 때문이다.
따라서 strdup을 통해 힙메모리로 static 메모리의 내용을 복사해준 후 포인터에 해당 힙메모리의 주소를 붙여주는 것이 바람직하다.
2) 주로 데이터를 읽어오는 단위를 2의 n 승으로 선언하게 된 관례 :
주로 메모리의 크기가 2의 배수로 선언되어 있기 때문에, 메모리를 한번에 가져오는 단위를 2의 n승으로 하는 것이 더 효율적으로 작용한다.
3) read()가 음수를 반환하는 경우? : 에러 발생
- EAGAIN : O_NONBLOCK으로 열렸지만 즉시 읽을 수 있는 데이터가 없다
- EIO : I/O 에러 발생
- EFAULT : buf가 접근할 수 없는 주소 공간을 가리킨다
추가) malloc() 실패 시 널가드 처리를 통해 get_next_line() 함수에서 (-1)이 반환될 수 있도록 하는 처리를 해주었다.
그런데 이때 malloc()이 쓰이는 함수인 ft_strdup()에 대해 get_all() 내에서 이 처리를 딱 하나 빼먹은 것을 이후에 발견하였다.
따라서 게시글의 코드를 다시 수정하였다. 이 부분에서 실수를 하지않도록 꼼꼼히 점검해야한다. 내 친구도 이 부분을 딱 하나 빠뜨려서 디팬스에 실패하였다고 한다.
참고 자료 링크 :
'IT > 42Seoul' 카테고리의 다른 글
[born2beroot] monitoring 파트 정리1 (0) | 2021.11.17 |
---|---|
[born2beroot] 개념 정리 - 프로젝트 개요 (0) | 2021.11.15 |
[GET_NEXT_LINE] 구현 준비 (0) | 2021.06.04 |
Libft 과제 시 유의점 (테스터기, protected, Makefile) (0) | 2021.05.31 |
malloc/calloc 관련 정리 (0) | 2021.05.30 |