Proven C BookEnglish GitHub

36 널 — 삼형제의 정식 취급

먼저 알아야 할 것

6장 주소의 특수 지식 · 0번지의 특별함
35장 객체, 주소, 포인터 · 포인터는 무엇을 가리키는 값이다

돌아보기

6장에서 “널 포인터(null pointer)의 내부 표현은 0이 아닐 수 있다”는 역사(Prime 50, CDC Cyber 180)를 보았다. 그러면 소스 코드에서 p == 0이라는 비교는 — 그런 기계에서 깨지는가?

답. 깨지지 않는다 — 그것이 표준의 약속이다. 소스의 정수 상수 0은 포인터 문맥에서 널 포인터 상수로 읽히고, 컴파일러가 그 기계의 실제 널 표현과 비교하도록 번역한다. 기호(소스의 0)와 표현(기계 속 비트)의 분리를 컴파일러가 지켜 주는 것이다. 깨지는 것은 다른 종류의 코드다 — “표현이 곧 0”이라고 비트 차원에서 가정하는 코드. 이 함정을 뒤에서 본다.

이 장의 필요성과 맥락

6장에서 얼굴만 익혀 둔 널 삼형제를 정식으로 취급한다. 포인터를 배운 바로 다음 자리인 것이 핵심이다 — 포인터를 손에 쥔 직후가 널 역참조 사고를 가장 처음 만나는 때이기 때문이다. 안전 수칙은 사고보다 먼저 와야 한다.

이 장이 끝나면

6장에서 얼굴만 익힌 널 삼형제 — 널 포인터, NULL/nullptr, NUL 문자 — 를 문법과 실무 규칙으로 정식 취급한다. “비어 있음”을 다루는 법은 포인터 안전 수칙의 첫 장이다.

이 장에서 답할 질문

  1. NUL 문자('\0') 쪽은 정식 취급이 언제인가 — 삼형제 중 하나가 아직 남았다.

36.1 nullptr — 비어 있음의 이름

포인터 변수는 선언 즉시 무언가를 가리키게 하거나, 아직 대상이 없다면 비어 있음을 명시해야 한다(23장의 초기화 규칙이 포인터에서는 더욱 사활적이다 — 쓰레기 주소를 따라가는 것이 최악의 사고이므로). 비어 있음의 표기는 두 가지가 통용된다 — 전통의 NULL 매크로와, C23이 들여온 키워드 nullptr. 이 책은 nullptr를 쓴다: NULL은 역사적으로 정의가 구현마다 달라 미묘한 함정(가변 인자 등)이 있었고, nullptr는 타입까지 명확한 현대적 표기다.

examples/ch36/null.c

#include <stdio.h>

int main(void)
{
    int *p = nullptr;           /* 아직 아무 데도 가리키지 않는다 */

    if (p == nullptr) {
        printf("p is empty for now\n");
    }

    int n = 7;
    p = &n;
    if (p != nullptr) {
        printf("now p points to %d\n", *p);
    }
    return 0;
}

실행 결과

p is empty for now
now p points to 7

수칙도 이 시연 그대로다 — 포인터를 쓰기 전에 비어 있는지 확인한다. 비어 있는 포인터를 역참조하는 것(*p)은 계약 밖(정의되지 않은 동작) 이고, 6장에서 배운 대로 보호 모드 환경에서는 대개 그 자리에서 프로그램이 붕괴한다 — 시끄럽게 죽는 것이 그나마 친절이라는 것도 6장의 이야기 그대로다.

실제 사례. 십억 달러의 실수 — 널의 발명자가 남긴 사과

“비어 있음”이라는 값 자체가 논쟁의 역사를 가졌다. 널 참조를 1965년 ALGOL 계열 언어에 처음 도입한 토니 호어(Tony Hoare — 31장 퀵소트 계열 알고리즘의 그 호어다)는 2009년의 강연에서 그것을 “나의 십억 달러짜리 실수”라 불렀다 — “구현하기 쉽다는 유혹을 참지 못했고, 그 뒤 40년간 널 역참조가 일으킨 붕괴와 취약점의 비용이 십억 달러를 넘을 것”이라는 자기 고발이다. 현대 언어들(Rust, Swift, Kotlin)이 “비어 있을 수 있음”을 타입으로 강제 검사하게 진화한 것은 이 반성의 직계 후손이다. C에는 그 강제가 없다 — 검사는 문화와 규율의 몫이고, 그래서 이 책은 검사를 관용구로 못박는다.

36.2 어디까지 0을 써도 되는가 — 널 포인터 상수의 정확한 정의

6장에서 「소스의 0은 기호」라고 했다. 그 기호의 자격을 표준이 정확히 정해 두었다. 널 포인터 상수(null pointer constant)는 다음 둘 중 하나다.

여기에 C23이 키워드 nullptr를 더했다. 그래서 아래 다섯 가지는 모두 같은 뜻이다.

적는 법널 포인터 상수인가비고
0그렇다가장 오래된 표기
0L, 0u, '\0'그렇다값이 0인 정수 상수식이면 된다
(void *)0그렇다NULL이 흔히 이렇게 정의된다
NULL그렇다구현이 위 둘 중 하나로 정의한 매크로
nullptr그렇다C23. 타입이 nullptr_t로 분명하다
const int zero = 0;zero아니다상수식이 아니라 변수 — 컴파일 오류
실행 중에 0이 된 int 변수아니다정수를 포인터에 넣는 것은 별개의 이야기

표 36.1

마지막 두 줄이 요점이다. 자격은 값이 0인지가 아니라 상수식인지에 달렸다. 그래서 int *p = zero;처럼 값이 0인 변수를 넘기는 것은 널 대입이 아니라 「정수를 포인터로」라는 전혀 다른 일이 되고, 오늘날의 컴파일러는 이것을 오류로 잡는다.

36.2.1 담긴 뒤의 비트는 별개다

표기가 같다고 표현까지 같은 것은 아니다. 여기서 6장의 복선이 회수된다.

examples/ch36/nullrep.c

/* 소스의 0 과 기억 속의 비트 — 널 포인터의 '표기'와 '표현'은 다른 층이다. */
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

/* 포인터 하나의 바이트를 그대로 들여다본다 */
static void dump(const char *how, const void *pobj, size_t n)
{
    const unsigned char *b = pobj;
    int all_zero = 1;

    printf("  %-22s", how);
    for (size_t i = 0; i < n; i++) {
        printf(" %02X", b[i]);
        if (b[i] != 0) all_zero = 0;
    }
    printf("   all bits zero: %s\n", all_zero ? "yes" : "no");
}

struct node {
    int          id;
    struct node *next;      /* 포인터 멤버 */
    double       weight;    /* 부동소수 멤버 */
};

int main(void)
{
    printf("pointer size on this implementation: %zu bytes\n\n", sizeof(int *));

    /* ① 세 가지 표기는 모두 '널 포인터 상수'다 — 문법의 층 */
    puts("[the same null written three ways]");
    int *a = 0;          dump("int *a = 0;",       &a, sizeof a);
    int *b = NULL;       dump("int *b = NULL;",    &b, sizeof b);
    int *c = nullptr;    dump("int *c = nullptr;", &c, sizeof c);
    printf("  are all three equal: %s\n",
           (a == b && b == c) ? "yes - the standard promises it" : "no");

    /* 0L 과 (void *)0 도 널 포인터 상수다. 반면 '값이 0인 변수'는 아니다:
         const int zero = 0;
         int *p = zero;        ← 컴파일 오류. 상수식이 아니라 변수이기 때문. */
    int *d = 0L;         dump("int *d = 0L;",        &d, sizeof d);
    int *e = (void *)0;  dump("int *e = (void *)0;", &e, sizeof e);

    /* ② 비교와 대입은 언제나 옳다 — 기계 속 표현이 무엇이든 */
    puts("\n[comparison holds whatever the representation]");
    printf("  a == 0      : %s\n", a == 0       ? "true" : "false");
    printf("  a == NULL   : %s\n", a == NULL    ? "true" : "false");
    printf("  a == nullptr: %s\n", a == nullptr ? "true" : "false");
    printf("  !a          : %s\n", !a           ? "true" : "false");

    /* ③ 구조체를 0 으로 채우는 두 가지 방법 — 뜻이 다르다 */
    puts("\n[two ways to 'empty' a struct]");
    struct node x = { 0 };              /* 값의 층: 널 포인터와 0.0 을 약속 */
    struct node y;
    memset(&y, 0, sizeof y);            /* 표현의 층: 모든 비트를 0 으로 */

    dump("next of { 0 }", &x.next, sizeof x.next);
    dump("next of memset", &y.next, sizeof y.next);
    printf("  x.next == nullptr : %s   <- the standard promises it\n",
           x.next == nullptr ? "true" : "false");
    printf("  x.weight == 0.0   : %s   <- the standard promises it\n",
           x.weight == 0.0 ? "true" : "false");
    printf("  y.next == nullptr : %s   <- true on this implementation, that is all\n",
           y.next == nullptr ? "true" : "false");

    /* ④ calloc 도 '모든 비트 0' 쪽이다 */
    puts("\n[what calloc gives is all-bits-zero too]");
    int **arr = calloc(4, sizeof *arr);
    if (!arr) { perror("calloc"); return 1; }
    dump("arr[0] of calloc", &arr[0], sizeof arr[0]);
    printf("  arr[0] == nullptr : %s   <- true on this implementation, that is all\n",
           arr[0] == nullptr ? "true" : "false");
    free(arr);

    puts("\nin short: the 0 you write in the source is *notation*; what lands in memory is *representation*.");
    puts("        comparison and assignment live at the notation layer, so they are always right,");
    puts("        while memset and calloc live at the representation layer and promise no null.");
    return 0;
}

실행 결과

pointer size on this implementation: 8 bytes

[the same null written three ways]
  int *a = 0;            00 00 00 00 00 00 00 00   all bits zero: yes
  int *b = NULL;         00 00 00 00 00 00 00 00   all bits zero: yes
  int *c = nullptr;      00 00 00 00 00 00 00 00   all bits zero: yes
  are all three equal: yes - the standard promises it
  int *d = 0L;           00 00 00 00 00 00 00 00   all bits zero: yes
  int *e = (void *)0;    00 00 00 00 00 00 00 00   all bits zero: yes

[comparison holds whatever the representation]
  a == 0      : true
  a == NULL   : true
  a == nullptr: true
  !a          : true

[two ways to 'empty' a struct]
  next of { 0 }          00 00 00 00 00 00 00 00   all bits zero: yes
  next of memset         00 00 00 00 00 00 00 00   all bits zero: yes
  x.next == nullptr : true   <- the standard promises it
  x.weight == 0.0   : true   <- the standard promises it
  y.next == nullptr : true   <- true on this implementation, that is all

[what calloc gives is all-bits-zero too]
  arr[0] of calloc       00 00 00 00 00 00 00 00   all bits zero: yes
  arr[0] == nullptr : true   <- true on this implementation, that is all

in short: the 0 you write in the source is *notation*; what lands in memory is *representation*.
        comparison and assignment live at the notation layer, so they are always right,
        while memset and calloc live at the representation layer and promise no null.

시연이 두 층을 나란히 보인다. 다섯 가지 표기가 전부 같은 값이 되고 (a == b && b == c), 비교도 전부 참이다 — 표기의 층은 표준이 약속한다. 반면 인쇄된 바이트는 이 구현이 그렇게 정했을 뿐이다. 6장의 Prime 50이나 CDC Cyber 180에서 같은 프로그램을 돌렸다면 비교는 그대로 참이지만 바이트는 0이 아니었을 것이다.

하는 일어느 층인가널을 약속하는가
p = nullptr; / p = 0;표기★ 약속한다
p == nullptr / !p표기★ 약속한다
struct s x = { 0 };★ 약속한다 — 포인터 멤버는 널, 실수 멤버는 0.0
memset(&x, 0, sizeof x)표현약속하지 않는다
calloc(n, size)표현약속하지 않는다
memcmp(&p, zeros, sizeof p)표현널인지 묻는 방법이 아니다

표 36.2

시연의 셋째·넷째 부분이 아래 세 줄을 실물로 보인다. { 0 }으로 만든 구조체는 표준이 포인터 멤버를 널로, 실수 멤버를 0.0으로 약속하고, memsetcalloc이 기계에서 우연히 같은 결과를 낸다.

플랫폼 노트. C++의 사정 — 같은 0, 다른 규칙

이 자리는 C와 C++이 갈리는 유명한 지점이다. 뿌리는 하나다 — C++에는 void *에서 다른 포인터로의 암묵적 변환이 없다.

그래서 C++의 NULL((void *)0)으로 정의될 수 없다. 정의하면 int *p = NULL;이 곧바로 오류가 되기 때문이다. 실제로 이 기계에서 확인해 보면 C의 NULL((void *)0)이고 g++의 NULL은 컴파일러 확장인 __null이다. 전통적인 C++ 구현은 그냥 0이나 0L로 정의했다.

0을 널로 쓰는 데서 C++만의 사고가 생긴다. 오버로딩이다.

void f(int);
void f(char *);
f(NULL);      // NULL 이 0 이면 → f(int) 가 조용히 뽑힌다

직접 돌려 보면 NULL0으로 정의했을 때 f(int)가 뽑히고, g++의 __null에서는 「모호하다」는 오류가 난다. 어느 쪽이든 원하던 f(char *)는 아니다.

무엇CC++
(void *)0을 다른 포인터에된다오류 — 캐스트가 필요하다
NULL의 정의대개 ((void *)0)0·0L·__null
값이 0인 const int 변수널 아님(상수식이 아니다)C++03은 널, C++11부터 오류
전용 키워드C23의 nullptrC++11의 nullptr(타입 std::nullptr_t)

표 36.3

셋째 줄의 변화가 흥미롭다. C++03의 정의는 「0으로 평가되는 정수 상수식」 이어서 const int zero = 0;zero도 널 포인터 상수였는데, C++11이 「0인 정수 리터럴 또는 std::nullptr_t 값」으로 좁혔다. 같은 코드가 -std=c++03에서는 컴파일되고 -std=c++11에서는 오류가 되는 것을 이 책도 확인했다.

결론은 두 언어가 같다 — nullptr를 쓴다. C++11이 먼저 들여왔고 C23이 뒤따랐으며, 오버로딩 사고도 「0인가 포인터인가」의 모호함도 이 한 낱말로 사라진다.

36.3 함정 — 표현을 0으로 가정하는 코드

deepqa의 복선을 회수한다. 다음 두 코드는 뜻이 다르다:

int *arr[8];
for (int i = 0; i < 8; i += 1) { arr[i] = nullptr; }  /* 올바르다 */
/* memset(arr, 0, sizeof arr);     — 비트를 0으로 채운다: 다른 뜻! */

첫 줄은 “널 포인터를 담아라”(표기의 세계 — 어떤 표현이든 컴파일러가 알아서). 주석의 memset은 “모든 비트를 0으로 채워라”(표현의 세계). 널 표현이 전부 0인 오늘의 주류 기계에서는 결과가 우연히 같지만, 6장의 역사가 보여 주듯 그 우연은 계약이 아니다. 실무에서 memset 방식을 흔히 보게 되는데(그리고 주류 플랫폼에서 실제로 동작하는데), 이식성의 눈 으로는 회색 지대임을 알고 쓰는 것과 모르고 쓰는 것이 다르다 — 이 책의 선택은 명시적 nullptr 대입이다.

문. NUL 문자('\0') 쪽은 정식 취급이 언제인가 — 삼형제 중 하나가 아직 남았다.

답. 세 장 뒤다. NUL 문자는 포인터의 세계가 아니라 문자열의 세계에서 일하는 물건이라(끝 표시), 문자열을 정식으로 다루는 42장이 그 무대다. 기다리는 동안 구별만 다시 — nullptr는 “가리킬 곳 없음”(포인터 값), '\0'은 “여기가 글의 끝”(문자 값, 크기 1바이트). 사는 세계가 다른 남남이다.

비어 있음을 다루는 법을 갖췄다. 다음 장은 포인터에 걸린 나머지 규칙들 — 6장의 정렬이 포인터 캐스트에 거는 제약과, 14장에서 예고한 프로버넌스 (출처 딱지)의 실무 감각이다.