42 문자열
먼저 알아야 할 것
돌아보기
9장에서 C가 택한 문자열 표현은 “끝에 표지를 세우는” NUL 종단이라 했고, 그 대가 세 가지(길이는 세어야 안다, NUL을 내용에 못 담는다, 표지를 잃으면 폭주)를 예고했다. 이제 배열(38장)을 배웠으니 — C 문자열의 정확한 정체를 스스로 정의해 보면?
답. char 배열인데, 어딘가에 값 0인 바이트(NUL 문자, '\0')가 있고, “문자열”이란 시작부터 그 표지 직전까지를 뜻한다는 약속 — 이것이 전부다. 별도의 문자열 타입은 없다. 배열, 그리고 약속. 이 장은 그 약속의 사용법과 비용을 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 왜 C는
const를 붙이지 않았는가? - 최신 표준과 앞으로의 표준에서는 어떤가?
- 한글 처리를 제대로 하려면 — 글자 단위로 다루려면 — 어떻게 해야 하는가?
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) / char | UTF-8 | 3 |
u"AB" | char16_t | UTF-16 | 6 |
U"AB" | char32_t | UTF-32 | 12 |
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장에서 심은 “안전한 입력” 씨앗이 드디어 회수된다.
주
- 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↩