김현우
  • 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은 종결자에 의해 제한되지 않는다 — 검색할 길이를 지