Proven C Book←↑→

87 매크로에서 키워드로 — bool·nullptr과 그 동료들

먼저 알아야 할 것

83장 새 표준이 보탠 것과 *_s 논쟁 · 표준이 보탠 것들
31장 불리언과 비교 · 참·거짓의 타입

돌아보기

83장에서 C23이 <stdbool.h>를 사실상 불필요하게 만들었고 nullptr이 들어왔다고 표로만 지나갔다. 그런데 매크로로 잘 돌아가던 것을 왜 굳이 언어에 넣는가 — 이름만 바뀐 것 아닌가?

답. 이름만 바뀐 것이 아니다. 매크로는 전처리기가 글자를 바꾸는 것이라 타입도 범위(scope)도 없고, 사용자가 #undef할 수도, 다른 뜻으로 다시 정의할 수도 있다. 키워드는 언어의 문법 요소라 타입이 있고, 진단을 받을 수 있고, 아무도 재정의할 수 없다. 이 차이가 실제로 사고를 막는 자리가 이 장의 내용이다 — 특히 nullptr은 이름 바꾸기가 아니라 새 타입 이다.

이 장의 필요성과 맥락

C23 이 언어에 손댄 자리를 라이브러리 부의 끝에 두는 것이 이상해 보일 수 있다. 그러나 bool·nullptr 은 오랫동안 헤더의 매크로였고, 그것이 키워드가 된 이야기라 라이브러리와 언어의 경계에 놓이는 것이 맞다. 83장의 「표준이 보탠 것」 바로 뒤가 그 자리다.

이 장이 끝나면

C23이 언어에 손댄 가장 눈에 띄는 변화를 다룬다. 오랫동안 헤더의 매크로로 흉내 내던 것들 — bool·true·false, static_assert, alignas, thread_local — 이 왜 키워드로 올라섰는지, nullptr이 무엇을 막으려고 새로 생겼는지, 어떤 규칙과 제한이 따라오는지, 그리고 기존 코드를 어떤 순서로 옮기는지까지.

이 장에서 답할 질문

  1. bool을 printf로 찍을 때 어떤 서식을 쓰는가?
  2. 옛 표준으로 계속 컴파일해야 하는 프로젝트라면, 이 장은 남의 이야기인가?

87.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_assertstatic_assert<assert.h>
_Alignas·_Alignofalignas·alignof<stdalign.h>
_Thread_localthread_local<threads.h>
NULL 매크로nullptr(새 타입)<stddef.h> 등
(없음)constexpr—
(GCC 확장)typeof·typeof_unqual—

표 87.1 — 매크로에서 키워드가 된 이름들

헤더는 여전히 있고 포함해도 무해하다 — 표준이 옛 코드를 깨뜨리지 않는다는 원칙(69장의 gets 이야기)이 여기서도 지켜졌다. 다만 새로 쓰는 코드는 포함할 이유가 없다.

87.2 bool — 무엇을 막는가#

C에는 오랫동안 참·거짓 타입이 없었다. int로 흉내 냈고, 프로젝트마다 typedef int BOOL;과 #define TRUE 1이 굴러다녔다. C99가 _Bool을 들이고 <stdbool.h>가 예쁜 이름을 붙여 주었으며, C23이 그것을 키워드로 올렸다.

bool이 int와 다른 점은 이름이 아니라 변환 규칙이다.

반례. 참을 1과 비교하기

int a = isupper('A');      /* 구현에 따라 0 이 아닌 아무 값 */
int b = isupper('B');
if (a == b) { … }          /* 둘 다 참인데 값이 달라 거짓이 될 수 있다 */
if (a == 1) { … }          /* 더 나쁘다 */

72장에서 본 대로 <ctype.h>의 분류 함수는 “0이 아닌 값”만 약속한다. 참끼리 비교하려면 값이 아니라 진리값으로 좁혀야 한다.

bool a = isupper('A');     /* 여기서 1 로 정규화된다 */
bool b = isupper('B');
if (a == b) { … }          /* 안전 */

문. bool을 printf로 찍을 때 어떤 서식을 쓰는가?

답. 전용 서식이 없다. bool은 가변 인자로 넘어가며 int로 승격되므로 (63장의 승격 규칙) %d를 쓴다 — 예제가 그렇게 했다. %s와 삼항 연산자로 사람이 읽을 말을 찍는 것도 흔한 관행이다.

주의할 자리는 scanf 쪽이다. bool을 직접 받는 서식이 없으므로 int로 받아 옮겨야 한다. 그리고 sizeof(bool)은 1이 보통이지만 표준이 1이라고 약속하지는 않는다 — 구조체 배치를 계산할 때 이 값을 가정하지 않는다(47장).

bool의 남은 함정 둘. 첫째, 비트필드에 bool을 쓰면 폭이 1이라도 잘 동작하지만 배치는 구현 정의다(47장). 둘째, bool 배열은 원소마다 한 바이트를 쓴다 — C++의 vector<bool>처럼 비트로 압축하지 않는다. 비트로 압축하고 싶으면 손으로 마스크를 짜거나(29장) <stdbit.h>의 도구를 쓴다.

87.3 nullptr — 이름 바꾸기가 아니다#

4장에서 널 삼형제를 구별했다. NULL은 매크로이고, 그 정의는 구현마다 0이거나 ((void *)0)이다. 이 자유가 실제 사고를 낳았다.

사고 1 — 가변 인자에서 크기가 어긋난다. 가변 인자 함수는 인자의 타입을 모르므로 넘어온 비트를 그대로 읽는다(63장). NULL이 0으로 정의된 구현 에서 execl("/bin/ls", "ls", NULL)이라 적으면 int 0이 넘어가고, 포인터가 8바이트인 기계에서는 위쪽 4바이트가 쓰레기로 남는다. 목록의 끝을 못 알아본 함수가 폭주하는 것이다. 그래서 옛 코드는 (char *)0이라 적어야 했다. nullptr은 언제나 포인터 크기라 이 문제가 없다.

사고 2 — 정수인지 포인터인지 흐려진다. NULL이 0인 구현에서는 foo(NULL)이 정수 0을 넘기는 것과 구별되지 않는다. _Generic(63장)으로 타입에 따라 갈래를 나누는 코드에서 이 모호함이 그대로 사고가 된다. nullptr은 nullptr_t라는 자기 타입을 가지므로 갈래가 분명하다.

사고 3 — C++과의 어긋남. C++은 2011년에 같은 이유로 nullptr을 먼저 들였다. 두 언어를 오가는 헤더(56장)에서 NULL의 의미가 갈리는 자리가 있었는데, C23이 같은 낱말을 채택해 그 틈이 좁아졌다.

NULLnullptr
정체매크로(구현 정의)키워드, 타입 nullptr_t
가변 인자위험(크기 어긋남)안전
_Generic 분기모호분명
정수와 비교NULL == 0이 통할 수 있다불가 — 진단
bool 변환—된다(거짓)
재정의가능(#undef)불가

표 87.2 — C23 이 들여온 낱말

규칙 몇 가지를 못박아 둔다. nullptr은 어떤 객체 포인터 타입으로도 변환되고, 함수 포인터로도 변환된다(64장). 두 nullptr끼리, 그리고 nullptr과 아무 포인터끼리 ==·!=로 비교할 수 있다. bool로 변환하면 거짓이다. 그러나 정수와는 비교할 수 없고, 정수로 변환되지도 않는다 — nullptr == 0은 진단 대상이다. “널 포인터는 정수 0과 같은 것”이라는 옛 직관을 언어가 끊어 놓은 셈이다.

흔한 오해. “nullptr을 쓰면 널 역참조 사고가 줄어든다”

다른 종류의 문제다. nullptr이 막는 것은 널을 적는 방식에서 오는 사고(크기 어긋남, 타입 모호함)이지, 널을 역참조하는 사고가 아니다. 포인터가 널인지 확인하는 일은 여전히 사람의 몫이고(37장), 그 부담을 줄이는 것은 언어가 아니라 설계 — 널을 애초에 만들지 않는 자료구조, 반환값으로 실패를 알리는 규약(제12부) — 의 몫이다.

87.4 나머지 승격과 새 낱말#

static_assert — 컴파일 시간에 조건을 검사하고, 어기면 번역이 실패한다. 예제 첫 줄이 그것이다. 실행 시간 assert(80장)와 성격이 다르다 — 이쪽은 “이 기계에서 이 코드가 성립하는가”를 묻는 도구라, 타입 크기·정렬·구조체 배치 가정을 코드에 못박는 데 쓴다. C23에서는 메시지를 생략할 수도 있다.

alignas·alignof — 4장에서 본 정렬을 언어로 다루는 낱말이다. 캐시 라인에 맞춰 배치하거나(13장의 거짓 공유 회피), 하드웨어가 요구하는 정렬을 맞출 때 쓴다.

thread_local — 갈래마다 따로 존재하는 변수를 만든다. 85장에서 본 공유의 문제를 공유하지 않음으로 피하는 도구다. errno가 실은 이 방식으로 구현된다(80장).

constexpr — 예제에서 본 대로 진짜 상수를 만든다. const int는 상수 식이 아니라서 배열 크기나 case 라벨에 못 썼고(24장), 그래서 그 자리는 오랫동안 #define의 몫이었다. constexpr은 그 자리를 타입 있는 이름으로 되찾아 준다 — 매크로가 언어로 승격되는 이 장의 주제가 여기서도 반복된다.

typeof — 식의 타입을 그대로 받아 적는다. GCC 확장으로 30년 넘게 쓰이던 것이 표준이 됐다. 매크로에서 임시 변수를 선언할 때 특히 요긴하다.

실제 사례. 밑줄 붙은 이름들의 사연 — _Bool은 왜 그렇게 생겼나

왜 처음부터 bool이라 하지 않았는가. 표준이 새 키워드를 만들면 그 낱말을 이미 이름으로 쓰던 기존 코드가 전부 깨지기 때문이다. 세상에는 typedef int bool;이나 struct bool { … };을 담은 코드가 산더미처럼 있었다.

그래서 표준은 사용자가 쓸 수 없도록 예약해 둔 이름들(59장의 예약 표) — 밑줄 하나에 대문자로 시작하는 이름 — 을 쓴다. _Bool, _Static_assert, _Alignas, _Atomic(85장), _Generic이 전부 이 규칙의 산물이다. 그리고 헤더가 예쁜 이름을 매크로로 얹어 주어, 포함한 사람만 짧은 이름을 쓰게 했다. 포함하지 않은 옛 코드는 아무것도 깨지지 않는다.

C23의 승격은 “이제 짧은 이름을 써도 될 만큼 시간이 지났다”는 판단이다. 그럼에도 밑줄 이름들은 여전히 살아 있어서, 두 이름은 같은 것을 가리킨다. 옛 코드를 깨뜨리지 않으려는 표준의 습성이 이름에까지 남은 사례다 — 69장의 gets 이야기와 같은 성격이고, 반대 방향의 결정이다.

87.5 속성 — 타입이 담지 못하는 뜻을 적는다#

승격된 낱말들이 값과 타입의 자리를 되찾았다면, C23 이 함께 들인 속성(attribute)은 다른 자리를 채운다 — 「이 함수의 결과를 버리지 말라」, 「여기서 흘러내리는 것은 일부러다」처럼 타입에 담기지 않는 뜻이다. 그전까지 이런 말은 컴파일러마다 다른 확장(__attribute__((warn_unused_result)))으로만 할 수 있었다.

examples/ch82/attributes.c

/* C23 의 속성 — 타입이 담지 못하는 「뜻」을 컴파일러에게 적어 두는 법 */
#include <stdio.h>
#include <stdlib.h>

/* ① nodiscard — 돌려준 값을 버리면 안 되는 함수.
   실패를 값으로 알리는 함수에 붙이면, 확인을 잊은 코드가 경고가 된다. */
[[nodiscard]] static int checked_add(int a, int b, int *out)
{
    if (b > 0 && a > 2147483647 - b) return 0;   /* 넘침: 실패 */
    *out = a + b;
    return 1;
}

/* ② maybe_unused — 빌드 구성에 따라 안 쓰일 수도 있는 것에 미리 붙인다 */
static void log_line([[maybe_unused]] const char *tag, const char *msg)
{
#ifdef VERBOSE
    printf("  [%s] %s\n", tag, msg);
#else
    printf("  %s\n", msg);
#endif
}

/* ③ noreturn — 돌아오지 않는 함수. 부르는 쪽의 뒷줄은 죽은 코드가 된다 */
[[noreturn]] static void die(const char *why)
{
    printf("  fatal: %s\n", why);
    exit(EXIT_FAILURE);
}

static const char *classify(int n)
{
    switch (n) {
    case 0:
        printf("  zero, and it keeps going\n");
        [[fallthrough]];          /* ④ 일부러 흘린다는 표시 */
    case 1:
        return "small";
    default:
        return "large";
    }
}

int main(void)
{
    int sum = 0;

    if (checked_add(2000000000, 2000000000, &sum))
        printf("  sum: %d\n", sum);
    else
        printf("  overflow refused\n");

    if (checked_add(2, 3, &sum))
        printf("  sum: %d\n", sum);

    log_line("main", "attributes carry intent, not types");
    printf("  classify(0) = %s\n", classify(0));
    printf("  classify(9) = %s\n", classify(9));

    if (sum != 5) die("checked_add lost a value");
    return 0;
}

실행 결과

  overflow refused
  sum: 5
  attributes carry intent, not types
  zero, and it keeps going
  classify(0) = small
  classify(9) = large

문법은 대괄호 둘이다 — [[이름]], 인자가 있으면 [[이름(인자)]]. 놓는 자리는 꾸미려는 대상 앞이고, 매개변수 하나에도 붙는다. 표준이 정한 것은 일곱이다.

속성무엇을 말하는가이 책에서
[[nodiscard]]돌려준 값을 어떤 식으로든 다루라 — 버리면 경고44·53·92장
[[deprecated]]아직 되지만 새 코드에서 권하지 않는다. 문자열로 이유를 적을 수 있다이 절
[[maybe_unused]]안 쓰일 수도 있으니 그걸로 나무라지 말라82장
[[fallthrough]]switch 에서 흘러내리는 것이 일부러다32장
[[noreturn]]이 함수는 돌아오지 않는다25·82장
[[unsequenced]]같은 입력이면 같은 값이고, 밖의 무엇에도 기대지 않는다이 절
[[reproducible]]같은 입력이면 같은 값이지만, 부르는 차례는 여전히 뜻이 있다이 절

표 87.3 — 표준이 정한 일곱 속성

앞의 다섯은 C++ 에서 건너온 것이고, 뒤의 둘은 C23 이 스스로 만든 것이다. 이 둘은 함수의 성질을 적어 최적화기에게 「같은 호출을 두 번 하지 않아도 된다」를 알리는 쪽이라, 다른 다섯과 결이 다르다.

87.5.1 속성은 무엇을 하는가 — 재어 본다#

속성은 코드가 하는 일을 바꾸지 않는다. 진단을 바꾼다. 세 개를 어겨 보면 이 기계의 두 컴파일러가 이렇게 말한다.1

gcc:   ignoring return value of 'check', declared with attribute
       'nodiscard' [-Wunused-result]
       'old_check' is deprecated: use check instead [-Wdeprecated-declarations]
       'unsequenced' attribute ignored [-Wattributes]

clang: ignoring return value of function declared with 'nodiscard' attribute
       [-Wunused-result]
       'old_check' is deprecated: use check instead [-Wdeprecated-declarations]
       unknown attribute 'unsequenced' ignored [-Wunknown-attributes]

마지막 줄이 중요하다. ★ 두 컴파일러 모두 [[unsequenced]] 를 아직 구현하지 않았고, 그래서 그냥 무시한다. 표준에 있다고 컴파일러에 있는 것은 아니다 (19장에서 본 격차가 여기서도 나타난다). 그런데 무시해도 프로그램은 멀쩡히 돈다 — 그것이 다음 규칙 덕이다.

87.5.2 모르는 속성은 무시한다#

표준은 구현이 모르는 속성을 만나면 무시하라고 정한다. 속성 문법이 「괄호만 짝이 맞으면 아무 토큰이나」를 허용하는 것도 그래서다(부록 E). 이 한 줄이 속성을 이식성 있는 장치로 만든다 — 컴파일러 확장을 쓰려면 #ifdef 로 감싸야 했지만, 속성은 그냥 적어 두면 아는 컴파일러만 알아듣는다.

확장은 접두사로 적는다 — [[gnu::packed]], [[clang::overloadable]] 처럼. 이름 앞에 임자를 밝혀 두어 표준의 이름들과 부딪히지 않게 한 것이다.

반례. 속성으로 계약을 대신하기

[[unsequenced]] 는 「이 함수는 밖의 무엇에도 기대지 않는다」는 약속이지 검사가 아니다. 어기면 컴파일러는 그 약속을 믿고 호출을 지우거나 합치므로, 틀린 약속은 조용히 틀린 프로그램이 된다 — 53장에서 본 계약의 성질 그대로다. 붙이기 전에 「정말 그런가」를 사람이 확인해야 한다.

[[nodiscard]] 는 반대다. 어겨도 프로그램의 뜻이 바뀌지 않고 경고만 난다. 그래서 이쪽은 의심스러우면 붙이는 쪽이 이득이고, 저쪽은 확실할 때만 붙인다.

87.6 기존 코드를 어떻게 옮기는가#

한꺼번에 바꿀 필요는 없다. 순서를 정해 두면 위험이 거의 없다.

  1. 먼저 컴파일러 판을 정한다. -std=c23(또는 c2x)을 쓸 수 있는지, 대상 플랫폼 전부에서 되는지 확인한다. 하나라도 안 되면 아래 5번의 껍데기 전략으로 간다.
  2. 자체 BOOL·TRUE·FALSE를 걷어낸다. 프로젝트 헤더의 typedef int BOOL;을 지우고 bool로 바꾼다. 이때 값 비교를 하던 곳이 없는지 함께 본다(위 반례) — 오히려 버그가 드러나는 자리다.
  3. NULL을 nullptr로 바꾼다. 기계적 치환으로 대부분 끝나지만, 두 자리는 손으로 확인한다. 가변 인자 호출(여기서 실제 버그가 고쳐진다)과, NULL을 정수 0처럼 쓰던 코드(여기서 컴파일 오류가 난다 — 잘된 일이다).
  4. _Static_assert·_Alignas·_Thread_local을 밑줄 없는 이름으로. 같은 것의 다른 표기이므로 위험이 없다. 이어서 이제 필요 없어진 <stdbool.h>·<stdalign.h> 포함을 정리한다.
  5. 옛 판을 함께 지원해야 하면 껍데기를 한 자리에. 86장에서 한 것과 같다.

    #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의 낱말들은 그 위험을 언어가 대신 막아 주는 것일 뿐, 위험 자체는 예전부터 거기 있었다.

복습 정리

기억할 것요지
승격의 뜻매크로(글자 치환) → 키워드(타입·진단·재정의 불가)
bool0 아닌 값을 1로 정규화한다 — 참끼리 비교가 안전해진다
bool 출력%d(가변 인자에서 int로 승격)
nullptr이름이 아니라 새 타입. 가변 인자·_Generic 사고를 막는다
nullptr 제한정수와 비교·변환 불가. bool로는 거짓
constexpr#define 상수를 타입 있는 이름으로 되찾는다
밑줄 이름옛 코드를 깨지 않으려는 예약 이름 공간의 산물
옮기는 순서판 확인 → 자체 BOOL 제거 → NULL 치환 → 밑줄 정리 → 껍데기

표 87.4 — 매크로에서 키워드로 — 기억할 것

스물한 장에 걸쳐 표준 라이브러리와 최신 표준의 지형을 걸었다. 표준이 무엇을 약속하고 무엇을 약속하지 않는지, 어디가 미끄럽고 왜 그런지를 보았다.

이 부의 남은 두 장은 한 층 아래로 내려간다. 46장에서 “비싸다”고만 하고 지나간 자리 — 프로그램의 기억이 운영체제와 임베디드 칩에서 각각 어떤 배치로 놓이는지(88장), 그리고 그 배치의 힙 구역에서 할당자가 실제로 무슨 일을 하는지(89장)다. 그 두 장이 다음 부의 설계를 이해하는 바탕이 된다.

주

  1. gcc (Debian 14.2.0-19) 14.2.0 · clang 22.1, -std=c23 -Wall -Wextra. ↩