null 포인터( pointer )
포인터( pointer )는 객체의 메모리 주소를 저장하는 변수입니다.
그리고 아래와 같이, 포인터 ptr
이 객체 x
의 주소를 저장하고 있는 상태를, "포인터 ptr
이 객체 x
를 가리키고 있다"라고 얘기합니다.
int x = 0;
int* ptr = &x; // 객체 x를 주소를 저장
그런데, 포인터는, 참조( reference )와 달리, 현재 가리키고 있는 객체가 없을 수도 있습니다.
이러한 포인터를 null 포인터( pointer )라고 합니다.
그리고, null 포인터를 만드는 방법은 다음과 같이 선언하는 것입니다.
int* null_pointer{}; // value-list initialization
위의 값-리스트 초기화는, 리스트 초기화( list initialization )의 하나로, 객체의 값을 기본값으로 설정하는 초기화 방법입니다.
[C++] 초기화( initialization )의 세 가지 방법
변수의 초기화( initialization )C++을 사용해서, 코딩을 하다 보면 의외로 다양한 방법으로 변수를 초기화할 수 있음을 알게 됩니다. 그런데, 대부분은 자기만의 특정한 방식의 초기화 방법
codingbonfire.tistory.com
그리고, int
타입 같은 원시 타입( primitive type )의 기본값과 같이, 포인터의 기본값도 0
입니다.
● 참고로, "그럼 기본값이 0
이 아닌 타입이 있나?"라고 생각할 수 있습니다.
그렇지만, 다음과 같은 클래스 타입의 기본값을 0
이라고 말하기는 어렵습니다.
struct Coordinate{
int x = 3;
int y = 4;
};
int main(){
Coordinate cd{}; // 기본값 0?
std::cout << "x,y: " << cd.x << "," << cd.y << '\n';
}
▼출력
x,y: 3,4
위의, Coordinate
타입의 기본값을 어떤 한 개의 값이라고 말하기 어렵고, 굳이 얘기하자면 (3,4)
라고 하는 것이 그나마 적절할 것입니다.
이렇게, 클래스 객체를 값-리스트 초기화( value-list initialization ) 방법으로 초기화하게 되면, 클래스 객체의 데이터 멤버들은 각각의 기본값으로 초기화됩니다.
그리고, cd.x
의 기본값은 0
이 아니라 3
입니다.
예전엔, null 포인터를 표시하기 위해, 값 0
대신, 다음과 같이 전처리 지시자( preprocessor directives )를 사용해, 기호 NULL
을 정의해 사용해 왔습니다.
#define NULL 0 // 전처리 지시자
int* variable{ NULL }; // nullptr 선언
nullptr 리터럴( literal )
nullptr은 C++ 11에 도입한 리터럴로, 포인터가 어떠한 객체도 가리키고 있지 않다는 것을 나타내는 값입니다.
이것은, 기존에 같은 의미로 쓰였던, 기호 NULL
을 대체하기 위해 만들어졌습니다. NULL
을 대체하기 위해 만들어진 만큼, 지시하는 대상이 없는 포인터를 나타내는 의미로서의 NULL
이 사용되었던 위치엔 전부 사용할 수 있습니다.
int* obj_pointer = nullptr; // int 타입 포인터 초기화
이렇게, NULL
의 사용을 대체할 수 있는 리터럴을 도입한 이유는, NULL
이 어떤 값으로 정의되어 있는지에 따라, 같은 코드를 사용한다고 하더라도 다른 결과가 나올 수 있기 때문입니다.
void compute(int val){ // int 타입 매개 변수
cout << "compute by val\n";
}
void compute(int* ptr){ // 포인터 타입 매개 변수
cout << "compute by pointer\n";
}
int main(){
int arr[] = { 1, 2, 3, 4, 5 };
int* ptr = arr;
compute(ptr); // 포인터 타입의 매개 변수를 가진 함수 호출
compute(NULL); // 포인터 사용을 의도했지만, 다른 함수를 호출
}
위의 compute(ptr)
코드는 compute(int* ptr)
함수를 호출합니다.
그리고, 그다음의 compute(NULL)
코드도, null 포인터를 표시하기 위해 NULL
을 사용했으므로, 같은 함수를 호출하기를 예상할 것입니다.
그런데, NULL
을 int
타입의 값 0
으로 정의한 컴파일러( visual studio )는, compute(int val)
함수를 호출합니다.
하지만, NULL
을 아래와 같이 int*
타입의 값 0
으로 정의한 컴파일러는, compute(int* ptr)
함수를 호출합니다.
#define NULL ((int*)0)
심지어, 이 NULL
을 long long
타입의 값 0
으로 정의한 컴파일러( gcc )는, 위의 두 함수 중 어떤 함수를 호출해야 할지 몰라서, 컴파일 오류를 출력합니다.
error: call of overloaded 'compute(NULL)' is ambiguous
그렇지만, compute 함수의 인자로 nullptr 리터럴을 사용하면, 위와 같은 문제를 고민할 필요 없이, 항상 compute(int* ptr)
함수를 호출하게 됩니다.
그런데, nullptr은 어떻게 이 문제를 해결하는 걸까요?
nullptr 리터럴( literal )의 타입
C++에서는 변수뿐만 아니라, 모든 값도 타입을 갖고 있습니다.
예를 들어, 0
의 타입은 int
타입이지만, 0LL
의 타입은 long long
타입입니다.
그리고, nullptr 리터럴도 타입을 갖고 있는데, std::nullptr_t
타입이 그것입니다.
이 nullptr_t
는 지시하고 있는 객체가 없는 null 포인터를 정의하기 위한 만들어진 새로운 타입입니다.
이와 같이, 새로운 타입을 만든 이유는, 모든 타입의 포인터들을 초기화시켜야 할 리터럴이, 기존의 특정 포인터 타입이 될 수 없기 때문입니다.
그리고, 이 타입은, 암시적이든 명시적이든 상관없이, 모든 포인터 타입으로 변환이 가능하지만, 다른 타입이 nullptr_t
타입으로 변환될 수는 없도록 구현되었습니다.
이제, 이러한 정보를 가지고, 다시 코드를 보면 문제를 어떻게 해결하고 있는지 알 수 있을 것입니다.
void compute(int val){
cout << "compute by val\n";
}
void compute(int* ptr){
cout << "compute by pointer\n";
}
int main(){
compute(nullptr); // nullptr로 함수 호출
}
▼출력
compute by pointer
먼저, 함수 compute를 호출하면, 인자인 nullptr은 int*
타입으로 암시적인 변환이 일어납니다.
nullptr을 int
타입으로 변경하는 변환은 정의되어 있지 않기 때문입니다.
그다음에, 이 변환된 값 (int*)0
이 포인터를 매개 변수로 받는 함수 compute(int* ptr)
에 넘겨집니다.
그래서, NULL
를 사용할 때와 달리, 발생 가능한 모호성이 완전히 사라지게 되는 것입니다.
● 참고로, 다른 타입을 nullptr로 변환할 수 없으므로, 다음의 함수는 nullptr_t
타입의 값이나 표현식만을 사용해서 호출할 수 있습니다.
void compute_nullptr( nullptr_t ptr){ // nullptr_t 타입의 매개 변수
// Do something...
}
int main(){
compute_nullptr(nullptr);
void* pVoid = nullptr; // void 포인터
compute_nullptr( pVoid); // error !
}
▼출력
error: cannot convert 'void*' to 'std::nullptr_t'
그리고 위와 같이, 모든 타입의 객체를 가리키는 void 포인터( pointer )일지라도, nullptr_t
타입으로는 변환할 수 없습니다.
정리
- 어떤 객체도 가리키고 있지 않은 포인터를 null 포인터라고 합니다.
- nullptr은
nullptr_t
타입을 가진 리터럴( literal )입니다. - 이 nullptr을 사용해서 값
0
과NULL
기호를 사용할 때 발생할 수 있는 문제들을 해결할 수 있습니다.
이 글과 관련 있는 글들
전처리( preprocessing )에 대한 설명과 전처리 지시자들( directives )