Proven C BookEnglish GitHub

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

먼저 알아야 할 것

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

돌아보기

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

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

이 장의 필요성과 맥락

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

이 장이 끝나면

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

이 장에서 답할 질문

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

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_assertstatic_assert<assert.h>
_Alignas·_Alignofalignas·alignof<stdalign.h>
_Thread_localthread_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이 그것을 키워드로 올렸다.

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

반례. 참을 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) {}          /* 안전 */

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

답. 전용 서식이 없다. 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장). NULL0으로 정의된 구현 에서 execl("/bin/ls", "ls", NULL)이라 적으면 int 0이 넘어가고, 포인터가 8바이트인 기계에서는 위쪽 4바이트가 쓰레기로 남는다. 목록의 끝을 못 알아본 함수가 폭주하는 것이다. 그래서 옛 코드는 (char *)0이라 적어야 했다. nullptr은 언제나 포인터 크기라 이 문제가 없다.

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

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

NULLnullptr
정체매크로(구현 정의)키워드, 타입 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 기존 코드를 어떻게 옮기는가

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

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

복습 정리

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

표 82.3

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

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