heron

[C] 1. "NULL의 정체"

1. 서론

NULL이 무엇을 의미하는지는 널리 알려져 있다. '존재하지 않음'을 표현하는 식별자. 그러나 이것이 정확히 무엇이며, 어떻게 동작하는지는 대부분 알지 못한다. 그러므로, C/C++을 다루면서 NULL이 정확히 무엇인지 모르고 있는 당신! 당신을 위한 글을 남긴다.

현재 글은 C언어의 NULL만을 다룬다. C++을 위한 NULL과 nullptr은 다음 포스트에 이어서 다루도록 하겠다.

2. NULL의 개념적 필요성

포인터는 '주소를 저장하는 변수'이다. 그러나 포인터가 아직 유효한 대상을 가리키지 않거나, 현재 가리킬 대상이 없음을 명시적으로 표현해야 할 경우가 많다. 이러한 상황을 표현하기 위해 정의된 특별한 포인터 값이 바로 '널 포인터'이다.

주요 사용처:

3. C 표준 정의

3.1. NULL Pointer Constant (Compile time)

프로그래머가 소스코드에 기술하는 표현식 중, 컴파일 타임에 '정수 0'으로 평가될 수 있는 모든 표현식을 '정수 상수 표현식(Integer constant expression)'이라 한다. 예를 들면 다음과 같다.

표현식 설명
0 int 타입의 0
0L long int 타입의 0
2-2 (평가 결과가) int 타입의 0
(int)0 int로 캐스팅 된 0

한편, 위와 같은 표현식을 void *1) 로 캐스팅 한 것 또한 Null pointer constant 이다.

요약하자면 다음과 같다. Null pointer constant 는 다음 두 가지로 정의된다. [1] (C99, C11)

  1. 값이 0으로 평가되는 정수 상수 표현식(Integer constant expression)
  2. 위와 같은 표현식을 void * 타입으로 캐스팅 한 것.

3.2. NULL Pointer (Runtime)

한편, 널 포인터 상수가 포인터 타입으로 변환되었을 때, 그것을 '널 포인터'라고 한다. 이때 이 포인터는, 어떠한 것(객체, 함수)을 가리키는 포인터와 같이 않음이 보장된다.

즉, 컴파일러는 소스코드의 '널 포인터 상수'를 읽고, 실제 포인터 값(널 포인터)을 생성한다.

3.3. NULL Macro

그러나 NULL은 C언어의 식별자가 아니다. 단지, 식별자처럼 동작할 수 있도록 다양한 헤더에 중복으로 정의되어 있는 매크로2)이다. (실제로 존재하는 것은 '널 포인터' 뿐이고, 우리가 사용하는 것은 매크로.)

표준 정의는 <stddef.h>에 되어 있지만, <stdio.h>, <stdlib.h>, <string.h>, <time.h>, <wchar.h> 등에 모두 정의되어 있다.

구체적으로는 다음 두 가지 방식으로 정의되어 있다. [1]

  1. #define NULL 0 OR #define NULL 0L
  2. #define NULL ((void *)0)

둘 모두 잘 작동한다. 정수 0은 컴파일러에 의해 void * 타입으로 캐스팅되어 정수 상수 표현식으로 확장되기 때문이다. [2]

이때 2번 정의에 주목하자. C언어에서는 2번의 정의가 더 높은 유연성을 가지고, C++에서는 2번의 정의가 불가능했다. C언어에서는 void * 타입이 다른 모든 객체 포인터 타입으로 묵시적 변환이 가능하지만, C++에서는 void *타입의 묵시적 변환이 허용되지 않기 때문이다. (즉, C++에서는 1번으로 NULL이 정의되고, C언어에서는 1번과 2번이 모두 혀용된다. 참고로, 바로 이것이 C++의 nullptr이 탄생하게 된 원인이다.)

3.4. 추상화 과정

  1. '아무것도 가리키지 않는다'라는 개념.
  2. 문법적 약속인 '널 포인터 상수' 정의.
  3. 문법에 기반해 작성된, 0으로 평가되는 표현식을 '널 포인터'로 변환하여 처리.

즉, 프로그래머는 소스코드에서 0 또는 NULL 을 기술하여 상수를 사용하고, 컴파일러는 그것을 런타임에 '널 포인터'로 전환하여 사용한다.

4. 오해와 진실

4.1. 널 포인터의 비트 패턴은 항상 0인가?

아니다! 사실 이것이 0인지 아닌지는 중요하지 않다. 우리에게 필요한것은 '가리킬 대상이 없음'을 의미하는 기호로서의 'NULL'이기 때문이다.

그렇다면 널 포인터를 '비트 패턴이 모두 0'이 아닌 것으로 정의한 사례가 있는가? [3]

comp.lang.c FAQ list · Question 5.17:

Q: Seriously, have any actual machines really used nonzero null pointers, or different representations for pointers to different types?

A: The Prime 50 series used segment 07777, offset 0 for the null pointer, at least for PL/I. ... Some Honeywell-Bull mainframes use the bit pattern 06000 for (internal) null pointers. ... The CDC Cyber 180 Series has 48-bit pointers consisting of a ring, segment, and offset. Most users (in ring 11) have null pointers of 0xB00000000000.

위와 같이 기기에 따라 다양한 방식의 널 포인터 정의가 사용되어 왔고, 그들 모두에서 이식성을 유지하기 위해 의식적으로 '컴파일러에 의해 정수 상수 표현식으로 번역됨이 보장되는 코드'를 작성해야 한다. 예를 들면 다음의 코드에는 문제가 있다.

#include <stdio.h>
#include <string.h> // memset

int main(){
    int* ptr;
    int size = 5;

    ptr = (int*)malloc(size * sizeof(int));

    if(ptr != NULL){
        memset(ptr, 0, size * sizeof(int));
    }

    return 0;
}

memset()으로 포인터 내부의 모든 비트를 0으로 바꾸었다고 해서, 그것이 항상 NULL과 동일하게 취급되는 것은 아니기 때문이다. (환경에 따라 예상치 못한 결과 출력 가능.)

그러나 다음과 같은 코드는 옳다.

#include <stdio.h>
#include <string.h> // memset

int main(){
    int* ptr = {0};

    if(ptr == NULL){
        printf("ptr == NULL");
    }

    return 0;
}

위 코드를 실행하면, 실제로 ptr == NULL이 출력된다. 이는 컴파일러가 정적 초기화 구문을 널 포인터 값으로 번역하여 ptr을 올바르게 NULL로 초기화 해주기 때문이다. (내부적인 비트패턴은 아무 의미가 없다. 코드가 컴파일러에게 어떻게 해석되는지가 중요할 뿐.)

4.2. 널 문자 상수('\0')는 NULL과 동등하다?

아니다!

지금 이 글을 읽는 당신은, 다음 세 표현의 차이를 정확히 알고 있는가?

이제 NULL0은 동등하다는 것을 잘 알 것이다. NULL은 구현에 의하여 정의된 int 또는 void * 타입의 '유효하지 않은 포인터'를 표현하는 식별자이며, 포인터 문맥에서 정수 리터럴인 0과 상호 변환될 수 있는 관계에 있다는 사실이 이해되었으리라. 그러나 '\0'은 아니다. 이것은 ASCII 48 값(숫자 0)을 나타내는 '문자 리터럴'이다. 정수 문맥에서는 여전히 0 대신 사용 가능하지만, 포인터 문맥에서는 사용되지 않는다. 그러니 용도에 맞게, 문자열의 끝을 나타내는데에만 사용하도록 하자.

5. NULL 역참조와 최적화

NULL의 사용에 주의할 점이 있다. 고수준의 최적화가 수행될 때, 포인터와 관련된 일부 코드블럭을 통째로 제거할 가능성이 있기 때문이다.

#include <stdio.h>

int main(){
    int x;
    int* ptr = &x;
    *ptr = 10;

    if(ptr == NULL){
        // 예외처리
    }

    return 0;
}

위와 같은 코드에서, 프로그래머가 *ptr = 10;과 같이 역참조를 수행했기에, 컴파일러는 ptr == NULL 을 검사하는 if문의 코드블록 전체를 '실행되지 않는 죽은 코드'로 판단한다.

한편, 정의되지 않은 행동(Undefined Behavior, UB)이라는 개념이 있다. 이것은 C 표준이 해당 코드의 동작에 어떠한 요구사항도 부과하지 않는 것을 의미한다. [4]

Undefined behavior

unspecified behavior - The behavior of the program varies between implementations, and the conforming implementation is not required to document the effects of each behavior.

For example, order of evaluation, whether identical string literals are distinct, the amount of array allocation overhead, etc. Each unspecified behavior results in one of a set of valid results.

핵심은 '각 동작의 효과를 문서화 할 필요가 없다(not required to document the effects of each behavior)'라는 부분이다. c 표준에서 이와 같이 UB를 정의하였기에, 컴파일러의 공격적인 최적화가 가능한 것이다. UB를 유발하는 모든 코드는 일반적으로 작성되지 않는다는 가정 하에 코드를 해석하기 때문이다. (올바른 프로그램에서는 일어나지 않는 일로 확정하고 코드 해석.)

중요한 것은, NULL 역참조가 UB라는 것이다. 그러니 조심하자. 당신의 코드가 어떤 동작을 하게 될지 누구도 알 수 없다. 심연에 발을 들이지 않기를.

6. 정리

  1. NULL은 구현으로 정의된 상수이다.
  2. NULL은 정수 리터럴 0 또는 0L 로 구현되거나, void *타입으로 캐스팅 된 정수 0으로 구현된다.
  3. 컴파일 타임에 0으로 평가되는 모든 상수는, 런타임에 '널 포인터'로 변환된다.
  4. '널 포인터'의 비트 패턴은 환경에 따라 다르다. (항상 0인 것은 아니다.)
  5. '\0'으로 표기되는 널 문자는, NULL과 전혀 무관하다. 그저 아스키 48번을 나타내는 리터럴이자, 문자열의 종료를 나타내는 기호일 뿐이다.
  6. NULL을 역참조 하는 것은 정의되지 않은 행동(UB)이다.
  7. NULL이 대입된 포인터가 사용된 예외처리 구문은, 컴파일러에 의해 통째로 제거될 가능성이 있다.

각주

참고문헌