1P by GN⁺ | ★ favorite | 댓글 1개
  • C의 NUL 종료 문자열은 길이 정보를 보존하지 않아 반복 탐색과 경계 오류를 낳으며, 현대에는 포인터와 크기를 함께 저장하는 길이 기반 문자열이 기본 설계로 더 적합함
  • 명시적 길이가 없으면 strlen 호출과 바이트 순회가 반복되고, snprintf, sizeof, strlen마다 종료 바이트 포함 여부가 달라 코드 작성과 검토가 어려워짐
  • 길이 기반 문자열은 빈 문자열을 size == 0으로 일관되게 처리하며, NUL 바이트를 포함한 임의 바이너리 데이터에도 탐색·분할·슬라이싱 연산을 그대로 적용할 수 있음
  • 원본 메모리의 일부를 가리키는 부분 문자열을 반환할 수 있어 자르기·검색·토큰화와 CSV·Markdown·JSON·C 파싱에서 중간 할당과 복사를 피할 수 있음
  • 센티널은 불변식 유지와 일부 토큰 탐색에 여전히 유용하고 기존 C·OS API와의 변환도 필요하지만, 대부분의 코드에서는 길이 기반 문자열에 API 경계의 불변성을 결합하는 편이 단순하고 유연함

NUL 종료 대신 포인터와 길이 저장

  • C 문자열은 문자 스트림을 가리키는 포인터와 마지막 NUL 바이트로 경계를 나타냄
    • 1970년대의 메모리·성능 제약에서는 합리적이었을 수 있지만, 현대에는 이 방식을 유지할 이유가 거의 없음
  • 최신 언어와 주요 프레임워크가 채택한 대안은 데이터 포인터와 크기를 함께 저장하는 구조체임
struct String
{
    u8* data;
    u64 size;
};
  • 문자열 리터럴은 sizeof(s) - 1로 길이를 계산해 String으로 만들 수 있으며, 비교·출력 같은 연산은 저장된 크기를 직접 이용함
  • 배열도 포인터로 변환되면 길이 같은 정보를 잃어 문자열과 비슷한 안전성·사용성 문제를 겪음

길이 정보를 버릴 때 생기는 비용

  • 길이를 저장하지 않으면 사용하는 코드마다 strlen을 반복 호출하거나 바이트를 하나씩 순회해야 하므로 불필요한 작업과 복잡성이 늘어남
  • 런타임에서 길이를 검사하고 강제하기도 더 느리고 번거로우며, 디버거와 분석 도구는 char*, char[N], char*[N]을 일관되게 취급하기 어려움
  • C의 메모리 문제와 오버플로 버그 상당수가 이 설계에서 비롯됐다는 견해가 있지만, 센티널만으로 충분하다는 반론도 있음
  • 길이를 모르는 문자열은 배열의 무작위 접근보다 연결 리스트와 같은 순차 접근 패턴에 가까워짐
  • NUL 종료 방식을 옹호하는 쪽에서는 순차 처리가 다중 패스보다 단일 패스 알고리듬을 유도해 효율적이라고 봄
    • 초보자를 비롯한 많은 프로그래머는 문자열 길이를 저렴하게 얻을 수 있다고 가정해 코드를 작성함
    • 일반적인 문자열 루틴도 길이가 제공되는 형태로 더 자연스럽게 작성되며, 반복적인 strlen 호출 때문에 실제 코드에서 같은 문자열을 여러 번 순회하게 됨
    • 패스 수가 적으면 항상 빠르다는 전제는 현대 CPU의 동작 방식을 오해한 결과임

snprintf, sizeof, strlen이 뜻하는 서로 다른 길이

  • snprintf의 두 정수는 같은 “길이”처럼 보이지만 NUL 포함 규칙이 서로 반대임
int snprintf(char *str, size_t size, const char *format, ...);
  • 입력 size는 출력 버퍼의 NUL 종료 공간까지 포함해야 하지만, 반환값은 NUL 바이트를 제외하고 생성됐을 문자열 길이를 나타냄
  • 문자열 관련 함수나 API를 사용할 때마다 다음 조건을 따로 확인해야 함
    • 함수가 NUL 바이트를 직접 기록하는지
    • 할당 크기에 NUL 바이트 공간을 추가해야 하는지
    • 크기 인자와 반환값이 NUL 바이트를 포함하는지
  • sizeof("some string")은 NUL 바이트를 포함하지만 strlen("some string")은 제외함
  • 리터럴 기반 코드를 런타임 포인터 기반 코드로 바꾸면서 동일한 - 1을 유지하면 길이가 하나 짧아지는 버그가 생김
#define TEST_STRING "some string"
size_t size = sizeof(TEST_STRING) - 1;

const char *str = "some string";
size_t size = strlen(str) - 1;

중간 NUL 바이트를 반복해서 덮어쓰는 작업

  • 여러 snprintf 반환값을 오프셋에 더해 문자열을 이어 붙이면, 각 호출이 기록한 NUL 종료 바이트를 다음 호출이 덮어씀
int offset = 0;
offset += snprintf(ptr + offset, size - offset, "%d", my_int);
offset += snprintf(ptr + offset, size - offset, "%s", my_str);
offset += snprintf(ptr + offset, size - offset, "%f", my_flt);
  • 중간 종료 바이트를 기록하는 작업은 불필요하지만, 성능에 미치는 영향 자체는 크지 않을 수 있음
  • 더 큰 문제는 이 동작이 직관적이지 않아 snprintf가 NUL 종료를 하지 않는다는 오해를 낳는다는 점임
  • 그 결과 마지막에 ptr[offset] = 0;을 추가하는 코드가 생기지만, 이 작업도 불필요하며 같은 오해를 더욱 강화함

빈 문자열과 널 문자열의 이중 상태

  • NUL 종료 모델에서는 빈 문자열과 널 포인터가 서로 다른 유효하지 않은 상태처럼 취급돼 문자열 처리 코드가 둘 다 검사해야 함
  • C#의 문자열과 배열도 참조형이므로 널 상태에서 Length를 읽거나 순회하면 예외가 발생해 별도 처리가 필요함
  • 흔한 대응은 널 대신 항상 빈 문자열이나 빈 배열을 전달하고 반환하는 것임
    • String.IsNullOrEmpty, “Don’t Return Null”, “Don’t Pass Null” 같은 규칙도 이 구분을 처리하기 위한 방식임
  • 포인터와 길이를 가진 구조체에서는 빈 문자열 여부를 size == 0 하나로 판단할 수 있음
    • 포인터는 널일 수도 있고 유효한 주소일 수도 있음
    • 문자열 끝까지 이동했거나 큰 문자열 내부의 빈 부분 문자열을 가리킬 때는 길이가 0이어도 유효한 포인터를 가질 수 있음
    • 길이를 확인한 뒤에만 역참조한다면, 이미 해제된 메모리를 가리키는 포인터도 길이가 0인 상태에서는 접근되지 않음

바이너리 데이터와 동일한 표현 사용

  • 길이 기반 문자열은 센티널에 의존하지 않아 NUL 바이트가 포함된 임의 바이너리 데이터도 안전하게 저장할 수 있음
  • ASCII 문자열 함수가 UTF-8에서도 동작하듯, 스캔·분할·트리밍·슬라이싱 같은 길이 기반 연산을 바이트 배열에도 적용 가능함
  • NUL 종료 바이너리 형식은 종료 바이트를 저장할지 별도로 결정해야 함
    • 저장하면 읽기·쓰기 코드가 일반적인 문자열 처리 방식과 가까워짐
    • 저장하지 않으면 공간과 처리 오버헤드를 줄일 수 있으며, 이는 바이너리 형식을 사용하는 주요 이유 중 하나임
  • 길이와 내용을 모두 저장하면서 NUL 바이트까지 붙이는 형식은 저장된 길이가 종료 바이트를 포함하는지 다시 확인해야 하므로 같은 모호성을 되살림

할당과 복사 없는 부분 문자열

  • NUL 종료를 강제하면 트리밍·슬라이싱·분할·토큰화·검색 결과의 끝에도 종료 바이트를 배치해야 하므로 새 문자열과 중간 버퍼를 할당·복사하게 됨
  • 길이 기반 문자열은 원본의 일부를 가리키는 포인터와 길이만 반환할 수 있음
String StrPrefix(String str, u64 size);
String StrPostfix(String str, u64 size);
String StrChop(String str, u64 size);
String StrSkip(String str, u64 size);
String Substr(String a, u64 min, u64 max);

String StrFindNeedle(String str, String needle);
String StrTrim(String str);
  • CSV, Markdown, JSON, C용 렉서와 파서는 각 토큰을 복사하지 않고 입력 버퍼의 슬라이스를 파스 트리의 이름과 값으로 보관할 수 있음
  • 파스 트리를 사용하는 후속 코드도 같은 슬라이스를 전달받아 추가 메모리 관리 없이 처리 가능함

센티널이 여전히 유용한 경우

  • 센티널은 자료구조의 불변식을 유지하고 일부 상황에서 성능 이점을 줄 수 있음
  • 현대 하드웨어와 컴파일러에서는 성능상 이점이 특수한 사례를 제외하면 대부분 약해졌지만, 프로그램이 관리해야 할 불변식 수를 줄이는 효과는 남아 있음
  • 직접 작성한 토큰 검사에서는 NUL 센티널 덕분에 반복문이 현재 바이트를 안전하게 읽을 수 있음
    • s[i] == 'f' && s[i+1] == 'o' && s[i+2] == 'r' 같은 미리보기는 바이트 순서와 단락 평가를 이용해 조기에 실패할 수 있음
    • 길이 기반 문자열은 공백으로 토큰이 끝난 경우와 입력 자체가 끝난 경우를 구분해야 하며, 미리보기에도 더 체계적인 경계 검사가 필요함
  • 기존 C 라이브러리와 OS API 대부분이 NUL 종료 문자열을 요구하므로 변환 비용이 발생함
    • Windows에서는 UTF-8을 UTF-16으로 이미 변환해야 하므로 NUL 바이트를 추가하는 비용은 상대적으로 작음
    • OS와 벤더가 부과하는 제약은 실제보다 과대평가되고, 길이 기반 문자열의 이점은 과소평가될 수 있음

문자열 API에 불변성을 적용하는 방법

  • 완성도 높은 문자열 계층의 전체 API 설계와 추상화는 범위 밖이지만, 핵심 구조 원칙으로 불변성을 권장함
  • 문자열은 한 번 생성되면 내용을 변경하지 않고 상수처럼 다루며, 문자열을 받아 문자열을 반환하는 함수도 이 속성을 유지해야 함
  • 불변성은 함수 시그니처와 API 경계에서 보장하면 충분함
    • 함수 내부에서는 필요에 따라 제자리 수정이나 다른 절차적 처리를 사용할 수 있음
    • 상위 정보 흐름에서는 함수형 프로그래밍의 불변성 이점을 얻고, 하위 구현에서는 절차적 프로그래밍의 유연성을 유지함
  • 이 규칙은 언어나 런타임이 강제하지 않아도 코딩·API 관례로 운용할 수 있음
  • 문자열 유형과 연산을 명확히 구분하면 규칙을 유지하는 부담이 크지 않으며, 코드베이스가 적용 범위와 완화 시점을 직접 정할 수 있음
  • 언어도 하나의 API이므로 언어 위원회가 정한 제약에만 의존하지 않고, 필요한 세분성으로 규칙을 설계할 수 있음

길이 기반 문자열의 구현 사례

다른 문자열 표현이 가진 제약

  • 유연한 배열 멤버

    • Redis의 SDS처럼 구조체 앞부분에 현재 길이와 최대 용량을 저장하고, 마지막 필드를 유연한 배열 멤버로 선언해 문자 데이터를 배치함
    • strlen의 O(n) 스캔과 수동 연결·경계 관리 같은 C 문자열의 문제를 피할 수 있음
    • 문자열 리터럴과 직접 호환하기 어렵고, 길이 기반 문자열의 핵심 장점인 효율적인 부분 문자열을 지원하지 못함
  • 스트레치 버퍼

    • 스트레치 버퍼는 동적 배열과 비슷하며, 길이와 용량 헤더를 명시적 구조체가 아닌 포인터 앞의 별도 영역에 저장함
    • 유연한 배열 멤버 방식의 단점에 더해 모든 char*에 거의 보이지 않는 메타데이터 규칙을 부여하며, 의미 있는 문자열 연산마다 이 헤더를 API 모델의 일부로 사용해야 함
    • 포인터 표현은 타입 안전성을 제공하고 .str 또는 ->str 접근을 피할 수 있지만, 이 이점만으로 숨은 규칙을 정당화하기는 어려움
    • 별도 capacity 필드는 동적 배열에는 자연스럽지만, 문자열에서는 서로 다른 개념을 한 타입에 섞어 유형 간 구분을 흐림
    • 유연한 배열 멤버에도 같은 문제가 있을 수 있으나, 초기 고정 크기를 용량으로 사용하고 동적 확장을 피할 수 있다는 차이가 있음
  • Pascal 스타일

    • Pascal식 짧은 문자열은 보통 256바이트의 고정 크기 문자 배열을 사용하며, 길이용 1바이트를 예약하거나 NUL 종료를 이용함
    • 과거의 실제 메모리·하드웨어 제약에서는 유효했지만, 현대에는 좋은 아레나 메모리 할당 전략을 사용할 수 있어 특수한 상황 외에는 적합하지 않음

길이 기반 문자열을 기본값으로 삼아야 하는 이유

  • 길이 기반 문자열의 장점은 일부 커뮤니티와 숙련된 프로그래머 사이에서 알려져 있지만, 많은 개발자는 여전히 이런 구현을 고려한 적이 없음
  • 관련 자료가 여러 곳에 흩어져 있어 길이 손실, 종료 바이트 모호성, 바이너리 처리, 부분 문자열, 센티널의 장단점과 대체 구현을 함께 비교하기 어려웠음
  • 기본 문자열 타입을 포인터와 길이의 슬라이스 형태로 만들면 경계 정보 보존, 상태 단순화, 바이너리 호환, 할당 없는 부분 문자열을 하나의 설계로 해결할 수 있음

댓글과 토론

Lobste.rs 의견들
  • 패딩 바이트를 피하려면 구조체 멤버 순서를 다음과 같이 바꾸는 편이 낫다고 봄

    struct String  
    {  
        u8* data;  
        u64 size;  
    };  
    

    대신:

    struct String  
    {  
        u64 size;  
        u8* data;  
    };  
    
    • 64비트 포인터 환경에서는 두 필드의 크기가 같아 어느 순서든 패딩이 없고, 32비트 환경에서는 포인터로 표현할 수 있는 범위보다 큰 u64 자체가 낭비임
      CHERI처럼 128비트 포인터를 쓰면 첫 번째 배치는 datasize 사이에, 두 번째 배치는 구조체 끝에 정렬용 패딩이 생기므로 결국 공간이 조금 낭비됨

    • 올바른 정의는 가변 길이 배열 멤버를 쓰는 형태임

      typedef struct {  
        u64 len;  
        u8 buf[];  
      } String;  
      
  • C 표준에는 u8u64 타입이 정의돼 있지 않으며, 이는 Rust식 표기임
    32비트 환경에서 u64로 길이를 나타내는 것도 어울리지 않고, C에서 배열 크기를 표현하는 관용적인 타입은 size_t이므로 다음과 같이 쓰는 편이 자연스러움

    struct String  
    {  
        char* data;  
        size_t size;  
    };  
    

    글을 쓴 사람이나 이를 생성한 LLM이 C를 제대로 모르는 듯함

    • 많은 C 프로젝트가 u8u64typedef로 정의해 쓰며, 이는 매우 흔한 관행임
      표준의 uintN_t 명칭을 번거롭게 여기는 경우도 많고, 규모가 큰 프로젝트 중에는 해당 표준보다 오래된 것도 있으며 Linux 커널도 이런 별칭을 사용함
      오히려 이 비판이 C 프로젝트에 익숙하지 않다는 점을 드러냄
  • C나 C++를 몇 달 이상 작성했다면 포인터와 길이를 함께 전달하는 방식은 이미 잘 알려진 사실 아닌가 싶음
    현대적인 C 코드베이스는 거의 모두 결국 데이터 포인터와 길이를 함께 넘기거나 자체 문자열 라이브러리를 갖추게 됨

    • 길이 저장 여부는 C 문자열이 가진 문제 중 하나일 뿐이며, 수명 관리에서도 골칫거리가 많음
      현대 C++의 std::string_view는 “이 문자열을 볼 수는 있지만 변경하거나 보관해서는 안 된다”는 의미를 명시해 버그를 크게 줄여줌
      C 문자열을 받는 API에서는 이런 제약이 문서상의 약속에 불과함
  • 진짜 실수는 길이 저장이나 널 종료 여부가 아니라, 임의의 맥락에서 의미를 갖기엔 너무 광범위한 String이라는 개념 자체라고 봄
    문자열은 단순한 문자 배열을 넘어 온갖 용도로 쓰이며, 여기서 “문자”라는 말조차 진공 속 구형 소를 가정하는 물리학 농담처럼 지나치게 단순화돼 있음
    “길이”가 자소 클러스터 수인지, 코드 포인트 수인지, 전체 바이트 수인지부터 불분명함
    인코딩은 무엇인지, 사용자에게 노출되어 현지화가 필요한지, 정제됐는지, 맵 키처럼 수정할 이유가 없는 불가분 값인지도 구분해야 함
    이런 관점에서 보면 통상적인 구현의 문자열 타입은 더 이상 원하지 않게 됨

    • Rust에 문자열 타입이 너무 많다고 불평하기도 하지만, 그중 일부는 이런 정보를 타입 시스템에 담기 위한 것임
      어떤 라이브러리도 자소 클러스터 수를 단순히 “길이”라고 부르지는 않는 것으로 앎
      기본 인코딩을 UTF-8로 정하고 다른 인코딩이나 바이트 배열에는 별도 타입을 쓰는 관례로 일부 문제를 해결할 수 있으며, 대부분은 타입 시스템으로 표현할 수 있음
  • 내가 알기로 C에는 대부분의 현대 언어가 제공하는 언어 차원의 문자열 개념이 없음
    문자열 조작은 언어 자체가 아니라 표준 라이브러리에서 제공함
    K&R 2판은 C가 문자·숫자·주소처럼 컴퓨터가 직접 다루는 대상에 가까운 비교적 저수준 언어이며, 문자열·집합·목록·배열 같은 복합 객체 전체를 직접 조작하는 연산을 제공하지 않는다고 설명함
    이런 기능의 부재가 큰 결함처럼 보일 수 있지만, 언어를 작게 유지한 덕분에 짧게 기술하고 빠르게 배울 수 있으며 프로그래머가 언어 전체를 이해하고 일상적으로 사용할 수 있다는 장점이 있음
    C가 왜 이렇게 설계됐고 당연하게 여겨지는 현대 기능들이 왜 없는지 이해하려면 K&R 2판의 서문과 도입부를 읽어보길 강력히 권함

    • 다만 문자열 리터럴의 타입과 메모리 배치가 라이브러리 형태를 결정한다는 제한적인 의미에서는 문자열도 C 언어의 일부
      이는 Rust 같은 저수준 언어의 언어와 라이브러리 간 구분과도 크게 다르지 않음
  • 직렬화 방식으로 https://cr.yp.to/proto/netstrings.txt 같은 제안이 있고, https://web.archive.org/web/20230305073119/… 처럼 복잡한 구조체까지 다루는 방식도 있음
    하지만 안전하게 파싱하기에는 너무 단순한 모양인지, XML·JSON·YAML·Protocol Buffers 등의 열성 지지자들이 계속 바쁘게 일할 수 있게 됨