- Libft - 나만의 첫 번째 라이브러리2025년 03월 12일 23시 45분 12초에 업로드 된 글입니다.작성자: kugorang
들어가며
Libft는 학생들이 표준 C 라이브러리 함수의 동작과 특수한 경우를 이해하기 위해 재구현하는 École 42의 기초 프로젝트이다. Libft에서는 libc 함수의 선택적 재구현에 초점을 맞추고 있다. 이 심층 분석에서는 표준 동작(Linux/POSIX 매뉴얼 페이지 기준), 주요 차이점, 구현 시 핵심 사항을 중심으로 각 함수를 분석한다. 또한 성능 최적화, 보안 고려사항, 경계 조건, 그리고 포괄적인 이해를 돕는 추가적인 맥락에 대해서도 살펴본다.
💡 초보자를 위한 설명: École 42는 코딩 교육을 제공하는 혁신적인 학교이다. Libft 프로젝트는 C 언어의 기본 라이브러리 함수들을 직접 구현함으로써 그 작동 방식을 깊이 이해하도록 하는 첫 번째 주요 과제이다. 이는 마치 자동차의 부품을 직접 분해하고 다시 조립해보는 것과 같이, 프로그래밍의 기초를 탄탄히 다지는 과정이다.
Part 1 - 함수 분석
Libft Part 1에서는 아래의 표준 함수를 다시 구현해야 한다.
- isalpha, isdigit, isalnum, isascii, isprint
- toupper, tolower
- strlen
- memset, bzero
- memcpy, memmove
- strlcpy, strlcat
- strchr, strrchr, strnstr
- strncmp
- memchr, memcmp
- atoi
- calloc, strdup
각각의 함수(명확성을 위해 범주별로 분류)를 검토하고 표준 함수와 비교해본다.
문자 분류 함수:
isalpha
,isdigit
,isalnum
,isascii
,isprint
이 함수들(
<ctype.h>
에 선언되어 있음)은 문자 값을 테스트한다.- 표준 동작: C 표준(C89/C99)에서,
isalpha(int c)
는c
가 (프로그램의 현재 로케일에서) 알파벳 문자일 경우 0이 아닌 값(true)을 반환하고, 그렇지 않으면 0을 반환한다[^1].isdigit
(0-9 사이의 숫자인지 확인),isalnum
(영숫자),isprint
(공백을 포함한 출력 가능한 문자), 그리고isascii
(값이 ASCII 범위 0-127에 속하는지 여부)에도 유사한 논리가 적용된다. 메뉴얼에 따르면, 이러한 함수의 인수는 반드시 unsigned char(일반적으로 0~255) 또는 특수 값EOF
로 표현할 수 있어야 한다[^2]. 음수(EOF
가 아닌)를 전달하면 정의되지 않은 동작이 발생한다. 이는 구현에서 내부적으로 입력 값을unsigned char
로 형변환하여 음수 인덱스로 배열에 접근하는 것을 방지함을 의미한다(룩업 테이블 사용 시).
💡 초보자를 위한 설명: 룩업 테이블은 미리 계산된 결과를 저장해두고 필요할 때 빠르게 참조하는 배열이다. 예를 들어 256개의 요소를 가진 배열을 만들어 각 ASCII 값에 대해 그것이 알파벳인지 아닌지를 0 또는 1로 저장해두면, 검사 시 단순히 배열을 인덱싱하여 결과를 얻을 수 있다.
- 로케일 및 구현: 표준
isalpha
관련 함수는 로케일을 인식한다(예를 들어, 추가 문자가 알파벳인 로케일)[^3]. 기본 "C" 로케일에서는 일반적으로 ASCII 문자와 숫자만 인식한다. 많은 libc 구현은 효율성을 위해 룩업 테이블을 사용하는 매크로로 이를 정의한다. libft의 경우, "C" 로케일을 효과적으로 가정하는 간단한 검사(예:c >= 'A' && c <= 'Z'
등)를 통해 구현할 수 있다. 전체 로케일 지원을 처리하는 것은 libft 프로젝트의 범위를 벗어나므로 이 방식은 libft에 적합하다.
💡 초보자를 위한 설명: 로케일(locale)은 프로그램이 지역별 차이(언어, 문자셋, 날짜 형식 등)를 처리하는 방법을 정의한다. "C" 로케일은 기본 로케일로, 영어 기반 ASCII 문자만을 다룬다. 다른 로케일은 à, é, ñ 같은 비-ASCII 문자도 알파벳으로 취급할 수 있다.
- isascii 상세 정보:
isascii(int c)
는 C89/C99 표준의 일부가 아니며, C 값이 ASCII 범위(0~127)에 있는지 확인하는 POSIX 및 BSD 확장이다. true이면 0이 아닌 값을 반환한다. ASCII가 고정되어 있기 때문에 여기에는 로케일 고려 사항이 없다. 일부 시스템(예: Windows)에서는isascii
를 사용할 수 없거나 밑줄(예:_isascii
)이 함께 제공될 수 있다. 구현은 일반적으로 간단한 범위 검사를 수행한다. - 구현을 위한 고려 사항: 함수가
unsigned char
값(일반적으로 0-255)의 전체 범위와 EOF를 올바르게 처리하는지 확인한다. libft의 경우char
값으로만 테스트할 가능성이 높기 때문에 문자열의 끝(일반적으로 -1)을 처리하지 않을 수도 있지만, 요구 사항을 알고 있으면 좋다. 안정성 고려 사항에는char
가 부호 있는 값인지 부호 없는 값인지에 대한 가정을 피하는 것이 포함된다. 음수 값으로 인한 문제를 피하기 위해 테이블 또는 범위 검사를 사용하는 경우unsigned char
로 명시적으로 형변환해야 한다. 이러한 경우 성능은 일반적으로 중요하지 않다(O(1) 검사). 더 간단한 구현이 좋다(예: isalpha에 대한 반환(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
). - 표준과의 차이점: Libft의 버전은 "C" 로케일의 표준 버전과 같이 작동한다. 이 버전은 로케일별 알파벳이나 로케일별 인쇄 가능한 기호를 인식하지 않을 수 있다. 또한, ctype.h의 반환 값은 true에 대해 0이 아닌 값으로 지정되어 있지만 반드시
1
일 필요는 없다. 구현에 따라 true에 대해1
을 반환할 수 있으며, 이는 괜찮다. C에서는 0이 아닌 값은 모두 true로 간주된다는 점을 기억하자.
[^1]: isalpha() == true evaluates to false? - c++ - Stack Overflow
[^2]: isprint man page
[^3]: isalpha문자 대소문자 변환:
toupper
,tolower
이 함수는 가능한 경우 문자의 대소문자를 변환한다.
- 표준 동작:
toupper(int c)
는c
가 'a'–'z'에 속할 경우 소문자를 대문자로 변환하고 반환한다.c
가 소문자가 아닐 경우,c
를 변경하지 않고 반환한다[^4]. 마찬가지로,tolower
는 대문자 'A'–'Z'를 소문자로 변환한다. 또한, 분류 함수와 유사하게c
가unsigned char
또는 EOF로 표현 가능해야 한다. - 로케일 고려:
isalpha
와 마찬가지로 표준toupper/tolower
는 로케일을 인식한다(예를 들어, 터키어 로케일에서는 점 없는 i/I 규칙이 있다). 그러나 libft에서는 표준 방식이 아닌 간단한 ASCII 전용 방식으로 구현해도 된다. 예를 들어,toupper
의 경우,c
가 'a'와 'z' 사이에 있으면 32를 빼서 대문자를 얻고, 그렇지 않으면c
를 반환한다. 이것은 기본 영어 알파벳을 다룬다.
💡 초보자를 위한 설명: ASCII에서 소문자 'a'는 97이고 대문자 'A'는 65이다. 두 값의 차이는 32이다. 이러한 패턴은 모든 알파벳에 적용되므로, 소문자에서 32를 빼면 대문자가 되고, 대문자에 32를 더하면 소문자가 된다. 이 성질을 이용해 간단히 변환 함수를 구현할 수 있다.
- 구현 시 주의사항: 결과를
int
로 반환하도록 주의한다(함수는 int를 반환하도록 정의되어 있다). 알파벳이 아닌 문자의 경우, 원래 값을 반환한다. 여기에서는 단순한 산술 연산만 수행되므로 메모리나 안정성은 큰 문제가 되지 않는다. 한 가지 고려해야 할 사항은 매핑이 ASCII 연속성을 가정한다는 것이다(ASCII/UTF-8에서 A-Z와 a-z에 유효함). 여기에는 동적 메모리나 특별한 성능 트릭이 필요하지 않다. - 표준과의 차이점: 로케일 지원이 없으면 특정 국제 문자는 처리되지 않지만, ASCII 문자의 경우 표준 동작과 일치한다. 또한, 표준
toupper
/tolower
는 입력 값이 unsigned char 범위 밖에 있고 EOF가 아닌 경우 정의되지 않은 동작을 하므로, 필요하면 다시 형변환해야 한다. Libft 테스터는 아마도 유효한 char 값만 입력할 것이다.
[^4]: ctype, isalpha, isupper, islower, isdigit, isxdigit, isalnum, isspace ...
문자열 길이:
strlen
이 함수는 C-문자열(null로 끝나는 문자열)의 길이를 계산한다.
- 표준 동작:
size_t strlen(const char *s)
는 문자열s
에서 널 바이트('\0'
)를 제외한 문자 수를 반환한다[^5]. 기본적으로,'\0'
을 찾을 때까지s
를 순회하고, 발견된 문자 수를 반환한다.s
가 유효하지 않은 포인터이거나 널 종결되지 않은 경우, 동작은 정의되지 않는다(충돌이 발생하거나 범위를 벗어날 수 있다).
💡 초보자를 위한 설명: C 언어에서 문자열은 문자 배열로 표현되며, 항상 널 문자('\0')로 끝난다. 이 널 문자는 문자열의 끝을 나타내는 특수 문자이다.
strlen
함수는 이 널 문자를 만날 때까지 문자를 세어 문자열의 길이를 구한다. 여기서 길이란 널 문자를 제외한 실제 문자 수를 말한다.- 구현 고려 사항: 간단한 구현은
'\0'
까지 문자를 반복적으로 처리하면서 카운트한다. 널 종결자를 넘어서 읽지 않도록 해야 한다.strlen
함수는 매우 일반적인 연산이기 때문에 성능 최적화가 가능할 수 있다(예: 한 번에 여러 바이트를 확인하기 위해 워드 단위 연산을 사용하는 등). 그러나 기본 구현에서는 명확성이 핵심이다. 카운터와 반환 유형에size_t
를 사용해야 한다. 문자열은 상당히 길 수 있고,int
는 매우 긴 문자열의 경우 오버플로우가 발생할 수 있기 때문이다.strlen
은 메모리를 할당하거나 수정하지 않기 때문에 메모리 관리가 직접적으로 적용되지는 않지만, 안전성 측면에서NULL
입력에 주의해야 한다(일반적으로 NULL을 전달하면 정의되지 않은 동작이 발생한다). 구현에서 명시적으로 처리할 필요는 없지만, 테스트할 때 NULL이 주어졌을 때 함수가 충돌하지 않는지 확인하는 것이 좋다. - 경계 조건: 빈 문자열(
""
)은 0을 반환해야 한다. 매우 긴 문자열에도 문제 없이 작동해야 한다(이 함수는 이론적으로는SIZE_MAX
까지의 길이에 대해 작동해야 하지만, 실제로는 사용 가능한 메모리가 한계이다). 종료자가 발견되지 않으면, 이 함수는 허용되지 않는 메모리로 실행될 가능성이 높다(따라서 테스트할 때는 항상'\0'
이 있는지 확인해야 한다). 표준strlen
에는 이것에 대한 내장된 안전 장치가 없다. - 표준과의 차이점: 올바른 구현은 표준
strlen
과 동일하게 작동한다. 미묘한 차이점 중 하나는 매우 긴 문자열을 전달하는 경우이다. 이 경우 그 길이가size_t
에 맞지 않게 될 수 있다(실제 시스템에서는 주소 지정 가능한 것보다 더 많은 메모리를 필요로 하기 때문에 사실상 불가능하다). 그러나 이론적으로는 카운터의 오버플로가 발생할 수 있다. 표준도 이 문제에 대해 보호하지 않는다. 요약하면, libftstrlen
은 모든 정상적인 사용에 대해 표준 동작을 반영해야 한다.
[^5]: strchr(3) - Arch Linux manual pages
메모리 설정 및 초기화:
memset
및bzero
이 함수들은 메모리 버퍼를 초기화하는 작업을 처리한다.
memset
: 메모리 블록을 특정 바이트 값으로 설정한다. 함수 원형:void *memset(void *b, int c, size_t len)
- 표준 동작:
memset
은b
[^6][^7]이 가리키는 블록의 첫 번째len
바이트에 바이트 값(unsigned char)c
를 쓴다. 이 함수는 메모리 영역b
에 대한 포인터를 반환한다. 이것은 매우 저수준 루틴이며, libc에서 많이 최적화되는 경우이다.
- 표준 동작:
💡 초보자를 위한 설명:
memset
은 메모리의 연속된 영역을 특정 값으로 채우는 함수이다. 예를 들어, 정수 배열을 0으로 초기화하거나 버퍼를 특정 문자로 채울 때 사용할 수 있다. 이 함수는 메모리를 바이트 단위로 다루기 때문에 어떤 데이터 타입에도 사용할 수 있다.- 구현 고려 사항: 단순한 구현은
len
번 반복하고 바이트를 할당하는 것이다. 그러나 성능을 위해, 시작을 정렬한 후 메모리를 워드 단위로 설정하는 것이 일반적이다(예:unsigned long
이상의 청크 단위로). 한 번에 4바이트 또는 8바이트를 쓰는 것이 한 번에 1바이트를 쓰는 것보다 효율적이기 때문이다. 일부 구현에서는 루프 오버헤드를 줄이기 위해 Duff의 장치 또는 루프 언롤링을 사용한다. 또한, 최신 CPU에서는 벡터 명령어(x86의 SSE/AVX, ARM의 NEON)를 사용하면 한 번에 16바이트 또는 32바이트를 설정하여 속도를 크게 높일 수 있다. libft의 경우, 간단한 루프 구현 방식이 허용된다.c
를unsigned char
로 캐스팅하는 것을 확인해야 한다. 그래야 음수 값이 전달되더라도(int로 전달되지만, 일반적으로 0-255 범위) 올바른 바이트 값으로 래핑된다. 메모리 측면에서,memset
은 아무것도 할당하거나 해제하지 않는다; 단지 기존 버퍼에 쓰는 것뿐이다. 따라서 버퍼가 존재하고len
이 올바른지 확인하여 오버플로우를 방지해야 한다. - 경계 조건:
len
이 0이면,memset
은 아무것도 하지 않고b
를 반환해야 한다.b
가NULL
이고len
이 0보다 크면, 동작은 정의되지 않는다(NULL에 쓰기 때문).b
가NULL
이고len
이 0인 경우, 표준은 바이트가 쓰여지지 않았기 때문에 NULL 또는 구현에 정의된 동작을 반환하도록 허용한다. 일반적으로,b
(NULL)를 반환한다. 구현에서 이를 적절하게 처리하도록 결정할 수 있지만(b
가 NULL이면 NULL을 반환), C 표준에서는 엄격하게 요구하지 않는다(길이 값이 0이 아닌 경우 NULL로 호출하지 말 것). - 표준과의 차이점: libft
memset
은 표준과 동일한 결과를 만들어야 한다. 함수 원형과 반환 값에 주의해야 한다. 한 가지 차이점으로, 구버전 또는 POSIX가 아닌 시스템은 유사한 기능인bfill
을 가지고 있거나memset
이 없을 수도 있다(아주 오래된 버전). 그러나 요즘에는memset
이 보편적으로 사용된다. Libft는 실제 libc가 사용하는 하드웨어 특정 최적화에 의존하지 않는다는 점을 제외하고는 다른 차이가 없다. bzero
: 메모리 블록을 0으로 설정한다. 함수 원형:void bzero(void *s, size_t n)
.- 표준/역사:
bzero
는 레거시 BSD 함수이다; 4.2 BSD에 등장했다.s
에서 시작하여n
바이트를 0으로 설정한다. 기본적으로memset(s, 0, n)
과 동일하다. 실제로 POSIX는bzero
를 사용 중단(POSIX.1-2001의 레거시)으로 표시하고, POSIX.1-2008에서 이를 제거했으며, 대신memset
를 권장한다[^7]. 많은 최신 시스템은 여전히 호환성을 위해bzero
를 가지고 있지만, C 표준 라이브러리에는 없다.
- 표준/역사:
💡 초보자를 위한 설명:
bzero
는 메모리 영역을 0으로 채우는 오래된 함수이다. 현대 프로그래밍에서는memset(ptr, 0, size)
를 사용하는 것이 권장되지만, 레거시 코드에서는 여전히bzero
를 볼 수 있다. Libft에서 이 함수를 구현하는 것은 실제로는memset
을 호출하는 래퍼 함수를 만드는 것과 같다.- 구현: 가장 간단한 구현은
memset(s, 0, n)
이다. libft에서는 작성한 후 자신의ft_memset
를 호출하거나, 바이트를 0으로 설정하는 루프를 작성할 수 있다.n = 0
을 적절하게 처리하는지 확인해야 한다(아무 작업도 하지 않아야 한다). - 고려 사항:
bzero
는 반환 값(void)이 없기 때문에 순수한 연산이다. 메모리 및 경계 조건 고려 사항은memset
와 동일하다. 작은 차이점 하나는,bzero
가 표준에서 제거되었기 때문에 일부 플랫폼(Windows MSVC 등)에는 이 기능이 없다는 것이다. 코드를 이식하는 경우,bzero
를memset
로 대체해야 한다. libft의 목적(리눅스/유닉스)에 따르면, 이 정도면 충분하다. - 표준과의 차이점: 현대적인 코드는
bzero
를 거의 사용하지 않지만, libft는 역사적 이유로 이것을 포함하고 있다. 우리의 구현은 0-채우기(zero-filling)를 정확히 복제해야 한다. 여기에는memset
이 0을 처리하는 것 외에 특별한 트릭이 없다.
[^6]: [PATCH v2] x86-64: Optimize bzero - Sourceware
[^7]: allow bzero in kernel - LWN.net메모리 복사 및 이동:
memcpy
와memmove
이 함수들은 메모리 영역들 사이에서 바이트를 복사한다.
memcpy
: 소스에서 목적지까지n
바이트를 복사한다. 함수 원형:void *memcpy(void *dst, const void *src, size_t n)
.- 표준 동작:
memcpy
는src
에서dst
로 정확히n
바이트를 복사하며, 소스와 대상 메모리 영역이 겹치는 경우에는 안전성을 보장하지 않는다[^8][^9]. 영역이 겹치면, 동작은 정의되지 않는다(복사본이 손상될 수 있음). 이 함수는dst
를 반환한다. 일반적으로,memcpy
는 많이 사용되기 때문에 가능한 한 최적화된 방식으로 구현된다. 표준은 종종 워드 크기의 연산 또는 특수 CPU 명령어로 구현할 수 있도록 허용한다.
- 표준 동작:
💡 초보자를 위한 설명:
memcpy
는 한 메모리 영역에서 다른 메모리 영역으로 데이터를 복사하는 함수이다. 주의할 점은 두 메모리 영역이 겹치는 경우 예측할 수 없는 결과가 발생할 수 있다는 것이다. 예를 들어, 배열의 일부를 같은 배열의 다른 위치로 이동시키려 할 때memcpy
를 사용하면 문제가 발생할 수 있다.- 구현 고려 사항: libft의 경우,
src
에서dst
로 바이트를 복사할 때 겹치지 않는 순서로 복사해야 한다. 일반적으로 이것은 단순히 처음부터 끝까지 순방향으로 복사하는 것을 의미한다.src
와dst
가 겹치지 않는 것으로 알려진 경우, 이 방법은 잘 작동한다. 그러나 겹치고src < dst
인 경우, 이 순방향 복사는 아직 복사되지 않은 데이터를 덮어쓸 수 있다(따라서 정의되지 않은 동작). 그러나memcpy
는 겹침을 처리할 필요가 없으므로 추가 검사를 작성할 필요가 없다. 이는memmove
의 역할이다. 효율적인 복사에 집중하자. 바이트 단위로 복사하는 루프는 간단하다. 더 큰 유형(예:size_t*
)으로 캐스팅하고 워드 단위로 복사하여 속도를 높일 수도 있지만, 그렇게 할 때는 정렬에 주의해야 한다. libft의 제약 조건을 고려할 때, 바이트 루프는 허용 가능하고 명확하다. - 성능: 실제 세계의
memcpy
는 어셈블리 언어로 고도로 최적화되는 경우가 많다.memcpy
가 벡터화된 명령어를 사용하거나 한 번에 8바이트씩 복사하는 것이 일반적이다. 그러나memcpy
는 겹침을 가정할 수 없기 때문에, 때로는memmove
보다 약간 더 빠를 수 있다(예방적 논리가 필요 없음)[^9]. 요점은 겹침이 문제가 되지 않는다면,memcpy
를 사용하고, 그렇지 않으면memmove
[^10]를 사용하는 것이다. - 표준과의 차이점: 올바른 구현은 겹치지 않는 입력에 대해 동일해야 한다.
n == 0
인 경우에도 처리하는 것이 좋다(dst를 반환하고, 아무런 조치도 취하지 않음).dst
또는src
가NULL
이고n > 0
인 경우, 그것은 정의되지 않은 상태이다. 또한, 반환 유형이dst
를 가리키는void*
임을 유의해야 한다. 구현은 표현식에서 사용할 수 있도록 마지막에dst
포인터를 반환해야 한다. memmove
: 가능한 겹치는 영역 간에n
바이트를 복사한다. 함수 원형:void *memmove(void *dst, const void *src, size_t len)
.- 표준 동작: 소스와 목적지가 겹치는 경우,
memmove
는 마치 바이트를 먼저 임시 버퍼에 복사한 다음 목적지에 복사하는 것처럼 원본 소스 바이트가 손상 없이 목적지에 복사되도록 보장한다[^10]. 실제로는 구현에서 성능을 위해 실제 전체 임시 버퍼를 사용하지 않고 올바른 방향으로 복사한다:dst < src
이면 (목적지 영역이 소스 앞에 있으므로) 앞으로 복사하면 된다(memcpy처럼), 이렇게 하면 복사할 미래 바이트를 덮어쓰지 않는다.dst > src
인 경우(목적지가 메모리에서 소스 뒤에 시작하는 경우), 뒤에서부터 복사하는데, 영역의 끝에서 시작하여 끝의 바이트가 덮어쓰기 전에 먼저 복사된다. 이렇게 하면 겹침을 안전하게 처리할 수 있다.memmove
도dst
를 반환한다.
- 표준 동작: 소스와 목적지가 겹치는 경우,
💡 초보자를 위한 설명:
memmove
는memcpy
와 유사하지만 메모리 영역이 겹치는 경우에도 안전하게 작동한다. 예를 들어 배열에서 일부 요소를 같은 배열의 다른 위치로 이동시킬 때 사용할 수 있다. 이 함수는 내부적으로 복사 방향을 조정하여 원본 데이터가 손상되지 않도록 보장한다.- 구현 고려 사항: libft의 경우, 복사 방법을 결정하기 위해
dst
와src
의 상대적 위치를 확인해야 한다. - 일반적인 패턴:
if (dst < src) { // 0부터 len-1까지 앞으로 복사 } else if (dst > src) { // len-1부터 0까지 거꾸로 복사 } // 같으면 아무것도 하지 않음(또는 표준 복사)
- 포인터 산술과
void*
와char*
간의 변환에 주의하자. 복사 과정에서unsigned char*
또는char*
를 사용하자. 이러한 유형을 사용하면 포인터 산술이 바이트 단위로 처리된다. 또한len == 0
인 경우(아무것도 하지 않음)를 처리하자. 메모리 할당이 없으며, 제자리에서 작동한다. - 안전성: 오프바이원(off-by-one, 한 바이트 차이) 오류 없이 겹침 사례를 올바르게 처리하는지 확인하자. 예를 들어, 후방 복사에서는 인덱스
len-1
에서 시작하여 0까지 내려간다(0 포함). 인덱스에size_t
를 사용하는 경우 부호가 없다는 점에 주의하자(0 아래로 내려가면 거대한 값으로 래핑되지 않게 하자).for (i = len; i > 0; i--) { dst[i-1] = src[i-1]; }
와 같은 for 루프는 잘 작동한다. - 표준과의 차이점: 올바르게 구현된 경우, 기능 면에서 차이가 없다. 한 가지 주목할 점: 일부 libc 구현은 어셈블리로 memmove를 최적화할 수 있다. 또한, 추가 검사와 잠재적인 역방향 루프로 인해 성능이 일반적으로
memcpy
보다 약간 떨어진다. 그러나 memmove에서는 속도보다 정확성이 우선이다. 어떤 복사에서도 항상 memmove를 사용하는 것은 겹침이 없다는 것을 아는 경우 불필요하게 느릴 수 있다. 따라서 두 함수가 존재한다. libft에서는 요구에 따라 두 함수를 모두 별도로 구현한다.
[^8]: memcpy Vs memmove - C Board
[^9]: memmove and memcpy - C | Tek-Tips
[^10]: ft_memmove | Guide - GitBook안전한 문자열 복사 및 연결:
strlcpy
및strlcat
이 두 함수는 표준이 아닌(ISO C의 일부가 아닌) 함수지만 더 안전한 문자열 복사 및 연결을 제공하기 위한 BSD의 널리 사용되는 확장이다.
- 목적 및 배경:
strlcpy
와strlcat
은strcpy/strncpy
및strcat/strncat
의 더 안전한 대안으로 OpenBSD에서 도입되었다. 문서에 따르면, "이들은 쉽게 오용될 수 있는 함수 strncpy(3)와 strncat(3)의 보다 안전하고, 일관되며, 오류가 덜 발생하는 대체품으로 설계되었다."[^11].strncpy
와 달리,strlcpy
는 항상 출력을 널 종결(size 매개변수가 > 0인 한)하며, 버퍼에 추가적인 널로 채우지 않는다. 마찬가지로,strlcat
은 크기 검사와 함께 추가하며 종결을 보장한다.
💡 초보자를 위한 설명: 문자열 조작 함수에서 버퍼 오버플로우는 심각한 보안 문제를 일으킬 수 있다. 전통적인
strcpy
와strcat
함수는 목적지 버퍼의 크기를 고려하지 않아 위험하다.strncpy
와strncat
은 이를 개선했지만 여전히 문제가 있다. BSD에서 개발된strlcpy
와strlcat
은 항상 문자열을 널 종결하고 잘림 여부를 쉽게 확인할 수 있어 더 안전하다.strlcpy
: 시그니처:size_t strlcpy(char *dst, const char *src, size_t dstsize)
.src
에서dst
로 최대dstsize - 1
문자를 복사하고,dstsize
> 0이면 결과를 널 종결한다. 이 함수는src
의 길이(복사하려고 시도한 것)를 반환하는데, 이는 잘림을 감지하기 쉽게 한다: 반환 값이 >=dstsize
이면, 잘림이 발생했다(src 길이 ≥ dstsize이기 때문).- 표준 대 구현:
strlcpy
는 표준 C에 없으므로 BSD man 페이지를 참조한다. 이 함수는dstsize
가 0인 경우(이 경우 반환을 위해 src의 길이만 계산하고 바이트를 쓰지 않음)를 제외하고 항상 종료 '\0'을 쓴다[^12][^13]. 구현할 때dstsize == 0
을 처리하는 데 주의하자(이 경우 src의 길이를 반환하지만 바이트를 쓰지 않는다).strlen(src)
또는 유사한 함수를 사용하여 src 길이를 얻는다. 일반적으로 다음과 같이 수행한다:src
문자를 반복하며,src
의 끝에 도달하거나dstsize-1
문자를 복사할 때까지dst
에 복사한다.dst
끝이 널 종결되도록 한다(dstsize
> 0인 경우).- (아직
src
의 끝에 도달하지 않았다면) 반환 값을 계산하기 위해src
의 길이를 계속 카운트한다. src
의 길이를 반환한다.
- 고려 사항:
dst
는 적어도dstsize
바이트가 할당되어 있어야 한다.dstsize
가src
의 모든 내용을 보유하기에 너무 작은 경우, 복사본은 잘리지만 여전히 널 종결된다. 반환 값이src
의 전체 길이이므로 호출자는 이를 쉽게 감지하고 필요한 경우 더 큰 버퍼를 할당할 수 있다.dst
에dstsize-1
바이트 이상을 절대 쓰지 않도록 버퍼 오버플로우를 방지하자.dstsize
가 0이면strlen(src)
를 계산하여 반환해야 한다(그리고 당연히dst
에 쓰지 않는다). 메모리 관리는 호출자의 책임이다(그들이 버퍼를 제공한다). 또한src
와dst
가 겹치면strlcpy
의 동작은 공식적으로 정의되지 않는다(strcpy
와 마찬가지로)[^14]. 일반적으로 겹치지 않는 C 문자열을 위한 것이다. strncpy
와의 차이점:strncpy
(ISO C)는 크기를 가지지만 소스가 크기보다 길면 널 종결자를 보장하지 않는다. 또한 소스가 짧으면 널로 채우는데, 이는 종종 불필요하다.strlcpy
는 항상 종결하고(size=0일 때 제외) 패딩을 하지 않아 이러한 문제를 해결한다[^15][^16]. 이로 인해strlcpy
는 문자열 처리에 훨씬 더 안전하며 많은 사람들이strncpy
보다 권장한다[^11].- 플랫폼 가용성:
strlcpy
는 BSD, macOS, libbsd를 통한 일부 Linux 배포판 또는 커널에서 사용할 수 있지만, 주목할 만한 점은 Linux의 glibc가strlcpy
추가를 오랫동안 거부했다는 것이다. Linux에서 컴파일하는 경우 직접 정의해야 할 수도 있다(따라서 libft에서 구현하는 것이다!). Windows에서도strlcpy
는 기본적으로 사용할 수 없다. 따라서 libft에서 이를 구현하면 라이브러리 코드의 이식성이 향상된다. strlcat
: 시그니처:size_t strlcat(char *dst, const char *src, size_t dstsize)
.src
를dst
끝에 추가한다(이때dst
는 처음부터 널 종결되어 있어야 함).dst
에 총dstsize-1
바이트까지 추가한다(기존dst
내용과 널 종결자 포함). 이 함수는dst
의 초기 길이에src
의 길이를 더한 값을 반환한다 - 즉, 만들려고 시도한 길이를 반환한다. 반환 값이 >=dstsize
이면 잘림이 발생했다.- 표준 대 구현: 역시 비표준이며, BSD에서 왔다. 로직은 다음과 같다:
dst
의 길이를 찾는다(dstsize
까지,dstsize
를 넘어 읽지 마세요 -dstsize
바이트 내에서 널이 발견되지 않으면dst
가 버퍼 크기 내에서 적절히 널 종결되지 않았다는 의미이며 함수는 중지할 수 있다).dlen
을dst
의 길이라고 하자.dlen >= dstsize
이면dst
버퍼가 원래dst
문자열을 보유하기에 충분히 크지 않거나(또는 제공된 크기 내에서 널 종결되지 않음) 이 경우strlcat
은 단순히dstsize + strlen(src)
를 반환하고 아무 작업도 수행하지 않는다(추가가 발생하지 않았으며 버퍼가 가득 찼음을 신호). 그렇지 않으면dst[dlen]
부터src
를 추가한다. 최대dstsize - dlen - 1
바이트를 복사한다(널 공간 남김). 항상 결과를 널 종결한다. 그런 다음dlen + strlen(src)
를 반환한다(만들려고 시도한 문자열의 길이). 이 반환 값 로직을 통해 호출자는 잘림이 발생했는지 알 수 있다(반환된 값이 >= dstsize인 경우). - 고려 사항:
dstsize
가dlen
보다 작은 경우(연결할 공간이 없음)를 올바르게 처리하자. 또한dstsize == 0
을 처리하자(아무것도 쓸 수 없다).memcpy
와 마찬가지로 버퍼를 오버플로우하지 않도록 주의하자. 또한 안전을 위해dst
를 읽어 길이를 찾는 것이dstsize
를 넘어가면 안 된다.
[^11]: strlcat(3) manual page
[^12]: strlcpy, strlcat — size-bounded string copying and concatenation
[^13]: Yes this is something to get use to. The BSDs created strlcpy(3) and ...
[^14]: strlcat/strlcpy vs overlapping arguments - misc@openbsd.org
[^15]: strcpy strncpy() strlcpy() and strscpy()
[^16]: Stop using strncpy already! | Random ASCII - WordPress.com문자열 검색 함수:
strchr
,strrchr
,strnstr
이 함수들은 문자열 내에서 문자나 하위 문자열을 검색한다:
strchr
: 시그니처:char *strchr(const char *s, int c)
. 문자열s
에서 문자c
(문자로 취급하지만 int로 전달됨)의 첫 번째 발생을 찾는다. 종료 널 문자'\0'
도 이 함수에서는 문자열의 일부로 간주된다[^5]. 따라서c
가'\0'
이면,strchr
은s
끝의 종결자에 대한 포인터를 반환한다.c
가 발견되면,s
에서 해당 위치에 대한 포인터를 반환한다; 발견되지 않으면NULL
을 반환한다[^5].
💡 초보자를 위한 설명:
strchr
은 문자열에서 특정 문자를 찾는 함수이다. 찾고자 하는 문자가 처음 나타나는 위치의 포인터를 반환한다. 예를 들어 "Hello"에서 'e'를 찾으면 'e'의 위치를 가리키는 포인터를 반환한다. 이 함수는 널 문자도 문자열의 일부로 간주하므로, 널 문자('\0')를 찾으면 문자열의 끝을 가리키는 포인터를 반환한다.- 구현: 간단한 구현은
s
를 (널 바이트 포함) 반복하고 각 문자를 확인한다. C에서는 종종 다음과 같이 작성된다:이는 일반 문자를 찾는 경우와'\0'
을 찾는 경우를 모두 다룬다.while (*s != '\0') { if (*s == (char)c) return (char*)s; s++; } if ((char)c == '\0') return (char*)s; return NULL;
- 고려 사항:
s
는 입력에서const char*
이지만 함수는char*
를 반환하므로, 반환에서(char*)s
로 캐스팅이 필요하다(const를 제거하기 위해). libft의 버전은 비consts
를 취하도록 프로토타입이 지정될 수 있다. 로컬 또는 새 버퍼에 대한 포인터를 반환하지 않도록 주의하자; 원래 문자열로의 포인터를 반환하자.s
가 NULL이면 동작이 정의되지 않는다(충돌이 발생할 가능성이 높음); 명시적으로 처리할 필요는 없지만, 안전을 위해 확인할 수 있다. 성능 측면에서, 이는 O(n) 스캔이다. - 경계 조건:
c
가 발견되지 않고c
가'\0'
이 아니면, NULL을 반환한다.c
가'\0'
이면, 문자열 끝에 대한 포인터를 반환한다(표준은 널 종결자가 문자열의 일부로 간주되기 때문에 이를 보장한다). strrchr
: 시그니처:char *strrchr(const char *s, int c)
. 문자열s
에서c
의 마지막 발생을 찾는다. 또한 널 종결자를 고려하므로,'\0'
을 검색하면 종결자에 대한 포인터를 반환한다(이는strrchr
의 경우 실제로s + strlen(s)
와 동일하다). 발견되지 않으면 NULL을 반환한다.
💡 초보자를 위한 설명:
strrchr
은strchr
와 유사하지만 문자열을 뒤에서부터 검색하여 마지막으로 나타나는 문자를 찾는다. 예를 들어 "Hello"에서 'l'을 찾으면 두 번째 'l'의 위치를 가리키는 포인터를 반환한다.- 구현: 가장 쉬운 방법은
s
의 끝까지 이동한 다음(즉, 길이를 찾거나'\0'
까지 루프) 거꾸로 이동하며c
를 찾는 것이다. 또는 단순히 반복하고c
를 찾을 때마다 해당 위치를 저장한 후, 루프 후에 마지막으로 저장된 위치를 반환할 수 있다:const char *last = NULL; while (*s) { if (*s == (char)c) last = s; s++; } if ((char)c == '\0') return (char*)s; // s는 이제 '\0'에 있음 return (char*) last;
- 경계 조건:
strchr
과 유사하다.s
가 비어 있고c
가'\0'
이 아니면, 결과는 NULL이다.c
가 '\0'이면, 결과는 문자열 끝에 대한 포인터이다(빈 문자열의 경우에도 시작과 끝이 같으므로 시작). 여러 번 발생하는 경우, 마지막을 반환하는지 확인하자. strnstr
: 시그니처:char *strnstr(const char *haystack, const char *needle, size_t len)
. 이 함수는 표준 C나 POSIX의 일부가 아니다; BSD에서 유래했다.haystack
에서needle
의 첫 발생을 찾되,haystack
의 최대len
문자만 검색한다[^17]. 다시 말해, 이는haystack
의 처음len
바이트 내에서만 검색하는strstr
과 같다.
💡 초보자를 위한 설명:
strnstr
함수는 문자열 내에서 부분 문자열을 찾는다는 점에서strstr
과 유사하지만, 검색 범위를 최대len
문자로 제한한다. 예를 들어 "Hello, world!"에서 "world"를 찾되 처음 7글자만 검색하라고 하면, 이 함수는 NULL을 반환한다(찾는 부분이 검색 범위를 벗어남).- 표준 대 구현: ISO C에 없기 때문에, 다른 플랫폼에서는 사용할 수 없을 수 있다(glibc에는 없지만, BSD 및 일부 다른 곳에는 있음). 의미는 다음과 같다:
needle
이 빈 문자열이면,strnstr
은 정의에 따라haystack
을 반환한다(시작 지점에 대한 포인터)[^18].needle
이haystack
의 처음len
문자 내에서 발견되지 않으면, NULL을 반환한다.needle
이 발견되면,haystack
내 해당 하위 문자열의 시작 부분에 대한 포인터를 반환한다.- 검색은 최대
haystack
의len
문자까지만 검사한다.haystack
이len
보다 짧으면, 실질적으로 전체 문자열을 검색한다(종결자 확인까지).
- 구현 접근 방식:
if (*needle == '\0') return (char*)haystack; size_t needle_len = strlen(needle); if (needle_len == 0) return (char*)haystack; if (len < needle_len) return NULL; // needle을 찾기에 공간이 부족함 size_t limit = len - needle_len + 1; for (size_t i = 0; i < limit && haystack[i] != '\0'; i++) { if (haystack[i] == needle[0]) { // 잠재적 일치 if (strncmp(haystack + i, needle, needle_len) == 0) { return (char*)(haystack + i); } } } return NULL;
[^17]: strnstr - man pages section 3: Basic Library Functions
[^18]: man page strnstr section 3 - manpagez문자열 비교:
strncmp
- 표준 동작:
int strncmp(const char *s1, const char *s2, size_t n)
은 C-문자열s1
과s2
의 최대n
문자를 비교한다.strcmp
와 유사하지만n
문자 이상을 비교하지 않는다. 비교는 사전식 순서로 이루어진다: 차이가 발견되거나n
문자가 비교되거나 널 종결자에 도달할 때까지 문자별로 비교한다. 문자열이 어떤 위치에서 다르고 아직n
에 도달하지 않았다면, 두 개의 다른 문자의 차이(종종(unsigned char)*s1 - (unsigned char)*s2
로 구현됨)를 반환한다. 한 쪽이 끝나거나n
문자가 동일했을 때까지 같다면, 함수는 둘 다 동시에 끝났거나n
문자가 동일했다면 0을 반환한다; 하나가 끝나고('\0'
으로) 다른 하나가 여전히 문자가 있고 아직 n을 비교하지 않았다면, 더 짧은 것을 "작다"고 간주한다. 요약하면:- 첫
n
문자에서s1
이 사전식 순서로s2
보다 작으면 < 0 반환. - 첫
n
문자가 같으면(또는n
이 0이면) 0 반환. - 첫
n
문자에서s1
이 사전식 순서로s2
보다 크면 > 0 반환.'\0'
이후의 바이트는 비교되지 않는다(널 종결자는 효과적으로 루프를 중단한다)^19.
- 첫
💡 초보자를 위한 설명:
strncmp
는 두 문자열을 최대 n개의 문자까지 사전식으로 비교한다. 사전식 비교란 알파벳 순서대로 비교하는 것을 의미한다. 예를 들어 "apple"과 "banana"를 비교하면 'a'는 'b'보다 사전식으로 앞에 있으므로 "apple"이 "더 작다". 또한 두 문자열이 동일하면 0을, 첫 번째 문자열이 사전식으로 앞에 있으면 음수를, 뒤에 있으면 양수를 반환한다.- 구현:
size_t i = 0; while (i < n) { unsigned char c1 = s1[i]; unsigned char c2 = s2[i]; if (c1 != c2) { return c1 - c2; } if (c1 == '\0' || c2 == '\0') { // 하나가 다른 것보다 짧거나 둘 다 끝났으면 중단 break; } i++; } // 여기에 도달했다면, n 문자를 비교했거나 널로 인해 중단되었음 if (i == n) { return 0; // 첫 n 문자가 같음 } // 널로 인해 중단했다면, 그 널까지 두 문자열이 같았음. return ((unsigned char)s1[i] - (unsigned char)s2[i]);
- 경계 조건:
n
이 0이면,strncmp
는 0을 반환해야 한다(아무것도 비교하지 않기 때문에). - 테스트: 첫 문자가 다른 문자열, 일부 같은 접두사 후 다른 문자열, 하나의 문자열이 다른 것의 접두사인 경우, 완전히 같은 문자열, 그리고 n이 길이보다 작거나, 같거나, 또는 더 큰 경우를 테스트하자.
메모리 검색 및 비교:
memchr
및memcmp
memchr
: 시그니처:void *memchr(const void *s, int c, size_t n)
. 메모리 영역s
의 첫n
바이트를 바이트 값c
(unsigned char로 해석됨)에 대해 스캔한다[^20]. 찾으면s
에서 일치하는 바이트에 대한 포인터를 반환하고, 첫n
바이트에서 바이트가 발견되지 않으면 NULL을 반환한다.
💡 초보자를 위한 설명:
memchr
은 메모리 블록에서 특정 바이트 값을 찾는 함수이다.strchr
과 유사하지만 문자열이 아닌 임의의 메모리에서 작동하기 때문에 널 종결자를 고려하지 않고 지정된 길이까지만 검색한다. 예를 들어 바이너리 데이터에서 특정 바이트를 찾을 때 유용하다.- 표준 동작: 이것은 기본적으로
strchr
(문자열을 검색하는 함수)와 동등한 메모리 버전이다. 그러나memchr
은 종결자에 의해 제한되지 않는다 — 검색할 길이를 지정해야 한다.n
이 0이면,memchr
은 NULL을 반환한다(확인할 바이트 없음).c
가 위치 i(s
에서 0-인덱스)에서 발견되면, void(또는 char)로 캐스팅된s + i
를 반환한다. 발견되지 않으면 NULL을 반환한다[^20]. - 구현:
0
부터n-1
까지 루프:unsigned char *p = (unsigned char*)s; for (size_t i = 0; i < n; i++) { if (p[i] == (unsigned char)c) { return p + i; } } return NULL;
- 고려 사항:
s
가 범위 내에'\0'
을 포함하더라도,memchr
은 다른 모든 바이트와 마찬가지로 취급한다(c가 0이고 범위 내에서 찾으면 중지). 따라서 문자열이 아닌 바이너리 데이터에 안전하게 사용할 수 있다.n == 0
경우를 처리하자(즉시 NULL 반환).s
가 NULL이고n > 0
이면, 그것은 유효하지 않다(정의되지 않은 동작) memcmp
: 시그니처:int memcmp(const void *s1, const void *s2, size_t n)
. 이는 메모리 영역s1
과s2
의 첫n
바이트를 비교한다.
💡 초보자를 위한 설명:
memcmp
는 두 메모리 영역을 바이트 단위로 비교하는 함수이다.strncmp
와 유사하지만 널 종결자에 중단되지 않고 정확히 지정된 바이트 수만큼 비교한다. 예를 들어 두 바이너리 데이터가 동일한지 확인하거나, 구조체의 내용을 비교할 때 사용할 수 있다.- 표준 동작:
s1
에서s2
와 다른 첫 번째 바이트가 각각s2
의 해당 바이트보다 작거나, 같거나, 큰 경우 <0, =0, 또는 >0인 정수를 반환한다[^21].n
이 0이면, 0을 반환한다(비교할 바이트 없음). 본질적으로, 임의의 메모리 데이터에 대한strncmp
처럼 작동하지만 널 종결자의 개념 없이 - 길이는 항상 지정된다. - 구현:
const unsigned char *p1 = s1; const unsigned char *p2 = s2; for (size_t i = 0; i < n; i++) { if (p1[i] != p2[i]) { return p1[i] - p2[i]; } } return 0;
- 고려 사항: n=0을 처리하자(즉시 0 반환). 포인터 중 하나가 NULL이고 n > 0이면, 정의되지 않는다. 또한, 메모리에서 바이트를 비교하는 것은 다중 바이트 값을 비교하는 것과 같지 않다는 점에 유의하자 - memcmp는 endianness나 데이터 유형을 모르고, 문자 그대로 바이트별로 비교한다.
[^20]: man memchr (1): find byte in memory - Manpages.org
[^21]: Memcmp implementation in C - etutorialspoint숫자 변환:
atoi
- 표준 동작:
int atoi(const char *str)
는 문자열str
의 초기 부분을int
값으로 변환한다. 선행 공백을 건너뛰고, 그 다음 선택적 플러스 또는 마이너스 기호, 그리고 숫자를 처리한다. 숫자가 아닌 첫 문자(또는 문자열 끝)에서 변환이 중지된다. 숫자가 발견되지 않으면, 결과는 0이다. 숫자가int
범위를 초과하면, 동작은 정의되지 않는다 (strtol
과 달리errno
를 통한 잘 정의된 오버플로우 처리가 없음)^22. 실제로, 많은 구현은 값이 int에 맞지 않을 경우 오버플로우하고 랩어라운드된다.
💡 초보자를 위한 설명:
atoi
(ASCII to Integer)는 문자열을 정수로 변환하는 함수이다. 공백을 건너뛰고, 부호를 처리한 다음, 연속된 숫자 문자를 정수 값으로 변환한다. 예를 들어 " -123abc"는 -123으로 변환된다. 비숫자 문자('a')에서 변환이 중단된다. 이 함수는 오류 처리가 제한적이어서 더 강력한strtol
함수를 대신 사용하는 것이 권장되기도 한다.- 구현: 다음과 같이 구현할 수 있다:
- 공백 문자(스페이스, 탭 등)를 건너뛴다. 표준은 "C" 로케일에서
isspace()
에 해당하는 것을 공백으로 정의한다. '-'
또는'+'
기호를 확인하여 결과가 음수인지 결정한다.- 숫자 [0–9]를 반복하며,
int
에 값을 누적한다. 각 숫자에 대해,value = value * 10 + (digit*value)
를 수행한다. - 숫자가 아닌 문자를 만나거나 문자열 끝에 도달하면 중지한다.
- 결과에 부호를 적용한다.
- 결과를 반환한다.
- 공백 문자(스페이스, 탭 등)를 건너뛴다. 표준은 "C" 로케일에서
- 오버플로우 우려: C 표준은 오버플로우를 정의되지 않은 상태로 남겨두기 때문에, 간단한 구현은 오버플로우를 확인하지 않고 랩되도록 할 수 있다. 그러나 강력한 구현은 다음 곱셈/추가에서
value
가 오버플로우되는지 확인할 수 있다. 오버플로우가 감지되면, 그에 따라 INT_MAX 또는 INT_MIN을 반환하도록 선택할 수 있지만,atoi
는 오류를 신호하도록 되어 있지 않다. - 고려 사항: 프로젝트가 아마도 수동으로 수행하길 기대하기 때문에 구현하기 위해
strtol
을 사용하지 말자. 또한,value
의 타입에 주의하자 - 오버플로우를 계산에서 캐치하려면long
을 사용해야 한다. 왜냐하면int
는 매우 긴 문자열의 경우 오버플로우되고 표준 C에서 부호 있는 int의 오버플로우는 정의되지 않기 때문이다. long으로 계산하고 끝에 캐스팅할 수 있다. - 경계 조건 테스트:
"42"
-> 42," -42abc"
-> -42('a'에서 중지)," +0"
-> 0," -+10"
-> 0(첫 번째 공백이 아닌 것은 '-'이고 그 다음 플러스는 유효하지 않은 형식이므로 숫자를 찾지 못함, 결과 0),"2147483647"
(INT_MAX) -> 2147483647,"2147483648"
(INT_MAX+1) -> 정의되지 않음(32비트 int에서 오버플로우되어 음수로 랩될 가능성이 높음).
메모리 할당:
calloc
및 동적 문자열 복제:strdup
calloc
: 시그니처:void *calloc(size_t count, size_t size)
. 각각size
바이트인count
요소의 배열을 위한 메모리를 할당하고, 할당된 메모리에 대한 포인터를 반환한다. 메모리는 0으로 설정된다. 할당이 실패하면 NULL을 반환한다.
💡 초보자를 위한 설명:
calloc
은 메모리를 할당하고 0으로 초기화하는 함수이다.malloc
과 유사하지만 두 가지 차이점이 있다. 첫째, 총 크기를 직접 지정하는 대신 요소 수와 요소 크기를 곱해 계산한다. 둘째, 할당된 메모리를 자동으로 0으로 초기화한다. 예를 들어calloc(5, sizeof(int))
는 5개의 int를 저장할 수 있는 메모리를 할당하고 모두 0으로 설정한다.- 표준 동작:
malloc
과의 두 가지 주요 차이점:- 총 바이트 수를 결정하기 위해
count * size
를 곱한다. - 할당된 메모리를 0으로 초기화한다.
count * size
가size_t
의 표현 가능한 범위를 오버플로우하면(즉, 요청된 크기가 너무 큼), 좋은 구현은 잘못된(더 작은) 양의 메모리를 할당하는 대신 실패(NULL 반환)해야 한다[^24]. 실제로, OpenBSD man 페이지와 POSIX는 곱셈이 오버플로우되면calloc
이 NULL을 반환하고errno
를 ENOMEM으로 설정해야 한다고 말한다[^24]. - 총 바이트 수를 결정하기 위해
- 구현: libft에서는 실제로 메모리를 할당할 수 없을 수 있다(
malloc
이 일반적으로 금지되거나 허용될 수 있음).malloc
을 사용할 수 있다고 가정하면:- 오버플로우 확인:
count != 0 && size > SIZE_MAX / count
이면 곱셈이 오버플로우되므로 NULL을 반환한다. - 그렇지 않으면
bytes = count * size
를 계산한다. malloc(bytes)
를 사용하여 할당한다.- malloc이 성공하면, 메모리를 0으로
memset
한다. - 포인터를 반환한다.
- 오버플로우 확인:
- 경계 조건: 곱셈을 오버플로우하는 매우 큰 값이 전달되면, 우아하게 실패하는지 테스트하자. 또한
calloc(0,5)
또는(5,0)
또는(0,0)
시나리오를 테스트하자. 표준은 NULL 또는 해제할 수 있는 포인터 중 하나가 괜찮다고 말한다[^25]. strdup
: 시그니처:char *strdup(const char *s)
. 이 함수는 ISO C90에는 없지만 POSIX에 명시되어 있다. 문자열s
를 복사하기에 충분한 메모리를 할당하고(종료 널 포함) 새 복사본에 대한 포인터를 반환한다[^27]. 메모리는malloc
(또는 동등한 것)으로 얻어지며, 더 이상 필요하지 않을 때 호출자가 해제해야 한다[^28]. 메모리 할당이 실패하면 NULL을 반환한다.
💡 초보자를 위한 설명:
strdup
은 문자열의 복제본을 만드는 함수이다. 원본 문자열과 동일한 내용을 가진 새로운 문자열을 위한 메모리를 할당하고, 그 문자열을 복사하여 포인터를 반환한다. 이 함수는 메모리 관리가 필요한 경우에 유용하다. 예를 들어 함수 내에서 지역 변수로 생성된 문자열을 함수 외부로 반환해야 할 때 사용할 수 있다. 반환된 문자열은 사용 후free
로 해제해야 한다.- 표준 동작: 본질적으로 다음을 수행한다:
size_t len = strlen(s); char *new = malloc(len + 1); if (!new) return NULL; memcpy(new, s, len+1); // +1은 널 종결자 포함 return new;
- 고려 사항: libft가 이 구현에
malloc
을 허용할 가능성이 높기 때문에, 실패를 처리하는지 확인하자.malloc
이 NULL을 반환하면, 그것을 전파하자(strdup은 NULL을 반환). 그렇지 않으면 복사하자. 이전 부분에서ft_strlen
과ft_memcpy
를 사용하여 구현할 수 있다. 널 종결자도 복사하는 것에 주의하자. 또한, 반환 타입은char*
이다(const가 아님, 이는 새로운 수정 가능한 복사본이므로). - 메모리 및 성능: 복잡도는 길이와 복사로 인해 O(n)이다. 최적화할 것이 많지 않다. 간단하다. 널 공간 할당이나 복사 길이의 off-by-one 오류와 같은 실수를 피하자.
[^24]: malloc(3) - OpenBSD manual pages
[^25]: 内存管理函数*申请内存空间api-CSDN博客
[^26]: MEM07-C. Ensure that the arguments to calloc(), when multiplied ...
[^27]: A question about strdup() - FedoraForum.org
[^28]: strdup(3p) - Arch Linux manual pages성능 최적화 고려 사항
이러한 함수의 올바른 구현이 주요 목표이지만, 최적화 방법을 이해하는 것은 실제 라이브러리가 libc 함수를 최적화하는 데 많은 투자를 하기 때문에 가치가 있다. 다음은 성능 관련 인사이트이다:
- SIMD(벡터 명령어) 사용: 현대 CPU에는 하나의 명령어로 여러 데이터 포인트를 처리할 수 있는 SIMD 명령어 집합(x86의 SSE2/AVX, ARM의 NEON 등)이 있다. 많은 libc 구현은
memcpy
,memmove
,memset
,memcmp
등의 함수에 이를 활용한다. 예를 들어,memset
,memcpy
,memmove
의 일부 구현은 SSE2 명령어를 사용하여 한 번에 16바이트를 이동하여 대규모 메모리 연산에서 처리량을 크게 증가시킨다[^29]. 마찬가지로,memcmp
는 바이트별 비교 대신 16바이트 청크를 병렬로 비교할 수 있다. SIMD를 활용하면 큰 입력에 대해 이러한 연산을 한 자릿수 더 빠르게 만들 수 있다.
💡 초보자를 위한 설명: SIMD(Single Instruction, Multiple Data)는 CPU가 한 번의 명령으로 여러 데이터를 동시에 처리할 수 있게 하는 기술이다. 예를 들어 일반적인 연산은 한 번에 하나의 값을 처리하지만, SIMD는 한 명령으로 4개, 8개 또는 그 이상의 값을 병렬로 처리할 수 있어 성능이 크게 향상된다. 메모리 복사와 같은 작업에서 특히 유용하다.
- 루프 언롤링 및 Duff의 장치: 루프 언롤링은 루프가 한 번의 반복에서 여러 반복의 작업을 수행하는 수동 최적화로, 루프 오버헤드(증가, 비교, 점프)를 줄인다. 예를 들어, 루프에서 1바이트씩 복사하는 대신, 각 반복에서 4바이트를 복사하고(나머지는 별도로 처리) 한다. Duff의 장치는 C에서 루프와 switch-case를 교차시켜 트릭한 방식으로 루프를 펼치는 기술이다. 이는 역사적으로 오래된 컴파일러에서 메모리 설정/복사를 최적화하는 데 사용되었다[^30]. 오늘날의 컴파일러는 종종 간단한 루프를 자동으로 펼칠 수 있지만, 때로는 고정된 작은 카운트나 특정 패턴을 최적화하기 위해 수동 언롤링이 여전히 도움이 될 수 있다. 언롤링은 또한 명령어 파이프라이닝을 돕는다 – 여러 복사 작업이 동시에 진행되도록 한다. 단점은 코드 크기 증가와 복잡성이다.
💡 초보자를 위한 설명: 루프 언롤링은 반복 횟수를 줄이기 위해 루프 내용을 여러 번 복제하는 최적화 기법이다. 예를 들어 1부터 10까지 더하는 루프에서 매번 1씩 증가하는 대신, 한 번의 반복에서 2개의 숫자를 더하면 루프 반복 횟수가 절반으로 줄어든다. 이는 반복 확인, 증가, 점프 등의 루프 오버헤드를 줄여 속도를 높인다.
- 워드 단위 작업: 이러한 함수의 많은 부분은 정렬이 허용되면 기계 워드(예: 32비트 또는 64비트) 청크로 작동할 수 있다. 예를 들어,
strlen
은 종종 각 바이트를 개별적으로 확인하는 대신sizeof(size_t)
바이트를 한 번에 읽고 비트 트릭을 사용하여 워드 내에서 0 바이트를 감지하여 종결자를 찾는 속도를 높인다.memchr
(대상 문자에 대해 8바이트를 한 번에 확인) 및memcpy
(한 번에 8 또는 16바이트 복사)에 대한 유사한 트릭이 있다. 아이디어는 긴 데이터에 대한 반복 횟수를 줄이는 것이다. 메모리 정렬에 주의해야 한다 – 일부 아키텍처에서는 비정렬 주소에서 4바이트 또는 8바이트 워드를 읽으면 오류나 성능 저하가 발생할 수 있다. x86은 관대하다(단지 성능 저하가 있음). 반면 오래된 ARM 아키텍처는 정렬되지 않은 액세스에서 오류가 발생했다(ARMv7 이상은 대부분의 작업에 대해 비정렬을 허용하지만 몇 가지 주의 사항이 있음). 따라서 최적화된 구현은 종종 먼저 포인터를 정렬(dst 또는 src가 워드 정렬될 때까지 바이트 복사)한 다음, 워드를 복사하고, 남은 바이트를 복사한다.
💡 초보자를 위한 설명: 워드 단위 작업은 컴퓨터의 자연 데이터 크기(32비트 또는 64비트)로 한 번에 더 많은 데이터를 처리하는 기법이다. 예를 들어 메모리를 1바이트씩 복사하는 대신 4바이트 또는 8바이트씩 복사하면 더 효율적이다. 이는 마치 책을 한 글자씩 옮기는 대신 한 페이지씩 옮기는 것과 같다.
- CPU 캐시 고려 사항: 메모리 작업은 캐시에 매우 민감하다. 메모리에 순차적으로 액세스하는 것은 일반적으로 캐시 친화적이다(프리페칭 및 캐시 라인 버스트로 인해 순차적 복사가 빠름). 그러나 대규모 작업은 여전히 캐시를 넘칠 수 있다. 일부 최적화는 x86에서 비시간적 쓰기(예:
MOVNTDQ
명령어 사용)를 사용하여 큰 블록에 대해 캐시를 오염시키지 않고 메모리를 복사한다. 이는 캐시에 즉시 필요하지 않다는 가정 하에 사용된다. 이는 glibc에서 큰memset
/memcpy
에 사용되어 큰 블록을 복사할 때 성능을 향상시킨다[^31]. 또한, 대상을 캐시 라인(일반적으로 64바이트)에 정렬하면 분할 캐시 라인 작업을 줄이기 때문에 복사 속도를 향상시킬 수 있다[^32]. 중요한 시나리오에서, 개발자는 프리페칭을 고려한다: CPU가 실제로 처리하기 전에 미래 주소를 읽어 캐시로 가져와 대규모 이동에 대한 메모리 지연을 숨긴다[^33].
💡 초보자를 위한 설명: CPU 캐시는 자주 사용하는 데이터를 RAM보다 훨씬 빠른 메모리에 저장하는 시스템이다. 데이터가 캐시에 있으면 접근 속도가 10배 이상 빨라진다. 성능 최적화에서는 캐시 친화적인 방식으로 메모리에 접근하는 것이 중요하다. 순차적으로 메모리에 접근하면 CPU가 앞으로 필요할 데이터를 미리 캐시에 로드할 수 있어 훨씬 효율적이다.
- 다양한 아키텍처:
- x86_64에서: SSE2(128비트 레지스터)가 보편적으로 있으며, 종종 AVX(256비트) 및 새로운 CPU에서는 AVX512가 있다. Glibc는 ifunc 또는 CPU 기능 검사를 통해 동적으로 사용 가능한 최상의 명령어를 사용한다. 예를 들어, 큰 청크에 대해 256비트 너비 복사를 사용할 수 있다. X86에는 또한
REP MOVSB
및REP STOSB
와 같은 메모리를 효율적으로 복사/설정하는 하드웨어 구현 특수 명령어가 있다. - ARM(AArch64)에서: SIMD를 위한 NEON(128비트)이 있다. 또한, ARMv8은 DC ZVA(Data Cache Zero by Address)와 같은 특수 명령어를 도입했는데, 이는 한 번에 전체 캐시 라인을 0으로 설정한다. libc는 ARM에서 큰 크기에 대한
memset
구현에 DC ZVA를 사용한다 — 한 명령어로 64바이트를 0으로 설정할 수 있어 매우 빠르다[^34]. 복사를 위해 ARM은 한 번에 16바이트를 이동하기 위해 LDP/STP(load/store pair)를 사용할 수 있다. - 각 아키텍처는 특이점이 있을 수 있다; 최적화된 코드는 종종 여러 버전을 가진다(작은 크기용, 큰 크기용, 포인터가 특정 방식으로 정렬되었을 때 등). libft 구현자로서, 전부 구현할 필요는 없지만, "프로"가 하는 일을 아는 것이 유용하다.
- x86_64에서: SSE2(128비트 레지스터)가 보편적으로 있으며, 종종 AVX(256비트) 및 새로운 CPU에서는 AVX512가 있다. Glibc는 ifunc 또는 CPU 기능 검사를 통해 동적으로 사용 가능한 최상의 명령어를 사용한다. 예를 들어, 큰 청크에 대해 256비트 너비 복사를 사용할 수 있다. X86에는 또한
💡 초보자를 위한 설명: 다양한 CPU 아키텍처(x86, ARM 등)는 각각 고유한 명령어 집합과 최적화 기회를 제공한다. 예를 들어 x86 프로세서는 메모리 복사를 위한 특수 명령어를 가지고 있고, ARM 프로세서는 메모리를 0으로 설정하는 효율적인 방법을 제공한다. 전문적인 C 라이브러리는 이러한 특성을 활용하여 각 플랫폼에 최적화된 구현을 제공한다.
- 고수준 언어 vs C 구현: 일부 언어의 표준 라이브러리가 이러한 루틴을 구현하는 방식을 지칭할 수 있다. 예를 들어, C++의
std::copy
는 크고 간단히 복사 가능한 유형에 대해 결국 memmove 또는 어셈블리를 호출할 수 있다. Java나 C#과 같은 관리형 언어에서, 그들의Array.Copy
는 런타임에서 고도로 최적화된다(종종 밑에서 C/C++로). 일반적으로, 저수준에서, C는 최대한 메탈에 가깝기 때문에, 추가 최적화는 종종 어셈블리 언어나 컴파일러 내장 함수로 들어가는 것을 포함한다. 현대 컴파일러는 이러한 종류의 단순한 루프를 패턴을 감지하면 꽤 잘 최적화한다. - 불필요하게 바퀴를 재발명하지 말자: 최적화의 한 가지 교훈은 자신만의 고성능 버전을 작성하는 것이 사소하지 않다는 것이다. 실제로, 일반적인 조언은 성능 중요 작업에 표준 라이브러리를 의존하는 것이다. 왜냐하면 그들은 플랫폼에 맞게 조정되어 있기 때문이다[^35]. 예를 들어, 한 개발자는
std::memcpy
(즉, libc memcpy)가 "하드웨어에 적응하면서 탁월한 성능을 제공하며, 메모리 정렬에 대한 가정을 하지 않는다"[^35]라고 측정했다. 다시 말해, 라이브러리 구현은 이미 최상의 기술을 사용할 가능성이 높다; 순수 C로 순진하게 재구현하면 크게 성능이 떨어질 수 있다. libft의 경우, 학습 연습이므로 괜찮지만, 프로덕션에서는 매우 특정한 사용 사례가 없는 한memcpy
를 능가하기 어렵다. - 특정 트릭: 다른 마이크로 최적화 포함:
- 여러 문자를 확인하기 위한 비트 트릭 사용: 예를 들어,
strlen
의 경우, 워드에 0 바이트가 있는지 확인하는 알려진 트릭은 다음과 같은 비트 연산을 사용하는 것이다:(x - 0x01010101UL) & ~x & 0x80808080UL
은x
의 어떤 바이트가 0이면 0이 아닌 값이 된다(이는 32비트 청크에 대해 작동한다; 64비트 버전도 있다). 이는 널 종결자를 찾는 속도를 높일 수 있다. - 타이트한 루프에서 분기 예측 실패를 피하기 위해 분기를 정렬하거나 조건부 이동 사용.
- 유익한 곳에서 코드를 인라인화하여 호출 오버헤드를 피하는 것.
- 매우 큰 복사에 대한 프리페치 명령어 사용(필요하기 전에 데이터를 캐시로 가져옴).
- 작은 비효율성 피하기: 예를 들어,
toupper
에서 각 글자에 대해 if 캐스케이드를 사용하는 것보다 하나의 비교 범위 검사가 더 빠르다.
- 여러 문자를 확인하기 위한 비트 트릭 사용: 예를 들어,
💡 초보자를 위한 설명: 실제 프로그래밍에서는 표준 라이브러리 함수를 다시 구현하는 것보다 기존 함수를 사용하는 것이 대부분 더 효율적이다. 표준 라이브러리는 이미 고도로 최적화되어 있고 다양한 하드웨어와 환경에서 테스트되었기 때문이다. Libft 프로젝트는 이러한 함수들이 어떻게 작동하는지 이해하기 위한 교육용 목적이 크다.
그러나 조기 최적화가 버그의 근원이 될 수 있다는 점을 기억하자. libft의 경우, 먼저 정확성에 초점을 맞추자; 위의 기술은 이 단계에서 더 교육적이다.
[^29]: Improving performance with SIMD intrinsics in three use cases
[^30]: Duff's Device - SoByte
[^31]: Intel Lands A Nice Memset Performance Optimization In Glibc
[^32]: Optimizing memcpy improves speed (Hint: The obvious byte ... - Reddit
[^33]: Fast memcpy, A System Design - SIGARCH
[^34]: AArch64: Optimize memset - glibc - Fork of glibc for development
[^35]: Going faster than memcpy - Squadrick보안 고려 사항
저수준 C 함수는 종종 메모리 관련 보안 함정을 가지고 있다. 이러한 함수를 재구현할 때는 버퍼 오버플로우와 같은 문제를 이해하고 완화하는 것이 중요하다:
- 버퍼 오버플로우/언더플로우 방지: 메모리와 문자열을 다루는 함수는 올바르게 사용되지 않으면 버퍼 오버플로우 취약성의 일반적인 원인이다. 예를 들어, 적절한 크기 검사 없이
strcpy
또는strcat
를 사용하면 배열 끝을 넘어 쓸 수 있다. libft를 구현할 때, 함수 자체가 올바르게 사용된다면 메모리를 오버플로우하지 않도록 하자.strlcpy
/strlcat
와 같은 함수는 버퍼 크기를 요구하고 최대 size-1 쓰기를 수행함으로써 오버플로우를 방지하도록 명시적으로 설계되었다[^36].strlcpy/strlcat
를 구현할 때, 버퍼를 넘어 한 바이트를 쓸 수 있는 off-by-one 오류를 피하기 위해 모든 포인터 산술을 두 번 확인하자(취약성을 도입할 수 있는 고전적인 버그).memcpy
/memmove
의 경우, 정확히n
바이트만 복사하는지, 더 많거나 적지 않은지, 그리고 루프가 경계를 올바르게 처리하는지 확인하자.
💡 초보자를 위한 설명: 버퍼 오버플로우는 배열이나 버퍼의 경계를 넘어 데이터가 쓰여질 때 발생하는 심각한 보안 취약점이다. 이는 마치 컵에 너무 많은 물을 부어 넘치게 하는 것과 같다. 프로그래밍에서 이러한 오버플로우는 시스템을 해킹하는 데 이용될 수 있어 매우 위험하다. 안전한 함수는 항상 버퍼 크기를 고려하고 쓰기 작업을 제한한다.
memcpy
vsmemmove
안전한 사용: 논의된 대로,memcpy
는 겹치는 버퍼를 처리하지 않는다. 프로그래머가 실수로 겹치는 영역에 대해memcpy
를 사용하면 데이터 손상을 일으킬 수 있다. 이는 논리적 버그이지만, 중요한 데이터나 메타데이터를 손상시킨다면 잠재적인 보안 문제가 될 수도 있다.memmove
는 약간의 성능 저하를 감수하고 항상 겹침에 대해 안전하다[^37]. 보안 중심 접근 방식은 확실하지 않다면memmove
를 사용하는 것이다. 일부 코딩 표준은 겹침이 없다는 것을 알지 않는 한memmove
를 선호한다. 왜냐하면 알 수 없는 시나리오에서memcpy
를 사용하면 미묘한 버그가 발생할 수 있으며, 이는 악용 가능할 수 있기 때문이다. libft에서는 두 가지를 모두 구현하지만, 자신의 코드에서 사용할 때는 그 차이를 이해하는 것이 도움이 된다.
💡 초보자를 위한 설명:
memcpy
와memmove
의 주요 차이점은 메모리 영역이 겹칠 때의 동작이다.memcpy
는 단순히 소스에서 목적지로 바이트를 복사하므로, 겹치는 경우 데이터가 손상될 수 있다. 반면memmove
는 임시 버퍼를 사용하거나 복사 방향을 조정하여 겹치는 경우에도 안전하게 작동한다. 보안 관점에서는 메모리 영역이 겹치지 않는다고 확신할 수 없을 때 항상memmove
를 사용하는 것이 좋다.- 더 안전한 대안 및 현대적 함수: 일부 표준 함수는 안전하지 않은 것으로 간주된다. 예를 들어,
gets()
(우리 목록에는 없지만, 개념으로)는 입력이 버퍼보다 크면 항상 오버플로우되기 때문에 C에서 제거되었다.strcpy
/strcat
와 같은 함수는strlcpy/strlcat
또는 최소한 신중하게 사용하는strncpy/strncat
을 선호한다. 언급한 바와 같이,strncpy
도 문제가 있다. 현대 코드에서는 또한 버퍼에 문자열을 안전하게 구성하기 위한snprintf
등이 있다. libft는 이러한 "더 안전한" 함수 중 일부(strlcpy
,strlcat
)를 재구현한다. 이들이 존재하는 이유(특정 오버플로우를 방지하기 위해)를 아는 것이 중요하다. 예를 들어,strlcpy
는 항상 종료 널을 작성하므로, 후속 작업에서 오버런을 일으킬 수 있는 종료되지 않은 문자열 생성을 방지한다[^36]. - 메모리 취약성 완화: 운영 체제와 컴파일러는 버퍼 오버플로우와 같은 메모리 오류를 완화하기 위한 여러 메커니즘을 가지고 있다:
- ASLR(주소 공간 레이아웃 무작위화): 이는 프로그램 실행 시마다 메모리 주소 레이아웃을 무작위화한다[^38]. 함수 구현 관점에서, ASLR은 코딩 방식을 변경하지 않지만, 오버플로우가 발생하더라도 공격자가 중요한 주소(예: 주입된 쉘코드의 위치나 반환 주소)를 쉽게 추측할 수 없다는 것을 의미한다. 그러나 ASLR은 오버플로우가 주소를 누출하거나 알려진 기준에 대해 상대적인 함수 포인터를 덮어쓸 수 있다면 무력화될 수 있다. 따라서 이는 안전망이지 보장이 아니다.
💡 초보자를 위한 설명: ASLR은 프로그램이 실행될 때마다 메모리 주소를 무작위로 배치하는 보안 기술이다. 이는 마치 방어하기 위해 매일 다른 경로로 출퇴근하는 것과 같다. 공격자가 특정 메모리 주소를 찾아 악용하기 어렵게 만든다. 그러나 이는 완벽한 보호책이 아니므로, 여전히 취약점이 없는 코드를 작성하는 것이 중요하다.
- DEP/NX(데이터 실행 방지 / 비실행 메모리): 이는 데이터 메모리(스택, 힙)를 비실행으로 표시한다. 따라서 버퍼 오버플로우로 공격자가 버퍼에 기계 코드를 주입하더라도, 그 메모리는 코드를 실행하지 않기 때문에 직접 실행할 수 없다. 이는 공격을 반환 지향 프로그래밍(ROP) 또는 다른 기술로 전환시킨다. 우리 함수의 경우, 이는
strcpy
에서의 고전적인 스택 버퍼 오버플로우가 스택에서 코드를 직접 실행하는 것으로 이어지지 않지만, 여전히 반환 주소를 오버라이드할 수 있다는 것을 의미한다. - 스택 카나리: 컴파일러는 저장된 반환 포인터 앞의 스택에 카나리 값을 삽입할 수 있다. 버퍼 오버플로우가 반환 포인터를 덮어쓰면, 카나리도 덮어쓴다. 함수가 반환하기 전에, 카나리가 온전한지 확인한다; 그렇지 않으면, 버퍼 오버플로우가 발생했음을 알고 프로그램을 중단할 수 있다[^39]. 우리 구현은 할당된 버퍼 너머로 쓰는 것이 이러한 카나리를 트리거하거나 충돌을 일으킬 것임을 염두에 두어야 한다. 이는 좋은 일이다 – 메모리를 조용히 손상시키는 것보다 충돌하는 것이 낫다. 올바른 사용으로 이들이 트리거되지 않도록 하는 것이 목표이다.
💡 초보자를 위한 설명: 스택 카나리는 버퍼 오버플로우 공격을 감지하기 위한 보안 기법이다. 함수가 실행될 때 반환 주소 앞에 특별한 값(카나리)이 배치되고, 함수가 끝날 때 이 값이 변경되었는지 확인한다. 이는 마치 금고에 침입자가 있는지 알려주는 경보 시스템과 같다. 공격자가 메모리를 덮어쓰면 카나리 값도 손상되어 공격이 감지된다.
- FORTIFY_SOURCE: 이는 glibc/컴파일러의 기능으로,
memcpy
,strcpy
와 같은 특정 호출을 알려진 버퍼 크기가 사용 가능할 때 더 안전한 버전으로 대체한다. 예를 들어, 컴파일러가 대상 버퍼 크기를 알고 있다면(alloca
또는 정적 배열에서),memcpy
길이가 초과하는지 확인할 수 있다. 그렇다면, 런타임(또는 때로는 컴파일 타임)에 오류를 트리거한다[^40]. 이는 직접 구현하는 것이 아니라, 함수가 드롭인 대체로 사용될 때 이러한 검사가 없다는 것을 알아두는 것이 좋다. - 메모리 할당 보안:
calloc
및strdup
를 구현할 때, 실패 시 어떤 일이 일어나는지 고려하자. 실패 시 항상 NULL을 반환하는 것이 중요하다(초기화되지 않은 포인터를 반환하지 않음). 메모리 부족 상황에서,calloc
이 실패하고 어쨌든 포인터를 반환하면, 호출자는 잘못된 포인터를 사용하여 예측할 수 없는 동작을 일으킬 수 있다. 또한,calloc
에서 0으로 초기화하는 것은 초기화되지 않은 메모리 사용을 방지하는 데 도움이 된다. 이는 이전 사용에서 OS에 의해 사용된 잔여 데이터(잠재적으로 민감한 데이터)를 포함할 수 있다. 이는 미묘한 보안 향상이다:malloc
은 성능상의 이유로 메모리를 0으로 만들지 않기 때문에, 할당하고 설정하지 않고 사용하면 실수로 데이터를 노출할 수 있다.calloc
은 깨끗한 슬레이트를 보장한다.
💡 초보자를 위한 설명: 메모리 할당의 보안 측면에서 두 가지 주요 원칙이 있다. 첫째, 할당이 실패하면 항상 NULL을 반환하여 프로그램이 유효하지 않은 메모리를 사용하지 않도록 해야 한다. 둘째, 초기화되지 않은 메모리 사용은 민감한 정보 누출로 이어질 수 있다.
calloc
은 메모리를 0으로 초기화하여 이 문제를 해결한다.- 할당에서의 정수 오버플로우: 언급한 바와 같이,
count
와size
가 사용자 제어(파일이나 입력에서 읽는 등)인 경우, 공격자는count * size
곱셈에서 정수 오버플로우를 악용하려고 할 수 있다. 예를 들어, 32비트size_t
에서,count=0xFFFF_FFFF
과size=16
은 작은 수로 오버플로우된다. 검사 없이,calloc
은 작은 버퍼(예상보다 적은 바이트)를 할당하지만 프로그램은count*size
바이트를 얻었다고 가정하고 진행하여 힙 오버플로우를 일으킬 수 있다. 따라서, 그 오버플로우를 처리하는 것은 보안 요구 사항이다[^26]. 우리는ft_calloc
에 그 검사를 포함할 것이다. 표준 libc의calloc
은 이 검사를 수행하고 안전하게 실패한다[^24]. - Off-by-one 오류: 구현에서 많은 보안 버그는 off-by-one 오류에서 비롯된다 – 너무 많은 바이트를 쓰거나, 문자열 종결을 잊는 등. 예를 들어, 부주의한
strcpy
구현은 종결자를 복사한 후 무언가를 추가하여 두 개의 널(양성)을 추가하거나 종결자를 놓칠 수 있다(양성 아님). 고전적인 예는strncat
을 잘못 구현하여 종결자를 넣지 않는 것이다. 우리 집합에서,strlcpy
/strlcat
은 종결자를 신중하게 처리해야 한다. 엣지 케이스를 테스트하면 이를 잡을 수 있다. 보안을 위해, 항상 약간 적게 복사하는 것이 과도하게 복사하는 것보다 낫다.
💡 초보자를 위한 설명: Off-by-one 오류는 반복 횟수나 버퍼 크기를 계산할 때 정확히 1만큼 틀리는 흔한 프로그래밍 실수이다. 예를 들어 10개 요소가 있는 배열에서 인덱스 0부터 10까지 반복하면 마지막 인덱스(10)가 배열 범위를 벗어난다. 이러한 오류는 보안 취약점으로 이어질 수 있어 특히 주의해야 한다.
- Assert 또는 Sanitizer 사용: 이를 개발하는 동안, AddressSanitizer나 Valgrind와 같은 도구를 사용하여 경계를 벗어난 읽기/쓰기를 감지하는 것이 도움이 된다(이는 테스트에서 버퍼 언더플로우/오버플로우를 잡는 방법). 42 커리큘럼에서는 이러한 도구를 사용하지 않을 수 있지만, 일반적으로 좋은 방법이다.
- 시간 및 사이드 채널 고려 사항: 이러한 특정 함수에서는 일반적으로 큰 관심사가 아니지만, 때로는
memcmp
또는strncmp
가 타이밍이 중요한 보안 컨텍스트에서 사용될 수 있다(해시나 비밀 비교와 같이, 조기 불일치가 타이밍 차이를 통해 접두사가 얼마나 일치하는지 드러낼 수 있음). 이러한 경우, 일정 시간 비교가 필요하다(첫 불일치에서 중단되지 않음). 표준memcmp
/strcmp
는 일정 시간이 아니다; 차이가 발견되는 즉시 반환한다. 이를 암호화 비밀을 비교하는 데 사용하면 타이밍 누출이 될 수 있다. libft 컨텍스트에서는 일정 시간 동작을 구현할 필요가 없지만, 보안에 민감한 앱에서는 고려 사항이다.
요약하면, 핵심 보안 요점: 오버플로우를 방지하기 위해 길이 검증(버퍼 오버플로우와 정수 오버플로우 모두), 불확실한 겹침에 대해 안전한 함수 선호(
memcpy
보다memmove
), 항상 문자열을 올바르게 널 종결, 그리고 할당 실패를 우아하게 처리하자. 현대 시스템은 ASLR, DEP, 카나리와 같은 완화 기능을 제공하지만[^38][^39], 이에 의존하지 말고 처음부터 올바르고 안전한 코드를 작성하는 것이 가장 좋다.[^36]: What is the purpose of strlcpy and what was in the first version of its ...
[^37]: How does memmove allow the copying to be done in a non ... - Reddit
[^38]: ASLR and Buffer Overflow Attacks - Blue Goat Cyber
[^39]: Stack Canaries – Gingerly Sidestepping the Cage - SANS Institute
[^40]: A developer's guide to secure coding with FORTIFY_SOURCE경계 조건 및 테스트 전략
libft 구현이 강력한지 확인하기 위해 수많은 경계 조건을 고려하고 테스트를 설계해야 한다:
- NULL 입력: 이러한 함수 대부분은 입력으로 NULL 포인터에 대한 유효한 동작을 정의하지 않는다(사소한 경우 제외). 예를 들어,
ft_strlen(NULL)
은 정의되지 않았다 - 함수가 널 포인터를 역참조하기 때문에 아마도 충돌할 것이다. 모든 함수에서 NULL에 대한 특별한 검사를 추가할 필요는 없다(표준은 그렇게 하지 않음). 그러나 인식하고 호출하면 어떤 일이 일어나는지 테스트할 수 있다(아마도 세그폴트). 하지만 내부적으로 0-크기 할당을 처리할 수 있는ft_calloc(0,0)
과 같은 하나의 함수는 처리할 수 있다. 또한,ft_strlcpy(dst, NULL, size)
는 size가 0이면 충돌하지 않아야 한다(src를 건드리지 않을 수 있으므로). 그러나, size > 0이면 충돌한다. 제어된 라이브러리에서는 정의되지 않는 한 호출자가 NULL을 전달하지 않을 책임이 있다. 테스트를 작성할 때, 놀라움이 없는지 확인하기 위해 몇 가지를 포함할 수 있다: 예를 들어,ft_memset(NULL, 'A', 0)
를 호출하면 아무것도 하지 않고 충돌하지 않아야 한다(길이가 0이므로 아무 작업도 하지 않음).
💡 초보자를 위한 설명: NULL 입력을 처리하는 것은 많은 함수에서 중요한 고려사항이다. NULL은 '아무것도 가리키지 않는 포인터'를 나타내며, 역참조하면 프로그램이 충돌할 수 있다. 표준 C 라이브러리 함수는 일반적으로 NULL 입력에 대한 동작을 정의하지 않는다. Libft에서도 표준과 일관성을 유지하는 것이 중요하지만, 특정 함수에서는 추가적인 안전장치를 고려할 수 있다.
- 경계 값 및 극단적 크기:
- 도메인의 가장자리에서 함수를 테스트한다.
ft_isascii
의 경우,c = -1
과c = 128
을 테스트한다(unsigned char로 표현할 수 없어 0을 반환해야 함, 그리고 128은 0-127 범위 밖이므로 마찬가지로 0을 반환해야 함).ft_isalpha
의 경우,'A'
,'Z'
,'a'
,'z'
(모두 true여야 함), 그리고 '@'(범위 바로 바깥) 및 '{'(바로 'z' 뒤) 등 이러한 범위 바로 바깥의 문자들은 모두 false를 반환해야 한다. 또한 0, 127, 그리고 아마도 128이나 255와 같은 값을 테스트하여 캐스팅을 어떻게 처리했는지 확인하자.
- 도메인의 가장자리에서 함수를 테스트한다.
💡 초보자를 위한 설명: 경계 값 테스트는 함수의 허용 범위 경계에서 동작을 확인하는 중요한 테스트 기법이다. 예를 들어 배열 크기가 10인 경우, 인덱스 0, 9, 10을 테스트한다. 이러한 테스트는 "경계 부근에서 버그가 발생하기 쉽다"는 원칙에 기반한다. 극단적 크기(매우 큰 값 또는 작은 값)도 함수의 견고성을 확인하는 데 중요하다.
ft_toupper/ft_tolower
의 경우, 경계 문자와 비문자(변경되지 않아야 함)를 테스트한다.ft_strlen
의 경우, 빈 문자열, 1-문자 문자열, 매우 큰 문자열에서 테스트한다. 가능하다면, (아마도 몇 천 문자의) 큰 버퍼를 만들어 성능이 괜찮고 결과가 정확한지 확인하자. 또한 널 종결자가 아닌 다른 문자에서 중지하지 않고 널 종결자에서 중지하는지 확인하기 위해 비인쇄 문자가 있는 문자열도 테스트한다.ft_memset
의 경우,len = 0
(메모리를 전혀 변경하지 않아야 함)과 일반 길이로 테스트한다.ft_bzero
의 경우, 0과 함께하는 memset과 유사하다.ft_memcpy
의 경우, 버퍼의 일반 복사를 테스트하고, 소스/목적지가 겹치는 경우를 시도하여 처리하지 않는지 확인한다(겹쳐서 손상이 발생하면, 그것은 memmove가 아니기 때문에 예상된 결과이다).ft_memmove
의 경우, 양방향으로 겹치는 복사를 테스트한다: 예를 들어, "1234567890" 버퍼를 만들고, ft_memmove(buf+2, buf, 5)를 수행하여 겹침(목적지가 소스 내부에서 시작)이 올바르게 처리되는지 확인한다. 또한 역방향도 테스트한다: ft_memmove(buf, buf+2, 5). 예상 결과 또는memmove
와 비교하자. 또한 비겹침도 일관성 검사로 테스트한다(memcpy처럼 동작해야 함).ft_strlcpy
: 이것은 많은 엣지 시나리오가 있다.dstsize = 0
으로 테스트한다(src 길이를 반환하고 dst에는 아무것도 하지 않아야 함). dstsize = 1로 테스트한다(최대 0 문자를 복사하고 여전히 널 종결해야 함, 실질적으로 dst가 src가 비어 있지 않다면 빈 문자열로 만들고, src 길이를 반환). dstsize가 src 길이보다 큰 경우 테스트한다(널을 포함한 전체 src를 복사하고 src 길이를 반환해야 함). dstsize가 정확히 src 길이인 경우 테스트한다(src 길이-1 문자와 널을 복사한다. 즉, src의 마지막 문자를 자른다).ft_strlcat
: 역시 많은 케이스가 있다. dst가 비어 있을 때, strlcat은 본질적으로 strlcpy처럼 작동해야 한다(단, dst가 있는 곳에서 시작). dst가 비어 있지 않을 때, 올바르게 추가하고 오버런하지 않는지 테스트한다. 또한dstsize
가 초기 dst 길이보다 작은 경우 테스트한다(함수는 아무것도 추가하지 않고 dstsize + length(src)를 반환해야 함). 전체 src + 종결자를 위한 공간이 정확히 있는 경우와 src의 일부만을 위한 공간이 있는 경우를 테스트한다.ft_strchr
/ft_strrchr
: 존재하는 문자 찾기(시작, 중간, 끝에 있는 문자, strchr에서 '\0'을 찾을 경우 종결자를 의미함), 그리고 존재하지 않는 문자 찾기(NULL을 반환해야 함)를 테스트한다. strrchr의 경우, 문자가 여러 번 나타나는 경우 마지막 것을 얻는지 확인한다. 둘 다에서 '\0' 검색을 포함하자.ft_strnstr
: needle이 빈 문자열인 경우를 테스트한다(BSD 사양에 따라 haystack을 반환해야 함). needle이 길이 제한 내의 haystack 시작, 중간, 끝에 있는 경우를 테스트한다. needle이 전체 haystack에서 발견되지만 길이 제한이 너무 짧아 도달할 수 없는 경우를 테스트한다(이 경우 NULL을 반환해야 함). 또한 haystack 길이가 len보다 작은 경우(이 경우 함수는 정상 strstr처럼 동작)와 needle이 haystack보다 긴 경우(즉시 NULL)를 테스트한다.ft_strncmp
: n = 0으로 테스트한다(항상 0을 반환). 위치 1에서 다른 문자열과 n=5(pos1에서 차이에 해당하는 0이 아닌 값을 반환해야 함)를 테스트한다. n보다 긴 공통 접두사를 가진 문자열(n까지만 비교하므로 0을 반환해야 함)을 테스트한다. 하나의 문자열이 다른 것의 접두사이고 n이 그 접두사 길이보다 크지만 두 번째 문자열의 전체 길이보다 작은 경우 - 예: s1="abc", s2="abcdef", n=6: 루프가 'a','b','c'를 비교한 다음 s1에서 '\0'에 도달하여 중단하고, 차이를 반환한다(s1 < s2를 나타내는 '\0' - 'd'의 음수 값).ft_memchr
: 존재하는 바이트 찾기(배열 중간, 첫 바이트, 마지막 허용 바이트(n-1 인덱스)에 있는 등)를 테스트한다. 존재하지 않는 바이트 검색(NULL을 반환해야 함)을 테스트한다. n=0을 테스트한다(첫 바이트가 일치하더라도 항상 NULL을 반환해야 함, 0바이트 검색을 지시했기 때문에). 또한 c가 0인 경우도 테스트한다(유효함; n 내에 있으면 첫 0 바이트에서 중지).ft_memcmp
: 동일한 메모리 비교(0을 반환해야 함)를 테스트한다. 첫 바이트에서 다른 경우(바이트1 - 바이트2와 같은 값을 반환해야 함)를 테스트한다. 나중 바이트에서 다른 경우 그 차이에 기반한 반환인지 확인한다. 예: "Hello"와 "Healo"를 n=5로 비교하면 'l'(0x6C)과 'a'(0x61)의 차이 -> 0x0B(11)를 반환해야 한다. 'l'(108)이 'a'(97)보다 11 더 크기 때문이다.ft_atoi
: 다양한 것이 필요하다: 선행 공백이 있는 문자열(" \t\n 42"
-> 42), 플러스/마이너스 포함("+42"
-> 42,"-42"
-> -42,"+-42"
-> 0), 숫자가 아닌 문자("123abc"
-> 123, 'a'에서 중지), 단지 기호("-"
또는"+"
단독 -> 0, 기호 뒤에 숫자 없음), 큰 숫자:"2147483647"
-> 2147483647 (INT_MAX),"-2147483648"
-> -2147483648 (32비트인 경우 INT_MIN).ft_calloc
: 이것은 메모리를 반환하기 때문에 직접 테스트하기 까다롭다. 그러나 주어진 크기에 대해 반환된 메모리가 0으로 설정되어 있는지 테스트할 수 있다. 예를 들어, 10개의 int를 할당하고 모두 0인지 확인한다. 또한 0바이트 요청 시를 테스트한다: 예: ft_calloc(0, 5) 또는 (5, 0) 또는 (0,0).ft_strdup
: 빈 문자열 복제를 테스트한다(원본과 별개의 ""에 대한 포인터를 반환해야 함). 정상 문자열을 테스트하고 복제본을 수정해도 원본이 변경되지 않는지 확인한다(진정으로 새로운 할당인지 확인). 또한 길이 1인 문자열, 더 큰 문자열도 테스트한다.- 특수 문자 및 비ASCII:
- ctype 함수(
isalpha
등)의 경우, 기본적으로 ASCII 문자/숫자만 고려한다. 악센트 문자(예: 'é'는 확장 ASCII에서 233일 수 있거나 UTF-8에서는 멀티바이트)와 같은 값으로 테스트할 수 있다. 그러나 C 로케일에서 표준 isalpha는 233 값을 가진 문자가 있으면 아마도 거짓을 반환할 것이다(A-Z/a-z 범위 밖). 구현은 아마도 'A'-'Z' 또는 'a'-'z' 범위 밖의 모든 문자를 거짓으로 처리할 것이며, 이는 괜찮다. 또한 음수 및 >127 값으로isascii
를 테스트하면 거짓을 반환해야 한다.
- ctype 함수(
💡 초보자를 위한 설명: ASCII는 기본적인 영문자, 숫자, 특수 문자만 포함하는 문자 인코딩 시스템이다. 비ASCII 문자는 확장 ASCII나 UTF-8 같은 다른 인코딩 체계에서 처리된다. 대부분의 C 함수는 기본적으로 ASCII 문자만을 고려하므로, 비ASCII 문자(예: 'é', 'ñ')에 대한 테스트는 함수의 제한을 이해하는 데 도움이 된다.
strlen
및 기타: C-문자열에 비ASCII 문자(상위 비트가 설정된 바이트)를 포함시키면,strlen
은 0까지 다른 문자와 마찬가지로 바이트를 카운트한다. UTF-8인지 아닌지는 상관하지 않고, 그냥 0까지 바이트를 카운트한다. 실제 멀티바이트 UTF-8 시퀀스가 있는 경우,strlen
은 문자가 아닌 바이트를 카운트한다(C에서 예상됨). 바이트 >= 128로 아무것도 깨지지 않는지 확인하자.strdup
: 비인쇄 문자나 멀티바이트 포함, 모든 바이트를 잘 복제해야 한다(여전히 0까지 바이트 시퀀스만 복사).toupper/tolower
: 'é'와 같은 악센트 문자를 전달하면(char가 부호 있는 경우 -23과 같은 음수 값으로 올 수 있음, 인코딩에 따라), 구현은 이를 처리하지 않을 가능성이 높다(표준은 unsigned char 범위가 아닌 경우 동작이 정의되지 않음). 그러나 unsigned char로 캐스팅하면, 일부 확장 코드를 변환하게 된다. 그러나 "C" 로케일에서,toupper
는 일반적으로 표준 ASCII 문자만 알고 있다.- 예상 반환 값과의 일치: 테스트에 대한 알려진 참조(실제 라이브러리 함수 호출 등)를 사용하여 결과를 비교한다. 예를 들어,
ft_memcmp
와memcmp
,ft_strlcpy
와 알려진 구현 또는 해야 할 일을 시뮬레이션하는 등을 비교한다. 이는 동작(특히 반환 값, 얼마나 반환하는지 또는 비교의 부호)이 올바른지 확인한다.
💡 초보자를 위한 설명: 함수 구현을 테스트할 때 가장 좋은 방법 중 하나는 표준 라이브러리 함수와 결과를 비교하는 것이다. 예를 들어
ft_strlen
함수를 테스트하려면 동일한 입력으로 표준strlen
함수를 호출하고 두 결과가 일치하는지 확인한다. 이는 구현이 표준과 동일하게 작동하는지 검증하는 효과적인 방법이다.- 자동화된 테스트: 이 모든 것을 체계적으로 테스트하는 작은 main을 작성하는 것이 좋다. 42는 일반적으로 일부 테스트를 제공하지만, 자신의 테스트를 작성하면 놀라움을 피할 수 있다. 또한, 테스트할 때 메모리 누수 방지를 위해 모든 메모리 할당과 해제를 추적하는 것이 좋다.
요약하면, 철저한 테스트 전략은 정상 케이스, 경계 조건(0-길이, 정확히 용량, 논리적으로 하나 적거나 하나 더), 그리고 잘못된 사용(NULL 포인터나 허용되지 않는 겹침 등)을 다루어 함수가 어떻게 반응하는지 확인한다. 이를 예상하면 구현을 조정하거나 최소한 동작을 문서화할 수 있다.
추가 컨텍스트 및 더 알아볼 주제
기술적 구현을 넘어, 블로그 포스트에 포함할 만한 libc 함수에 대한 흥미로운 역사적, 실용적 포인트가 있다:
- 역사적 배경 및 진화: 이러한 많은 함수는 C와 UNIX의 초기부터 유래했다. 예를 들어, C 표준 라이브러리는 K&R C 및 후에 ANSI C(C89)로 공식화되었다.
strcpy
,strcat
,strchr
등과 같은 함수는 원래 UNIX V7(또는 그 이전)의 문자열 라이브러리에서 온 것이다.bzero
는 Berkeley(BSD) 함수이다; 4.2 BSD에 등장했다. 언급했듯이, 나중에 레거시로 표시되어 POSIX에서 제거되었다[^7].memcpy
와memmove
는 오래된bcopy
(또 다른 BSD 함수)를 대체하기 위해 ANSI C 시기에 도입되었다. 이야기가 있다: memmove는 특히 겹치는 복사를 memcpy로 구현하는 것이 임시 버퍼 없이는 이식 가능하게 불가능하다는 것을 누군가가 깨달았기 때문에 C에 추가되었다. 그래서 memmove가 표준화되었다. 이러한 진화를 이해하는 것은 블로그에서 흥미로울 수 있다: 안전한 프로그래밍의 필요성이 어떻게 새로운 함수(strlcpy 같은)를 밀어내고 이전 것들(bzero, gets 등)을 폐기하게 했는지 보여준다.
💡 초보자를 위한 설명: C 라이브러리의 역사는 컴퓨터 과학 발전과 밀접하게 연관되어 있다. 초기 UNIX 시스템에서 개발된 많은 함수들이 시간이 지나면서 보안과 효율성이 향상되었다. 일부 함수는 더 안전한 대안으로 대체되었고, 일부는 폐기되었다. 이러한 역사적 맥락을 이해하면 왜 특정 함수가 특정 방식으로 작동하는지, 그리고 어떤 함수를 사용해야 하는지에 대한 통찰력을 얻을 수 있다.
strdup
도 역시 BSD에서 유래했다(4.3BSD에 있었던 것 같다)이고 결국 POSIX에 포함되었다.strlcpy
/strlcat
은 1990년대 후반 OpenBSD(OpenBSD 2.4, 1998년 즈음)에서 strcpy/strcat의 많은 보안 버그에 대한 대응으로 만들어졌다. Todd C. Miller와 Theo de Raadt의 유명한 논문 "strlcpy and strlcat - consistent, safe, string copy and concatenation"[^11]이 있어 이들의 사용을 옹호한다. 이들은 POSIX에 추가되지 않았지만, 많은 OS가 채택했다. C 표준 위원회(WG14)는 (C18까지) 부분적으로 논쟁 때문에 이들을 ISO C 표준에 추가하지 않았다(Linus Torvalds와 같은 일부 주목할 만한 사람들은 과거에strlcpy
에 대해 목소리를 높였다. 다르게 문자열을 관리하는 것을 선호한다[^41]).- 플랫폼 차이(Linux vs Windows vs 다른 것들): Linux에서는 일반적으로 GNU C 라이브러리(glibc) 또는 작은 시스템에서는 musl과 같은 것을 가지고 있다. Windows에서는 C 런타임(MSVC CRT 또는 Universal C runtime)이 유사한 함수를 제공하지만 때로는 다른 이름이나 동작으로:
- Microsoft는 종종 비표준 또는 잠재적으로 안전하지 않은 함수에 밑줄을 접두사로 붙인다. 예를 들어,
_strdup
이 있다(그리고 오래된 MSVC에서는 사용 중단된strdup
도 있다고 생각한다. 그러나 현대 MSVC에서는_strdup
을 기대한다). 그들은 또한 대소문자를 구분하지 않는 비교에_stricmp
를 가지고 있는 반면, POSIX는strcasecmp
를 가지고 있다. isascii
는 C 표준에 없지만, Linux에서는 종종_BSD_SOURCE
또는 유사한 것이 정의되어 있을 때<ctype.h>
에 있다. Windows에서는_isascii
가 있다.strnstr
은 기본적으로 Windows나 Linux(glibc)에서 사용할 수 없다.strnstr
을 사용하는 크로스 플랫폼 코드를 작성한다면, 직접 구현해야 한다(이것이 libft가 하게 하는 것이다).strlcpy/strlcat
은 언급했듯이 glibc와 MSVC에 없다. Linux에서는 사람들이 때때로strncpy
를 사용하거나 자신의strlcpy
를 작성한다(또는 libbsd를 사용하여 가져온다). Windows에서는 일반적으로strncpy_s
(보안 버전) 또는 보안 CRT의 다른 더 안전한 문자열 함수가 대신 사용될 수 있다. Microsoft는 버퍼 크기를 가져오는_s
함수(예:strcpy_s
)를 추가했기 때문이다.memset
,memcpy
,memmove
등과 같은 메모리 함수는 동일한 의미를 가지고 어디에나 존재한다. 그러나 흥미롭게도, 성능은 다를 수 있다: 예를 들어, Linux에서memmove
는 Windows의memmove
보다 느릴 수 있거나 그 반대일 수 있다. 왜냐하면 다른 전략 때문이다. 또한, 정렬 가정이 다르다; 일부 오래된 플랫폼은 특정 연산에 대해 정렬된 메모리를 요구할 수 있다.- 또 다른 차이점: Windows에서는 DLL(C 런타임)에서 이러한 함수에 대한 호출 규약이 일부 내부 구현 차이가 있을 수 있지만, 사용자 관점에서는 동일한 사용법이다.
개발자에게, 이러한 차이를 아는 것은 코드에서strlcpy
에 의존한다면, MSVC에서 자신의 구현을 추가하지 않으면 컴파일되지 않을 것임을 의미한다. 또는bzero
를 사용한다면, 모든 시스템에 있지 않다. 따라서, 이식성 고려 사항은 종종 특정 함수를 피하거나 대체를 제공하는 데 이어진다(다시, libft는 자신만의 이식 가능한 세트를 갖기 위해 준비하는 것과 같다).
- Microsoft는 종종 비표준 또는 잠재적으로 안전하지 않은 함수에 밑줄을 접두사로 붙인다. 예를 들어,
💡 초보자를 위한 설명: 다양한 운영 체제와 컴파일러는 C 라이브러리 함수를 조금씩 다르게 구현한다. Windows와 Linux는 특히 차이가 많다. Windows는 보안 강화 버전의 함수에
_s
접미사를 사용하고, 비표준 함수에_
접두사를 사용한다. 이러한 플랫폼 차이를 이해하는 것은 이식 가능한(여러 시스템에서 작동하는) 코드를 작성하는 데 중요하다.- 실제 사용 및 응용: 이러한 기본 함수는 더 높은 수준의 코드를 위한 구성 요소이다. 실제 프로젝트에서는 거의 사용자 정의
memcpy
를 작성하지 않지만, 어디에나 사용한다. 예를 들어:- 네트워크 패킷 파싱(memchr이 구분자를 찾을 수 있고, memcmp가 헤더를 비교할 수 있음 등).
- 메모리에서 구조체나 이미지 복사(memcpy/memmove).
- 동적 배열이나 문자열 타입 구현(C++ std::string이 내부적으로 확장하거나 데이터를 복사할 때 memcpy를 사용할 가능성이 높음).
- 구조체 배열 할당(calloc은 새 메모리가 0으로 시작하도록 보장하므로, NULL 포인터나 0 숫자 필드와 같은 기본값을 설정하는 데 중요할 수 있음).
- 대소문자를 구분하지 않는 비교는 각 문자에 대해
toupper
/tolower
를 사용하거나strcasecmp
(libft에는 없지만 tolower를 사용하여 구축할 수 있음)를 사용할 수 있다. - 텍스트 내 검색(strnstr은 알려진 길이의 버퍼에서 부분 문자열을 찾는 데 사용할 수 있으며, 이는 텍스트 파싱이나 버퍼와 길이가 있는 네트워크 데이터 처리에서 흔하다).
- 문자열을 int로 변환(atoi)은 설정 값이나 간단한 입력을 읽을 때 일반적이다. 강력한 코드는 오류를 감지하기 위해
strtol
을 사용할 수 있지만,atoi
는 빠른 변환에 편리하며 오래된 코드나 빠른 스크립트에서 많이 사용된다.
블로그에 이들이 어떻게 함께 작동하는지 보여주는 예를 포함하면 도움이 될 수 있다. 예를 들어, 수정될 문자열의 복사본을 저장해야 할 때 자신만의 간단한strdup
을 만드는 것이 유용할 수 있다고 보여줄 수 있다.
💡 초보자를 위한 설명: C 표준 라이브러리 함수들은 많은 실제 응용 프로그램에서 기본 구성 요소로 사용된다. 예를 들어 네트워크 프로그래밍에서
memcpy
는 패킷 데이터를 처리하는 데 필수적이고, 파일 파싱에서strnstr
은 특정 패턴을 찾는 데 유용하다. 이러한 기본 함수들이 어떻게 더 복잡한 시스템의 기반이 되는지 이해하면 자신의 코드를 더 효율적으로 작성할 수 있다.- C 표준화 참고: 일부 함수는 특정 C 표준 개정에서 변경되거나 추가되었다:
memmove
는 K&R C에는 없었지만 ANSI C에 의해 추가되었다. C(memmove 이전)의 초기 구현에서 프로그래머가 수동으로 겹침을 처리해야 했다는 유명한 일화가 있는데, 이는 오류가 발생하기 쉬웠다.calloc
은 오랫동안 존재했다(K&R에서도 C 라이브러리에 calloc이 있었다).strncpy
와strncat
도 버퍼 오버플로우 문제에 대응하여 초기 표준의 일부였지만, 자체적인 특이점을 도입했다(널 종결을 보장하지 않는 등).- ISO C(C99, C11 등)는 많은 새로운 기본 문자열 함수를 도입하지 않았지만, C11은 부록 K에서 일부 더 안전한 함수(
strcpy_s
,sprintf_s
등)를 도입했다. 그러나 이들은 선택 사항이며 널리 채택되지 않았다(Microsoft는 채택했지만, glibc는 그렇지 않았다). - POSIX는 후에
strdup
,strnlen
(최대 길이가 있는 strlen과 유사),strndup
등을 도입했다. - libft 함수 중 표준 C가 아닌 것(isascii, strlcpy, strlcat, strnstr, strdup, bzero)과 표준인 것(나머지)을 알아두는 것이 흥미롭다. 이를 아는 것은 이식 가능한 코드를 작성하는 데 좋다.
- 메모리 함수 내부: 심층 주제: 특정 CPU에 대해 glibc가 어떻게 이들을 어셈블리로 구현하는지. 예를 들어, x86_64용 glibc의
memcpy
는 정렬과 크기를 확인하고 작은 vs 큰 복사에 대해 다른 전략을 사용하는 복잡한 루틴을 가지고 있다. 이것은 아마도 너무 저수준이지만, 블로그는 어셈블리 구현의 스니펫을 보여주어 복잡성을 이해하게 할 수 있다. - 일반적인 함정: 이 함수들과 관련된 일반적인 실수에 대해 논의할 수 있다:
- 언급한 대로 strlcpy/strlcat의 off-by-one 오류.
- 할당이나 루프에서 널 종결자를 고려하지 않는 것.
- 오류 검사가 필요할 때
strtol
을 사용해야 하는데atoi
를 사용하는 것. - 문자열 비교처럼 작동할 것으로 기대하고
memcmp
를 사용하는 것은 문제가 될 수 있다(특히 데이터에 널이 있을 수 있는 경우, memcmp는 여전히 0을 포함한 바이트별로 확인한다). isdigit
등이 ASCII 숫자로 제한된다고 가정하는 것; 일부 로케일에서는 다른 유니코드 숫자도 고려할 수 있다.- 사람들이
toupper
/tolower
가 unsigned char 값이나 EOF에만 안전하게 호출될 수 있다는 것을 잊는 것. 누군가char c = -10; printf("%c", toupper(c));
라고 쓰면 정의되지 않은 동작을 호출한다. 따라서 모범 사례로,toupper((unsigned char)c)
와 같이 캐스팅할 수 있다.
💡 초보자를 위한 설명: C 함수를 사용할 때 흔히 저지르는 실수들이 있다. 예를 들어
memcmp
와strcmp
의 차이를 이해하지 못하거나, 문자열 함수에서 널 종결자의 중요성을 간과하는 것이다. 이러한 함정을 알면 디버깅하기 어려운 미묘한 버그를 피할 수 있다. 특히 C는 메모리 관리와 안전성에 대한 많은 책임을 프로그래머에게 맡기기 때문에 주의가 필요하다.- 결론 생각: 아마도 libft를 구현하는 것이 이러한 구성 요소에 대한 이해와 감사를 구축한다는 것을 언급하고, 일단 자신만의 라이브러리가 있으면, 바퀴를 다시 발명하지 않도록 미래 42 프로젝트에서 사용할 수 있다는 것을 언급할 수 있다(그것이 libft의 요점이다 - 나중 프로젝트에서 자신의 함수를 사용하기 위해 가져간다).
[^41]: Linus on why strlcpy shouldn't be used : r/programming - Reddit
참고 문헌
위의 분석은 정확성을 위해 공식 문서와 전문가 논평을 참조한다. 예를 들어, 표준 동작과 반환 값은 매뉴얼 페이지와 POSIX 사양[^1][^2][^5]에서 확인된다.
strlcpy
/strlcat
의 전통적인 함수에 대한 장점은 OpenBSD 자체 문서에서 가져왔다[^11]. 성능 인사이트는 libc 함수[^29]에서 SSE2 사용과 같은 구현에서 알려진 최적화와 최적화된memcpy
루틴[^35]에 의존하라는 권장 사항을 참조한다. 보안 논의에는 버퍼 오버플로우와 스택 카나리[^39]와 같은 완화 및calloc
오버플로우 검사[^24]의 중요성에 대한 알려진 사실이 포함된다. 이 총체적인 접근 방식은 libft 함수의 재구현이 올바를 뿐만 아니라 소프트웨어 엔지니어링의 모범 사례에 의해 알려지도록 보장한다.728x90Part 2 – 추가 함수
Part 2 함수는 기본 C 기능을 확장한다. 일부는 다른 라이브러리나 언어에서 사용 가능한 동작을 모방하지만 ISO C 표준 라이브러리의 일부는 아니다. 각 함수의 목적, 유사한 표준 함수(차이점 참고), 그리고 중요한 구현 세부 사항을 살펴본다.
ft_substr
- 목적:
ft_substr
은 주어진 문자열s
의 부분 문자열인 새 문자열을 생성한다. 시작 인덱스start
와 최대 길이len
을 받아,s
에서start
부터 시작하여 최대len
문자를 포함하는 새로 할당된 문자열을 반환하며, 이후에 널 종결자가 온다.
💡 초보자를 위한 설명: 부분 문자열이란 원본 문자열의 일부분을 의미한다. 예를 들어, "Hello"에서 인덱스 1부터 길이 3의 부분 문자열은 "ell"이다. 여기서 인덱스는 0부터 시작한다는 점을 기억하자.
- 표준 라이브러리 대응: C 표준 라이브러리에는 직접적인
substr
함수가 없다. 유사한 기능은strncpy
또는 POSIX.1-2008 함수strndup
으로 달성할 수 있다. 예를 들어,strndup(s + start, len)
은s + start
에서 최대len
바이트를 할당하고 복제한다[^42]. 그러나strndup
은 C90/C99에서 비표준이다(ISO C에 없음) - POSIX에서 도입되었으며 항상 해제해야 하는 새 문자열을 할당한다[^42].strncpy
와 달리, 대상 버퍼를 미리 할당하고 널로 채우는 것이 필요하지만,ft_substr
은 자체 메모리 할당을 처리한다. 원본 문자열 끝을 넘어 복사할 위험이 없다. 왜냐하면 원본 문자열의 끝 또는 지정된 길이 중 먼저 오는 것까지만 복사하기 때문이다. - 동작 세부 사항:
start
가s
의 끝을 넘어가면(즉,start >= strlen(s)
), 함수는 일반적으로 빈 문자열(단지'\0'
을 포함하는 새 문자열)을 반환한다.len
이start
위치에서s
의 끝을 넘어가면, 사용 가능한 문자만 복사된다. 이 동작은 부분 문자열이 유효하고 널 종결되도록 보장한다. 예를 들어,ft_substr("Hello", 1, 3)
을 호출하면"ell"
을 반환한다. - 구현 고려 사항:
- 메모리 관리:
ft_substr
은 새 문자열을 위한 메모리를 할당한다(일반적으로malloc
으로). 할당된 크기는min(len, strlen(s) - start)
에 널 종결자를 위한 1바이트를 더한 것이어야 한다. 오버플로우를 방지하기 위해 적절히 할당하고 메모리 할당이 실패하면NULL
을 반환하는 것이 중요하다. 호출자는 작업이 완료되면 반환된 문자열을 해제할 책임이 있다(표준 라이브러리의strdup/strndup
와 마찬가지로[^42]).
- 메모리 관리:
💡 초보자를 위한 설명: malloc 함수는 메모리를 동적으로 할당하는 함수로, 사용이 끝난 후에는 반드시 free로 해제해야 한다. 메모리 누수를 방지하기 위해 항상 malloc으로 할당한 메모리는 free로 해제하는 습관을 들이자.
- 경계 조건: 입력 문자열 포인터
s
가NULL
이면, 함수는 이상적으로NULL
을 반환하거나(또는 우아하게 처리) 널 포인터를 역참조하는 것을 피해야 한다.start
가 범위를 벗어나면(문자열 길이보다 큼), "하위 문자열 없음"을 신호하되 여전히 유효한 문자열을 제공하기 위해 빈 문자열을 반환하는 것(NULL보다)이 일반적인 선택이다.len
이 0이면, 함수는 빈 문자열도 반환해야 한다(단일 바이트 문자열""
할당). 이러한 모든 시나리오는 함수를 강력하게 만들기 위해 처리되어야 한다. - 성능: 함수는 복사할 문자 수 n에 대해 O(n) 시간으로 실행된다(필요한 경우
strlen(s)
를 계산하기 위한 O(m)도 포함). 이는 대부분의 용도에 효율적이다. 메모리 할당이 가장 비용이 많이 드는 부분일 수 있지만, 새 문자열을 생성하는 데는 피할 수 없다. 최적화를 위해, 필요한 범위가 결정되면s
의 전체 길이를 계산하는 것을 피할 수 있다(일찍 중지). 그러나 일반적인 문자열 길이를 고려할 때, 간단한 구현으로 충분하다.
💡 초보자를 위한 설명: O(n)은 알고리즘의 시간 복잡도를 나타내는 표기법으로, 입력 크기 n에 비례하여 실행 시간이 증가함을 의미한다. 여기서는 문자열 길이에 비례하여 처리 시간이 증가한다.
- 고수준 언어와의 차이점: 고수준 언어(또는 C++
std::string
)에서, 부분 문자열 연산은 사용자의 명시적 메모리 관리가 필요하지 않을 수 있다. C에서,ft_substr
은 적절한 할당으로 문자열의 일부를 가져오는 안전하고 편리한 방법을 제공한다.
[^42]: strdup(3) - Linux manual pages
ft_strjoin
- 목적:
ft_strjoin
은 두 문자열s1
과s2
를 새 문자열로 연결한다. 결합된 문자열을 위해 충분히 큰 버퍼를 할당하고,s1
다음에s2
를 복사한 후 종결 널 바이트를 추가한다.
💡 초보자를 위한 설명: 문자열 연결(concatenation)은 두 문자열을 이어붙이는 작업이다. 예를 들어 "Hello"와 "World"를 연결하면 "HelloWorld"가 된다.
- 표준 라이브러리 대응: 표준 C는 새로 할당된 버퍼에 두 개의 임의 문자열을 결합하는 원콜 함수를 제공하지 않는다. 일반적으로, 수동으로 할당된 버퍼에
strcat
/strcpy
를 사용하거나, 두 문자열을 하나로 형식화하기 위해sprintf
/snprintf
를 사용한다. 예를 들어,%s
형식 지정자로snprintf
를 사용하면 할당과 함께 문자열을 연결할 수 있지만, 심지어snprintf
도 (GNU 특정asprintf
를 사용하지 않는 한) 버퍼를 미리 할당해야 한다.asprintf
함수(GNU 확장)는 형식 문자열이 주어지면 새 문자열을 할당할 수 있다[^43], 그러나 이는 어디에나 이식 가능하지 않다. 따라서,ft_strjoin
은 동적 메모리 관리와 함께 문자열을 안정적으로 결합하는 방법을 제공하여 격차를 메운다. - 동작 세부 사항: 결과 문자열의 길이는
strlen(s1) + strlen(s2)
이다.ft_strjoin(NULL, s2)
또는ft_strjoin(s1, NULL)
은 NULL 입력을 빈 문자열로 처리하도록 정의될 수 있다(그러나 이는 libft 구현에 따라 달라진다 - 일반적으로, libft는 달리 명시되지 않는 한 유효한 비NULL 입력을 기대한다). 연결 후, 새 문자열은 널 종결된다. 예를 들어,ft_strjoin("Hello, ", "42")
는"Hello, 42"
를 반환한다. - 구현 고려 사항:
- 메모리 관리: 함수는
(len_s1 + len_s2 + 1)
바이트를 할당해야 한다. 복사하기 전에 할당이 성공했는지 확인해야 한다. 사용 후, 호출자는 결합된 문자열을 해제해야 한다. 입력에서 버퍼를 재사용하지 않는다 - 이는 새로운 할당이다. - 데이터 복사: strcpy/strcat 또는 memcpy/memmove를 사용하여 문자열을 새 버퍼로 복사할 수 있다. 일반적인 구현은: 버퍼 할당, strcpy를 사용하여 s1을 복사, 그 다음 strcat(또는 오프셋이 있는 strcpy)을 사용하여 s2를 추가한다. 버퍼를 오버플로우하지 않도록 주의해야 한다 - 정확한 길이를 할당했으므로, 정확히 len_s1과 len_s2 문자를 복사하는 한 안전하다.
- 경계 조건: s1과 s2 모두 빈 문자열이면, 함수는 빈 문자열을 반환해야 한다(널 종결자가 있는 ""). 하나가 비어 있고 다른 하나가 비어 있지 않으면, 비어 있지 않은 문자열을 효과적으로 복제해야 한다. 할당이 실패하면, NULL을 반환한다. NULL 입력 처리(구현된 경우)는 단순히 NULL을 빈 문자열과 동등하게 취급하거나 직접 NULL을 반환할 수 있다.
- 성능: 시간 복잡도는 s1과 s2의 길이 n과 m에 대해 O(n + m)이다. 이에는 길이를 계산하고 문자를 복사하는 시간이 포함된다. 이는 선형적이며 일반적으로 괜찮다. 불필요한 중간 단계(예: 반복적으로 재할당하거나 문자열을 다시 스캔)를 피해야 한다. (memcpy와 같은) 저수준 복사를 사용하는 것이 루프에서 문자별로 복사하는 것보다 약간 더 빠를 수 있다.
- 표준 접근법과의 비교: 문자열을 결합하기 위해 sprintf를 사용하는 것은 형식 지정자를 파싱하는 오버헤드를 추가하므로, ft_strjoin이 더 효율적이고 간단할 수 있다. 또한, strcat과 달리, 이 함수는 버퍼 할당을 관리할 필요가 없어 안전성을 향상시킨다(올바르게 사용된다면 오버플로우 위험이 없음).
- 메모리 관리: 함수는
💡 초보자를 위한 설명: (len_s1 + len_s2 + 1)에서 +1은 문자열 끝을 나타내는 널 종결자('\0')를 위한 공간이다. C 문자열은 항상 이 널 문자로 끝나야 하며, 이것이 없으면 문자열 관련 함수들이 문자열의 끝을 인식할 수 없다.
[^43]: c - Using itoa() Function - Stack Overflow
ft_strtrim
- 목적:
ft_strtrim
은 문자열에서 지정된 문자 집합의 선행 및 후행 발생을 제거한다. 이는 입력 문자열s1
의 다듬어진 버전인 새 문자열을 반환한다. 문자열set
에 있는 모든 문자가 시작과 끝에서 제거된다.
💡 초보자를 위한 설명: 문자열 다듬기(trimming)는 문자열 앞뒤의 특정 문자들을 제거하는 작업이다. 예를 들어, " Hello " 문자열에서 공백을 다듬으면 "Hello"가 된다. 여기서는 더 다양한 문자를 다듬을 수 있다.
- 표준 라이브러리 대응: C에는
<string.h>
에 직접적인 다듬기 등가물이 없다. 공백을 다듬는 것은ctype.h
함수, 예를 들어isspace
를 사용하여 수동으로 할 수 있다(양쪽 끝에서 반복). 그러나 임의의 문자를 다듬는 단일 표준 함수는 없다. 일부 라이브러리는 유사한 기능을 제공한다(예를 들어, C++는std::string::erase
와find_first_not_of
/find_last_not_of
로 다듬을 수 있고, 많은 스크립팅 언어는trim()
함수가 있다). C에서, 일반적인 접근 방식은 수동적이다: 왼쪽에서 다듬기 문자가 아닌 첫 인덱스를 찾고, 오른쪽에서 다듬기 문자가 아닌 마지막 인덱스를 찾은 다음, 이러한 경계를 사용하여 부분 문자열을 만든다. libft의ft_strtrim
은 이 과정을 자동화한다. - 동작 세부 사항:
ft_strtrim(s1, set)
은set
에 없는 문자가 발견될 때까지s1
을 처음부터 스캔한다 - 이것이 새로운 시작점이다. 마찬가지로,set
에 없는 문자가 발견될 때까지 끝에서부터 뒤로 스캔한다 - 이것이 새로운 끝점이다. 그런 다음 함수는 이 두 인덱스 사이의 부분 문자열을 반환한다(시작 포함, 끝 제외). _set_에 있고 끝단에 있는 문자는 "잘린다." 예를 들어," 42Gyeongsan "
을" "
(공백) 집합으로 다듬으면"42Gyeongsan"
이 된다(선행/후행 공백 제거).s1
이 완전히set
의 문자로 구성되어 있으면, 결과는 빈 문자열(""
)이어야 한다.set
이 비어 있으면, 일반적으로 함수는s1
의 복사본을 반환한다(다듬을 것이 없음). - 구현 고려 사항:
- 메모리 관리: 함수는 다듬어진 문자열을 위한 메모리를 할당한다(new_start에서 new_end까지의 길이 +
'\0'
을 위한 1). 할당이 실패하면,NULL
을 반환한다. 결정된 다듬기 길이가 0인 경우에도(모든 문자가 다듬어짐), 여전히 빈 문자열 종결자를 위해 1바이트를 할당해야 한다. - 경계 조건:
s1
이NULL
이면, 함수는NULL
을 반환해야 한다(유효하지 않은 입력).set
이NULL
이면, "다듬을 문자 없음"으로 처리하여(따라서s1
의 복사본 반환) 또는 마찬가지로NULL
을 반환할 수 있다(동작이 정의되지 않았으므로). 또한,s1
이 빈""
인 경우, 결과는 그냥 빈 문자열이어야 한다(다듬을 것이 없지만, 함수는 여전히 유효한 문자열, 아마도""
의 복제본을 반환해야 한다).set
에 널 종결자'\0'
이 포함된 경우(문자열이므로 가능성이 낮음), 다듬기는 정상 작동 이외의 효과가 없을 것이다. - 문자 확인:
set
의 구성원 검사에서 효율성이 중요하다. 간단한 접근 방식은s1
의 각 문자를set
의 모든 문자와 확인하는 것이다(최악의 경우 O(n*m)).set
이 보통 작기 때문에(예: 몇 개의 공백 문자), 이는 괜찮다. 또는 O(1) 멤버십 테스트를 위해 256 ASCII 문자의 배열이나 불리언 테이블을 사용할 수도 있다.
- 메모리 관리: 함수는 다듬어진 문자열을 위한 메모리를 할당한다(new_start에서 new_end까지의 길이 +
💡 초보자를 위한 설명: 여기서 O(n*m)은 두 문자열의 길이에 따라 시간 복잡도가 증가함을 의미한다. n은 원본 문자열의 길이, m은 제거할 문자 집합의 길이다. 이는 각 문자에 대해 집합의 모든 문자와 비교해야 할 수 있기 때문이다.
- 성능: 다듬기는 최악의 경우 O(n * m)로 실행된다(n =
s1
의 길이, m =set
의 길이). 그러나 일반적으로 m은 작다. 양쪽 끝에서의 스캔은s1
에서 선형적이다. 중간 부분 문자열의 할당과 복사도 선형적이다. 전체적으로, 대부분의 시나리오에서 효율적이다. 극도로 긴 문자열과 큰 집합이 예상되는 경우, 최적화는<string.h>
에서strspn
/strcspn
함수를 사용하는 것이다:strspn(s1, set)
은set
문자로 구성된 선행 세그먼트의 길이를 제공한다[^44], 그리고 사용자 정의 역방향 검사 또는strrpbrk
(있다면)를 후행 부분에 사용할 수 있다. - 안전성: 함수가 입력 문자열
s1
을 수정하지 않도록 해야 한다. 이는 단지 읽고 새 문자열을 생성해야 한다. 또한, 다듬기가 필요하지 않은 경우(즉, 첫 번째와 마지막 문자가 이미 집합에 없는 경우 - 함수는 원본 문자열의 복사본을 반환해야 함)를 적절히 처리해야 한다.
[^44]: GLib | Documentation - GitHub Pages
ft_split
- 목적:
ft_split
은 문자열s
를 주어진 구분자 문자c
를 기반으로 부분 문자열 배열로 나눈다. 이는 새로 할당된 문자열의 배열(char **)을 반환하며,NULL
포인터로 끝난다. 각 문자열은 구분자c
에 의해 분리된 원래s
의 한 세그먼트이다. 본질적으로, 문자열을 토큰화하지만 모든 토큰을 한 번에 반환한다.
💡 초보자를 위한 설명: 문자열 분할(splitting)은 특정 구분자를 기준으로 문자열을 여러 부분으로 나누는 작업이다. 예를 들어, "Hello,World,42"를 ','를 구분자로 분할하면 ["Hello", "World", "42"] 배열이 된다.
- 표준 라이브러리 대응: 가장 가까운 표준 함수는
strtok
(및 재진입 가능한 변형strtok_r
)이다. 그러나strtok
는 다르게 작동한다: 원래 문자열을 제자리에서 수정하여 널 종결자를 삽입하고, 연속적인 호출에서 토큰을 반복하기 위해 정적 상태를 사용한다[^46]. 이는 새 문자열에 대한 토큰을 할당하지 않는다 - 원래 문자열로의 포인터를 반환한다. 이로 인해 원래 문자열을 보존하거나 토큰을 독립적으로 유지하려는 경우strtok
가 적합하지 않다. 또한,strtok
는 내부 정적 버퍼로 인해 스레드 안전하지 않다[^45]. 대조적으로,ft_split
은 각 토큰을 별도의 문자열로 할당하고 모두 반환하며, 원래 문자열을 수정하지 않는다. 이와 같이 할당된 토큰 배열을 반환하는 정확한 ISO C 함수는 없다. 많은 고수준 언어는split()
함수를 가지고 있다;ft_split
은 C에서 그 기능을 제공한다. - 동작 세부 사항:
ft_split(s, c)
는 구분자c
의 연속된 발생을 빈 분할로 생성하는가? libft의 일반적인 구현에서, 연속된 구분자는 사이에 실제 토큰이 없는 경계로 처리되며(그리고 이러한 빈 토큰은 일반적으로 결과 배열에 포함되지 않는다). 예를 들어,"hello::world"
를 구분자':'
로 분할하면["hello", "world"]
를 반환하며,::
시퀀스에 대한 빈 문자열을 포함하지 않는다. 함수는 일반적으로 구분자 실행을 건너뛴다. 문자열이 구분자로 시작하면, 그것들은 건너뛴다(선행 빈 문자열 토큰 없음). 마찬가지로, 문자열이 구분자로 끝나면, 후행 토큰을 생성하지 않는다.s
가 빈 문자열이면, 결과는NULL
(토큰 없음)만 포함하는 배열이거나, 하나의 빈 문자열(해석에 따라 다르지만, libft는 일반적으로 빈 입력에 대해 토큰 없음을 반환)이다. 반환된 배열 자체는 할당되며, 내부의 각 문자열도 할당되고, 배열의 마지막 요소는 끝을 표시하는NULL
포인터이다. - 구현 고려 사항:
- 메모리 관리: 이 함수는 여러 할당을 수행해야 한다: 토큰 수 + 1(NULL 종결자용)과 같은 크기의 char* 배열과 각 토큰에 대한 하나이다. 할당 실패를 신중하게 처리해야 한다: 할당이 실패하면, 이미 할당된 토큰과 배열을 모두 해제하여 메모리 누수를 방지해야 한다.
ft_split
함수는 libft에서 일반적으로 뭔가 잘못되면 모든 것을 해제하는 헬퍼를 호출한다.
- 메모리 관리: 이 함수는 여러 할당을 수행해야 한다: 토큰 수 + 1(NULL 종결자용)과 같은 크기의 char* 배열과 각 토큰에 대한 하나이다. 할당 실패를 신중하게 처리해야 한다: 할당이 실패하면, 이미 할당된 토큰과 배열을 모두 해제하여 메모리 누수를 방지해야 한다.
💡 초보자를 위한 설명: 메모리 누수(memory leak) 방지는 매우 중요하다. 할당에 실패했을 때 이미 할당된 메모리를 모두 해제하지 않으면, 그 메모리는 프로그램이 종료될 때까지 계속 점유된 상태로 남게 된다.
- 토큰 카운팅: 일반적으로 먼저 얼마나 많은 토큰이 생성될지 카운트한다(
s
를 스캔하고 구분자에서 비구분자 영역으로의 전환을 카운트). 이는 결과 배열에 올바른 크기를 할당하는 데 도움이 된다. 카운팅은 모든 구분자 또는 구분자 없음과 같은 엣지 케이스를 처리해야 한다. - 토큰 복사: 각 토큰에 대해, 시작 인덱스(첫 번째로
c
가 아닌 문자)와 끝 인덱스(다음c
또는 문자열 끝)를 찾고ft_substr
과 유사한 논리나 수동 복사를 사용하여 토큰 문자열을 할당하고 채운다. - 경계 조건:
s
가NULL
이면,ft_split
은NULL
을 반환해야 한다(처리 없음).c
가s
에 나타나지 않는 문자이면, 함수는 길이 2의 배열을 반환해야 한다: 첫 번째 요소는s
의 복제본(전체 문자열이 하나의 토큰)이고, 두 번째 요소는NULL
이다.s
가 비NULL이지만 비어 있으면, 함수는NULL
만 포함하는 배열(0 토큰 표시) 또는 빈 문자열 토큰이 있는 배열을 반환할 수 있다; libft의 일반적인 접근 방식은 0 토큰(배열에NULL
종결자만)으로 취급하는 것이다. 토큰을 형성할 문자가 없기 때문이다. strtok
과의 비교: 언급했듯이,ft_split
은 독립적인 토큰을 생성한다. 내부 정적 상태가 없으므로 재진입 가능하고 스레드 안전하다(각 호출은 새로 할당된 배열을 생성). 트레이드오프는 더 많은 메모리를 사용한다는 것이다(새 문자열용)과 잠재적으로 문자열을 할당하고 복사하는 데 더 많은 CPU를 사용한다. 대조적으로,strtok
은 파싱 토큰을 게으르게, 제자리에서 수행하여 메모리 효율적이지만 입력을 파괴하고 순차적 처리가 필요하다. 디자인 선택은 필요에 따라 다르다:ft_split
은 토큰을 주변에 두거나 입력 문자열을 수정하거나 정적 내부 상태를 사용하는 것이 바람직하지 않은 환경(42 프로젝트와 같은)에서 작업할 때 적절하다.- 성능: 복잡성은 문자열을 스캔하는 O(n) 플러스 각 토큰에 대한 할당 비용(특히 토큰이 많은 경우 오버헤드를 추가할 수 있음)이다. 각 토큰 복사도 토큰 길이에 선형적이므로, 전체적으로 O(n)에 비례한다. 여기서 n은 입력 문자열의 길이이다(각 문자는 최대 한 번 검사되고 복사된다).
ft_split
은 일반적으로 중간 입력 크기에 효율적이다. 매우 큰 입력과 많은 토큰은 많은 작은 할당으로 할당자에 부담을 줄 수 있다; 그런 경우, 대안 전략(큰 블록 하나를 할당하고 토큰이 그 안을 가리키게 하는 것)이 더 효율적일 수 있지만, 그것은 메모리 관리를 복잡하게 한다. 별도의 할당에 대한 간단한 접근 방식은 일반적으로 괜찮다.
[^45]: strtok, strtok_s - cppreference.com - C++ Reference
[^46]: Strtok(), no token match - Stack Overflowft_itoa
- 목적:
ft_itoa
는 정수(int
)를 해당 숫자를 나타내는 널 종결 문자 문자열로 변환한다. 이는 그 십진수 표현을 포함하는 새 문자열을 반환한다(음수의 경우 선행-
포함).
💡 초보자를 위한 설명: "itoa"는 "integer to ASCII"의 줄임말이다. 이 함수는 숫자를 문자열로 변환한다. 예를 들어, 정수 42는 문자열 "42"로, -42는 "-42"로 변환된다.
- 표준 라이브러리 대응:
<stdlib.h>
또는 ISO C 어디에도itoa
라는 표준 C 함수는 없다.itoa
의 부재는 잘 알려진 누락이다; int에서 문자열로의 변환은 일반적으로sprintf
/snprintf
(예:sprintf(buf, "%d", n)
)와 같은 포맷된 출력 함수를 사용하거나 사용자 정의 코드를 통해 수행된다.itoa
함수는 일부 플랫폼(MSVC의_itoa
또는 오래된 Turbo C 라이브러리)에 비표준 확장으로 존재하지만, 이식 가능하거나 표준화되지 않았다[^43]. 실제로,itoa
는 명시적으로 C 표준 또는 POSIX 표준의 일부가 아닌_ 것으로 언급된다[^43]. 따라서, libft의ft_itoa
는 printf 계열 함수(특정 컨텍스트에서 금지되거나 비실용적일 수 있음)를 사용하지 않고 정수의 문자열 표현을 얻는 표준 준수 방법을 42 커리큘럼에 제공한다. - 동작 세부 사항:
ft_itoa(n)
는 음수 값과 0을 포함한 정수를 처리한다. 예를 들어,ft_itoa(42)
는"42"
,ft_itoa(-42)
는"-42"
를 반환한다. 또한 정수 최소값(INT_MIN
, 32비트 int의 경우 -2147483648)의 경계 조건도 처리해야 한다.INT_MIN
을 변환하려면 특별한 주의가 필요하다. 왜냐하면INT_MIN
의 절대값은 부호 있는 32비트에서 표현할 수 없기 때문이다(|INT_MIN|은 2147483648로, INT_MAX보다 1 더 큼). 올바른 구현은 일반적으로 특수 케이스나 더 큰 타입(long long)을 사용하여 절대값을 계산하는 방법으로 이를 처리한다. 이 함수는 호출자가 해제해야 하는 새로 할당된 문자열을 반환한다. - 구현 고려 사항:
- 메모리 관리: 함수는 가능한 가장 긴 int 문자열과 널 종결자를 담을 만큼 충분히 큰 버퍼를 할당한다. 32비트 int의 10진수 변환의 경우, 최대 11자이다(10자리 십진수
"2147483648"
와 더불어-
부호, 그리고 널 바이트). 안전한 접근 방식은 모든 경우를 다루기 위해 12바이트를 할당하는 것이다. 일부 구현은 먼저 제수나 로그 접근법을 사용하여 숫자의 자릿수를 결정한 다음, 정확히 필요한 길이를 할당한다. 문자열을 구성한 후, 포인터를 반환한다. 호출자는 다 쓰면 해제해야 한다. - 알고리즘: 일반적인 방법은 먼저 부호를 처리하는 것이다(n이 음수인지 확인하고, 그것을 기록한 다음, 숫자 추출을 위해 절대값으로 작업). 그런 다음, n % 10(또는 abs(n % 10))로 마지막 숫자를 가져오고 n /= 10으로 다음 숫자로 이동하여 숫자를 반복적으로 추출한다. 이러한 숫자는 역순으로 나오므로, 버퍼에 역순으로 저장하거나 저장한 후 끝에 반전시킬 수 있다. 또 다른 방법은 끝에서 시작하여 버퍼를 거꾸로 채우는 것이다: 마지막 문자부터 버퍼를 채우고(이렇게 하면 자연스럽게 역순을 처리하고 부호와 널 종결자를 적절히 배치).
- 경계 조건:
- n이 0이면, 함수는 문자열 "0"을 반환해야 한다. 숫자 추출 루프가 그렇지 않으면 빈 문자열을 생성할 수 있으므로 이 경우를 명시적으로 처리해야 한다.
- n이 INT_MIN이면, 그 값을 담을 long long을 사용하는 것이 한 가지 접근 방식이다(예: long long tmp = n; 그런 다음 절대값에 대해 tmp로 작업). 또 다른 더 간단한 접근 방식은 INT_MIN을 감지하고 직접 리터럴 문자열 "-2147483648"을 반환하는 것이다. 이는 알려져 있기 때문이다(이렇게 하면 INT_MIN을 부정하려고 시도하는 것을 피할 수 있으며, 이는 32비트 int에서 오버플로우됨). 많은 libft 구현은 이를 특수 케이스로 처리한다.
- 음수 숫자: -를 위한 공간을 할당하고 결과의 시작에 배치하는 것을 잊지 말자.
- 성능: 정수를 문자열로 변환하는 것은 빠른 작업이다(O(d), 여기서 d는 자릿수로, 최대 10 또는 11까지). 주요 비용은 할당과 아마도 제수/모듈로 루프인데, 이는 int에 대해 사소하다. 이는 매우 빡빡한 루프에서 수행되지 않는 한 일반적으로 성능에 중요하지 않다.
- sprintf와의 비교: sprintf(<stdio.h>에서)를 사용하면 더 많은 오버헤드(형식 파싱, 표준 I/O 라이브러리 링킹 필요)와 버퍼 크기에 주의하지 않으면 버퍼 오버플로우 위험이 따른다. ft_itoa는 경량화되고 안전하다. 정확히 필요한 공간을 계산하기 때문이다. 또한 부동 소수점이나 로케일 문제를 피한다(일부 더 일반적인 숫자-문자열 함수와 달리).
- 진법과 기수: libft의 ft_itoa는 일반적으로 10진수 변환만을 위한 것이다. 일부 비표준 itoa 구현은 기수(바이너리, 헥스 등)를 지정할 수 있다. 필요하다면, 다른 기수에 대해 유사한 접근 방식을 확장할 수 있지만, libft의 범위는 십진수 정수에서 문자열로의 변환이다.
- 메모리 관리: 함수는 가능한 가장 긴 int 문자열과 널 종결자를 담을 만큼 충분히 큰 버퍼를 할당한다. 32비트 int의 10진수 변환의 경우, 최대 11자이다(10자리 십진수
💡 초보자를 위한 설명: 32비트 정수의 최댓값은 2,147,483,647이고 최솟값은 -2,147,483,648이다. 최솟값을 문자열로 변환하면 11글자('-' 포함)가 된다. 항상 널 종결자를 위한 공간을 추가로 확보해야 함을 잊지 말자.
ft_strmapi
- 목적:
ft_strmapi
는 문자열의 각 문자에 함수를 적용하여 새 문자열을 만든다. 구체적으로, 문자열s
와 인덱스(unsigned int
)와 문자(char
)를 받아 새 문자를 반환하는 함수f
를 받는다.ft_strmapi
는 위치i
의 각 문자가f(i, s[i])
인 새 문자열을 만든다.
💡 초보자를 위한 설명: 이 함수는 문자열의 각 문자를 변환하는 함수다. 'map'이라는 이름은 함수형 프로그래밍에서 온 것으로, 각 요소에 함수를 적용하여 새로운 컬렉션을 만드는 개념이다. 'i'는 위치 정보를 제공하므로 위치에 따라 다른 변환을 할 수 있다.
- 표준 라이브러리 대응: 표준 C 라이브러리에는 동등한 것이 없다. 이는 (하스켈의
map
또는 C++ STL의 시퀀스에 대한std::transform
과 같은) 함수형 프로그래밍에서의 고차 함수 개념과 유사하다. C에서는 일반적으로 변환을 적용하기 위해 루프를 작성한다. libft는 콜백f
에서 변환 로직을 정의하고,ft_strmapi
가 반복과 새로운 문자열 구축을 처리하는 함수적 스타일 분리를 장려하기 위해ft_strmapi
를 제공한다. 이 함수는 .NET의strtok
또는 일부 언어의String.map
과 유사하지만, C의 libc에는 원래 그런 것이 없다. - 동작 세부 사항: 원래 문자열
s
는 수정되지 않는다 -ft_strmapi
는 그것을 읽고 별도의 새 문자열을 생성한다. 새 문자열의 길이는s
와 동일하다(s
가 NULL이 아닌 경우, 이 경우 동작은NULL
을 반환하는 것이다). 함수f
는 각 인덱스와 문자로 호출된다. 예를 들어,f
가 인덱스가 짝수이면 대문자, 홀수이면 소문자로 하는 함수라면,ft_strmapi("Hello", f)
는 그런 교대 대소문자 효과를 생성할 것이다. 결과 문자열은 널 종결된다. - 구현 고려 사항:
- 메모리 관리:
strlen(s)
길이의 새 문자열이 할당된다(종결자를 위해 +1). 할당이 실패하면,NULL
을 반환한다.s
가NULL
이면, 논리적으로도NULL
을 즉시 반환한다(매핑할 문자열이 없음). 함수는 오류가 발생하면 중간 리소스를 해제해야 한다(이 간단한 경우에서, 유일한 리소스는 구축 중인 새 문자열이다). - 함수 적용: 인덱스
0
부터s
의len-1
까지 간단히 루프하고, 각 인덱스에 대해f(i, s[i])
를 호출한다. 반환된 문자를 같은 위치의 새 문자열에 수집한다. 루프 후,'\0'
을 추가한다.f
가 가정을 깨는 부작용을 갖지 않도록 하는 것이 중요하다(원래 문자열s
자체를 수정하면 안 됨). 그러나f
는 char 값만 받기 때문에,s
가 전역이 아닌 한 직접s
를 변경할 수 없다. 계약은 단지 "이 내용으로 무언가를 하세요"이다. - 경계 조건:
s
또는f
중 하나가NULL
이면,ft_strmapi
는 작업을 수행할 수 없다. 방어적 구현은 확인하고 그 중 하나가NULL
이면NULL
을 반환할 수 있다. 또한,s
가 빈 문자열""
이면,ft_strmapi
는 빈 문자열을 반환해야 한다(하지만 여전히 할당되며, 유효한 새 문자열임). 함수f
는 널 문자로 호출될 것으로 기대하지 않을 수 있다; 반복은 일반적으로 문자열 끝에서 중지하고 종결자에 대해f
를 호출하지 않아야 한다. - 성능: 이는 길이 n의 문자열에 대해 O(n) 시간으로 실행된다. 각 문자에 대해 함수 f를 호출하는 오버헤드는 직접 작업을 수행하는 인라인 루프보다 약간 느릴 수 있지만, 대부분의 사용에서는 차이가 미미하다. 디자인은 원시 속도보다 유연성에 관한 것이다. 성능이 중요한 시나리오에서는, 함수 호출 오버헤드를 피하기 위해 특정 로직을 인라인화할 수 있지만, 대부분의 경우 ft_strmapi는 충분히 빠르고 병목은 다른 곳에 있을 것이다.
- 사용 사례: 이 함수는 변환된 문자열 복사본을 만드는 데 유용하다(예: ROT13 인코딩, 대소문자 토글, 암호 적용 등), 여기서 각 문자에 대한 작업은 독립적이다. 이는 코드 재사용을 장려한다(동일한 함수 f를 여러 문자열에 재사용할 수 있음).
- 메모리 관리:
💡 초보자를 위한 설명: 방어적 프로그래밍은 예상치 못한 입력에도 프로그램이 안정적으로 동작하도록 하는 기법이다. NULL 체크는 가장 기본적인 방어적 프로그래밍 기법 중 하나다.
ft_striteri
- 목적:
ft_striteri
는ft_strmapi
와 유사하지만 문자열에 제자리에서 작동한다. 문자열s
의 각 문자에 함수f
를 적용하며, 인덱스와 문자에 대한 포인터를 전달하여f
가 문자를 직접 수정할 수 있게 한다. 함수는 (void를) 반환하지 않는다. 이의 목적은 콜백을 통해 원래 문자열을 변형하는 것이다.
💡 초보자를 위한 설명: 이 함수는 ft_strmapi와 달리 새 문자열을 생성하지 않고 원본 문자열을 직접 수정한다. f 함수는 각 문자의 포인터를 받아 그 문자를 직접 변경할 수 있다.
- 표준 라이브러리 대응: 다시, 이를 직접 하는 표준 C 함수는 없다. C에서 문자열의 각 문자에 작업을 적용하는 일반적인 방법은
for
루프를 작성하는 것이다.ft_striteri
는 반복 로직이 내부적으로 처리되고 사용자는 문자를 어떻게 처리할지 아는 함수를 제공하는(필요한 경우 위치 정보 포함) 편리한 패턴을 제공한다. 이는 다른 라이브러리(예를 들어, GLib의g_strcanon
은 각 문자에 함수를 적용하지만, 정확히 같지는 않거나 단순히 콜백으로 반복하는 아이디어)의 반복 매크로나 함수와 다소 유사하다. - 동작 세부 사항:
ft_striteri(s, f)
는s
의 각 문자에 대해f(index, &char)
를 호출한다.f
는s
의 현재 문자에 대한char *
(포인터)를 받기 때문에, 제자리에서 문자를 수정할 수 있다. 예를 들어,f
가 인덱스가 짝수인 경우 문자를'X'
로 설정하는 함수라면,ft_striteri("abcd", f)
를 호출하면 원래 문자열을"XbXd"
로 수정한다. 작업이 제자리에서 이루어지므로, 새 문자열은 생성되지 않는다. 일반적으로,ft_striteri
는 인덱스 0부터 문자열 끝까지 반복하며,'\0'
종결자는 처리하지 않는다(중지 시점을 알기 위해 제외). 함수 자체는 값을 반환하지 않으며, 결과는 입력 문자열에 대한 부작용이다. - 구현 고려 사항:
- 안전성:
s
가NULL
이거나f
가NULL
이면, 함수는 아무것도 하지 않거나(또는 즉시 반환) 오류를 피해야 한다. 이 함수는 할당이 없다(원래 문자열에서 작동). 따라서 메모리 할당 실패를 처리할 필요가 없다. 그러나f
를 통해 문자열을 수정하면 경계를 벗어나는 문제가 발생하지 않도록 주의해야 한다 - 기존 문자열의 각 문자에 대한 포인터만f
에 제공하므로,f
는 그 하나의 문자 이상을 쓰지 않아야 한다. - 함수
f
계약: 함수 포인터f
는 이론적으로 받은char *
로 무엇이든 할 수 있다 - 다른 문자로 변경하거나 심지어 인접한 메모리를 망가뜨리는 암시까지(그것은 오용일 것). 예상된 사용은 그 포인터를 통해 문자를 변경하는 것이다. 구현자는 의도적으로 문자열을 단축하려는 경우가 아니라면(여기서는 권장되지 않음)f
가 중간 문자열에'\0'
을 쓰지 않아야 한다고 문서화해야 한다. 일반적으로,f
는 포인터를 문자 배열의 한 요소로 취급해야 한다. - 성능:
ft_strmapi
와 마찬가지로, 이는 각 문자마다 함수 호출 오버헤드가 있는 n 문자에 대해 O(n)으로 실행된다. 제자리에서 이루어지므로, 일정한 추가 메모리를 사용한다. 성능이 우려된다면, 인라인 루프가 반복된 콜백보다 빠를 수 있지만 - 문자열이 극도로 크거나 함수 호출이 극도로 무겁지 않는 한 차이는 미미하다.ft_striteri
의 이점은 명확성과 재사용이다. - 사용 사례: 규칙에 따라 문자열을 변형하고 싶을 때 ft_striteri를 사용하자. 예를 들어, 문자열을 제자리에서 rot13으로 인코딩하려면, 각 문자를 13씩 이동하는 함수를 전달할 수 있다. 또는 인덱스에 따라 달라지는 암호를 적용하려면(아마도 인덱스와 XOR 또는 그런 것), ft_striteri는 인덱스 컨텍스트를 제공한다. 이는 매번 루프를 작성하는 것을 절약한다. 하지만 일부는 루프가 충분히 간단하다고 주장할 수 있다. 이 함수는 ft_strmapi와 함께, C에서 함수 포인터가 공통 패턴을 추상화하는 데 어떻게 사용될 수 있는지 보여준다(C++의 알고리즘과 유사).
- 안전성:
💡 초보자를 위한 설명: 여기서의 '부작용'은 함수가 무언가를 반환하지 않고 입력을 직접 변경하는 것을 의미한다. 함수형 프로그래밍에서는 이런 부작용을 최소화하는 것이 좋은 관행으로 여겨지지만, 이 경우에는 원본 문자열을 변경하는 것이 함수의 목적이다.
ft_putchar_fd
- 목적:
ft_putchar_fd
는 단일 문자를 주어진 파일 디스크립터에 출력한다. 이 함수는 문자c
와 파일 디스크립터fd
를 받아, 그 문자를 해당 파일 디스크립터에 쓴다.
💡 초보자를 위한 설명: 파일 디스크립터(file descriptor)는 유닉스 계열 시스템에서 열린 파일을 참조하는 정수다. 표준 입력(0), 표준 출력(1), 표준 오류(2)가 가장 흔한 파일 디스크립터다. 이 함수를 사용하면 특정 파일이나 스트림에 문자를 쓸 수 있다.
- 표준 라이브러리 대응: C 표준 라이브러리의
putchar
는 표준 출력(stdout)에 문자를 쓰고,fputc
는 주어진FILE*
스트림에 문자를 쓸 수 있다. 그러나ft_putchar_fd
는 저수준 파일 디스크립터(시스템 호출에서 사용되는 정수 파일 디스크립터)로 작동하도록 설계되었다.write()
시스템 호출을 직접 사용하는 것 외에는, 원시 파일 디스크립터를 받아 문자를 쓰는 표준 C 함수가 없다. 내부적으로,ft_putchar_fd
는 일반적으로 한 바이트를 쓰기 위해write(fd, &c, 1)
을 호출하여 구현된다. 이는 기본적으로 C 라이브러리의 버퍼링을 통해putc/putchar
가 할 일이다. 따라서ft_putchar_fd
는fputc(c, stream)
와 유사하지만,FILE*
스트림이 필요하지 않고 - 파일 디스크립터 정수를 사용한다. 예를 들어,ft_putchar_fd('A', 1)
은 파일 디스크립터 1(관례상 표준 출력)에 문자'A'
를 쓸 것이다. - 동작 세부 사항: 함수는 정확히 한 바이트(문자)를 쓴다.
fd
가 유효하고 쓰기가 가능하면, 그 바이트는 기록될 것이다.fd
가 터미널이나 콘솔을 가리키면, 문자는 거기에 나타난다; 파일을 가리키면, 현재 파일 위치에 바이트가 기록된다. 단일 문자에 대한 명시적인 널 종결자는 없다. 함수는 아무것도 반환하지 않으며, 일반적으로void
로 정의된다. 오류의 경우(유효하지 않은fd
나 I/O 오류와 같은), 함수 자체는 이를 보고할 수 없다(void이므로). 그러나 기본write
는-1
을 반환할 것이다. libft의 간단한 출력 함수는 일반적으로 오류를 처리하거나 전파하지 않는다 - 파일 디스크립터가 유효하고 쓰기가 성공한다고 가정한다. - 구현 고려 사항:
- 시스템 호출 vs. stdio:
write()
를 사용하는 것이 간단한 방법[^47]이다. 이는 단지fd
가 있을 때 FILE 스트림을 설정하는 오버헤드를 피한다. 루프에서 반복적으로 수행된다면 한 번에 한 바이트씩 쓰는 것은 비효율적일 수 있음을 주의하자; 그런 경우, 누적하여 청크 단위로 쓰는 것이 좋다. 그러나ft_putchar_fd
는 종종 문자를 하나씩 출력하는 데 사용된다(문자열의 문자를 루프에서 출력하는 것과 같이, 이상적으로는ft_putstr_fd
를 사용해야 하지만). - 파일 디스크립터: 함수는 모든 파일 디스크립터에서 작동해야 한다. 일반적인 디스크립터는 0(stdin), 1(stdout), 2(stderr)이다. 예를 들어,
fd=2
에 쓰면 문자를 표준 오류로 보낸다. 또한 파일이나 소켓을 위해open()
에서 얻은 디스크립터일 수도 있다. 함수는 구분하지 않는다; 그냥 바이트를 쓴다. - 경계 조건: fd가 음수이거나 열린 디스크립터가 아니면, write는 실패한다. libft는 일반적으로 호출을 수행하는 것 이상으로 이를 처리하지 않는다. ft_putchar_fd가 void를 반환하기 때문에, 호출자는 나중에 errno를 확인하는 등의 방법이 아니면 오류를 쉽게 감지할 수 없다. 강력한 응용 프로그램에서는, 오류를 처리하기 위해 int를 반환하는 버전(fputc가 문자나 오류 시 EOF를 반환하는 것처럼)이 필요할 수 있지만, 여기서는 단순성이 선택되었다.
- putchar와의 비교: 표준 putchar(c)는 fputc(c, stdout)과 동등하다[^48] - stdout에 쓴다[^48]. ft_putchar_fd는 목적지가 모든 fd가 될 수 있다는 점에서 이 기능을 확장한다. 이는 시스템의 write 호출에 대한 얇은 래퍼이다. 이 함수(그리고 다음 ft_put* 함수들)는 C 버퍼링된 I/O(stdio) 라이브러리를 사용할 수 없거나 원하지 않을 때, 또는 파일 디스크립터를 직접 사용하여 사용자 정의 출력(네트워크 소켓이나 파일과 같은)에 쓸 때 특히 유용하다.
- 시스템 호출 vs. stdio:
💡 초보자를 위한 설명: write() 함수는 운영체제에 직접 파일 쓰기를 요청하는 시스템 콜이다. fputc()나 putchar()와 같은 C 표준 라이브러리 함수들은 성능 향상을 위해 내부적으로 버퍼링을 사용하지만, write()는 즉시 쓰기를 수행한다.
[^47]: libcgc/transmit.md at master · CyberGrandChallenge/libcgc · GitHub
[^48]: Why does C puts appends a newline while fputs doesn't?ft_putstr_fd
- 목적:
ft_putstr_fd
는 전체 문자열을 주어진 파일 디스크립터에 쓴다.char *s
와int fd
를 받아, 문자열s
(종결 널 바이트 제외)를 파일 디스크립터에 쓴다.
💡 초보자를 위한 설명: 이 함수는 ft_putchar_fd와 달리 한 번에 전체 문자열을 출력한다. 문자열은 널 종결자('\0')를 제외하고 쓰여진다.
- 표준 라이브러리 대응: 가장 가까운 등가물은
fputs(s, stream)
또는 시스템 호출 레벨에서write(fd, s, strlen(s))
이다. 표준 라이브러리는 또한puts
를 가지고 있는데, 이는 stdout에 쓰고 줄바꿈을 추가한다[^48](그러나ft_putstr_fd
는 줄바꿈을 추가하지 않는다 -fputs
의 동작과 유사). 또 다른 유사한 함수는 stdout에 쓰는 경우write(1, s, nbytes)
이지만, 다시 말하면,write
는 길이를 지정해야 한다.ft_putstr_fd
는 내부적으로write(fd, s, ft_strlen(s))
를 사용하거나, 문자별로 루프에서 쓸 수 있다. 전체 문자열을 한 번에 출력하기 위해write
를 사용하는 것은 각 문자에 대해ft_putchar_fd
로 루프하는 것보다 더 효율적이다. - 동작 세부 사항: 이 함수는
s[0]
부터 시작하여'\0'
이전까지 문자열s
의 문자를 출력한다. 널 종결자는 출력하지 않는다. 문자열이 비어 있으면, 아무것도 쓰지 않는다(호출은 본질적으로 no-op).s
가NULL
이면, 많은 구현은 단순히 아무것도 하지 않거나(안전 조치) 잠재적으로 충돌할 수 있다, 확인하지 않으면. 쓰기 전에s != NULL
을 확인하는 것이 신중하다. 성공하면, 문자열의 내용이 대상(콘솔, 파일 등)에 나타난다. 오류 시, 함수가 void를 반환하기 때문에, 오류는 직접 보고되지 않는다. - 구현 고려 사항:
- 쓰기 방법: 일반적으로
write
에 대한 단일 호출을 사용하는 것이 가장 좋다:strlen
을 통해 길이를 알고, 그런 다음write(fd, s, length)
를 호출한다. 이렇게 하면 모든 데이터를 한 번에 커널로 푸시하여 효율적이다. 또는 루프에서 각 문자에 대해ft_putchar_fd
를 호출할 수 있지만, 이는 문자당 하나의 시스템 호출이 되어 긴 문자열에 대해 상당히 느리다. 따라서 좋은 구현은 쓰기를 배치한다. - 문자열 길이: 구현은 얼마나 많은 바이트를 쓸지 결정하기 위해
strlen
을 사용한다. 이는 O(n)이며, 쓰기 호출은 n 문자에 대해 O(n)이므로, 전체적으로 O(n)이다. 괜찮다. 한 가지 코너:s
가 극도로 긴 경우(INT_MAX보다 긴),strlen
은 size_t를 반환하고,write
는 size_t 카운트를 받으므로, 이론적으로는 여전히 처리할 수 있다. 매우 큰 쓰기는 OS에 의해 분할될 수 있다(내부 버퍼보다 크면). 그러나 우리 관점에서는 하나의 호출이다. - 경계 조건: s가 NULL이면, (유효하지 않은 포인터를 write에 전달하는 것을 피하기 위해) 쓰기를 건너뛴다. fd가 유효하지 않으면, write는 -1을 반환한다; libft는 이를 처리하지 않지만 필요하다면 확인할 수 있다. 또 다른 경계 조건: strlen(s)가 0인 경우(빈 문자열), 여전히 길이 0으로 write를 호출하는 것은 괜찮다(아무 일도 일어나지 않고 0을 반환).
- 버퍼링: 이것이 저수준 쓰기를 사용하기 때문에, FILE* 버퍼링을 바이패스한다. 일부 컨텍스트에서, ft_putstr_fd를 fd=1(stdout)에 사용하는 것은 stdout의 버퍼를 사용하지 않을 것이다. 이는 stdout에서 ft_putstr_fd와 printf를 혼합한다면, 하나는 직접 FD로 가고 다른 하나는 버퍼링될 수 있기 때문에 예상과 다른 순서가 될 수 있다는 것을 의미한다. 42 프로젝트에서는, 일반적으로 혼란을 피하기 위해 한 가지 출력 스타일에 고수한다.
- puts/fputs와의 비교: fputs(s, stdout)은 콘솔에 유사한 출력을 하지만 버퍼링된 stdio를 통해 한다. puts(s)는 출력하고 줄바꿈을 추가[^48]하지만, ft_putstr_fd는 그렇지 않는다. 따라서 ft_putstr_fd는 임의의 디스크립터에 대한 fputs와 정확히 같다. 이는 목적지에 대해 더 많은 유연성을 제공한다.
- 쓰기 방법: 일반적으로
💡 초보자를 위한 설명: 문자열 처리 함수에서 strlen()을 호출하는 것은 문자열 길이를 알아내는 일반적인 방법이다. 이 함수는 문자열을 처음부터 끝까지 스캔하여 널 종결자를 찾을 때까지의 문자 수를 반환한다. O(n)은 문자열 길이에 비례하는 시간이 소요됨을 의미한다.
ft_putendl_fd
- 목적:
ft_putendl_fd
는 문자열을 주어진 파일 디스크립터에 출력하고, 그 뒤에 줄바꿈 문자('\n'
)를 따라오게 한다. 본질적으로, 이는ft_putstr_fd
와 같지만 항상 출력 끝에 줄바꿈을 추가한다.
💡 초보자를 위한 설명: 'endl'은 'end line'의 약자로, 이 함수는 문자열을 출력한 후 줄바꿈을 추가한다. 이는 각 출력이 별도의 줄에 나타나도록 보장한다.
- 표준 라이브러리 대응: 표준
puts
함수는 stdout에 쓸 때 유사하게 동작한다: 문자열을 출력하고 줄바꿈을 추가한다[^48]. 실제로,puts(s)
는fputs(s, stdout)
다음에fputc('\n', stdout)
와 동등하다고 지정되어 있다[^48].ft_putendl_fd
는 이를 모든 파일 디스크립터fd
로 일반화한다. 임의의 파일에 대해 이를 수행하는 단일 표준 호출은 없다(fprintf(fdopen(fd), "%s\n", s)
를 사용하는 것이 번거로움을 제외하고). 그래서ft_putendl_fd
는 모든 fd에서 출력 문자열이 줄바꿈으로 끝나도록 보장하는 편리한 방법을 제공한다. - 동작 세부 사항: 이는 전체 문자열
s
를 쓰고 나서'\n'
을 쓴다.s
가 이미 줄바꿈으로 끝나더라도,ft_putendl_fd
는 여전히 또 다른 줄바꿈을 추가할 것이다(그래서 빈 줄이 생길 수 있음). 일반적으로, 이는 정확히 두 번의 쓰기를 수행한다: 문자열용 하나와 줄바꿈용 하나이다(또는 가능한 경우 줄바꿈을 포함하여strlen(s) + 1
바이트를 쓰는 방식으로 결합할 수도 있다). 줄바꿈은 출력이 자체 라인에 있도록 보장한다. 예를 들어,ft_putendl_fd("Hello", 1)
은Hello
를 출력하고 나서 줄바꿈을 추가하여 후속 출력이 다음 줄로 이동하게 한다. - 구현 고려 사항:
- 쓰기: 간단한 접근 방식은
ft_putstr_fd(s, fd)
를 호출한 다음ft_putchar_fd('\n', fd)
를 호출하는 것이다. 이는 두 번의 시스템 호출(문자열용 하나, 줄바꿈용 하나)이 된다. 또는write(fd, s, len)
을 하고 나서write(fd, "\n", 1)
을 할 수 있다. 차이는 무시할 수 있지만,s
+\n
인 버퍼를 구성하고 한 번에 모두 쓰는 방식으로 결합할 수 있다. 그러나 그런 버퍼를 구성하려면 메모리 할당이나 문자열 크기의 스택 버퍼가 필요할 수 있으며, 이는 가치가 없을 수 있다. 줄바꿈을 위한 단일 바이트 쓰기가 대부분의 경우 비싸지 않다는 점을 고려하면, 두 번의 쓰기가 괜찮다. - 경계 조건:
s
가NULL
이면, 이상적으로는 아무것도 하지 않는다.s
가 비어 있으면, 이 함수는 단지 줄바꿈만 쓸 것이다(그래서 효과적으로 새로운 줄로 이동). 이는 허용 가능한 동작이다(일부 구현은s
가 비어 있으면 아무것도 출력하지 않기로 선택할 수 있지만, 일반적으로 여전히 줄바꿈을 출력한다. 왜냐하면 "빈 문자열 플러스 줄바꿈"은 단지 줄바꿈이 되기 때문이다). 다른 것들과 마찬가지로, 유효하지 않은fd
는write
에서 잡히지 않는 오류를 일으킬 것이다. - 사용: 이 함수는 각 메시지가 줄바꿈으로 끝나야 하는 파일이나 소켓에 라인을 출력하는 데 편리하다. 이는 puts의 동작을 모방하지만 모든 파일 디스크립터에 대해 가능하다. 표준 출력에 ft_putendl_fd(str, 1)를 사용하는 것은 puts(str)과 동등하다(단, puts는 int를 반환하고 오류를 표시하거나 다르게 플러시할 수 있음).
- 이중 줄바꿈 없음: 의도적으로 추가 빈 줄을 원하지 않는 한, 사용자는 s 끝에 \n을 포함하지 않도록 주의해야 한다. ft_putendl_fd는 항상 자체적으로 하나의 줄바꿈을 추가한다.
- 쓰기: 간단한 접근 방식은
💡 초보자를 위한 설명: 이 함수는 ft_putstr_fd의 확장 버전으로 생각할 수 있다. 문자열을 출력하는 것은 동일하지만, 줄바꿈을 추가하여 출력을 더 읽기 쉽게 만든다.
ft_putnbr_fd
- 역할:
ft_putnbr_fd
는 정수n
을 주어진 파일 디스크립터fd
에 출력한다. 숫자는 십진수 문자열 표현으로 변환되어 출력된다. 이는 효과적으로 파일 디스크립터에 대한itoa
와putstr
의 조합이지만, 편의를 위해 하나의 함수로 수행된다.
💡 초보자를 위한 설명: 이 함수는 정수를 문자로 변환하여 출력한다. 내부적으로는 ft_itoa와 비슷한 동작을 하지만, 문자열을 생성하는 대신 바로 파일 디스크립터에 출력한다.
- 표준 라이브러리 대응: stdio에는 직접적인
putnbr
이 없지만,dprintf(fd, "%d", n)
(파일 디스크립터에 출력하는 POSIX 함수) 또는FILE*
가 있는 경우fprintf(stream, "%d", n)
을 사용하여 달성할 수 있다. 내부적으로,ft_putnbr_fd
는 (ft_itoa
로직과 유사하게) 수동으로 변환을 수행하고 문자를 출력할 가능성이 높다. 이는 무거운printf
기계를 사용하는 것을 피한다. 따라서ft_putnbr_fd(n, fd)
는fprintf(fdopen(fd), "%d", n)
와 유사하지만, FILE*를 열거나 형식 문자열을 사용할 필요가 없다. - 동작 세부 사항: 숫자가 음수이면 마이너스 기호를 출력하고, 그 뒤에 십진수로 숫자의 자릿수를 출력한다.
n
이 0이면, "0"을 출력한다.n
이 INT_MIN이면, "-2147483648"을 올바르게 출력해야 한다. 출력 후 줄바꿈은 없다(ft_putendl_fd
와 달리,ft_putnbr_fd
는 숫자 자체만 출력한다). 출력 후, 파일 위치는 출력된 자릿수만큼 전진한다(다른 쓰기 작업과 마찬가지로). - 구현 고려 사항:
- 재귀 방법: 가장 중요한 자릿수부터 가장 작은 자릿수까지 재귀를 사용하여 자릿수를 출력한다. 의사 코드:이는 마지막 숫자를 제외한 모든 자릿수를 재귀적으로 출력한 다음, 마지막 자릿수를 출력한다. 수동 문자열 구성 대신 호출 스택을 활용한다. INT_MIN에 대한 특수 케이스가 필요하다.
-INT_MIN
은 오버플로우되기 때문이다. 종종-
를 출력하고, long으로 캐스팅하여n
을 양수로 설정하거나, 특수 케이스로 처리한다:n == INT_MIN
이면, "-2147483648"를 직접 출력한다(리터럴).if (n < 0) { '-'를 출력; n = -n (INT_MIN 조심) } if (n>= 10) ft_putnbr_fd(n / 10, fd); (n % 10 + '0') 문자를 fd에 출력;
- 반복(문자열 구성) 방법: 내부적으로
ft_itoa
를 사용하여 문자열을 얻은 다음ft_putstr_fd
를 사용한다. 이는 간단하고 코드를 재사용하지만, itoa 문자열에 대한 추가 메모리 할당이 발생한다. 파일 디스크립터에 쓰는 것이 정적 버퍼를 사용할 수 있다고 가정하면, 일부는 할당을 피하고 대신 재귀를 사용할 수 있다.
- 재귀 방법: 가장 중요한 자릿수부터 가장 작은 자릿수까지 재귀를 사용하여 자릿수를 출력한다. 의사 코드:이는 마지막 숫자를 제외한 모든 자릿수를 재귀적으로 출력한 다음, 마지막 자릿수를 출력한다. 수동 문자열 구성 대신 호출 스택을 활용한다. INT_MIN에 대한 특수 케이스가 필요하다.
💡 초보자를 위한 설명: 재귀 방법은 함수가 자기 자신을 호출하는 방식으로, 큰 문제를 작은 문제로 나누어 해결한다. 예를 들어 1234를 출력하기 위해, 먼저 123을 출력하는 문제를 해결한 다음 4를 출력한다. 재귀는 우아한 해결책이지만, 너무 깊게 중첩될 경우 스택 오버플로우가 발생할 수 있다.
- 할당 필요 없음: 재귀 접근 방식은 새 문자열을 할당하지 않고 직접
fd
에 출력하므로 더 메모리 효율적일 수 있다. 그러나 재귀는 각 자릿수에 대해 스택 프레임을 사용한다(32비트에 대해 최대 ~10 호출 깊이, 이는 괜찮음). 많은 libft 구현은 우아함을 위해 재귀를 선택한다. - INT_MIN 처리: 언급했듯이,
-2147483648
은n = -n
으로 처리할 수 없다(32비트에서 2147483648은 범위를 벗어남).if (n == INT_MIN)
직접 확인으로 리터럴을 쓰고 반환하거나, long으로 캐스팅하여 처리할 수 있다:long nl = n; if (nl < 0) { '-'를 출력, fd); nl = -nl; } // 그런 다음 nl을 양수로 처리.
- 경계 조건: 0이 "0"을 출력하고 음수가 적절히 처리되는지 확인하자.
fd
가 유효하지 않으면, 평소와 같이 쓰기가 조용히 실패한다. 각 자릿수에ft_putchar_fd
로 개별적으로 출력하는 경우, 여러 번의 쓰기가 된다는 점에 유의하자. 그러나 각 자릿수는 단일 바이트이므로 괜찮다. 많은 숫자를 출력할 때 출력 성능이 우려된다면, 버퍼를 구성한 다음 한 번의write
를 선호할 수 있지만, 일반적으로 숫자는 작고 이를 무시할 수 있다. - 성능: 정수 출력은 최대 ~11자(32비트의 경우)이므로, 성능은 문제가 되지 않는다. 루프에서 여러 번 호출되더라도, 오버헤드는 매우 긴 문자열 출력과 같은 다른 작업에 비해 미미하다. 재귀 솔루션은 함수 호출에 약간의 오버헤드가 있지만, 깊이가 적기 때문에 무시할 수 있다.
- 코드 단순성 vs 재사용:
ft_putnbr_fd
내부에서ft_itoa
를 사용하면 이전 함수를 재사용할 수 있지만(중복 로직 감소), 출력 후 바로 해제될 동적 할당이 필요하다. 직접 접근 방식은 이를 피한다. 이는 디자인 선택이다; libft 컨텍스트에서는 둘 다 허용된다(동작이 올바르다면 엄격한 규칙 없음).
보너스 – 연결 리스트 함수
libft의 보너스 부분은 단일 연결 리스트를 조작하기 위한 일련의 함수를 소개한다. libft에서 리스트는 다음 구조로 정의된다:
typedef struct s_list { void *content; struct s_list *next; } t_list;
각 노드(
t_list
)는 포인터content
(어떤 타입의 데이터든 가리킬 수 있음)와 리스트의 다음 노드를 가리키는 포인터(NULL
이면 마지막 노드)를 가진다. 이 보너스 함수들은 이러한 리스트를 생성하고 관리하기 위한 기본 작업을 제공한다. 여기서는 연결 리스트의 일반적인 개념을 논의하고, 이러한 유틸리티를 표준 라이브러리(glibc, BSD libc 등)에서 사용 가능한 것과 비교한 다음, 각 함수(ft_lstnew
,ft_lstadd_front
,ft_lstsize
,ft_lstlast
,ft_lstadd_back
,ft_lstdelone
,ft_lstclear
,ft_lstiter
,ft_lstmap
)와 구현 고려 사항을 살펴본다.연결 리스트 개념 및 표준 라이브러리 비교
- 연결 리스트의 개념: 연결 리스트는 각 요소(노드)가 데이터와 시퀀스의 다음 요소에 대한 포인터/참조를 포함하는 동적 데이터 구조이다. _단일 연결 리스트_에서 각 노드는 단방향 체인을 형성하며 다음 노드만 가리킨다[^49]. 리스트는 일반적으로 첫 번째 노드(헤드)에 대한 포인터를 통해 접근된다. 배열과 달리, 연결 리스트 요소는 메모리에 연속적으로 저장되지 않는다; 각 노드는 필요에 따라 할당되며, 링크(포인터)가 이들을 연결한다. 이를 통해 요소를 이동시키지 않고도 효율적인 삽입 및 제거가 가능하다(단지 포인터 업데이트만). 하지만 접근 시간이 선형적이라는 의미이다 – 요소를 찾기 위해 헤드에서 시작하여 여러 노드를 통과해야 할 수 있다. 또한, 요소당 포인터의 메모리 오버헤드가 있다. 핵심 속성은 리스트 크기가 런타임에 노드를 할당하거나 해제함에 따라 증가하거나 감소할 수 있다는 것이다.
💡 초보자를 위한 설명: 연결 리스트는 각 요소가 다음 요소를 가리키는 구조로, 배열과 달리 메모리에 연속적으로 저장되지 않는다. 배열이 한 줄로 늘어선 사람들이라면, 연결 리스트는 각자 다음 사람의 위치만 알고 있는 사람들의 그룹이라고 볼 수 있다. 이 구조는 중간에 사람을 추가하거나 제거하기 쉽지만, 특정 위치의 사람을 찾으려면 처음부터 차례로 찾아가야 한다.
- 표준 라이브러리(glibc)와 연결 리스트: ISO C 표준 라이브러리는 연결 리스트, 큐 또는 트리와 같은 고수준 데이터 구조의 구현을 포함하지 않는다[^50][^51]. C 표준 라이브러리의 설계 철학은 저수준 구성 블록(malloc, free 등)을 제공하고 데이터 구조 구현은 프로그래머나 다른 라이브러리에 맡기는 것이었다. 따라서 glibc(Linux에서 일반적으로 사용되는 GNU C 라이브러리)에는
<stdlib.h>
또는 유사한 곳에list
데이터 타입이 없다. 그렇긴 하지만, 일부 비표준 또는 확장 라이브러리는 그런 기능을 제공한다:- BSD의
<sys/queue.h>
: 이것은 BSD 시스템에서 사용 가능한 헤더이며(호환성을 위해 glibc에서도 제공됨[^52]), 연결 리스트, 꼬리 큐 등을 구현하기 위한 매크로를 정의한다. 이것은 함수 집합이 아니라, 리스트 구조에 대한 매크로 템플릿의 모음이다. 문서에 따르면,<sys/queue.h>
는 단일 연결 리스트(SLIST)와 이중 연결 리스트(LIST)를 포함한 여러 데이터 구조를 정의하고 작동시키기 위한 매크로를 제공한다[^53]. 이러한 매크로는 타입 안전한 방식으로 노드 추가, 제거, 순회와 같은 공통 작업을 처리한다(리스트 타입은 매크로를 통해 정의됨). 이는 BSD의 확장 라이브러리의 일부이다(ISO C나 POSIX가 아니지만, 시스템 프로그래밍에서 널리 사용됨). - GLib(GNOME의 일부, glibc가 아님): GLib 유틸리티 라이브러리는
GList
(이중 연결 리스트)와GSList
(단일 연결 리스트) 데이터 구조를 전체 API(요소를 추가, 앞에 추가, 제거 등을 위한 함수)와 함께 제공한다[^54][^55]. GLib은 C에서 편의 데이터 구조를 위해 사용할 수 있는 서드파티 라이브러리이다. 이는 표준 라이브러리를 넘어서며 커널 수준 코드에서는 사용되지 않지만, 리스트가 필요한 C 프로젝트에서 흔한 솔루션으로 언급할 가치가 있다. - C++ 비교: C++의 표준 라이브러리(STL)에는
std::list
(이중 연결 리스트)와 다른 컨테이너가 있다[^56]. 그러나 순수 C에서는, 그런 내장 기능이 없다.
<sys/queue.h>
매크로와 같은 것을 사용하거나[^53] 직접 작성해야 한다. Ecole 42의 libft 보너스는 자신만의 최소한의 연결 리스트 함수를 구현하는 방법을 가르친다. - BSD의
💡 초보자를 위한 설명: C 표준 라이브러리는 기본적인 메모리 관리 함수는 제공하지만, 연결 리스트와 같은 고급 데이터 구조는 제공하지 않는다. 이는 프로그래머가 직접 구현하거나 서드파티 라이브러리를 사용해야 함을 의미한다. libft에서 이러한 함수들을 구현하는 것은 중요한 학습 경험이 된다.
- 연결 리스트 vs 배열(성능): 단일 연결 리스트의 일반적인 작업:
- 리스트 앞에 요소를 추가하거나 제거하는 것은 O(1)이다(헤드 포인터만 조정)[^57].
- 요소를 찾거나 끝을 찾기 위한 순회는 리스트 길이에 따라 O(n)이다. 포인터를 하나씩 따라가야 하기 때문이다.
- 단일 연결 리스트의 끝에 삽입하는 것은 헤드 포인터만 있는 경우 O(n)이다(끝까지 순회해야 함). 그러나 꼬리 포인터를 유지하면 O(1)이 될 수 있다.
- 리스트의 크기 가져오기는 순회로 카운트하는 경우 O(n)이다. 별도의 카운터를 유지하지 않는 한.
💡 초보자를 위한 설명: 배열과 연결 리스트는 각각 장단점이 있다. 배열은 인덱스로 즉시 접근할 수 있어 특정 위치의 데이터를 찾는 데 빠르지만(O(1)), 중간에 요소를 삽입하거나 삭제하려면 많은 요소를 이동해야 한다(O(n)). 반면 연결 리스트는 중간 삽입/삭제가 빠르지만(포인터만 변경하면 됨), 특정 위치를 찾으려면 처음부터 순차적으로 탐색해야 한다(O(n)).
- BSD
<sys/queue.h>
vs libft 접근 방식:<sys/queue.h>
의 매크로는 헤드 포인터(때로는 O(1) 꼬리 삽입을 위한 꼬리 포인터도)를 포함하는 구조를 정의한다. 예를 들어,SLIST_HEAD
매크로는 리스트의 첫 번째 요소를 가리키는 포인터를 포함하는 구조체를 정의한다[^58]. libft의 간단한 접근 방식에서는, 별도의 헤드 구조로 리스트를 감싸지 않는다; 대신, 헤드는 일반적으로 사용자의 코드에서t_list *
변수로 관리되며,ft_lstadd_front
등은 필요할 때 헤드에 대한 포인터-투-포인터를 받는다. BSD 매크로는 또한 안전한 제거, 순회 매크로(SLIST_FOREACH
등)를 제공하며, 특정 리스트 타입(꼬리 큐와 같은TAILQ
)을 사용하면 꼬리 포인터나 리스트 길이를 유지할 수 있다. libft의 함수는 최소한의 하위 집합이다: 헤드 포인터만 있고 길이나 꼬리가 저장되지 않은 단일 연결 리스트이므로, 마지막 찾기나 크기 가져오기와 같은 작업은 순회로 수행된다(O(n)). 이는 학습 목적과 작은-중간 크기 리스트에는 허용 가능하다. 성능이 중요하다면, 길이나 꼬리를 저장하도록 리스트 구조체를 확장할 수 있다.
[^49]: Linked Lists in C++ - AlgoCademy
[^50]: Data Structure and Algorithm Libraries in C - Reddit
[^51]: Data Structure and Algorithm Libraries in C - Reddit
[^52]: [PATCH] sys/queue.h: add a few more helpers from FreeBSD
[^53]: queue(7) - Linux manual page - man7.org
[^54]: Data Structures - GLib – 2.0
[^55]: GLib – 2.0 - GTK Documentation
[^56]: Section 1: C++ data structures – CS 61 2021
[^57]: Isn't the time complexity to insert an element on Singly Linked List O ...
[^58]: queue(3) - OpenBSD manual pages이제 각 libft 리스트 함수를 살펴보고, 그 역할을 설명하고, 구현 및 사용에 중요한 세부 사항을 알아본다:
ft_lstnew
- 역할: 새 리스트 노드를 생성한다.
t_list
노드에 대한 메모리를 할당하고,content
포인터를 제공된 인수로 설정하며,next
포인터를NULL
로 설정한다. 새 노드에 대한 포인터를 반환한다(또는 할당이 실패하면NULL
). 이것은 일반적으로 리스트를 구축할 때 가장 먼저 사용하는 함수이다:ft_lstnew
를 통해 노드를 생성한 다음 다른 함수를 사용하여 연결한다.
💡 초보자를 위한 설명: 이 함수는 연결 리스트의 새 노드를 만든다. 노드는 연결 리스트의 기본 구성 요소로, 데이터(content)와 다음 노드를 가리키는 포인터(next)로 구성된다. 처음 생성될 때 노드는 아직 아무 노드와도 연결되지 않았으므로 next는 NULL로 설정된다.
- 비교 가능한 기능: 표준 라이브러리에는
list
함수가 없으므로, 직접적인 대응물이 없다. 이는 한 단계로malloc
+ 구조체 설정과 같다. C++에서는new ListNode(content)
와 유사한 단계일 수 있다. GLib에서는g_slist_append(NULL, data)
가 가장 가까울 것이다. 이는 해당 데이터로 새로운 단일 노드 리스트를 생성한다. 그러나ft_lstnew
는 더 간단하다: 그냥 하나의 노드를 만들며, 아직 어디에도 연결하지 않는다. - 구현 세부 사항:
ft_lstnew
는void *content
를 받는다. 새 노드에 대해sizeof(t_list)
바이트를 할당해야 한다. 할당 후,node->content
는content
매개변수로 설정되고(데이터를 복사하지 않고, 단지 포인터를 저장),node->next
는NULL
로 설정된다(새 노드는 처음에 아무것도 연결되지 않음).- 메모리:
malloc
을 사용한다. 결과가NULL
인지 확인하자(메모리 부족); 그렇다면, 단순히NULL
을 반환한다. 아직 다른 것이 할당되지 않았기 때문에 실패 시 정리할 것이 많지 않다.
- 메모리:
- 콘텐츠 소유권: 중요한 고려 사항 중 하나는
ft_lstnew
가 콘텐츠의 복사본을 만들지 않는다는 것이다. 단지 포인터를 저장한다. 이는 호출자가 가리키는 콘텐츠가 리스트 노드의 수명 동안 유효하게 유지되도록 해야 하며, 필요한 경우 복제본을 관리해야 함을 의미한다. 예를 들어, 정수를 저장하려면,int
를 할당하거나 정적 또는 스택 변수의 주소를 사용할 수 있다(단, 범위를 벗어나는 스택 주소를 사용하는 것은 버그). 종종, 동적으로 할당된 콘텐츠나 정적으로 할당된 전역 데이터를 사용한다.
💡 초보자를 위한 설명: 이 함수는 데이터의 복사본을 만들지 않고 포인터만 저장한다는 점을 이해하는 것이 중요하다. 예를 들어, 문자열을 저장하려면 그 문자열은 노드보다 오래 지속되어야 한다. 임시 문자열을 사용하면 노드가 dangling 포인터(이미 해제된 메모리를 가리키는 포인터)를 가질 수 있다.
- 사용 사례: 노드를 생성한 후, 즉시
ft_lstadd_front
또는 다른 함수로 리스트에 추가할 수 있다. 또는 독립 요소로 사용할 수 있다. - 경계 조건: 할당 실패 외에는 여기서 많이 잘못될 수 있는 것이 없다. 콘텐츠 포인터가
NULL
인 경우를 처리해야 한다 - 괜찮다; 노드는 NULL 콘텐츠를 가질 수 있다(아마도 빈 페이로드를 나타냄). 함수는 여전히 노드를 할당하고 단순히content = NULL
로 설정해야 한다. - 예시:
t_list *node = ft_lstnew(ft_strdup("42Gyeongsan"));
은 콘텐츠가 문자열 "42Gyeongsan"인 리스트 노드를 생성한다.node->next
는 NULL이 된다. 리스트에서 사용하려면, 이를 리스트의 헤드로 유지하는 포인터를 유지하거나 기존 리스트에 추가할 수 있다.
ft_lstadd_front
- 역할: 연결 리스트 시작 부분에 노드를 추가한다. 리스트의 헤드 포인터에 대한 포인터(
t_list **lst
)와 추가할 노드(t_list *new
)를 받아,new
노드를 리스트의 첫 번째 요소로 만든다. 호출 후,new
는 리스트의 헤드가 되고, 그next
는 이전 헤드를 가리킨다.
💡 초보자를 위한 설명: 이 함수는 새 노드를 리스트의 맨 앞에 추가한다. t_list **lst는 포인터에 대한 포인터(이중 포인터)로, 리스트의 헤드 포인터를 직접 수정할 수 있게 해준다. 이렇게 함으로써 함수 호출 후에도 변경된 헤드 포인터 값이 유지된다.
- 비교 가능한 기능: 작업 측면에서, 이것은 종종 일부 라이브러리에서 _push_front_라고 불린다(예: C++의
std::forward_list::push_front
). BSD의SLIST_INSERT_HEAD(head, elm, field)
매크로는 유사한 작업을 수행한다(SLIST의 헤드에 새 요소 삽입)[^58]. libft의 리스트는 구조체에 캡슐화되어 있지 않으므로, 대신 직접 포인터를 조작하여 이를 달성한다. 이 연산은 단지 포인터 스왑이므로 O(1) 시간이다[^57]. - 구현 세부 사항:
*lst
가 현재 리스트의 헤드를 가리킨다고 가정하면(리스트가 비어 있으면NULL
일 수 있음).ft_lstadd_front(lst, new)
를 위해:new
가NULL
이 아닌지 확인한다(null 노드를 추가하는 것은 의미가 없다; 호출자가new
가 유효한 노드임을 보장한다고 가정).new->next = *lst
를 설정한다(새 노드를 이전 헤드에 연결).- 그런 다음
*lst = new
를 설정한다(헤드 포인터를 새 노드를 가리키도록 업데이트). - 이게 전부이다. 리스트가 비어 있었다면(
*lst
가 NULL), 이는 새 노드의 next를 NULL로 설정하고 헤드를 새 노드로 설정하므로 정확하다 – 이제 리스트에는 하나의 노드가 있다. 리스트가 비어 있지 않았다면,new->next
는 이제 이전 헤드를 가리키고, 새 노드가 헤드가 된다.
- 경계 조건:
lst
자체가 NULL이면(헤드 포인터에 대한 포인터가 유효하지 않음), 함수는 아무것도 할 수 없다; 그러나 일반적으로, 항상 실제 포인터의 주소를 전달한다.new
가 NULL이면, 강력한 구현은 아무것도 하지 않을 수 있지만, 일반적으로 유효한 노드가 전달된다고 가정한다.
💡 초보자를 위한 설명: 리스트 맨 앞에 추가하는 것은 가장 효율적인 연결 리스트 작업 중 하나이며, 단 두 개의 포인터 조작만 필요하다. 이는 항상 O(1) 시간이 걸린다는 의미로, 리스트 크기와 관계없이 상수 시간이 소요된다.
- 소유권: 추가되는 노드는 복사되지 않는다; 우리는 단지 그것을 다시 연결하고 있다. 이 후, 리스트는 그 노드를 소유한다(나중에 리스트를 정리할 때, 해당 노드가
ft_lstclear
에 의해 해제될 것이라는 의미). - 고려 사항: 이 함수는 아무것도 할당하거나 해제하지 않는다; 단지 연결한다. 따라서 매우 빠르다. 리스트 일관성 관점에서 이것은 원자적이어야 한다: 먼저 new->next를 설정한 다음 헤드를 설정하면 괜찮다(반대 순서로 하면 리스트가 깨질 것이다).
- 예시:
t_list *head = existing_list;
와t_list *node = ft_lstnew(data);
가 있다면,ft_lstadd_front(&head, node);
를 호출하면node
가 앞에 삽입된다.existing_list
가 [A->B->C]였다면, 호출 후: head -> [node -> A -> B -> C].
ft_lstsize
- 역할: 연결 리스트의 노드 수를 세어 반환한다. 리스트의 헤드(
t_list *lst
)를 받아 끝(NULL
에 도달할 때까지)에 도달할 때까지 통과하며 노드를 세어 수를 정수로 반환한다.
💡 초보자를 위한 설명: 이 함수는 단순히 리스트의 길이(노드 수)를 세어 반환한다. 연결 리스트에서는 크기 정보를 별도로 저장하지 않기 때문에, 전체 리스트를 순회하며 각 노드를 세어야 한다.
- 비교 가능한 기능: 이는 리스트의 길이를 계산하는 것과 동등하다. 리스트는 사용자 정의이므로 노드 수를 세는 직접적인 표준 함수가 없다. GLib과 같은 일부 라이브러리는 GList에 대한
g_list_length(list)
를 가지고 있다. BSD 매크로는 직접적인 길이 매크로를 제공하지 않는다(카운터를 유지하거나 필요할 때 순회해야 함). 일반적으로, 삽입/제거 시 카운터를 유지하여 O(1) 길이 쿼리를 하거나, 단순히 필요할 때 순회한다(O(n)). libft는 필요 시 순회를 선택한다. - 구현 세부 사항:
- 0에서 카운트를 시작한다.
- 리스트를 반복한다:
while (node != NULL) { count++; node = node->next; }
. - 카운트를 반환한다.
lst
가NULL
(빈 리스트)이면, 0을 반환한다.- 경계 조건: 특별한 것 없음; NULL 입력은 0이 된다. 이 함수는 간단하다.
- 성능: O(n) 시간으로, 긴 리스트의 경우 자주 호출된다면 다소 비용이 많이 들 수 있다. 예를 들어, 루프 내에서
ft_lstsize
를 호출하면 이차적이 된다. 개발자는 별도의 카운터를 유지하거나 루프를 신중하게 사용하여 최적화할 수 있다. 그러나 간헐적 사용이나 작은 리스트의 경우, 괜찮다.
💡 초보자를 위한 설명: 이 함수는 간단하지만, 리스트가 길어질수록 실행 시간이 선형적으로 증가한다(O(n)). 따라서 리스트 크기를 자주 확인해야 한다면, 별도의 크기 변수를 유지하는 것이 더 효율적일 수 있다.
- 가능한 최적화:
t_list
구조체가 매 추가/제거 시 업데이트되는size
필드를 가지도록 증강하여 이 함수를 O(1)로 만들 수 있다. 그러나, 이는 다른 모든 함수를 복잡하게 하고 신중하게 하지 않으면 불일치 위험이 있다. libft 접근 방식은 간단하게 유지한다(링크 외에 숨겨진 상태 없음). - 예시: 리스트가 [node1 -> node2 -> node3]이면,
ft_lstsize(node1)
은 3을 반환한다. 리스트가 비어 있으면(head = NULL),ft_lstsize(NULL)
은 0을 반환한다.
ft_lstlast
- 역할: 연결 리스트의 마지막 노드를 찾는다. 리스트의 헤드를 받아
->next
가NULL
이 될 때까지 순회한 다음, 해당 마지막 노드에 대한 포인터를 반환한다. 리스트가 비어 있으면(헤드가NULL
),NULL
을 반환한다.
💡 초보자를 위한 설명: 이 함수는 리스트의 마지막 노드를 찾는다. 마지막 노드는 next 포인터가 NULL인 노드이다. 단일 연결 리스트에서는 마지막 노드를 찾기 위해 처음부터 끝까지 모든 노드를 통과해야 한다.
- 비교 가능한 기능: 이것은 C++에서
std::forward_list::before_begin
과 유사하다(마지막 전을 가져오는 등). 그러나 기본적으로 단지 순회이다. BSD의<sys/queue.h>
매크로는 꼬리 포인터가 유지되는 경우 마지막 요소를 가져오는 방법을 제공한다(예를 들어, TAILQ_HEAD는 O(1)로 마지막을 가져올 수 있도록 마지막에 대한 포인터를 유지). 그러나 꼬리가 없는 단순 단일 리스트의 경우, 순회해야 한다. GLib의g_slist_last(list)
는ft_lstlast
와 정확히 동일한 작업을 수행한다(끝까지 선형 스캔). - 구현 세부 사항:
lst
가NULL
이면, 즉시NULL
을 반환한다(빈 리스트에는 마지막 요소가 없음).- 그렇지 않으면,
node = lst
를 설정한다.node->next
가NULL
이 아닌 동안,node = node->next
로 이동한다. - 루프가 끝나면,
node
는 마지막 요소이다(그 다음이 NULL).node
를 반환한다.
- 경계 조건: 빈 리스트는 NULL을 반환한다. 리스트에 요소가 하나만 있으면, 그 요소를 반환한다(그 다음이 NULL이므로, 루프가 실행되지 않고, 그것을 반환한다).
- 성능: 리스트 길이 n에 대해 O(n) 시간이다. 자주 마지막 노드를 가져와야 한다면(예: 루프에서 많은 요소를 추가하기 위해), 이는 비용이 많이 들게 된다(n개의 추가에 대해 이것을 매번 사용하면 O(n^2)). 이런 경우, 꼬리 포인터를 유지하거나 내부적으로 마지막을 찾는
ft_lstadd_back
을 사용하는 것이 일반적이다(호출당 여전히 O(n)). 적절한 크기에는 괜찮다. 성능이 중요하다면, libft 사용자는 자신이 꼬리를 추적하기로 결정할 수 있다.
💡 초보자를 위한 설명: 단일 연결 리스트에서 마지막 노드를 찾는 연산은 항상 리스트 전체를 순회해야 하므로 O(n) 시간이 소요된다. 마지막 노드에 자주 접근해야 한다면, 별도의 '꼬리 포인터'를 유지하는 것이 좋다. 이중 연결 리스트는 이전 노드에 대한 포인터도 가지고 있어 다양한 작업을 더 효율적으로 수행할 수 있다.
- 메모리/기타: 아무것도 할당하거나 수정하지 않는다(단지 포인터를 읽음).
- 예시: 리스트 [A->B->C]에 대해,
ft_lstlast(A)
는 C에 대한 포인터를 반환한다. 호출 후, 리스트는 변경되지 않으며, 우리는 단지 C에 대한 참조를 가진다.
ft_lstadd_back
- 역할: 연결 리스트 끝에 노드를 추가한다. 헤드 포인터에 대한 포인터(
t_list **lst
)와 추가할 노드new
를 받는다. 리스트 끝에new
노드를 연결한다. 즉, 현재 마지막 노드 다음에. 리스트가 비어 있었다면, 새 노드가 헤드가 된다(그 시나리오에서ft_lstadd_front
와 유사).
💡 초보자를 위한 설명: 이 함수는 새 노드를 리스트의 맨 끝에 추가한다. 리스트가 비어 있으면 새 노드가 첫 번째 노드가 된다. 리스트에 이미 노드가 있으면 마지막 노드를 찾아 그 next 포인터가 새 노드를 가리키도록 한다.
- 비교 가능한 기능: 종종 push_back 또는 _append_라고 불린다. C++의
std::list::push_back
또는std::forward_list::push_front
(forward_list는 push_back이 없음). 꼬리 포인터가 없는 경우, 이 작업은 순회가 필요하다. BSD 매크로는 이미 알려진 노드 다음에 삽입하는SLIST_INSERT_AFTER
을 가지고 있으며, 꼬리를 유지한다면, 꼬리에 삽입은 O(1)이다. 그러나 여기서는ft_lstlast
나 수동 루프로 마지막을 찾아야 한다. - 구현 세부 사항:
*lst
(헤드)가NULL
이면, 빈 리스트의 끝에 추가하는 것은 단지*lst = new
를 설정하는 것을 의미한다. (새 노드는 이 경우 헤드이자 꼬리이다.)*lst
가 NULL이 아니면,ft_lstlast
또는 오픈 코딩 루프를 사용하여 마지막 노드를 찾는다.last = ft_lstlast(*lst)
라고 하자.last->next = new
를 설정한다. 이것은 끝에 새 노드를 연결한다.new->next = NULL
을 설정한다(ft_lstnew
로 생성되었을 때 이미 그렇지 않다면).
- 경계 조건:
new
가 NULL이면, 아무것도 하지 않는다(추가할 노드 없음). 리스트 포인터lst
가 NULL이면(유효하지 않은 참조), 아무것도 할 수 없다. 일반적으로,ft_lstadd_back(&head, node);
로 사용한다. - 메모리: 내부에서 할당 없음(순회는 포인터를 읽는 것 외에는 아무것도 하지 않음). 단지 포인터 조정이다.
💡 초보자를 위한 설명: 리스트 끝에 노드를 추가하는 것은 맨 앞에 추가하는 것보다 더 많은 단계가 필요하다. 먼저 마지막 노드를 찾아야 하는데, 이것은 O(n) 시간이 걸린다. 그런 다음 마지막 노드의 next 포인터를 새 노드로 설정한다. 꼬리 포인터를 유지하면 이 작업을 O(1) 시간에 수행할 수 있다.
- 성능: 언급했듯이, 끝을 찾는 데 O(n)이다.
ft_lstadd_back
을 반복적으로 호출하여 리스트를 구축하면, n개의 삽입에 대해 O(n^2)의 전체 복잡성이 된다. 개선하려면, 외부적으로 마지막 노드에 대한 포인터를 유지하고 각 삽입 시 업데이트하여 전체적으로 O(n)으로 줄일 수 있다. 그러나 libft는 그렇게 하지 않는다. 이는 상대적으로 작은 리스트나 단일 시간 사용에 허용된다. - 작업 후: 새 노드는 이제 리스트의 일부이다.
new->next
가 NULL로 설정되는 것이 중요하다. 왜냐하면 마지막 요소여야 하기 때문이다.new
가 다른 리스트의 기존 노드였다면(일반적으로 발생하지 않음; 일반적으로ft_lstnew
로 새 노드를 생성한다), 이전 리스트에 링크하지 않도록 해야 한다. - 예시: 리스트가 [A->B->C]이고
node
가 D라면,ft_lstadd_back(&head, node)
후, 리스트는 [A->B->C->D]가 된다. 리스트가 비어 있었다면(head = NULL), 호출 후 head는 D를 가리킬 것이다(D->next = NULL).
ft_lstdelone
- 역할: 단일 리스트 노드를 삭제한다 - 특히, 하나의 노드 메모리와 필요한 경우 그 콘텐츠를 해제한다. 노드
lst
와 콘텐츠 해제 방법을 아는 함수 포인터del
을 받는다. 노드의 콘텐츠에del
을 적용한 다음, 노드 자체를 해제한다. 이 함수는 주변 노드를 다시 연결하는 것을 처리하지 않는다; 일반적으로 전체 리스트를 정리하거나 요소를 제거할 때 헬퍼로 사용된다.
💡 초보자를 위한 설명: 이 함수는 단일 노드와 그 내용을 메모리에서 해제한다. 중요한 점은 이 함수가 리스트의 연결을 재조정하지 않는다는 것이다. 따라서 리스트에서 노드를 제거하려면, 먼저 이전 노드의 next 포인터를 조정한 후 이 함수를 호출해야 한다.
- 비교 가능한 기능: 이것은 "하나의 노드 파괴"와 같다. C++에서는, 할당된 경우 단순히
delete node;
를 호출할 수 있으며, 필요한 경우 콘텐츠를 먼저 해제한다. GLib에서,g_list_free1(node)
는 하나의 GList 노드를 해제한다(그러나 콘텐츠는 해제하지 않음). 여기서는 콘텐츠를 제네릭하게 해제하기 위해del
콜백이 있다. 사용자 데이터를 누출하지 않기 위해 C의 제네릭 데이터 구조 라이브러리에서 함수 포인터를 제공하는 패턴은 일반적이다 -t_list
가 콘텐츠를void*
로만 알기 때문에, 어떻게 해제해야 하는지 모른다. 사용자는 콘텐츠가 malloc'd인 경우free
를 전달하거나, 콘텐츠 자체가 복잡한 구조인 경우 사용자 정의 함수를 전달할 수 있다. - 구현 세부 사항:
- 이 함수의 프로토타입은
void ft_lstdelone(t_list *lst, void (*del)(void*))
이다. lst
가 NULL이면, 아무것도 하지 않는다(삭제할 것 없음).- 그렇지 않으면,
del
이 NULL이 아니면,del(lst->content)
를 호출한다. 이는 콘텐츠 포인터가 가리키는 메모리를 해제하거나 삭제해야 한다(동적으로 할당된 경우). 콘텐츠 포인터가malloc
에서 온 문자열과 같은 경우,del
은free
가 되어 그 메모리를 해제한다. 콘텐츠가 더 복잡한 구조체 포인터인 경우,del
은 더 깊은 정리를 수행할 수 있다. - 그 후,
lst
노드 자체를 해제한다(free(lst)
).
- 이 함수의 프로토타입은
💡 초보자를 위한 설명: 이 함수는 del 함수 포인터를 사용하여 노드의 content를 어떻게 해제할지 결정한다. 범용성을 위해 이런 콜백 함수를 사용하는데, 이는 리스트가 어떤 타입의 데이터든 저장할 수 있도록 한다. 단순 문자열이면 free를 전달하고, 복잡한 구조체면 해당 구조체를 정리하는 사용자 정의 함수를 전달할 수 있다.
- 중요: 이 함수는
lst->next
또는 다른 노드를 처리하지 않는다.ft_lstdelone
을 호출하기 전에 리스트에서 노드를 연결 해제하는 것은 호출자의 책임이다. 예를 들어, 리스트에서 노드 X를 제거하려면, 일반적으로 X를 건너뛰도록 이전 노드의next
를 조정한 다음,ft_lstdelone(X, del)
을 호출하여 X를 안전하게 해제한다. 리스트에 여전히 연결되어 있는 노드에 대해 포인터를 고치지 않고ft_lstdelone
을 호출하면, 리스트에 댕글링 포인터가 생긴다. - 경계 조건:
del
이 NULL이면, 노드만 해제하고 콘텐츠는 건드리지 않을 수 있다(콘텐츠 해제가 필요하지 않거나 사용자가 처리할 것이라고 가정). 그러나 libft는 유효한 함수가 전달될 것으로 기대한다.del == NULL
을 처리하여 단순히 노드를 해제하고 콘텐츠를 건드리지 않는 것이 합리적이다(콘텐츠가 해제가 필요하지 않거나 사용자가 처리할 것으로 가정). - 메모리: 정확히 하나의
t_list
할당을 해제하고,del
이 해제하는 것도 해제한다. 호출 후, 해당 노드 포인터는 유효하지 않다. 호출자가 그에 대한 포인터를 가지고 있었다면, 더 이상 사용해서는 안 된다(일반적인 경우: 호출자는 리스트 링크를 업데이트하여 제거했을 것이다). - 예시: 문자열 리스트가 있고, 우리가 찾은 노드
curr
(이전 노드prev
가 가리킴)을 제거하려고 한다. 다음과 같이 한다:prev->next = curr->next; // 리스트에서 curr 연결 해제 ft_lstdelone(curr, free); // 노드와 문자열 해제
실행 후,curr
은 해제되고, 문자열은free
에 의해 해제되고, prev는 이제 curr 다음에 있던 것과 연결된다. curr이 헤드였다면, 대신 헤드 포인터를 조정했을 것이다.
ft_lstclear
- 역할: 전체 노드 리스트를 삭제하고, 각 노드와 그 콘텐츠를 해제한다. 리스트의 헤드 포인터에 대한 포인터(
t_list **lst
)와 콘텐츠를 해제하는del
함수를 받는다. 이는 리스트를 반복하고, 각 노드에 대해ft_lstdelone
을 사용하여 해제하고(따라서 또한del
을 통해 콘텐츠 해제), 마지막으로 원래 헤드 포인터를NULL
로 설정하여 리스트가 이제 비어 있음을 나타낸다.
💡 초보자를 위한 설명: 이 함수는 리스트 전체를 정리하여 메모리 누수를 방지한다. 모든 노드와 그 내용을 해제하고, 헤드 포인터를 NULL로 설정하여 리스트가 이제 비어 있음을 표시한다. 한 번의 호출로 모든 리스트 메모리가 정리된다.
- 비교 가능한 기능: 이것은 대량 클리어 작업이다. C++는
list.clear()
메서드를 가지고 있다. GLib는g_list_free_full(list, free_func)
을 가지고 있어, 모든 노드를 해제하고 각 데이터에 free_func을 사용한다(이것은del
이 free_func인 이것과 매우 유사). BSD 매크로에는 함수가 없지만, 반복하고 해제할 수 있다. 본질적으로,ft_lstclear
는ft_lstdelone
주위의 루프이다. - 구현 세부 사항:
- 아마도 프로토타입:
void ft_lstclear(t_list **lst, void (*del)(void *))
이다. *lst
가 NULL인 경우를 처리해야 한다(할 일 없음).- 루프를 사용한다: 이것은 각 노드가 해제되도록 하면서 우리가 여전히 다음 포인터를 가지고 있도록 해준다.
t_list *curr = *lst; t_list *next; while (curr) { next = curr->next; ft_lstdelone(curr, del); curr = next; } *lst = NULL;
- 루프 후,
*lst
를 NULL로 설정하여 호출자의 헤드 참조가 이제 빈 리스트를 나타내도록 한다(해제된 메모리에 대한 댕글링 포인터 방지).
- 아마도 프로토타입:
- 경계 조건:
lst
(헤드에 대한 포인터)가 NULL이면,*lst
를 설정할 수도 없다; 그 경우 아마도 아무것도 하지 않을 것이다.del
이 NULL이면, 앞서 말했듯이, 콘텐츠가 해제될 필요가 없거나 콘텐츠가 해제된다고 가정한다. 일반적으로, 유효한del
을 전달한다.
💡 초보자를 위한 설명: 리스트를 순회하면서 해제할 때 주의할 점은, 현재 노드를 해제하기 전에 다음 노드의 포인터를 저장해야 한다는 것이다. 그렇지 않으면 현재 노드가 해제된 후 다음 노드로 이동할 수 없게 된다.
- 이 함수는 리스트가 사라진다는 의미에서 멱등적이다. 이미 빈 리스트에 호출하면, 아무 일도 일어나지 않는다. 또한 항상 NULL을 반환한다.
- 성능: O(n), 여기서 n은 노드 수이다. 이는 모든 것을 해제하기 위해 예상된다. 각 노드 삭제는 O(1)이므로, 카운트에 선형적이다.
- 후속 효과: 해제 후, 노드 중 어느 것도 사용해서는 안 되며 이전 헤드 포인터도 마찬가지이다(NULL로 설정됨). 리스트의 노드를 가리키는 다른 포인터가 있었다면, 이제 그것들은 댕글링 상태이다 - 하지만 일반적으로, 이 간단한 시나리오에서는 다른 참조를 유지하지 않을 것이다.
- 예시:
head
가 리스트 [A->B->C]를 가리키고,ft_lstclear(&head, del)
을 호출하면, A(A의 콘텐츠에 del 사용)를 해제하고, A 노드를 해제하고, 그 다음 B의 콘텐츠와 노드, 그 다음 C의 콘텐츠와 노드를 해제한다. 마지막에,head
는 NULL로 설정된다.del
이free
였고 콘텐츠가 malloc'd 문자열이었다면, 그 문자열들도 모두 해제된다.
ft_lstiter
- 역할: 리스트를 반복하고 각 노드의 콘텐츠에 함수를 적용한다. 리스트의 헤드와 각 노드의
content
포인터에 적용되는 함수f
를 받는다(새 리스트는 생성되지 않음; 단지f
가 제공하는 부작용을 수행). 본질적으로, 이는 각 항목의 데이터로 무언가를 할 수 있는 순회이다.
💡 초보자를 위한 설명: 이 함수는 리스트의 모든 노드를 순회하면서 각 노드의 content에 함수 f를 적용한다. 새 리스트를 만들지 않고 원본 리스트의 내용을 변형하거나 처리할 때 유용하다. 예를 들어, 모든 요소를 출력하거나 모든 문자열을 대문자로 변환하는 등의 작업에 쓰인다.
- 비교 가능한 기능: C에서
for
루프로 반복하는 것과 유사하다. 고수준 용어로, C++의for_each
나 Python에서 단순히 반복하는 것과 유사하다. GLib는g_list_foreach(list, func, user_data)
를 가지고 있어, 각 요소의 데이터에 대해 함수를 호출한다.ft_lstiter
는 더 간단하다: 단지 콘텐츠를f
에 전달한다. 외부에서 수동 루프로 동일한 작업을 할 수 있지만, 이것은 그것을 캡슐화한다. - 구현 세부 사항:
- 프로토타입 아마도:
void ft_lstiter(t_list *lst, void (*f)(void *))
이다. lst
에서 시작하여 NULL이 아닌 동안 루프한다:t_list *curr = lst; while (curr) { f(curr->content); curr = curr->next; }
- 이는 리스트 구조나 콘텐츠를 수정하지 않는다(
f
자체가 하지 않는 한). 또한 자체적으로 아무것도 할당하거나 해제하지 않는다. - 경계 조건:
lst
가 NULL이면, 아무것도 하지 않는다(빈 리스트 반복은 아무것도 하지 않음).f
가 NULL이면, 호출할 수 없다 - 안전한 구현은f
가 NULL이면 그냥 아무것도 하지 않고 반환할 것이다. 일반적으로, 유효한 함수를 전달한다.
- 프로토타입 아마도:
- 가능한 부작용:
f
는 각 콘텐츠에 대한void*
를 가지므로,f
는 기술적으로 제자리에서 콘텐츠를 수정할 수 있다. 예를 들어, 콘텐츠가 int 포인터라면,f
는 역참조하여 int의 값을 변경할 수 있다. 또는 콘텐츠가 구조체 포인터라면, 필드를 수정할 수 있다. 그것은 괜찮고 때로는 의도된 것이다(각 요소의 데이터에 변환 적용).f
가 해서는 안 되는 것은 콘텐츠를 해제하거나 노드를 해제하는 것이다 - 그것은 반복을 혼란스럽게 할 것이다(신중하게 계획하지 않는 한). 계약은 단지 "이 콘텐츠로 무언가를 하라"이다.
💡 초보자를 위한 설명: 함수 포인터(f)를 사용하면 리스트 처리를 더 유연하게 할 수 있다. 콜백 함수를 통해 리스트 요소에 대해 다양한 작업을 수행할 수 있으며, 코드 재사용성이 향상된다. 하지만 f 함수 내에서 노드 구조를 변경하지 않도록 주의해야 한다.
- 사용 사례: 모든 요소 출력, 모든 요소를 특정 값으로 설정, 뭔가 누적 등. 이는 매번 루프를 다시 작성하지 않도록 편의성을 제공한다.
- 성능: 이는 n 노드에 대해 O(n)이다. 각 문자에 대해 함수
f
를 호출하는 오버헤드는ft_striteri
시나리오와 유사하다 - 대부분의 용도에서 무시할 만하다. - 예시: 문자열 포인터 리스트가 있고 각 문자열을 출력하고 싶다면, 다음을 할 수 있다:
void print_str(void *str) { printf("%s\n", (char*)str); } ft_lstiter(head, print_str);
이것은 리스트의 각 문자열을 자체 줄에 출력한다. 또는 int 포인터 리스트의 각 정수를 증가시키고 싶다면:void incr(void *n) { (*(int*)n)++; } ft_lstiter(int_list, incr);
이제 리스트 콘텐츠에 저장된 각 int가 1씩 증가한다.
ft_lstmap
- 역할: 기존 리스트의 각 요소에 함수를 적용하여 새 리스트를 생성한다. 각 노드의 콘텐츠를 함수
f
를 통해 매핑하여 새 콘텐츠를 생성하고, 각 결과에 대해 새 리스트 노드를 할당하고, 새 리스트에 연결한다. 또한 할당 실패 시 콘텐츠를 해제하기 위한del
함수를 받는다. 본질적으로, 함수형 프로그래밍의 맵 연산과 유사하게 리스트를 새 리스트로 변환한다: new_list[i] = f(old_list[i]). 원래 리스트는 변경되지 않는다.
💡 초보자를 위한 설명: 이 함수는 기존 리스트를 기반으로 새 리스트를 만든다. 각 노드의 내용에 함수 f를 적용하여 변환된 데이터로 새 노드를 생성한다. 원본 리스트는 그대로 유지되고, 변환된 데이터를 가진 완전히 새로운 리스트가 반환된다.
- 비교 가능한 기능: C++에서는, 알고리즘을 사용하여 반복하고 새 리스트를 구성하여 이를 수행할 수 있다. 함수형 언어에서,
map
은 이전 항목에서 새 리스트를 생성한다. GLib에는 직접적인g_list_map
이 없지만,g_list_foreach
와 무언가를 결합할 수 있다.ft_lstmap
은 반복, 할당, 변환을 결합하는 고수준 추상화이다. - 구현 세부 사항:
- 프로토타입:
t_list *ft_lstmap(t_list *lst, void *(*f)(void *), void (*del)(void *))
이다. lst
를 반복하고, 각 노드에 대해:- 노드의 콘텐츠에
f
를 적용하여 새 콘텐츠를 얻는다(new_content = f(old_content)
). ft_lstnew(new_content)
로 새 노드를 할당한다. 어느 시점에서 할당이 실패하면,del
을 사용하여 이미 생성된 모든 노드와 그 콘텐츠를 해제하고, 메모리 누수를 방지하기 위해NULL
을 반환해야 한다.- 새 노드를 새 리스트에 연결한다(예: 새 리스트의 시작에 대한 포인터를 유지하고,
ft_lstadd_back
또는 유사한 로직을 사용하여 각 새 노드를 추가). - 새 리스트의 헤드에 대한
new_head
포인터를 사용하고, 초기에 NULL로 설정한다. 또한 빠른 추가를 위해 아마도 새 리스트의 마지막 노드에 대한current_new
포인터를 유지한다(반복적으로 순회하는 것을 피하기 위해). lst
가 NULL이면, 단순히 NULL을 반환한다(매핑할 것 없음).f
가 NULL이면, 새 콘텐츠를 생성할 수 없다; 아마도 NULL을 반환하거나 최소한 아무것도 하지 않는다.- 정리를 위한
del
함수가 중요하다: 어느 시점에서 새 노드에 대한malloc
이 실패하면, 지금까지 구축된 새 리스트를 순회하고del
을 사용하여 각 노드의 콘텐츠를 해제하고, 노드를 해제해야 한다. 그런 다음 실패를 나타내는 NULL을 반환한다. - 모든 노드 처리 후, 새 리스트의 헤드를 반환한다.
- 프로토타입:
- 경계 조건:
- 중간에 메모리 실패: 누수 없도록 하자. 예를 들어, 3개의 노드를 성공적으로 매핑했지만, 4번째에서
ft_lstnew
가 NULL을 반환했다면(malloc 실패),ft_lstclear(&new_head, del)
을 호출하여 3개의 새 노드 콘텐츠와 노드를 해제해야 한다. 그런 다음 NULL을 반환한다.
- 중간에 메모리 실패: 누수 없도록 하자. 예를 들어, 3개의 노드를 성공적으로 매핑했지만, 4번째에서
💡 초보자를 위한 설명: 이 함수는 ft_lstiter의 확장 버전으로 볼 수 있다. 차이점은 원본 리스트를 변경하지 않고 새 리스트를 생성한다는 것이다. 메모리 할당이 실패할 경우를 대비한 오류 처리가 중요하며, 이전까지 할당된 모든 메모리를 해제해야 한다.
- 콘텐츠에 대해
f
가 NULL을 반환하는 경우:f
가 NULL을 반환하는 것이 오류로 간주되는지 또는 유효한 출력인지에 따라 처리 방법이 달라진다. 이는 모호하다 - 아마도f
는 항상 뭔가를 생성하는 데 성공한다고 가정한다.f
가 유효한 매핑된 콘텐츠로 NULL을 반환하는 경우,ft_lstmap
은 여전히 콘텐츠 = NULL로 노드를 생성한다. 노드 할당이 실패한 경우에만del
을 사용한다.f
가 무언가 새로운 것을 할당했고 오류로 인해 나중에 해제해야 하는 경우,del
이 그것을 처리한다. f
에 의해 생성된 콘텐츠에 대해del
사용은 콘텐츠가 동적으로 할당되거나 정리가 필요하다는 것을 시사한다.f
가 단지 정수를 void*로 캐스팅하여 반환한다면,del
은 no-op이거나 필요하지 않을 수 있다. 그러나 libft는 할당할 수 있는 경우del
을 요구한다.- 원래 리스트는 건드리지 않는다.
f
가 이전 콘텐츠를 수정하거나 다른 것을 한다면, 그것은 사용자의 책임이지만, 일반적으로 그렇게 하지 말아야 한다.
- 메모리: 이 함수는 잠재적으로 원래 리스트 길이만큼의 많은 새 노드를 할당한다(각각
ft_lstnew
를 통해).f
가 콘텐츠에 대한 새 데이터를 할당하는 경우, 노드당 또 다른 할당이 있다. 따라서 메모리 사용량은 리스트 길이에 비례한다. 중간에 뭔가 실패하면, 만든 것을 해제한다. - 성능: 반복에 대해 O(n)과 새 노드 생성에 대해 O(n)(전체적으로 O(n))이다. 각 노드 생성은 O(1)이다. 각 삽입에 대해 단순히
ft_lstadd_back
을 사용하면, k 요소에 대해 뒤에 추가하는 것은 O(k)이므로 전체적으로 O(n^2)이 된다. 이를 피하기 위해 마지막 새 노드에 대한 포인터를 유지하는 것이 좋다. 더 단순한 접근 방식은ft_lstadd_back
을 사용하는 것이지만, 리스트가 크면 성능이 저하될 수 있다. 이상적인 구현:new_head = NULL; t_list *new_node; t_list *last_new = NULL; for each element in lst: new_content = f(lst->content); new_node = ft_lstnew(new_content); if (!new_node) { ft_lstclear(&new_head, del); return NULL; } if (!new_head) { new_head = new_node; last_new = new_node; } else { last_new->next = new_node; last_new = new_node; }
💡 초보자를 위한 설명: 이 함수 구현에서 중요한 최적화는 마지막 노드에 대한 포인터(last_new)를 유지하는 것이다. 이렇게 하면 매번 리스트 끝을 찾기 위해 전체 리스트를 순회하지 않아도 되므로 O(n²)가 아닌 O(n) 시간 복잡도로 작업을 수행할 수 있다.
- 예시: int 포인터 리스트가 있고 각 새 int가 원래 값의 두 배인 int 포인터 리스트로 매핑하고 싶다면:
void *dup_int(void *n) { int *new_int = malloc(sizeof(int)); if (!new_int) return NULL; *new_int = (*(int*)n) * 2; return new_int; } t_list *newlist = ft_lstmap(int_list, dup_int, free);
이 후,newlist
는 두 배 값의 새 리스트이다. 원래int_list
는 변경되지 않는다. 처리 중 할당이 실패했다면,ft_lstmap
은free
를 통해 이미 생성된 새 int를 해제하고 NULL을 반환한다.
코드 품질 및 최적화
주어진 리스트 함수는 적절한 기본 API를 형성한다. 개선을 위해 고려할 수 있는 몇 가지 사항이 있다:
- 꼬리/크기 유지: 자주 뒤에 추가하거나 크기를 얻어야 한다면, 꼬리 포인터나 크기 카운트를 유지하도록 데이터 구조를 수정하면 이러한 작업의 성능을 획기적으로 향상시킬 수 있다(O(n)에서 O(1)로). 그러나 이는 복잡성을 추가하고 버그의 가능성을 높인다(모든 삽입/삭제 시 이를 업데이트해야 함). libft와 같은 간단한 라이브러리에서, 최소한으로 유지하는 선택을 했다. 프로덕션 준비 라이브러리를 구축한다면,
head
,tail
,length
를 포함하는 리스트 구조체를 만들 수 있다. - 견고성: 일부 함수는 NULL 입력에 대해 방어하지 않는다(사용자가 올바르게 호출한다고 가정). 더 견고하게 만들기 위해 검사를 추가할 수 있다(예:
ft_lstadd_back
에서 이중 포인터가 NULL이면if (!lst) return;
등). 이러한 검사는 오버헤드가 미미하지만 안전성을 향상시킨다.
💡 초보자를 위한 설명: 방어적 프로그래밍을 통해 함수를 더 안전하게 만들 수 있다. NULL 포인터 확인, 메모리 할당 실패 처리, 경계 조건 고려 등이 포함된다. 이러한 검사는 코드를 약간 더 길게 만들지만, 예기치 않은 오류와 충돌로부터 프로그램을 보호한다.
- const 정확성: libft 프로토타입은
const
를 사용하지 않지만, 이상적으로는 리스트를 수정하지 않는ft_lstiter
와 같은 함수는const t_list *
(그리고const void *
를 취하는 함수 포인터)를 취할 수 있다. 마찬가지로,ft_lstmap
의 입력 리스트는const t_list *
일 수 있다. 그러나 C에서, 이는f
와del
에 대한 함수 포인터 타입을 복잡하게 만들 것이다. libft는 const 없이 간단하게 유지한다. - 메모리 오류 처리:
ft_lstmap
에서 보여진 한 가지 좋은 관행은 실패 시 정리하는 것이다. 그 패턴은 필요한 경우 다른 함수에서 복제될 수 있다(다만 다른 함수는 정말로 여러 것을 할당하지 않는다. 예외적으로 파트 2의ft_split
도 부분 실패 시 정리해야 한다). 항상 메모리 누수나 이중 해제가 없도록 하자. - 문서화: 각 함수가 무엇을 기대하고 수행하는지에 대한 명확한 주석(또는 이 경우, man 페이지나 프로젝트 PDF에서의 이해)을 갖는 것은 올바르게 사용하는 데 중요하다.
- 경계 조건 테스트: 예를 들어, 빈 리스트에서
ft_lstadd_back
, 하나의 노드가 있는 리스트에서ft_lstclear
,f
가 때때로 NULL을 반환하는ft_lstmap
등을 테스트하여 모든 함수가 우아하게 처리되는지 확인한다.
다른 라이브러리와 비교하여, libft의 연결 리스트 함수는 기본적이지만 단일 연결 리스트에 대한 필수 작업을 다룬다. 이들은 단순성을 위해 일부 성능을 교환한다(캐시된 꼬리나 길이 없음). 그러나 많은 사용 사례에 대해 그것은 허용 가능하다.
void* content
와 함수 포인터del
및f
를 사용하는 디자인은 리스트가 제네릭(어떤 데이터 타입도 저장하고, 사용자 제공 함수로 적절히 해제하거나 변환할 수 있음)이 되도록 허용한다. 이는 C에서 제네릭 컬렉션을 달성하는 일반적인 C 기술이다(C에는 템플릿이나 제네릭스가 내장되어 있지 않으므로).참고 문헌
- man7.org Linux Manual Pages – strdup(3) 및 strndup(3) –
strndup
의 설명(POSIX.1-2008)[^42]. - Stack Overflow – C에서
itoa()
의 비표준 특성에 대한 논의[^43]. - cppreference.com –
strtok
문서 – 내부 상태 및 비재진입성에 대한 참고[^45]. - The Open Group Base Specifications Issue 7, IEEE Std 1003.1 (POSIX) –
strtok()
설명[^46]. - Stack Overflow –
puts
vsfputs
(줄바꿈 동작)에 대한 설명[^48]. - Linux Programmer's Manual –
<sys/queue.h>
(BSD 리스트/큐 매크로) – 제공된 데이터 구조(SLIST, LIST 등)[^53]. - CodeChef Data Structures Tutorial – 연결 리스트의 정의[^59].
- Stack Overflow – 단일 연결 리스트 작업의 시간 복잡성(헤드 삽입 O(1) 등)[^57].
728x90'프로그래밍 > 42 Gyeongsan' 카테고리의 다른 글
Get Next Line - 한 줄씩 읽는 것은 너무 지루하다 (0) 2025.03.16 다음글이 없습니다.이전글이 없습니다.댓글