Proven C BookEnglish GitHub

42 문자열

먼저 알아야 할 것

9장 문자와 텍스트 · 글자의 표현
38장 배열 · 배열과 첨자

돌아보기

9장에서 C가 택한 문자열 표현은 “끝에 표지를 세우는” NUL 종단이라 했고, 그 대가 세 가지(길이는 세어야 안다, NUL을 내용에 못 담는다, 표지를 잃으면 폭주)를 예고했다. 이제 배열(38장)을 배웠으니 — C 문자열의 정확한 정체를 스스로 정의해 보면?

답. char 배열인데, 어딘가에 값 0인 바이트(NUL 문자, '\0')가 있고, “문자열”이란 시작부터 그 표지 직전까지를 뜻한다는 약속 — 이것이 전부다. 별도의 문자열 타입은 없다. 배열, 그리고 약속. 이 장은 그 약속의 사용법과 비용을 다룬다.

이 장의 필요성과 맥락

문자열이 9장(글자)과 38장(배열) 둘 다 뒤에 와야 하는 이유는, C 의 문자열이 정확히 그 둘의 합이기 때문이다 — 글자를 담은 배열에 끝 표시를 붙인 것. 어느 한쪽만 알고 오면 「왜 길이를 따로 안 들고 다니는가」에 답할 수 없다. 그래서 여기, 둘 다 지난 다음이다.

이 장이 끝나면

C의 문자열을 정면으로 다룬다 — char 배열 + NUL 종단이라는 정체, 문자열 리터럴의 특별한 사정, 길이 재기의 진짜 비용, 그리고 한글이 드러내는 “글자 수 ≠ 바이트 수”의 실전까지. 널 삼형제의 마지막(NUL 문자)이 여기서 정식 취급된다.

이 장에서 답할 질문

  1. 왜 C는 const를 붙이지 않았는가?
  2. 최신 표준과 앞으로의 표준에서는 어떤가?
  3. 한글 처리를 제대로 하려면 — 글자 단위로 다루려면 — 어떻게 해야 하는가?

42.1 정체 — 배열, 그리고 약속

char greet[] = "안녕"; — 문자열 리터럴로 배열을 초기화하면, 컴파일러가 글자들의 바이트 뒤에 '\0'을 붙여 배열을 만든다. 시연으로 해부한다.

examples/ch42/str.c

#include <stdio.h>
#include <string.h>

int main(void)
{
    char greet[] = "안녕";      /* 글자 2개 — 그러나 바이트는? */

    printf("strlen(\"안녕\") = %zu\n", strlen(greet));

    for (size_t i = 0; i < strlen(greet); i += 1) {
        printf("%02X ", (unsigned char)greet[i]);
    }
    printf("\n");

    printf("sizeof greet = %zu (including the NUL terminator)\n", sizeof greet);
    return 0;
}

실행 결과

strlen("안녕") = 6
EC 95 88 EB 85 95 
sizeof greet = 7 (including the NUL terminator)

읽을거리가 겹겹이다. strlen(표준 <string.h>의 길이 재기)이 6을 답했다 — “안녕”은 글자 둘, 바이트 여섯이다(9장 UTF-8 표의 그대로: 한글 음절은 3바이트 구간). 바이트 덤프 EC 95 88 EB 85 95가 그 여섯 바이트의 민낯이고, sizeof greet는 7 — NUL 표지까지 담은 그릇의 크기다. 8장의 오개념(“한 글자=1바이트”)이 실행 결과로 반증되는 순간 이다.

strlen비용도 이제 정확히 말할 수 있다 — 9장의 예고대로, NUL 종단에는 길이가 적혀 있지 않으므로 strlen은 표지를 만날 때까지 한 칸씩 산책한다. 문자열이 길수록 오래 걸리고(칸 수에 비례), 루프 조건 에서 매번 strlen을 부르는 코드가 고전적인 성능 함정이 되는 이유다 (시연의 루프가 사실 그 무늬다 — 짧은 문자열이라 무해하지만, 긴 문자열이라면 길이를 변수에 받아 두는 것이 관행이다).

42.2 문자열 리터럴 — 읽기 전용의 땅

배열 초기화가 아니라 포인터에 리터럴을 담을 수도 있다 — 그리고 여기에 중요한 차이가 있다:

char buf[] = "고칠 수 있다";   /* 내 배열로 복사됨 — 수정 가능 */
const char *msg = "고치면 안 된다";  /* 리터럴 원본을 가리킴 */

문자열 리터럴 자체는 프로그램에 구워진 읽기 전용 데이터다 — 수정을 시도하는 것은 계약 밖이고, 현대 환경에서는 대개 그 자리에서 붕괴한다 (리터럴이 쓰기 금지 구역에 배치되므로 — 6장의 보호 구역이 또 하나의 친절을 베푸는 셈이다). 그래서 리터럴을 가리키는 포인터는 const char* 로 선언하는 것이 규칙이다 — 23장의 const가 “바꾸지 않겠다”는 문서 였다면, 여기서는 “바꾸면 안 되는 것을 바꾸려는 실수”를 컴파일 시점에 막아 주는 자물쇠로 일한다.

42.2.1 타입은 const가 아니다 — C의 이상한 자리

그런데 여기에 C의 유명한 모순이 있다. 수정하면 안 되는데, 타입에는 const가 붙어 있지 않다. "abcdef"의 타입은 const char[7]이 아니라 그냥 char[7]이다.

char *p = "hello";     /* C: 경고 없이 통과한다 */
p[0] = 'H';            /* 그러나 이것은 정의되지 않은 동작 */

컴파일러가 막아 주지 않는다는 뜻이다. “수정 불가”는 타입이 아니라 계약 으로만 존재하고, 어기면 52장의 정의되지 않은 동작이 된다 — 대개는 쓰기 금지 구역에 배치된 덕에 그 자리에서 죽지만, 그것은 구현의 친절이지 언어의 보장이 아니다.

C++는 다르다. C++에서 문자열 리터럴의 타입은 const char[N]이고, 위 코드의 첫 줄이 컴파일 오류다. 96장에서 볼 “두 언어의 차이” 목록에 이 항목이 들어간다.

문. 왜 C는 const를 붙이지 않았는가?

답. 기존 코드를 깨뜨리지 않기 위해서다. const라는 낱말 자체가 C89에 와서야 표준에 들어왔는데(23장), 그 시점에 세상에는 이미 문자열 리터럴을 char *에 담아 쓰는 코드가 산더미처럼 쌓여 있었다. 리터럴의 타입을 const char[N]으로 바꾸는 순간 그 코드가 전부 진단 대상이 된다.

64장에서 볼 gets의 장례가 20년 걸린 것과 같은 사정이다 — 표준은 기존 코드를 지켜야 하는 기관이라, “옳은 타입”과 “이미 쓰인 코드”가 충돌하면 후자를 택하는 쪽으로 기운다. C++은 1998년에 처음 표준을 만들며 그런 짐이 없었기에 처음부터 const를 붙일 수 있었다(96장의 “형제이지 부모와 자식이 아니다”가 여기서도 확인된다).

그래서 실무의 규율이 언어를 대신한다. 리터럴을 가리키는 포인터는 언제나 const char *로 선언한다. 그러면 타입이 못 하는 일을 사람이 적어 넣은 셈이 되고, 그 뒤로는 컴파일러가 지켜 준다. GCC·Clang의 -Wwrite-strings는 아예 리터럴의 타입을 const char[N]으로 바꿔 주는 선택지인데, 옛 코드에서 경고가 쏟아지는 탓에 기본값은 아니다.

문. 최신 표준과 앞으로의 표준에서는 어떤가?

답. C23까지 그대로다. 문자열 리터럴의 타입은 여전히 char[N]이고, 수정은 여전히 정의되지 않은 동작이다. C23은 두 가지를 바꾸면서도 이 자리는 건드리지 않았다 — u8"..."의 원소 타입을 char8_t로 정한 것과, 2의 보수를 못박은 것 모두 이 조항과는 별개다.

다음 판(C2y)에서도 바뀔 조짐은 보이지 않는다. 이유는 위와 같다 — 이제 와서 타입을 바꾸면 반세기 치 코드가 진단 대상이 되기 때문이다. 표준이 이 자리를 정리하는 방식은 타입을 고치는 것이 아니라 도구가 경고하게 하는 쪽이었고, 앞으로도 그럴 가능성이 높다.

요약하면 이렇게 기억하면 된다 — 타입은 char[N], 계약은 “수정 금지”, 방어는 const char *와 컴파일러 경고.

42.3 리터럴도 배열이다

앞 절에서 “리터럴을 배열에 복사할 수도, 포인터로 가리킬 수도 있다”고 했는데, 한 걸음 더 들어가면 놀라운 사실이 있다 — 문자열 리터럴 자체가 이미 배열이다. 타입까지 정확히 적으면 "abcdef"char[7]이다(여섯 글자 + NUL).

examples/ch42/literal.c

/* 문자열 리터럴의 정체 — 배열이라는 것, 이어 붙는다는 것, 그리고 접두사. */
#include <stdio.h>
#include <inttypes.h>   /* PRIu64 — 이어 붙이기 규칙 위에 세워진 표준 매크로 */
#include <wchar.h>

/* 서식 조각을 이름으로 두는 관용구 — 아래 printf 에서 이어 붙는다 */
#define TEMP_FMT  "%.1f"
#define ID_FMT    "%d"

/* 버전 문자열 조립 — 값은 한 곳에만 적는다 */
#define MY_PROGRAM_VERSION  "v3.1.2"
#define PROGRAM_TITLE       "my_program " MY_PROGRAM_VERSION

/* 숫자로 관리하는 버전은 두 겹 우회로 문자열이 된다(57장) */
#define VER_MAJOR 3
#define VER_MINOR 1
#define STR_RAW(x) #x
#define STR(x)     STR_RAW(x)
#define VERSION_FROM_NUMBERS  "v" STR(VER_MAJOR) "." STR(VER_MINOR)

int main(void)
{
    /* ── ① 리터럴은 배열이다 ─────────────────────────────────── */
    printf("sizeof \"abcdef\" = %zu  (six characters + NUL)\n", sizeof "abcdef");
    printf("sizeof \"\"       = %zu  (even an empty string has its NUL)\n", sizeof "");

    /* 배열이므로 첨자를 붙일 수 있다 — 38장의 a[i] == *(a+i) 그대로 */
    printf("\"abcdef\"[3] = %c\n", "abcdef"[3]);
    printf("3[\"abcdef\"] = %c   <- the subscript can be swapped (it means the same)\n", 3["abcdef"]);

    /* ── ② 인접한 리터럴은 하나로 이어진다 ───────────────────── */
    printf("%s\n", "abc" "def");                 /* -> "abcdef" */
    printf("sizeof(\"abc\" \"def\") = %zu\n", sizeof("abc" "def"));

    /* 긴 문장을 줄 나눠 적을 때 — 역슬래시 없이도 된다 */
    const char *help =
        "usage: tool [options] file\n"
        "  -v   verbose\n"
        "  -o   output file\n";
    printf("%s", help);

    /* ── ③ 조각을 이름으로 두고 조립하기 ─────────────────────── */
    puts(PROGRAM_TITLE);
    printf("assembled from digits: %s\n", VERSION_FROM_NUMBERS);

    int    id   = 7;
    double temp = 36.5;
    printf("id = " ID_FMT ", temp = " TEMP_FMT "\n", id, temp);

    /* 표준 라이브러리가 같은 기법을 쓴다 — 고정 폭 정수의 서식 매크로 */
    uint64_t total = 1234567890123ULL;
    printf("total = %" PRIu64 "\n", total);

    /* ── ④ 접두사 — 인코딩을 정하는 글자 ─────────────────────── */
    printf("sizeof \"AB\"  = %zu (char)\n",    sizeof "AB");
    printf("sizeof u8\"AB\" = %zu (UTF-8)\n",  sizeof u8"AB");
    printf("sizeof L\"AB\"  = %zu (wchar_t is %zu bytes)\n",
           sizeof L"AB", sizeof(wchar_t));
    printf("sizeof u\"AB\"  = %zu (UTF-16)\n", sizeof u"AB");
    printf("sizeof U\"AB\"  = %zu (UTF-32)\n", sizeof U"AB");

    /* 접두사가 있는 것과 없는 것을 이어 붙이면 있는 쪽을 따른다 */
    const wchar_t *w = L"wide" " and narrow";
    printf("L\"wide\" \" and narrow\" -> length %zu (the result is a wide string)\n",
           wcslen(w));
    return 0;
}

실행 결과

sizeof "abcdef" = 7  (six characters + NUL)
sizeof ""       = 1  (even an empty string has its NUL)
"abcdef"[3] = d
3["abcdef"] = d   <- the subscript can be swapped (it means the same)
abcdef
sizeof("abc" "def") = 7
usage: tool [options] file
  -v   verbose
  -o   output file
my_program v3.1.2
assembled from digits: v3.1
id = 7, temp = 36.5
total = 1234567890123
sizeof "AB"  = 3 (char)
sizeof u8"AB" = 3 (UTF-8)
sizeof L"AB"  = 12 (wchar_t is 4 bytes)
sizeof u"AB"  = 6 (UTF-16)
sizeof U"AB"  = 12 (UTF-32)
L"wide" " and narrow" -> length 15 (the result is a wide string)

출력의 첫 두 줄이 그 확인이다. sizeof "abcdef"가 7이고 sizeof ""도 1이다 — 포인터의 크기(8)가 아니라 배열의 크기가 나온다. 리터럴이 포인터로 무너지는 것은 38장에서 본 그 무너짐(decay)이고, sizeof는 무너짐의 예외라 배열의 진짜 크기가 보이는 것이다.

배열이라는 사실에서 재미있는 것이 따라 나온다. 배열이니 첨자를 붙일 수 있다.

"abcdef"[3]     /* 'd' */

그리고 38장에서 배운 항등식 a[i] ≡ *(a + i)를 떠올리면, 덧셈이 교환법칙을 따르므로 *(a + i) ≡ *(i + a) ≡ i[a]가 된다. 즉

3["abcdef"]     /* 역시 'd' — 문법적으로 완전히 합법이다 */

예제가 둘 다 d를 찍는 것이 그 증거다. 실무에서 쓸 일은 없고(읽는 사람을 당황시킬 뿐이다) 난독화 대회에서나 보이는 표기지만, 배열 첨자가 문법의 장식이 아니라 포인터 산술의 다른 표기일 뿐이라는 사실을 이보다 분명하게 보여 주는 예도 없다.

42.4 이어 붙이기 — 인접한 리터럴은 하나가 된다

문자열 리터럴 둘을 나란히 적으면 컴파일러가 하나로 이어 붙인다.

"abc" "def"        /* -> "abcdef" — 쉼표도 연산자도 없다 */

예제의 sizeof("abc" "def")가 7인 것이 그 확인이다(3+3+NUL). 이 일은 전처리기가 아니라 번역 단계 6에서 일어난다(57장의 표) — 그래서 매크로가 만들어 낸 문자열 조각도 옆의 리터럴과 이어진다.

쓸모가 둘이다.

① 긴 문장을 줄 나눠 적기. 역슬래시로 줄을 잇는 것보다 깔끔하다 — 들여쓰기가 문자열 안으로 들어가지 않기 때문이다.

const char *help =
    "사용법: tool [옵션] 파일\n"
    "  -v   자세히\n"
    "  -o   출력 파일\n";

② 조각을 이름으로 두고 조립하기. 이것이 실무에서 가장 자주 보는 용법이고, 세 가지 얼굴로 나타난다.

버전 문자열 조립. 값을 한 곳에만 적고 여기저기서 엮는다.

#define MY_PROGRAM_VERSION  "v3.1.2"
#define PROGRAM_TITLE       "my_program " MY_PROGRAM_VERSION

puts(PROGRAM_TITLE);                       /* my_program v3.1.2 */
puts("build: " __DATE__ " " __TIME__);     /* 컴파일러가 주는 리터럴과도 */

버전을 올릴 때 고칠 자리가 한 줄이라는 것이 요점이다. __DATE__처럼 컴파일러가 미리 정의해 둔 리터럴(57장에서 다룬다)과도 그대로 엮이므로, 배너와 --version 출력을 이 방식으로 만드는 프로그램이 많다.

서식 조각을 이름으로. 같은 기법을 printf의 서식에 쓴 것이다.

#define ID_FMT    "%d"
#define TEMP_FMT  "%.1f"

printf("id = " ID_FMT ", temp = " TEMP_FMT "\n", id, temp);

서식 문자열이 상수여야 한다는 요구(61장의 형식 문자열 취약점, 그리고 컴파일러의 서식 검사)를 지키면서도 조각을 이름으로 관리할 수 있다. printf(fmt_string_variable, …)처럼 변수를 넘기는 순간 그 검사가 전부 사라지는 것과 대조적이다 — 이어 붙이기는 컴파일 시간에 끝나므로 결과는 여전히 하나의 리터럴이다.

표준 라이브러리도 같은 기법을 쓴다. 78장과 부록 B에서 본 <inttypes.h>의 서식 매크로가 정확히 이 규칙 위에 서 있다.

printf("total = %" PRIu64 "\n", total);

PRIu64"lu""llu"(플랫폼마다 다르다)로 정의되어 있고, 앞뒤 조각과 이어 붙어 하나의 서식 문자열이 된다. 고정 폭 정수의 서식이 플랫폼마다 다르다는 문제를 이어 붙이기 하나로 푼 것이다.

세 사례가 결국 같은 한 가지다 — 리터럴 조각에 이름을 붙여 두고 쓰는 자리에서 엮는다. 그리고 셋 다 57장의 # 연산자와 짝을 이룬다. 숫자로 관리하는 버전이라면 숫자를 문자열로 바꿔 엮을 수 있다.

#define VER_MAJOR 3
#define VER_MINOR 1
#define STR_RAW(x) #x
#define STR(x)     STR_RAW(x)                     /* 두 겹 우회(57장) */
#define VERSION    "v" STR(VER_MAJOR) "." STR(VER_MINOR)   /* "v3.1" */

반례. 변수는 이어 붙지 않는다

const char *unit = "";
printf("temp = %.1f" unit "\n", t);   /* 컴파일 오류 */

이어 붙이기는 리터럴끼리의 규칙이다. unit은 변수이므로 이어 붙을 수 없다. 변수 내용을 끼우려면 자리표시자를 하나 더 쓰는 것이 정답이다 — printf("temp = %.1f%s\n", t, unit).

같은 이유로, 이어 붙일 조각을 #define으로 둘 때는 그 매크로가 문자열 리터럴로 펼쳐져야 한다. #define UNIT ℃처럼 리터럴이 아닌 것으로 정의해 두면 이어 붙지 않는다.

42.5 접두사 — 어떤 인코딩의 리터럴인가

리터럴 앞에는 글자를 하나 붙여 어떤 문자 타입의 배열인가를 정할 수 있다.

표기원소 타입인코딩sizeof "AB"
"AB"char실행 문자 집합(대개 UTF-8)3
u8"AB"char8_t(C23) / charUTF-83
u"AB"char16_tUTF-166
U"AB"char32_tUTF-3212
L"AB"wchar_t구현이 정한다예제의 기계에서 12

표 42.1

예제가 이 표를 실제로 찍는다. L"AB"가 12바이트인 것은 이 리눅스 기계의 wchar_t가 4바이트이기 때문이고 — 윈도우에서는 2바이트라 6이 된다. 67장에서 “wchar_t의 크기가 구현마다 다르다”고 한 것이 리터럴 차원에서 드러나는 자리다.

문자 상수도 같은 접두사를 쓴다(L'A', u'A'). 그리고 이어 붙이기와 접두사가 만나면 규칙이 하나 더 있다 — 한쪽에만 접두사가 있으면 있는 쪽을 따르고(예제의 L"wide" " and narrow"가 와이드 문자열이 된다), 서로 다른 접두사끼리 이어 붙이는 것은 계약 밖이다.

실제 사례. gets — 표준에서 퇴출당한 함수

NUL 종단의 세 번째 대가(표지를 잃으면 폭주)에 얽힌, C 표준 역사상 가장 유명한 장례식이 있다. 초창기 표준 라이브러리의 gets는 “한 줄을 읽어 배열에 담는” 함수였는데 — 그릇의 크기를 받지 않았다. 입력이 그릇보다 길면 그대로 이웃 기억을 덮어쓰는, 경계 침범(38장)이 설계에 내장된 함수였던 것이다. 1988년 인터넷 최초의 대규모 웜(Morris worm)이1 이 부류의 결함을 뚫고 퍼졌고, 수십 년의 사고 끝에 C11 표준이 gets삭제했다 — 표준이 자기 함수를 공식적으로 장례 치른 드문 사례다. 25장에서 이 책이 처음부터 fgets(그릇 크기를 받는 후계자)를 가르친 사연이 이것이고 — 다음 장에서 이 사고 부류 전체를 정면으로 본다.

문. 한글 처리를 제대로 하려면 — 글자 단위로 다루려면 — 어떻게 해야 하는가?

답. 두 층을 구별하는 것이 출발점이다. 바이트 층(C 문자열의 세계)에서는 UTF-8을 그냥 바이트 열로 다루면 되고 — 복사·연결·저장·전송은 글자 경계를 몰라도 안전하다(UTF-8의 자기 동기화 설계 덕이다, 9장). 글자 층이 필요한 일(글자 수 세기, 자르기, 대소문자)은 UTF-8 해독이 필요 하고, 표준 라이브러리의 지원이 빈약한 지점이라 라이브러리를 쓰는 것이 실무다(제12부가 그런 라이브러리 하나를 다룬다). 수칙 하나만 명심하면 사고의 대부분이 예방된다: 바이트 번호로 문자열을 자르지 말 것 — 한글 음절의 허리를 자르면 깨진 바이트 열이 된다.

문자열의 정체와 비용을 알았다. 다음 장은 이 부의 전환점이다 — 경계 침범이라는 사고 부류의 해부, 그리고 입력 해석의 실패를 다루는 다섯 규율 이다. 25장에서 심은 “안전한 입력” 씨앗이 드디어 회수된다.

  1. Eugene H. Spafford. 1988. The Internet Worm Program: An Analysis. Technical Report CSD-TR-823, Purdue University, West Lafayette, IN. spaf.cerias.purdue.edu/tech-reps/823.pdf