82 매크로에서 키워드로 — bool·nullptr과 그 동료들
먼저 알아야 할 것
*_s 논쟁 · 표준이 보탠 것들돌아보기
78장에서 C23이 <stdbool.h>를 사실상 불필요하게 만들었고 nullptr이 들어왔다고 표로만 지나갔다. 그런데 매크로로 잘 돌아가던 것을 왜 굳이 언어에 넣는가 — 이름만 바뀐 것 아닌가?
답. 이름만 바뀐 것이 아니다. 매크로는 전처리기가 글자를 바꾸는 것이라 타입도 범위(scope)도 없고, 사용자가 #undef할 수도, 다른 뜻으로 다시 정의할 수도 있다. 키워드는 언어의 문법 요소라 타입이 있고, 진단을 받을 수 있고, 아무도 재정의할 수 없다. 이 차이가 실제로 사고를 막는 자리가 이 장의 내용이다 — 특히 nullptr은 이름 바꾸기가 아니라 새 타입 이다.
이 장의 필요성과 맥락
bool·nullptr 은 오랫동안 헤더의 매크로였고, 그것이 키워드가 된 이야기라 라이브러리와 언어의 경계에 놓이는 것이 맞다. 78장의 「표준이 보탠 것」 바로 뒤가 그 자리다.이 장이 끝나면
bool·true·false, static_assert, alignas, thread_local — 이 왜 키워드로 올라섰는지, nullptr이 무엇을 막으려고 새로 생겼는지, 어떤 규칙과 제한이 따라오는지, 그리고 기존 코드를 어떤 순서로 옮기는지까지.이 장에서 답할 질문
bool을printf로 찍을 때 어떤 서식을 쓰는가?- 옛 표준으로 계속 컴파일해야 하는 프로젝트라면, 이 장은 남의 이야기인가?
82.1 무엇이 승격됐나
examples/ch82/keywords.c
/* C23 에서 키워드로 승격된 것들 — 무엇이 달라졌나 */
#include <stdio.h>
/* 승격 전에는 이 줄들이 <stdbool.h>, <stdalign.h>, <assert.h> 를 요구했다 */
static_assert(sizeof(int) >= 4, "this code assumes int is at least 32 bits");
/* nullptr 은 자기 타입(nullptr_t)을 가진다 — 0 도 (void *)0 도 아니다 */
static void take_ptr(int *p) { printf(" pointer: %s\n", p ? "has a value" : "null"); }
/* 가변 인자에서 NULL 과 nullptr 의 차이가 드러나는 자리 */
static void print_all(const char *first, ...)
{
printf(" %s ...\n", first);
}
int main(void)
{
bool ready = true; /* _Bool 도 매크로도 아닌 진짜 키워드 */
printf("sizeof(bool) = %zu, true = %d, false = %d\n", sizeof(bool), true, false);
printf("ready = %d, !ready = %d\n", ready, !ready);
/* bool 은 0 아닌 값을 전부 1 로 좁힌다 — int 로 흉내 낼 수 없는 성질 */
bool b = 42;
int i = 42;
printf("bool b = 42 -> %d, int i = 42 -> %d\n", b, i);
int x = 7;
int *p = &x;
int *np = nullptr;
take_ptr(p);
take_ptr(np);
printf("nullptr compared with nullptr: %d, compared with a pointer: %d\n", nullptr == nullptr, p == nullptr);
/* typeof: 이름 없는 타입을 그대로 받아 적는다 */
typeof(x) y = x * 2;
printf("typeof(x) y = %d\n", y);
/* constexpr: 진짜 상수 — 배열 크기에도, switch 라벨에도 쓸 수 있다 */
constexpr int LANES = 4;
int lane[LANES];
printf("constexpr LANES = %d, array element count = %zu\n", LANES, sizeof lane / sizeof lane[0]);
print_all("pass nullptr through the variable arguments", nullptr);
return 0;
}
실행 결과
sizeof(bool) = 1, true = 1, false = 0
ready = 1, !ready = 0
bool b = 42 -> 1, int i = 42 -> 42
pointer: has a value
pointer: null
nullptr compared with nullptr: 1, compared with a pointer: 0
typeof(x) y = 14
constexpr LANES = 4, array element count = 4
pass nullptr through the variable arguments ...
한 표로 정리한다. 왼쪽은 C17까지의 모습, 오른쪽이 C23이다.
| 예전 | C23 | 요구하던 헤더 |
|---|---|---|
_Bool + bool 매크로 | bool 키워드 | <stdbool.h> |
true·false 매크로(=1, 0) | true·false 키워드(bool 타입) | <stdbool.h> |
_Static_assert | static_assert | <assert.h> |
_Alignas·_Alignof | alignas·alignof | <stdalign.h> |
_Thread_local | thread_local | <threads.h> |
NULL 매크로 | nullptr(새 타입) | <stddef.h> 등 |
| (없음) | constexpr | — |
| (GCC 확장) | typeof·typeof_unqual | — |
표 82.1
헤더는 여전히 있고 포함해도 무해하다 — 표준이 옛 코드를 깨뜨리지 않는다는 원칙(64장의 gets 이야기)이 여기서도 지켜졌다. 다만 새로 쓰는 코드는 포함할 이유가 없다.
82.2 bool — 무엇을 막는가
C에는 오랫동안 참·거짓 타입이 없었다. int로 흉내 냈고, 프로젝트마다 typedef int BOOL;과 #define TRUE 1이 굴러다녔다. C99가 _Bool을 들이고 <stdbool.h>가 예쁜 이름을 붙여 주었으며, C23이 그것을 키워드로 올렸다.
bool이 int와 다른 점은 이름이 아니라 변환 규칙이다.
- 0이 아닌 값은 모두 1로 좁혀진다. 예제의
bool b = 42;가1을 찍은 것이 그것이다.int i = 42는 42 그대로다. - 그래서 두 참을 비교해도 참이다.
int흉내 시절의 고전적 버그가 여기서 사라진다.
반례. 참을 1과 비교하기
int a = isupper('A'); /* 구현에 따라 0 이 아닌 아무 값 */
int b = isupper('B');
if (a == b) { … } /* 둘 다 참인데 값이 달라 거짓이 될 수 있다 */
if (a == 1) { … } /* 더 나쁘다 */67장에서 본 대로 <ctype.h>의 분류 함수는 “0이 아닌 값”만 약속한다. 참끼리 비교하려면 값이 아니라 진리값으로 좁혀야 한다.
bool a = isupper('A'); /* 여기서 1 로 정규화된다 */
bool b = isupper('B');
if (a == b) { … } /* 안전 */문. bool을 printf로 찍을 때 어떤 서식을 쓰는가?
답. 전용 서식이 없다. bool은 가변 인자로 넘어가며 int로 승격되므로 (58장의 승격 규칙) %d를 쓴다 — 예제가 그렇게 했다. %s와 삼항 연산자로 사람이 읽을 말을 찍는 것도 흔한 관행이다.
주의할 자리는 scanf 쪽이다. bool을 직접 받는 서식이 없으므로 int로 받아 옮겨야 한다. 그리고 sizeof(bool)은 1이 보통이지만 표준이 1이라고 약속하지는 않는다 — 구조체 배치를 계산할 때 이 값을 가정하지 않는다(46장).
bool의 남은 함정 둘. 첫째, 비트필드에 bool을 쓰면 폭이 1이라도 잘 동작하지만 배치는 구현 정의다(46장). 둘째, bool 배열은 원소마다 한 바이트를 쓴다 — C++의 vector<bool>처럼 비트로 압축하지 않는다. 비트로 압축하고 싶으면 손으로 마스크를 짜거나(28장) <stdbit.h>의 도구를 쓴다.
82.3 nullptr — 이름 바꾸기가 아니다
6장에서 널 삼형제를 구별했다. NULL은 매크로이고, 그 정의는 구현마다 0이거나 ((void *)0)이다. 이 자유가 실제 사고를 낳았다.
사고 1 — 가변 인자에서 크기가 어긋난다. 가변 인자 함수는 인자의 타입을 모르므로 넘어온 비트를 그대로 읽는다(58장). NULL이 0으로 정의된 구현 에서 execl("/bin/ls", "ls", NULL)이라 적으면 int 0이 넘어가고, 포인터가 8바이트인 기계에서는 위쪽 4바이트가 쓰레기로 남는다. 목록의 끝을 못 알아본 함수가 폭주하는 것이다. 그래서 옛 코드는 (char *)0이라 적어야 했다. nullptr은 언제나 포인터 크기라 이 문제가 없다.
사고 2 — 정수인지 포인터인지 흐려진다. NULL이 0인 구현에서는 foo(NULL)이 정수 0을 넘기는 것과 구별되지 않는다. _Generic(58장)으로 타입에 따라 갈래를 나누는 코드에서 이 모호함이 그대로 사고가 된다. nullptr은 nullptr_t라는 자기 타입을 가지므로 갈래가 분명하다.
사고 3 — C++과의 어긋남. C++은 2011년에 같은 이유로 nullptr을 먼저 들였다. 두 언어를 오가는 헤더(54장)에서 NULL의 의미가 갈리는 자리가 있었는데, C23이 같은 낱말을 채택해 그 틈이 좁아졌다.
NULL | nullptr | |
|---|---|---|
| 정체 | 매크로(구현 정의) | 키워드, 타입 nullptr_t |
| 가변 인자 | 위험(크기 어긋남) | 안전 |
_Generic 분기 | 모호 | 분명 |
| 정수와 비교 | NULL == 0이 통할 수 있다 | 불가 — 진단 |
bool 변환 | — | 된다(거짓) |
| 재정의 | 가능(#undef) | 불가 |
표 82.2
규칙 몇 가지를 못박아 둔다. nullptr은 어떤 객체 포인터 타입으로도 변환되고, 함수 포인터로도 변환된다(59장). 두 nullptr끼리, 그리고 nullptr과 아무 포인터끼리 ==·!=로 비교할 수 있다. bool로 변환하면 거짓이다. 그러나 정수와는 비교할 수 없고, 정수로 변환되지도 않는다 — nullptr == 0은 진단 대상이다. “널 포인터는 정수 0과 같은 것”이라는 옛 직관을 언어가 끊어 놓은 셈이다.
흔한 오해. “nullptr을 쓰면 널 역참조 사고가 줄어든다”
nullptr이 막는 것은 널을 적는 방식에서 오는 사고(크기 어긋남, 타입 모호함)이지, 널을 역참조하는 사고가 아니다. 포인터가 널인지 확인하는 일은 여전히 사람의 몫이고(36장), 그 부담을 줄이는 것은 언어가 아니라 설계 — 널을 애초에 만들지 않는 자료구조, 반환값으로 실패를 알리는 규약(제12부) — 의 몫이다.82.4 나머지 승격과 새 낱말
static_assert — 컴파일 시간에 조건을 검사하고, 어기면 번역이 실패한다. 예제 첫 줄이 그것이다. 실행 시간 assert(75장)와 성격이 다르다 — 이쪽은 “이 기계에서 이 코드가 성립하는가”를 묻는 도구라, 타입 크기·정렬·구조체 배치 가정을 코드에 못박는 데 쓴다. C23에서는 메시지를 생략할 수도 있다.
alignas·alignof — 6장에서 본 정렬을 언어로 다루는 낱말이다. 캐시 라인에 맞춰 배치하거나(11장의 거짓 공유 회피), 하드웨어가 요구하는 정렬을 맞출 때 쓴다.
thread_local — 갈래마다 따로 존재하는 변수를 만든다. 80장에서 본 공유의 문제를 공유하지 않음으로 피하는 도구다. errno가 실은 이 방식으로 구현된다(75장).
constexpr — 예제에서 본 대로 진짜 상수를 만든다. const int는 상수 식이 아니라서 배열 크기나 case 라벨에 못 썼고(23장), 그래서 그 자리는 오랫동안 #define의 몫이었다. constexpr은 그 자리를 타입 있는 이름으로 되찾아 준다 — 매크로가 언어로 승격되는 이 장의 주제가 여기서도 반복된다.
typeof — 식의 타입을 그대로 받아 적는다. GCC 확장으로 30년 넘게 쓰이던 것이 표준이 됐다. 매크로에서 임시 변수를 선언할 때 특히 요긴하다.
실제 사례. 밑줄 붙은 이름들의 사연 — _Bool은 왜 그렇게 생겼나
왜 처음부터 bool이라 하지 않았는가. 표준이 새 키워드를 만들면 그 낱말을 이미 이름으로 쓰던 기존 코드가 전부 깨지기 때문이다. 세상에는 typedef int bool;이나 struct bool { … };을 담은 코드가 산더미처럼 있었다.
그래서 표준은 사용자가 쓸 수 없도록 예약해 둔 이름들(55장의 예약 표) — 밑줄 하나에 대문자로 시작하는 이름 — 을 쓴다. _Bool, _Static_assert, _Alignas, _Atomic(80장), _Generic이 전부 이 규칙의 산물이다. 그리고 헤더가 예쁜 이름을 매크로로 얹어 주어, 포함한 사람만 짧은 이름을 쓰게 했다. 포함하지 않은 옛 코드는 아무것도 깨지지 않는다.
C23의 승격은 “이제 짧은 이름을 써도 될 만큼 시간이 지났다”는 판단이다. 그럼에도 밑줄 이름들은 여전히 살아 있어서, 두 이름은 같은 것을 가리킨다. 옛 코드를 깨뜨리지 않으려는 표준의 습성이 이름에까지 남은 사례다 — 64장의 gets 이야기와 같은 성격이고, 반대 방향의 결정이다.
82.5 기존 코드를 어떻게 옮기는가
한꺼번에 바꿀 필요는 없다. 순서를 정해 두면 위험이 거의 없다.
- 먼저 컴파일러 판을 정한다.
-std=c23(또는c2x)을 쓸 수 있는지, 대상 플랫폼 전부에서 되는지 확인한다. 하나라도 안 되면 아래 5번의 껍데기 전략으로 간다. - 자체
BOOL·TRUE·FALSE를 걷어낸다. 프로젝트 헤더의typedef int BOOL;을 지우고bool로 바꾼다. 이때 값 비교를 하던 곳이 없는지 함께 본다(위 반례) — 오히려 버그가 드러나는 자리다. NULL을nullptr로 바꾼다. 기계적 치환으로 대부분 끝나지만, 두 자리는 손으로 확인한다. 가변 인자 호출(여기서 실제 버그가 고쳐진다)과,NULL을 정수 0처럼 쓰던 코드(여기서 컴파일 오류가 난다 — 잘된 일이다)._Static_assert·_Alignas·_Thread_local을 밑줄 없는 이름으로. 같은 것의 다른 표기이므로 위험이 없다. 이어서 이제 필요 없어진<stdbool.h>·<stdalign.h>포함을 정리한다.옛 판을 함께 지원해야 하면 껍데기를 한 자리에. 81장에서 한 것과 같다.
#if __STDC_VERSION__ < 202311L # include <stdbool.h> # define nullptr ((void *)0) /* 완전한 대체는 아니다 — 아래 주의 */ # define static_assert _Static_assert #endif이
nullptr껍데기는 타입까지 흉내 내지는 못한다._Generic분기나 타입 검사를 하는 코드에서는 기대와 다르게 동작할 수 있으므로, 그런 코드가 있다면 껍데기 대신 판을 올리는 쪽이 옳다.
문. 옛 표준으로 계속 컴파일해야 하는 프로젝트라면, 이 장은 남의 이야기인가?
답. 아니다 — 두 가지가 남는다. 첫째, 읽는 능력이다. 새 코드와 라이브러리가 이 낱말들을 쓰기 시작했으므로, constexpr이나 nullptr이 보일 때 뜻을 알아야 한다. 둘째, 지금 고쳐 둘 것이다. NULL을 가변 인자에 넘기며 캐스트를 빠뜨린 자리, 참을 == 1로 비교하는 자리, #define으로 상수를 만들며 타입을 잃은 자리 — 이것들은 옛 표준에서도 이미 위험하다. C23의 낱말들은 그 위험을 언어가 대신 막아 주는 것일 뿐, 위험 자체는 예전부터 거기 있었다.
복습 정리
| 기억할 것 | 요지 |
|---|---|
| 승격의 뜻 | 매크로(글자 치환) → 키워드(타입·진단·재정의 불가) |
bool | 0 아닌 값을 1로 정규화한다 — 참끼리 비교가 안전해진다 |
bool 출력 | %d(가변 인자에서 int로 승격) |
nullptr | 이름이 아니라 새 타입. 가변 인자·_Generic 사고를 막는다 |
nullptr 제한 | 정수와 비교·변환 불가. bool로는 거짓 |
constexpr | #define 상수를 타입 있는 이름으로 되찾는다 |
| 밑줄 이름 | 옛 코드를 깨지 않으려는 예약 이름 공간의 산물 |
| 옮기는 순서 | 판 확인 → 자체 BOOL 제거 → NULL 치환 → 밑줄 정리 → 껍데기 |
표 82.3
열세 장에 걸쳐 표준 라이브러리와 최신 표준의 지형을 걸었다. 표준이 무엇을 약속하고 무엇을 약속하지 않는지, 어디가 미끄럽고 왜 그런지를 보았다.
이 부의 남은 두 장은 한 층 아래로 내려간다. 45장에서 “비싸다”고만 하고 지나간 자리 — 프로그램의 기억이 운영체제와 임베디드 칩에서 각각 어떤 배치로 놓이는지(83장), 그리고 그 배치의 힙 구역에서 할당자가 실제로 무슨 일을 하는지(84장)다. 그 두 장이 다음 부의 설계를 이해하는 바탕이 된다.