김현우
  • 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'를 소문자로 변환한다. 또한, 분류 함수와 유사하게 cunsigned 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에 맞지 않게 될 수 있다(실제 시스템에서는 주소 지정 가능한 것보다 더 많은 메모리를 필요로 하기 때문에 사실상 불가능하다). 그러나 이론적으로는 카운터의 오버플로가 발생할 수 있다. 표준도 이 문제에 대해 보호하지 않는다. 요약하면, libft strlen은 모든 정상적인 사용에 대해 표준 동작을 반영해야 한다.

    [^5]: strchr(3) - Arch Linux manual pages

     

    메모리 설정 및 초기화: memsetbzero

    이 함수들은 메모리 버퍼를 초기화하는 작업을 처리한다.

    • memset: 메모리 블록을 특정 바이트 값으로 설정한다. 함수 원형: void *memset(void *b, int c, size_t len)
      • 표준 동작: memsetb[^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의 경우, 간단한 루프 구현 방식이 허용된다. cunsigned char로 캐스팅하는 것을 확인해야 한다. 그래야 음수 값이 전달되더라도(int로 전달되지만, 일반적으로 0-255 범위) 올바른 바이트 값으로 래핑된다. 메모리 측면에서, memset은 아무것도 할당하거나 해제하지 않는다; 단지 기존 버퍼에 쓰는 것뿐이다. 따라서 버퍼가 존재하고 len이 올바른지 확인하여 오버플로우를 방지해야 한다.
    • 경계 조건: len이 0이면, memset은 아무것도 하지 않고 b를 반환해야 한다. bNULL이고 len이 0보다 크면, 동작은 정의되지 않는다(NULL에 쓰기 때문). bNULL이고 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 등)에는 이 기능이 없다는 것이다. 코드를 이식하는 경우, bzeromemset로 대체해야 한다. libft의 목적(리눅스/유닉스)에 따르면, 이 정도면 충분하다.
    • 표준과의 차이점: 현대적인 코드는 bzero를 거의 사용하지 않지만, libft는 역사적 이유로 이것을 포함하고 있다. 우리의 구현은 0-채우기(zero-filling)를 정확히 복제해야 한다. 여기에는 memset이 0을 처리하는 것 외에 특별한 트릭이 없다.

    [^6]: [PATCH v2] x86-64: Optimize bzero - Sourceware
    [^7]: allow bzero in kernel - LWN.net

     

    메모리 복사 및 이동: memcpymemmove

    이 함수들은 메모리 영역들 사이에서 바이트를 복사한다.

    • memcpy: 소스에서 목적지까지 n 바이트를 복사한다. 함수 원형: void *memcpy(void *dst, const void *src, size_t n).
      • 표준 동작: memcpysrc에서 dst로 정확히 n 바이트를 복사하며, 소스와 대상 메모리 영역이 겹치는 경우에는 안전성을 보장하지 않는다[^8][^9]. 영역이 겹치면, 동작은 정의되지 않는다(복사본이 손상될 수 있음). 이 함수는 dst를 반환한다. 일반적으로, memcpy는 많이 사용되기 때문에 가능한 한 최적화된 방식으로 구현된다. 표준은 종종 워드 크기의 연산 또는 특수 CPU 명령어로 구현할 수 있도록 허용한다.

     

    💡 초보자를 위한 설명: memcpy는 한 메모리 영역에서 다른 메모리 영역으로 데이터를 복사하는 함수이다. 주의할 점은 두 메모리 영역이 겹치는 경우 예측할 수 없는 결과가 발생할 수 있다는 것이다. 예를 들어, 배열의 일부를 같은 배열의 다른 위치로 이동시키려 할 때 memcpy를 사용하면 문제가 발생할 수 있다.

     

    • 구현 고려 사항: libft의 경우, src에서 dst로 바이트를 복사할 때 겹치지 않는 순서로 복사해야 한다. 일반적으로 이것은 단순히 처음부터 끝까지 순방향으로 복사하는 것을 의미한다. srcdst가 겹치지 않는 것으로 알려진 경우, 이 방법은 잘 작동한다. 그러나 겹치고 src < dst인 경우, 이 순방향 복사는 아직 복사되지 않은 데이터를 덮어쓸 수 있다(따라서 정의되지 않은 동작). 그러나 memcpy는 겹침을 처리할 필요가 없으므로 추가 검사를 작성할 필요가 없다. 이는 memmove의 역할이다. 효율적인 복사에 집중하자. 바이트 단위로 복사하는 루프는 간단하다. 더 큰 유형(예: size_t*)으로 캐스팅하고 워드 단위로 복사하여 속도를 높일 수도 있지만, 그렇게 할 때는 정렬에 주의해야 한다. libft의 제약 조건을 고려할 때, 바이트 루프는 허용 가능하고 명확하다.
    • 성능: 실제 세계의 memcpy는 어셈블리 언어로 고도로 최적화되는 경우가 많다. memcpy가 벡터화된 명령어를 사용하거나 한 번에 8바이트씩 복사하는 것이 일반적이다. 그러나 memcpy는 겹침을 가정할 수 없기 때문에, 때로는 memmove보다 약간 더 빠를 수 있다(예방적 논리가 필요 없음)[^9]. 요점은 겹침이 문제가 되지 않는다면, memcpy를 사용하고, 그렇지 않으면 memmove[^10]를 사용하는 것이다.
    • 표준과의 차이점: 올바른 구현은 겹치지 않는 입력에 대해 동일해야 한다. n == 0인 경우에도 처리하는 것이 좋다(dst를 반환하고, 아무런 조치도 취하지 않음). dst 또는 srcNULL이고 n > 0인 경우, 그것은 정의되지 않은 상태이다. 또한, 반환 유형이 dst를 가리키는 void*임을 유의해야 한다. 구현은 표현식에서 사용할 수 있도록 마지막에 dst 포인터를 반환해야 한다.
    • memmove: 가능한 겹치는 영역 간에 n 바이트를 복사한다. 함수 원형: void *memmove(void *dst, const void *src, size_t len).
      • 표준 동작: 소스와 목적지가 겹치는 경우, memmove는 마치 바이트를 먼저 임시 버퍼에 복사한 다음 목적지에 복사하는 것처럼 원본 소스 바이트가 손상 없이 목적지에 복사되도록 보장한다[^10]. 실제로는 구현에서 성능을 위해 실제 전체 임시 버퍼를 사용하지 않고 올바른 방향으로 복사한다: dst < src이면 (목적지 영역이 소스 앞에 있으므로) 앞으로 복사하면 된다(memcpy처럼), 이렇게 하면 복사할 미래 바이트를 덮어쓰지 않는다. dst > src인 경우(목적지가 메모리에서 소스 뒤에 시작하는 경우), 뒤에서부터 복사하는데, 영역의 끝에서 시작하여 끝의 바이트가 덮어쓰기 전에 먼저 복사된다. 이렇게 하면 겹침을 안전하게 처리할 수 있다. memmovedst를 반환한다.

     

    💡 초보자를 위한 설명: memmovememcpy와 유사하지만 메모리 영역이 겹치는 경우에도 안전하게 작동한다. 예를 들어 배열에서 일부 요소를 같은 배열의 다른 위치로 이동시킬 때 사용할 수 있다. 이 함수는 내부적으로 복사 방향을 조정하여 원본 데이터가 손상되지 않도록 보장한다.

     

    • 구현 고려 사항: libft의 경우, 복사 방법을 결정하기 위해 dstsrc의 상대적 위치를 확인해야 한다.
    • 일반적인 패턴:
      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

     

    안전한 문자열 복사 및 연결: strlcpystrlcat

    이 두 함수는 표준이 아닌(ISO C의 일부가 아닌) 함수지만 더 안전한 문자열 복사 및 연결을 제공하기 위한 BSD의 널리 사용되는 확장이다.

    • 목적 및 배경: strlcpystrlcatstrcpy/strncpystrcat/strncat의 더 안전한 대안으로 OpenBSD에서 도입되었다. 문서에 따르면, "이들은 쉽게 오용될 수 있는 함수 strncpy(3)와 strncat(3)의 보다 안전하고, 일관되며, 오류가 덜 발생하는 대체품으로 설계되었다."[^11]. strncpy와 달리, strlcpy는 항상 출력을 널 종결(size 매개변수가 > 0인 한)하며, 버퍼에 추가적인 널로 채우지 않는다. 마찬가지로, strlcat은 크기 검사와 함께 추가하며 종결을 보장한다.

     

    💡 초보자를 위한 설명: 문자열 조작 함수에서 버퍼 오버플로우는 심각한 보안 문제를 일으킬 수 있다. 전통적인 strcpystrcat 함수는 목적지 버퍼의 크기를 고려하지 않아 위험하다. strncpystrncat은 이를 개선했지만 여전히 문제가 있다. BSD에서 개발된 strlcpystrlcat은 항상 문자열을 널 종결하고 잘림 여부를 쉽게 확인할 수 있어 더 안전하다.

     

    • 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 길이를 얻는다. 일반적으로 다음과 같이 수행한다:
      1. src 문자를 반복하며, src의 끝에 도달하거나 dstsize-1 문자를 복사할 때까지 dst에 복사한다.
      2. dst 끝이 널 종결되도록 한다(dstsize > 0인 경우).
      3. (아직 src의 끝에 도달하지 않았다면) 반환 값을 계산하기 위해 src의 길이를 계속 카운트한다.
      4. src의 길이를 반환한다.
    • 고려 사항: dst는 적어도 dstsize 바이트가 할당되어 있어야 한다. dstsizesrc의 모든 내용을 보유하기에 너무 작은 경우, 복사본은 잘리지만 여전히 널 종결된다. 반환 값이 src의 전체 길이이므로 호출자는 이를 쉽게 감지하고 필요한 경우 더 큰 버퍼를 할당할 수 있다. dstdstsize-1바이트 이상을 절대 쓰지 않도록 버퍼 오버플로우를 방지하자. dstsize가 0이면 strlen(src)를 계산하여 반환해야 한다(그리고 당연히 dst에 쓰지 않는다). 메모리 관리는 호출자의 책임이다(그들이 버퍼를 제공한다). 또한 srcdst가 겹치면 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). srcdst 끝에 추가한다(이때 dst는 처음부터 널 종결되어 있어야 함). dst에 총 dstsize-1 바이트까지 추가한다(기존 dst 내용과 널 종결자 포함). 이 함수는 dst의 초기 길이에 src의 길이를 더한 값을 반환한다 - 즉, 만들려고 시도한 길이를 반환한다. 반환 값이 >= dstsize이면 잘림이 발생했다.
    • 표준 대 구현: 역시 비표준이며, BSD에서 왔다. 로직은 다음과 같다: dst의 길이를 찾는다(dstsize까지, dstsize를 넘어 읽지 마세요 - dstsize 바이트 내에서 널이 발견되지 않으면 dst가 버퍼 크기 내에서 적절히 널 종결되지 않았다는 의미이며 함수는 중지할 수 있다). dlendst의 길이라고 하자. dlen >= dstsize이면 dst 버퍼가 원래 dst 문자열을 보유하기에 충분히 크지 않거나(또는 제공된 크기 내에서 널 종결되지 않음) 이 경우 strlcat은 단순히 dstsize + strlen(src)를 반환하고 아무 작업도 수행하지 않는다(추가가 발생하지 않았으며 버퍼가 가득 찼음을 신호). 그렇지 않으면 dst[dlen]부터 src를 추가한다. 최대 dstsize - dlen - 1 바이트를 복사한다(널 공간 남김). 항상 결과를 널 종결한다. 그런 다음 dlen + strlen(src)를 반환한다(만들려고 시도한 문자열의 길이). 이 반환 값 로직을 통해 호출자는 잘림이 발생했는지 알 수 있다(반환된 값이 >= dstsize인 경우).
    • 고려 사항: dstsizedlen보다 작은 경우(연결할 공간이 없음)를 올바르게 처리하자. 또한 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'이면, strchrs 끝의 종결자에 대한 포인터를 반환한다. 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의 버전은 비const s를 취하도록 프로토타입이 지정될 수 있다. 로컬 또는 새 버퍼에 대한 포인터를 반환하지 않도록 주의하자; 원래 문자열로의 포인터를 반환하자. 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을 반환한다.

     

    💡 초보자를 위한 설명: strrchrstrchr와 유사하지만 문자열을 뒤에서부터 검색하여 마지막으로 나타나는 문자를 찾는다. 예를 들어 "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].
      • needlehaystack의 처음 len 문자 내에서 발견되지 않으면, NULL을 반환한다.
      • needle이 발견되면, haystack 내 해당 하위 문자열의 시작 부분에 대한 포인터를 반환한다.
      • 검색은 최대 haystacklen 문자까지만 검사한다. haystacklen보다 짧으면, 실질적으로 전체 문자열을 검색한다(종결자 확인까지).
    • 구현 접근 방식:
      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-문자열 s1s2의 최대 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이 길이보다 작거나, 같거나, 또는 더 큰 경우를 테스트하자.

     

    메모리 검색 및 비교: memchrmemcmp

    • 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). 이는 메모리 영역 s1s2의 첫 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 함수를 대신 사용하는 것이 권장되기도 한다.

     

    • 구현: 다음과 같이 구현할 수 있다:
      1. 공백 문자(스페이스, 탭 등)를 건너뛴다. 표준은 "C" 로케일에서 isspace()에 해당하는 것을 공백으로 정의한다.
      2. '-' 또는 '+' 기호를 확인하여 결과가 음수인지 결정한다.
      3. 숫자 [0–9]를 반복하며, int에 값을 누적한다. 각 숫자에 대해, value = value * 10 + (digit*value)를 수행한다.
      4. 숫자가 아닌 문자를 만나거나 문자열 끝에 도달하면 중지한다.
      5. 결과에 부호를 적용한다.
      6. 결과를 반환한다.
    • 오버플로우 우려: 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과의 두 가지 주요 차이점:
      1. 총 바이트 수를 결정하기 위해 count * size를 곱한다.
      2. 할당된 메모리를 0으로 초기화한다.
      count * sizesize_t의 표현 가능한 범위를 오버플로우하면(즉, 요청된 크기가 너무 큼), 좋은 구현은 잘못된(더 작은) 양의 메모리를 할당하는 대신 실패(NULL 반환)해야 한다[^24]. 실제로, OpenBSD man 페이지와 POSIX는 곱셈이 오버플로우되면 calloc이 NULL을 반환하고 errno를 ENOMEM으로 설정해야 한다고 말한다[^24].
    • 구현: libft에서는 실제로 메모리를 할당할 수 없을 수 있다(malloc이 일반적으로 금지되거나 허용될 수 있음). malloc을 사용할 수 있다고 가정하면:
      1. 오버플로우 확인: count != 0 && size > SIZE_MAX / count이면 곱셈이 오버플로우되므로 NULL을 반환한다.
      2. 그렇지 않으면 bytes = count * size를 계산한다.
      3. malloc(bytes)를 사용하여 할당한다.
      4. malloc이 성공하면, 메모리를 0으로 memset한다.
      5. 포인터를 반환한다.
    • 경계 조건: 곱셈을 오버플로우하는 매우 큰 값이 전달되면, 우아하게 실패하는지 테스트하자. 또한 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_strlenft_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 MOVSBREP 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 구현자로서, 전부 구현할 필요는 없지만, "프로"가 하는 일을 아는 것이 유용하다.

     

    💡 초보자를 위한 설명: 다양한 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 & 0x80808080ULx의 어떤 바이트가 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 vs memmove 안전한 사용: 논의된 대로, memcpy는 겹치는 버퍼를 처리하지 않는다. 프로그래머가 실수로 겹치는 영역에 대해 memcpy를 사용하면 데이터 손상을 일으킬 수 있다. 이는 논리적 버그이지만, 중요한 데이터나 메타데이터를 손상시킨다면 잠재적인 보안 문제가 될 수도 있다. memmove는 약간의 성능 저하를 감수하고 항상 겹침에 대해 안전하다[^37]. 보안 중심 접근 방식은 확실하지 않다면 memmove를 사용하는 것이다. 일부 코딩 표준은 겹침이 없다는 것을 알지 않는 한 memmove를 선호한다. 왜냐하면 알 수 없는 시나리오에서 memcpy를 사용하면 미묘한 버그가 발생할 수 있으며, 이는 악용 가능할 수 있기 때문이다. libft에서는 두 가지를 모두 구현하지만, 자신의 코드에서 사용할 때는 그 차이를 이해하는 것이 도움이 된다.

     

    💡 초보자를 위한 설명: memcpymemmove의 주요 차이점은 메모리 영역이 겹칠 때의 동작이다. 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]. 이는 직접 구현하는 것이 아니라, 함수가 드롭인 대체로 사용될 때 이러한 검사가 없다는 것을 알아두는 것이 좋다.
    • 메모리 할당 보안: callocstrdup를 구현할 때, 실패 시 어떤 일이 일어나는지 고려하자. 실패 시 항상 NULL을 반환하는 것이 중요하다(초기화되지 않은 포인터를 반환하지 않음). 메모리 부족 상황에서, calloc이 실패하고 어쨌든 포인터를 반환하면, 호출자는 잘못된 포인터를 사용하여 예측할 수 없는 동작을 일으킬 수 있다. 또한, calloc에서 0으로 초기화하는 것은 초기화되지 않은 메모리 사용을 방지하는 데 도움이 된다. 이는 이전 사용에서 OS에 의해 사용된 잔여 데이터(잠재적으로 민감한 데이터)를 포함할 수 있다. 이는 미묘한 보안 향상이다: malloc은 성능상의 이유로 메모리를 0으로 만들지 않기 때문에, 할당하고 설정하지 않고 사용하면 실수로 데이터를 노출할 수 있다. calloc은 깨끗한 슬레이트를 보장한다.

     

    💡 초보자를 위한 설명: 메모리 할당의 보안 측면에서 두 가지 주요 원칙이 있다. 첫째, 할당이 실패하면 항상 NULL을 반환하여 프로그램이 유효하지 않은 메모리를 사용하지 않도록 해야 한다. 둘째, 초기화되지 않은 메모리 사용은 민감한 정보 누출로 이어질 수 있다. calloc은 메모리를 0으로 초기화하여 이 문제를 해결한다.

     

    • 할당에서의 정수 오버플로우: 언급한 바와 같이, countsize가 사용자 제어(파일이나 입력에서 읽는 등)인 경우, 공격자는 count * size 곱셈에서 정수 오버플로우를 악용하려고 할 수 있다. 예를 들어, 32비트 size_t에서, count=0xFFFF_FFFFsize=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 = -1c = 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를 테스트하면 거짓을 반환해야 한다.

     

    💡 초보자를 위한 설명: 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_memcmpmemcmp, 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]. memcpymemmove는 오래된 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는 자신만의 이식 가능한 세트를 갖기 위해 준비하는 것과 같다).

     

    💡 초보자를 위한 설명: 다양한 운영 체제와 컴파일러는 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이 있었다).
      • strncpystrncat도 버퍼 오버플로우 문제에 대응하여 초기 표준의 일부였지만, 자체적인 특이점을 도입했다(널 종결을 보장하지 않는 등).
      • 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 함수를 사용할 때 흔히 저지르는 실수들이 있다. 예를 들어 memcmpstrcmp의 차이를 이해하지 못하거나, 문자열 함수에서 널 종결자의 중요성을 간과하는 것이다. 이러한 함정을 알면 디버깅하기 어려운 미묘한 버그를 피할 수 있다. 특히 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 함수의 재구현이 올바를 뿐만 아니라 소프트웨어 엔지니어링의 모범 사례에 의해 알려지도록 보장한다.

    728x90

    Part 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은 자체 메모리 할당을 처리한다. 원본 문자열 끝을 넘어 복사할 위험이 없다. 왜냐하면 원본 문자열의 끝 또는 지정된 길이 중 먼저 오는 것까지만 복사하기 때문이다.
      • 동작 세부 사항: starts의 끝을 넘어가면(즉, start >= strlen(s)), 함수는 일반적으로 빈 문자열(단지 '\0'을 포함하는 새 문자열)을 반환한다. lenstart 위치에서 s의 끝을 넘어가면, 사용 가능한 문자만 복사된다. 이 동작은 부분 문자열이 유효하고 널 종결되도록 보장한다. 예를 들어, ft_substr("Hello", 1, 3)을 호출하면 "ell"을 반환한다.
      • 구현 고려 사항:
        • 메모리 관리: ft_substr은 새 문자열을 위한 메모리를 할당한다(일반적으로 malloc으로). 할당된 크기는 min(len, strlen(s) - start) 에 널 종결자를 위한 1바이트를 더한 것이어야 한다. 오버플로우를 방지하기 위해 적절히 할당하고 메모리 할당이 실패하면 NULL을 반환하는 것이 중요하다. 호출자는 작업이 완료되면 반환된 문자열을 해제할 책임이 있다(표준 라이브러리의 strdup/strndup와 마찬가지로[^42]).

     

    💡 초보자를 위한 설명: malloc 함수는 메모리를 동적으로 할당하는 함수로, 사용이 끝난 후에는 반드시 free로 해제해야 한다. 메모리 누수를 방지하기 위해 항상 malloc으로 할당한 메모리는 free로 해제하는 습관을 들이자.

     

    • 경계 조건: 입력 문자열 포인터 sNULL이면, 함수는 이상적으로 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은 두 문자열 s1s2를 새 문자열로 연결한다. 결합된 문자열을 위해 충분히 큰 버퍼를 할당하고, 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::erasefind_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바이트를 할당해야 한다.
        • 경계 조건: s1NULL이면, 함수는 NULL을 반환해야 한다(유효하지 않은 입력). setNULL이면, "다듬을 문자 없음"으로 처리하여(따라서 s1의 복사본 반환) 또는 마찬가지로 NULL을 반환할 수 있다(동작이 정의되지 않았으므로). 또한, s1이 빈 ""인 경우, 결과는 그냥 빈 문자열이어야 한다(다듬을 것이 없지만, 함수는 여전히 유효한 문자열, 아마도 ""의 복제본을 반환해야 한다). set에 널 종결자 '\0'이 포함된 경우(문자열이므로 가능성이 낮음), 다듬기는 정상 작동 이외의 효과가 없을 것이다.
        • 문자 확인: set의 구성원 검사에서 효율성이 중요하다. 간단한 접근 방식은 s1의 각 문자를 set의 모든 문자와 확인하는 것이다(최악의 경우 O(n*m)). set이 보통 작기 때문에(예: 몇 개의 공백 문자), 이는 괜찮다. 또는 O(1) 멤버십 테스트를 위해 256 ASCII 문자의 배열이나 불리언 테이블을 사용할 수도 있다.

     

    💡 초보자를 위한 설명: 여기서 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에서 일반적으로 뭔가 잘못되면 모든 것을 해제하는 헬퍼를 호출한다.

     

    💡 초보자를 위한 설명: 메모리 누수(memory leak) 방지는 매우 중요하다. 할당에 실패했을 때 이미 할당된 메모리를 모두 해제하지 않으면, 그 메모리는 프로그램이 종료될 때까지 계속 점유된 상태로 남게 된다.

     

    • 토큰 카운팅: 일반적으로 먼저 얼마나 많은 토큰이 생성될지 카운트한다(s를 스캔하고 구분자에서 비구분자 영역으로의 전환을 카운트). 이는 결과 배열에 올바른 크기를 할당하는 데 도움이 된다. 카운팅은 모든 구분자 또는 구분자 없음과 같은 엣지 케이스를 처리해야 한다.
    • 토큰 복사: 각 토큰에 대해, 시작 인덱스(첫 번째로 c가 아닌 문자)와 끝 인덱스(다음 c 또는 문자열 끝)를 찾고 ft_substr과 유사한 논리나 수동 복사를 사용하여 토큰 문자열을 할당하고 채운다.
    • 경계 조건: sNULL이면, ft_splitNULL을 반환해야 한다(처리 없음). cs에 나타나지 않는 문자이면, 함수는 길이 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 Overflow

    ft_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의 범위는 십진수 정수에서 문자열로의 변환이다.

     

    💡 초보자를 위한 설명: 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을 반환한다. sNULL이면, 논리적으로도 NULL을 즉시 반환한다(매핑할 문자열이 없음). 함수는 오류가 발생하면 중간 리소스를 해제해야 한다(이 간단한 경우에서, 유일한 리소스는 구축 중인 새 문자열이다).
        • 함수 적용: 인덱스 0부터 slen-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_striterift_strmapi와 유사하지만 문자열에 제자리에서 작동한다. 문자열 s의 각 문자에 함수 f를 적용하며, 인덱스와 문자에 대한 포인터를 전달하여 f가 문자를 직접 수정할 수 있게 한다. 함수는 (void를) 반환하지 않는다. 이의 목적은 콜백을 통해 원래 문자열을 변형하는 것이다.

     

    💡 초보자를 위한 설명: 이 함수는 ft_strmapi와 달리 새 문자열을 생성하지 않고 원본 문자열을 직접 수정한다. f 함수는 각 문자의 포인터를 받아 그 문자를 직접 변경할 수 있다.

     

      • 표준 라이브러리 대응: 다시, 이를 직접 하는 표준 C 함수는 없다. C에서 문자열의 각 문자에 작업을 적용하는 일반적인 방법은 for 루프를 작성하는 것이다. ft_striteri는 반복 로직이 내부적으로 처리되고 사용자는 문자를 어떻게 처리할지 아는 함수를 제공하는(필요한 경우 위치 정보 포함) 편리한 패턴을 제공한다. 이는 다른 라이브러리(예를 들어, GLib의 g_strcanon은 각 문자에 함수를 적용하지만, 정확히 같지는 않거나 단순히 콜백으로 반복하는 아이디어)의 반복 매크로나 함수와 다소 유사하다.
      • 동작 세부 사항: ft_striteri(s, f)s의 각 문자에 대해 f(index, &char)를 호출한다. fs의 현재 문자에 대한 char *(포인터)를 받기 때문에, 제자리에서 문자를 수정할 수 있다. 예를 들어, f가 인덱스가 짝수인 경우 문자를 'X'로 설정하는 함수라면, ft_striteri("abcd", f)를 호출하면 원래 문자열을 "XbXd"로 수정한다. 작업이 제자리에서 이루어지므로, 새 문자열은 생성되지 않는다. 일반적으로, ft_striteri는 인덱스 0부터 문자열 끝까지 반복하며, '\0' 종결자는 처리하지 않는다(중지 시점을 알기 위해 제외). 함수 자체는 값을 반환하지 않으며, 결과는 입력 문자열에 대한 부작용이다.
      • 구현 고려 사항:
        • 안전성: sNULL이거나 fNULL이면, 함수는 아무것도 하지 않거나(또는 즉시 반환) 오류를 피해야 한다. 이 함수는 할당이 없다(원래 문자열에서 작동). 따라서 메모리 할당 실패를 처리할 필요가 없다. 그러나 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_fdfputc(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) 라이브러리를 사용할 수 없거나 원하지 않을 때, 또는 파일 디스크립터를 직접 사용하여 사용자 정의 출력(네트워크 소켓이나 파일과 같은)에 쓸 때 특히 유용하다.

     

    💡 초보자를 위한 설명: 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 *sint 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). sNULL이면, 많은 구현은 단순히 아무것도 하지 않거나(안전 조치) 잠재적으로 충돌할 수 있다, 확인하지 않으면. 쓰기 전에 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인 버퍼를 구성하고 한 번에 모두 쓰는 방식으로 결합할 수 있다. 그러나 그런 버퍼를 구성하려면 메모리 할당이나 문자열 크기의 스택 버퍼가 필요할 수 있으며, 이는 가치가 없을 수 있다. 줄바꿈을 위한 단일 바이트 쓰기가 대부분의 경우 비싸지 않다는 점을 고려하면, 두 번의 쓰기가 괜찮다.
        • 경계 조건: sNULL이면, 이상적으로는 아무것도 하지 않는다. s가 비어 있으면, 이 함수는 단지 줄바꿈만 쓸 것이다(그래서 효과적으로 새로운 줄로 이동). 이는 허용 가능한 동작이다(일부 구현은 s가 비어 있으면 아무것도 출력하지 않기로 선택할 수 있지만, 일반적으로 여전히 줄바꿈을 출력한다. 왜냐하면 "빈 문자열 플러스 줄바꿈"은 단지 줄바꿈이 되기 때문이다). 다른 것들과 마찬가지로, 유효하지 않은 fdwrite에서 잡히지 않는 오류를 일으킬 것이다.
        • 사용: 이 함수는 각 메시지가 줄바꿈으로 끝나야 하는 파일이나 소켓에 라인을 출력하는 데 편리하다. 이는 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에 출력한다. 숫자는 십진수 문자열 표현으로 변환되어 출력된다. 이는 효과적으로 파일 디스크립터에 대한 itoaputstr의 조합이지만, 편의를 위해 하나의 함수로 수행된다.

     

    💡 초보자를 위한 설명: 이 함수는 정수를 문자로 변환하여 출력한다. 내부적으로는 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 문자열에 대한 추가 메모리 할당이 발생한다. 파일 디스크립터에 쓰는 것이 정적 버퍼를 사용할 수 있다고 가정하면, 일부는 할당을 피하고 대신 재귀를 사용할 수 있다.

     

    💡 초보자를 위한 설명: 재귀 방법은 함수가 자기 자신을 호출하는 방식으로, 큰 문제를 작은 문제로 나누어 해결한다. 예를 들어 1234를 출력하기 위해, 먼저 123을 출력하는 문제를 해결한 다음 4를 출력한다. 재귀는 우아한 해결책이지만, 너무 깊게 중첩될 경우 스택 오버플로우가 발생할 수 있다.

     

    • 할당 필요 없음: 재귀 접근 방식은 새 문자열을 할당하지 않고 직접 fd에 출력하므로 더 메모리 효율적일 수 있다. 그러나 재귀는 각 자릿수에 대해 스택 프레임을 사용한다(32비트에 대해 최대 ~10 호출 깊이, 이는 괜찮음). 많은 libft 구현은 우아함을 위해 재귀를 선택한다.
    • INT_MIN 처리: 언급했듯이, -2147483648n = -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에서는, 그런 내장 기능이 없다.
        요약하면, glibc/ISO C는 직접적인 연결 리스트 API를 제공하지 않는다 - <sys/queue.h> 매크로와 같은 것을 사용하거나[^53] 직접 작성해야 한다. Ecole 42의 libft 보너스는 자신만의 최소한의 연결 리스트 함수를 구현하는 방법을 가르친다.

     

    💡 초보자를 위한 설명: C 표준 라이브러리는 기본적인 메모리 관리 함수는 제공하지만, 연결 리스트와 같은 고급 데이터 구조는 제공하지 않는다. 이는 프로그래머가 직접 구현하거나 서드파티 라이브러리를 사용해야 함을 의미한다. libft에서 이러한 함수들을 구현하는 것은 중요한 학습 경험이 된다.

     

      • 연결 리스트 vs 배열(성능): 단일 연결 리스트의 일반적인 작업:
        • 리스트 에 요소를 추가하거나 제거하는 것은 O(1)이다(헤드 포인터만 조정)[^57].
        • 요소를 찾거나 끝을 찾기 위한 순회는 리스트 길이에 따라 O(n)이다. 포인터를 하나씩 따라가야 하기 때문이다.
        • 단일 연결 리스트의 에 삽입하는 것은 헤드 포인터만 있는 경우 O(n)이다(끝까지 순회해야 함). 그러나 꼬리 포인터를 유지하면 O(1)이 될 수 있다.
        • 리스트의 크기 가져오기는 순회로 카운트하는 경우 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_lstnewvoid *content를 받는다. 새 노드에 대해 sizeof(t_list) 바이트를 할당해야 한다. 할당 후, node->contentcontent 매개변수로 설정되고(데이터를 복사하지 않고, 단지 포인터를 저장), node->nextNULL로 설정된다(새 노드는 처음에 아무것도 연결되지 않음).
        • 메모리: 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)를 위해:
        • newNULL이 아닌지 확인한다(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; }.
        • 카운트를 반환한다.
        • lstNULL(빈 리스트)이면, 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

      • 역할: 연결 리스트의 마지막 노드를 찾는다. 리스트의 헤드를 받아 ->nextNULL이 될 때까지 순회한 다음, 해당 마지막 노드에 대한 포인터를 반환한다. 리스트가 비어 있으면(헤드가 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와 정확히 동일한 작업을 수행한다(끝까지 선형 스캔).
      • 구현 세부 사항:
        • lstNULL이면, 즉시 NULL을 반환한다(빈 리스트에는 마지막 요소가 없음).
        • 그렇지 않으면, node = lst를 설정한다. node->nextNULL이 아닌 동안, 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에서 온 문자열과 같은 경우, delfree가 되어 그 메모리를 해제한다. 콘텐츠가 더 복잡한 구조체 포인터인 경우, 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_lstclearft_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로 설정된다. delfree였고 콘텐츠가 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을 반환한다.

     

    💡 초보자를 위한 설명: 이 함수는 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_lstmapfree를 통해 이미 생성된 새 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에서, 이는 fdel에 대한 함수 포인터 타입을 복잡하게 만들 것이다. libft는 const 없이 간단하게 유지한다.
    • 메모리 오류 처리: ft_lstmap에서 보여진 한 가지 좋은 관행은 실패 시 정리하는 것이다. 그 패턴은 필요한 경우 다른 함수에서 복제될 수 있다(다만 다른 함수는 정말로 여러 것을 할당하지 않는다. 예외적으로 파트 2의 ft_split도 부분 실패 시 정리해야 한다). 항상 메모리 누수나 이중 해제가 없도록 하자.
    • 문서화: 각 함수가 무엇을 기대하고 수행하는지에 대한 명확한 주석(또는 이 경우, man 페이지나 프로젝트 PDF에서의 이해)을 갖는 것은 올바르게 사용하는 데 중요하다.
    • 경계 조건 테스트: 예를 들어, 빈 리스트에서 ft_lstadd_back, 하나의 노드가 있는 리스트에서 ft_lstclear, f가 때때로 NULL을 반환하는 ft_lstmap 등을 테스트하여 모든 함수가 우아하게 처리되는지 확인한다.

    다른 라이브러리와 비교하여, libft의 연결 리스트 함수는 기본적이지만 단일 연결 리스트에 대한 필수 작업을 다룬다. 이들은 단순성을 위해 일부 성능을 교환한다(캐시된 꼬리나 길이 없음). 그러나 많은 사용 사례에 대해 그것은 허용 가능하다. void* content와 함수 포인터 delf를 사용하는 디자인은 리스트가 제네릭(어떤 데이터 타입도 저장하고, 사용자 제공 함수로 적절히 해제하거나 변환할 수 있음)이 되도록 허용한다. 이는 C에서 제네릭 컬렉션을 달성하는 일반적인 C 기술이다(C에는 템플릿이나 제네릭스가 내장되어 있지 않으므로).

     

    참고 문헌

    1. man7.org Linux Manual Pages – strdup(3)strndup(3)strndup의 설명(POSIX.1-2008)[^42].
    2. Stack Overflow – C에서 itoa()의 비표준 특성에 대한 논의[^43].
    3. cppreference.com – strtok 문서 – 내부 상태 및 비재진입성에 대한 참고[^45].
    4. The Open Group Base Specifications Issue 7, IEEE Std 1003.1 (POSIX) – strtok() 설명[^46].
    5. Stack Overflow – puts vs fputs(줄바꿈 동작)에 대한 설명[^48].
    6. Linux Programmer's Manual – <sys/queue.h> (BSD 리스트/큐 매크로) – 제공된 데이터 구조(SLIST, LIST 등)[^53].
    7. CodeChef Data Structures Tutorial – 연결 리스트의 정의[^59].
    8. Stack Overflow – 단일 연결 리스트 작업의 시간 복잡성(헤드 삽입 O(1) 등)[^57].

    [^59]: Linked List - Data Structures

    728x90
    댓글