Proven C BookEnglish GitHub

90 문자열과 텍스트

먼저 알아야 할 것

42장 문자열 · 문자열
9장 문자와 텍스트 · UTF-8 과 바이트

돌아보기

42장에서 C 문자열은 “NUL을 만날 때까지”가 곧 길이라, 길이를 알려면 매번 세어야 한다고 했다. 그러면 길이를 함께 들고 다니면 무엇이 좋아지는가 — 그리고 무엇을 잃는가?

답. 얻는 것이 셋이다. 길이를 세는 비용이 사라지고(𝑂(𝑛)𝑂(1)이 된다), 중간에 0바이트가 들어 있는 자료도 다룰 수 있으며, 무엇보다 경계를 검사할 수 있다 — 길이를 모르면 검사할 것 자체가 없다. 잃는 것은 둘이다. 문자열 하나가 한 워드가 아니라 두 워드가 되고, printf("%s") 처럼 NUL 종단을 기대하는 세상과 만날 때 변환이 필요해진다. 이 라이브러리는 그 변환 비용을 없애려고 내부적으로 NUL도 함께 유지한다 — 둘 다 가지는 절충이다.

이 장의 필요성과 맥락

85장의 첫째 버그를 다섯 번째 장에서야 답하는 것이 이상해 보일 수 있다. 그러나 길이를 지닌 문자열은 뷰(88장) 위에 서고, 그 문자열을 담을 기억은 할당자(89장)에서 오므로, 순서를 앞당길 수가 없다. 가장 유명한 문제일수록 답에 필요한 재료가 많다.

이 장이 끝나면

85장의 첫째 버그 — 그릇의 크기를 모르는 문자열 함수 — 에 대한 답이다. 길이를 지니고 다니는 문자열, 소유와 빌림의 구분, 그리고 이 라이브러리 에서 가장 논쟁적인 결정인 거부하되 자르지 않는다까지. 9장의 인코딩 이야기와 42장의 경계 이야기가 여기서 하나로 모인다.

이 장에서 답할 질문

  1. 그래도 로그 한 줄처럼 잘려도 괜찮은 자리가 있지 않은가?
  2. _insert_insert_grow 중 어느 쪽을 기본으로 삼아야 하는가?

90.1 두 개의 타입, 하나의 규칙

문자열 어휘는 둘뿐이다.

이름에 view가 있으면 빌린 것이고, 빌린 것은 파괴하지 않는다. 89장에서 세운 규칙이 문자열에 그대로 적용된 것이다. u8은 UTF-8을 뜻한다 — 9장에서 본 그 인코딩이 이 라이브러리의 기본 텍스트 표현이다.

속을 열어 보면 왜 둘로 갈랐는지가 분명해진다.

/* 빌린 문자열 — 88장의 mem_view_t 와 같은 모양이다 */
typedef struct {
    const proven_byte_t *ptr;
    proven_size_t        size;
} proven_u8str_view_t;

/* 소유하는 문자열 — 버퍼 하나와 "빌린 것인가" 깃발 */
typedef struct {
    proven_buf_t internal;   /* { ptr, len, cap } */
    bool         borrowed;
} proven_u8str_t;

proven_buf_t의 세 칸이 문자열의 성격을 그대로 말해 준다. len은 지금 담긴 내용의 바이트 수, cap은 버퍼 전체 크기이고, 그 차이가 NUL 자리를 포함한 여유다. 그래서 create(alloc, 64)가 실제로는 65바이트를 잡는다 — 64는 내용의 한도이고 한 바이트는 NUL의 몫이다. 이 한 바이트 덕에 proven_u8str_as_cstr이 복사도 할당도 없이 C 문자열을 내줄 수 있다.

borrowed 깃발은 _borrow로 만든 문자열을 표시한다. 이 깃발이 켜져 있으면 _destroy아무것도 해제하지 않고 구조체만 비운다 — 남의 기억을 돌려줄 수는 없기 때문이다.

문자열을 얻는 길은 셋이다.

함수할당파괴쓰는 자리
proven_u8str_create(alloc, limit)_destroy 필요빈 문자열로 시작해 채워 나갈 때
proven_u8str_create_from_view(alloc, v)_destroy 필요있는 내용을 복사해 소유할 때
proven_u8str_borrow(buf, cap)아니오불필요(무해)스택·정적 배열 위에서. 임베디드

표 90.1

_borrowcapNUL을 포함한 전체 용량이라는 점만 주의하면 된다 — buf[64]를 주면 내용은 63바이트까지다.

리터럴에서 뷰를 만드는 것은 매크로 하나로 끝난다.

PROVEN_LIT("hello")        /* 컴파일 시간에 { ptr, 5 } 가 된다 — strlen 없음 */
proven_u8str_view_from_cstr(p)   /* 실행 시간에 길이를 센다 */

PROVEN_LITsizeof("...") - 1로 길이를 뽑으므로 실행 시간 비용이 0이다. 문자열 리터럴을 넘길 때는 이쪽을 쓰는 것이 관행이고, 길이를 모르는 C 문자열에는 _from_cstr을 쓴다.

examples/ch90/ops.c

#include <proven.h>
#include <stdio.h>

static void show(const char *label, proven_u8str_view_t v)
{
    printf("%-12s [%.*s] (%zu bytes)\n", label, (int)v.size, (const char *)v.ptr, v.size);
}

int main(void)
{
    /* ① 할당 없는 문자열: 스택 버퍼를 빌려 쓴다.
          allocator 가 없는 시그니처 = 할당하지 않는다 */
    proven_byte_t buf[16];
    proven_u8str_t s = proven_u8str_borrow(buf, sizeof buf);

    proven_err_t e = proven_u8str_append(&s, proven_u8str_view_from_cstr("hello"));
    printf("append 'hello' : %s\n", proven_is_ok(e) ? "ok" : "refused");
    show("content", proven_u8str_as_view(&s));

    /* ② 자리가 모자라면 거부한다 — 자르지 않는다 */
    e = proven_u8str_append(&s, proven_u8str_view_from_cstr(" world, and more"));
    printf("append long    : %s (err=%d)\n",
           proven_is_ok(e) ? "ok" : "refused", (int)e);
    show("unchanged", proven_u8str_as_view(&s));   /* 실패 원자성 */

    /* ③ 찾기와 자르기 — 전부 view 위에서, 복사 없이 */
    proven_u8str_view_t csv = proven_u8str_view_from_cstr("name,age,city");
    proven_u8str_view_t comma = proven_u8str_view_from_cstr(",");

    proven_size_t at = proven_u8str_view_find(csv, 0, comma);
    printf("first comma at : %zu\n", at);
    show("field 1", proven_u8str_view_slice(csv, 0, at));

    /* ④ 구분자로 훑기: 못 찾으면 센티널(PROVEN_INDEX_NOT_FOUND)이 온다 */
    proven_size_t start = 0;
    int n = 0;
    for (;;) {
        proven_size_t hit = proven_u8str_view_find(csv, start, comma);
        proven_size_t end = (hit == PROVEN_INDEX_NOT_FOUND) ? csv.size : hit;
        char label[24];
        snprintf(label, sizeof label, "field %d", ++n % 100);
        show(label, proven_u8str_view_slice(csv, start, end - start));
        if (hit == PROVEN_INDEX_NOT_FOUND) break;
        start = hit + 1;
    }

    /* ⑤ 비교와 접두사 검사 */
    printf("eq 'name,age,city' : %d\n",
           proven_u8str_view_eq(csv, proven_u8str_view_from_cstr("name,age,city")));
    printf("starts with 'name' : %d\n",
           proven_u8str_view_starts_with(csv, proven_u8str_view_from_cstr("name")));
    return 0;
}

실행 결과

append 'hello' : ok
content      [hello] (5 bytes)
append long    : refused (err=2)
unchanged    [hello] (5 bytes)
first comma at : 4
field 1      [name] (4 bytes)
field 1      [name] (4 bytes)
field 2      [age] (3 bytes)
field 3      [city] (4 bytes)
eq 'name,age,city' : 1
starts with 'name' : 1

첫 줄이 89장의 규칙을 다시 보여 준다. proven_u8str_borrow에는 할당자가 없다 — 그러므로 할당하지 않는다. 스택에 잡은 16바이트 배열 위에서 문자열 연산을 하는 것이고, 임베디드에서 힙 없이 문자열을 다루는 방식이 정확히 이것이다.

90.2 거부하되, 자르지 않는다

두 번째 append가 이 부에서 가장 중요한 한 줄이다. 16바이트 그릇에 "hello" 다음으로 " world, and more"를 붙이려 하자 라이브러리는 아무것도 쓰지 않고 PROVEN_ERR_OUT_OF_BOUNDS를 돌려주었다. 그리고 그다음 줄이 보여 주듯 원래 내용 hello는 그대로다 — 87장에서 배운 실패 원자성이다.

85장에서 본 snprintf의 선택과 정반대다. 왜 자르지 않는가?

잘린 값은 짧은 값이 아니라 다른 값이기 때문이다. 잘린 경로는 다른 파일을 가리키고, 잘린 명령은 다른 명령이며, 잘린 사용자 이름은 다른 사람이다. 자르고 성공을 말하면 호출자는 자기가 받은 것이 요청한 것인지 알 수 없다. 그래서 이 라이브러리는 결정을 호출자에게 되돌려준다 — “자리가 모자랍니다. 어떻게 할까요?”

문. 그래도 로그 한 줄처럼 잘려도 괜찮은 자리가 있지 않은가?

답. 있다. 그래서 명시적으로 부분만 쓰는 함수가 따로 있다 — proven_u8str_append_partial은 들어간 바이트 수를 돌려준다. 이름이 길고 반환값이 다르다는 점이 핵심이다. 자르기는 여전히 가능하지만, 그것을 하려면 그렇게 적어야 한다. 기본값은 언제나 안전한 쪽이고 위험한 선택은 눈에 보이는 이름으로만 할 수 있다는 원칙(88장의 _unchecked와 같다)이 여기서도 지켜진다.

반례. 성장과 고정 용량을 헷갈리기

proven_u8str_t s = proven_u8str_borrow(buf, sizeof buf);
proven_err_t e = proven_u8str_append_grow(alloc, &s, view);  /* 위험 */

_append는 용량 안에서만 쓰고, _append_grow는 모자라면 할당자에게 더 달라고 한다 — 그래서 후자에만 할당자 인자가 있다. 문제는 빌린 버퍼(_borrow) 위에서 성장을 요구하는 경우다. 스택 배열은 자랄 수 없으므로 이것은 설계 오류다.

라이브러리는 이 경우 조용히 남의 기억을 재할당하지 않고 OUT_OF_BOUNDS를 돌려준다 — 들어가는 동안은 성공하고, 들어가지 않게 되는 순간 거부한다. 시그니처를 읽는 습관이 여기서 그대로 방어가 된다 — 할당자가 있으면 자랄 수 있고, 없으면 자랄 수 없다.

90.3 세 갈래의 쓰기 — 거부·자름·성장

이 라이브러리의 문자열 연산은 이름만 보고 갈래를 알 수 있게 되어 있다. “자리가 모자랄 때 어떻게 하는가”가 그 갈래다.

갈래이름의 꼴모자랄 때실패 원자성
고정 용량·원자적_append, _insert, _replace_at거부(OUT_OF_BOUNDS)예 — 원본 그대로
최선 노력·자름_append_partial, _append_fmt_trunc들어가는 만큼 쓰고 알린다아니오(의도적)
성장_append_grow, _insert_grow, _replace_at_grow할당자에게 더 달라고 한다예 — 할당 실패 시 원본 그대로

표 90.2

세 갈래가 다 필요한 이유는 자리마다 옳은 답이 다르기 때문이다. 파일 경로를 만드는 자리에서는 잘리면 안 되므로 거부가 옳고, 화면 한 줄에 맞춰 로그를 찍는 자리에서는 자름이 옳고, 내용이 얼마나 될지 모르는 자리에서는 성장이 옳다. 기본값(짧은 이름)이 거부라는 점이 이 설계의 태도를 보여 준다.

90.4 문자열을 고치는 연산들

붙이기만으로는 부족하다. 가운데를 고치고, 지우고, 되쓰는 연산이 함께 있다.

examples/ch90/edit.c

/* 문자열을 고치는 연산들 — 끼우기, 지우기, 바꾸기, 그리고 되쓰기.
   전부 "들어가면 하고, 안 들어가면 원본을 그대로 두고 거부한다". */
#include <proven.h>

static void show(const char *label, const proven_u8str_t *s)
{
    proven_u8str_view_t v = proven_u8str_as_view(s);
    proven_println("{:<16} \"{}\" (len {})",
                   PROVEN_ARG(label), PROVEN_ARG(v), PROVEN_ARG(v.size));
}

int main(void)
{
    proven_allocator_t alloc = proven_heap_allocator();

    /* ── ① 뷰에서 바로 만들기 ─────────────────────────────────── */
    proven_result_u8str_t r =
        proven_u8str_create_from_view(alloc, PROVEN_LIT("hello world"));
    if (!proven_is_ok(r.err)) return 1;
    proven_u8str_t s = r.value;
    show("just created", &s);

    /* ── ② 가운데에 끼우기 ────────────────────────────────────── */
    proven_err_t e = proven_u8str_insert_grow(alloc, &s, 5, PROVEN_LIT(","));
    show("insert(5, \",\")", &s);

    /* ── ③ 구간 바꾸기 — 길이가 달라도 된다 ───────────────────── */
    e = proven_u8str_replace_at_grow(alloc, &s, 7, 5, PROVEN_LIT("proven C"));
    show("replace_at", &s);

    /* ── ④ 처음 만나는 조각 바꾸기 ────────────────────────────── */
    e = proven_u8str_replace_first(&s, 0, PROVEN_LIT("hello"), PROVEN_LIT("HELLO"));
    show("replace_first", &s);

    /* 없는 것을 바꾸라고 하면 아무 일도 하지 않고 성공한다 */
    e = proven_u8str_replace_first(&s, 0, PROVEN_LIT("zzz"), PROVEN_LIT("!"));
    proven_println("replacing something absent -> err={} (left alone, and it succeeds)",
                   PROVEN_ARG((int)e));

    /* ── ⑤ 구간 지우기 ───────────────────────────────────────── */
    e = proven_u8str_remove(&s, 0, 6);
    show("remove(0,6)", &s);

    /* ── ⑥ 되쓰기 — 버퍼는 그대로 두고 내용만 비운다 ──────────── */
    e = proven_u8str_reset(&s);
    show("after reset", &s);

    e = proven_u8str_append(&s, PROVEN_LIT("written again"));
    show("after refilling", &s);
    proven_println("(reset is for rewriting without reallocating - for code that rebuilds every frame)");

    /* ── ⑦ 미리 잡아 두기 ────────────────────────────────────── */
    e = proven_u8str_reserve(alloc, &s, 256);
    proven_println("reserve(256)      -> err={} (worth especially much on an arena)",
                   PROVEN_ARG((int)e));

    /* ── ⑧ 조회 연산들 ───────────────────────────────────────── */
    proven_u8str_view_t v = proven_u8str_as_view(&s);
    proven_println("starts_with(\"written\")={} ends_with(\"again\")={} find(\"ten\")={}",
                   PROVEN_ARG((bool)proven_u8str_view_starts_with(v, PROVEN_LIT("written"))),
                   PROVEN_ARG((bool)proven_u8str_view_ends_with(v, PROVEN_LIT("again"))),
                   PROVEN_ARG(proven_u8str_view_find(v, 0, PROVEN_LIT("ten"))));

    proven_size_t nf = proven_u8str_view_find(v, 0, PROVEN_LIT("absent"));
    proven_println("when it is not found: {} (PROVEN_INDEX_NOT_FOUND)",
                   PROVEN_ARG((bool)(nf == PROVEN_INDEX_NOT_FOUND)));

    proven_u8str_destroy(alloc, &s);
    return 0;
}

실행 결과

just created     "hello world" (len 11)
insert(5, ",")   "hello, world" (len 12)
replace_at       "hello, proven C" (len 15)
replace_first    "HELLO, proven C" (len 15)
replacing something absent -> err=0 (left alone, and it succeeds)
remove(0,6)      " proven C" (len 9)
after reset      "" (len 0)
after refilling  "written again" (len 13)
(reset is for rewriting without reallocating - for code that rebuilds every frame)
reserve(256)      -> err=0 (worth especially much on an arena)
starts_with("written")=true ends_with("again")=true find("ten")=4
when it is not found: true (PROVEN_INDEX_NOT_FOUND)
함수하는 일계약
_insert(&s, i, v)i 자리에 끼워 넣는다i ≤ 길이. 뒤를 밀어낸다
_remove(&s, i, n)i부터 n바이트를 지운다범위를 넘으면 에러
_replace_at(&s, i, old, v)구간을 다른 내용으로길이가 달라도 된다
_replace_first(&s, off, t, r)처음 만나는 tr★ 없으면 성공으로 돌아온다
_reset(&s)내용만 비운다버퍼와 용량은 그대로
_reserve(alloc, &s, n)용량을 미리 n까지아레나에서 특히 값을 한다
_append_byte(alloc, &s, b)한 바이트 붙이기필요하면 늘린다

표 90.3

별표 친 _replace_first의 계약을 조심해야 한다. 찾지 못해도 에러가 아니라 성공이다 — “바꿀 것이 없었다”와 “바꿨다”를 구별하려면 proven_u8str_view_find로 먼저 확인해야 한다. 예제의 “없는 것 바꾸기”가 err=0으로 돌아오는 것이 그 확인이다.

_reset_reserve는 성능을 위한 짝이다. 프레임마다, 요청마다 문자열을 새로 짓는 코드에서 버퍼를 버리지 않고 되쓰는 것이 _reset이고, 얼마나 커질지 대충 아는 경우 미리 잡아 두는 것이 _reserve다. 89장에서 본 대로 아레나 위에서는 _reserve가 특히 값을 한다 — 중간에 다른 할당이 끼기 전에 크게 잡아 두면 제자리 성장이 가능해지기 때문이다.

문. _insert_insert_grow 중 어느 쪽을 기본으로 삼아야 하는가?

답. 용량을 내가 정한 자리에서는 _insert, 내용이 얼마나 될지 모르는 자리에서는 _insert_grow다. 판단의 기준은 간단하다 — “여기서 자리가 모자란 것이 버그인가, 아니면 일어날 수 있는 일인가.”

고정 크기 버퍼에 프로토콜 헤더를 조립하는 자리라면 모자란 것이 버그이고, 그때는 거부가 옳다(그리고 그 에러가 버그를 드러낸다). 사용자 입력을 이어 붙이는 자리라면 얼마든지 길어질 수 있으니 성장이 옳다. 임베디드 코드가 _grow 없는 판만 쓰는 이유도 같다 — 그쪽에서는 예측 불가능한 성장 자체가 금지이기 때문이다(94장).

90.5 찾기와 자르기 — 복사 없는 텍스트 처리

예제의 ③·④가 뷰의 진짜 쓸모다. 부분 문자열을 얻는 데 복사가 필요 없다 — 원본을 가리키는 포인터와 길이를 새로 계산할 뿐이다. CSV 한 줄을 세 필드로 가르는 동안 할당은 한 번도 일어나지 않았다.

이 무늬는 파서에서 특히 강력하다. 25장에서 sscanf로 줄을 해석할 때는 결과를 담을 버퍼를 미리 준비해야 했지만, 뷰로 자르면 원본 위에 표시만 남긴다. 대신 지켜야 할 것이 하나 늘어난다 — 88장에서 본 대로 원본이 살아 있는 동안만 유효하다.

find가 못 찾았을 때 돌려주는 값에도 규약이 있다. 0이나 음수가 아니라 PROVEN_INDEX_NOT_FOUND라는 이름의 센티널이다. 85장에서 센티널 값의 위험을 이야기했는데, 여기서는 이름이 붙어 있고 문서화되어 있다는 점이 다르다 — 이름 없는 마법의 수와 이름 붙은 계약은 다른 물건이다.

흔한 오해. “길이를 들고 다니면 글자 수를 아는 것이다”

아니다. 길이는 바이트 수다. 9장에서 배운 대로 UTF-8에서 한 글자는 1~4바이트이므로, 한글 “가”는 3바이트이고 이모지는 4바이트다. 예제의 출력이 굳이 “(5 bytes)”라고 밝히는 이유다. 그래서 “n번째 글자”를 다루는 일은 여전히 조심스럽고, 바이트 경계에서 아무 데나 자르면 9장에서 본 깨진 글자가 나온다. 문자열 라이브러리가 해결해 주는 것은 경계 침범이지 인코딩의 본질적 난이도가 아니다.

90.6 두 세계의 경계 — NUL 종단과 UTF-16

바깥 세계와 만나는 자리마다 변환이 필요하다.

실제 사례. “추측하지 말고 거부하라” — 인코딩 처리의 원칙

9장에서 본 텍스트 보안 문제들의 뿌리는 대개 너그러운 디코더다. 잘못된 UTF-8을 만났을 때 “비슷한 것으로 고쳐 읽는” 구현은 과거 여러 번 보안 사고의 통로가 됐다 — 특히 지나치게 긴 인코딩(overlong)을 허용한 디코더들이 검사를 우회당했다. 그래서 오늘의 규범은 한 문장이다. 유효하지 않은 입력은 고쳐 읽지 말고 거부하라. proven의 인코딩 변환이 PROVEN_ERR_INVALID_ENCODING을 돌려주며 멈추는 것이 그 규범의 구현이고, 이 원칙은 이 장 전체의 “자르지 않는다”와 정확히 같은 정신이다.

복습 정리

문자열 어휘 요약.

함수하는 일실패·소유
u8str_create(alloc, cap)소유 문자열 생성꾸러미 반환, _destroy 필요
u8str_borrow(buf, cap)남의 버퍼 위에 문자열할당 없음, 파괴 없음
u8str_append(&s, v)용량 안에서 덧붙임모자라면 거부(원본 보존)
u8str_append_partial들어가는 만큼넣은 바이트 수 반환
u8str_append_grow(alloc,…)모자라면 늘려서할당 실패 가능
u8str_view_find부분 문자열 위치없으면 PROVEN_INDEX_NOT_FOUND
u8str_view_slice부분 뷰복사 없음 — 원본 수명에 묶임
u8str_as_cstrNUL 종단 포인터복사 없음
u8str_view_to_cstr뷰 → C 문자열할당자 필요

표 90.4

문자열을 안전하게 담고 자를 수 있게 됐다. 그런데 아직 만드는 일이 남았다 — 수와 값을 글자로 바꾸고, 글자를 다시 수로 되돌리는 일. 85장의 셋째 버그가 기다리는 자리이자, 이 라이브러리에서 가장 눈에 띄게 다른 문법이 나오는 자리다.