SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++

'0' 대신 'nullptr'를 써야 하는 이유

보리남편 김 주부 2024. 8. 4. 10:33

💬 C++ Core Guidelines에서는 '0' 혹은 'NULL'보다는 'nullptr'를 사용하라고 권장하고 있는데, 권장하기에 사용하기보다는 왜 nullptr를 쓰는 게 나은지 다양한 방법으로 알아보자.

nullptr를 써야 하는 이유

널포인터 대신 0을 사용하면서 오류가 발생한다.

사례 1

Example
int main()  
{    
  int* p1 = 0;  
  int* p2 = nullptr;
}

 

위 코드는 문제없이 p1, p2에 널 포인트가 대입이 된다. 다음 예제도 문제가 없는지 검토해 보자.

Example

 

void foo(int* p) {}  
int main()
{
  int n = 0;
  foo(0); //1 ??
  foo(n); //2 ??
}

 

주석의 결과를 확인해 보면 다음과 같다.

주석 1 : OK - 0이 int 타입입니다. '리터럴 0'은 정수이지만 포인터로 암시적 형변환이 된다.
주석 2 : Error - 0을 가진 '정수형 변수 n'은 포인터로 변환될 수 없기 때문이다.

  • 리터럴에 대한 설명은 글 맨 아래 참조에 정리하였다.

사례 2

Example
nullptr 대신 리터럴 '0'를 사용했을 때 문제가 되는 예를 하나 더 확인해 보자.
아래 코드에 주석 1, 2의 결과와 그 이유를 생각해 보자.
#include<iostream>

template<typename F, typename T>
void forward_arg(F f, T&& arg)
{
  f(std::forward<T>(arg));
}

void foo(int* p) {}

int main()
{
  int n = 0;
  foo(0); // ok
  //foo(n); // error
  forward_arg(foo, 0);       // 1. result ??
  forward_arg(foo, nullptr); // 2. result ??
}
📌 우선 perfect forwarding을 모르면 아래 글을 우선 보고 오자.

2024.03.29 - [L C++] - perfect forwarding을 perfect하게 이해하기

  • 💡 std::forward를 모르지만 위 글을 읽지 않고 이어서 읽고 싶으면 std::forward(arg)는 static_cast<T&&>(arg) 이렇게 해석하면 된다.

 

주석의 결과와 이유를 확인해 보면 다음과 같다.

주석 1 : Error
void forward_arg(F f, T&& arg) // '리터럴 0'이 넘어오면
T&& arg => int&& arg = 0가 되면서 arg는 0을 가진 변수가 되고, f 함수는 아래와 같이 된다.
f(std::forward<T>(arg)) == f(std::forward<int>(arg)) == f(static_cast<int&&>(arg))
: arg를 l-value -> r-value로 속성을 변경했지만 arg는 int 타입이기에 아래와 같이 타입이 다르다는 에러가 발생한다.

invalid conversion from 'int' to 'int*' [-fpermissive]

주석 2 : OK
void forward_arg(F f, T&& arg) // nullptr이 넘어오면
T&& arg => nullptr_t&& arg = nullptr 가 되면서 f 함수는 아래와 같이 된다.
f(std::forward<T>(arg)) => f(std::forward<nullptr_t>(arg)) => f(static_cast<nullptr_t&&>(arg))
결국 이 코드는 foo(nullptr_t arg)가 되는데 nullptr_t가 포인터로 암시적 형변환이 되어 정상동작이 된다.

 

💬 nullptr_t가 왜 포인터로 형변환 되는지 확인하기 이전에 사례를 하나만 더 확인해 보자.

사례 3

Example
forwarding argument를 직접적으로 예를 들었지만 이를 사용한 예시가 바로 thread이다. 아래 예제를 보자.
#include <iostream>
#include <thread>

void foo(int* p) {}
int main()
{
  // std::thread t(&foo, 0);     //1. Error
  std::thread t(&foo, nullptr); // Ok..!!
  t.join();
}

 

주석의 결과와 이유를 확인해 보면 다음과 같다.

주석 1 : Error
std::thread t(&foo, 0);
thread 생성자가 &foo, 0을 받게 되면 새로운 스레드를 생성해서 "foo(0)"을 실행하게 되는데 인자를 전달(forward) 하기 때문에 foo(arg)와 같은 에러가 발생하게 된다.

 


🚀 nullptr의 출생의 비밀을 알아보자.


C++11 이전에는 nullptr를 아래처럼 정의해 썼었다고 한다.

Code
struct nullptr_t
{
  operator int*() const { return 0; }
  operator char*() const { return 0; }
  ...
  operator xxx const { return 0; }
}

nullptr_t mynullptr;

 

nullptr_t는 모든 타입의 포인터를 변환 연산자로 정의하여 암시적 형변환 시킨 형태의 구조체이다. 

📌 '변환 연산자'가 생소한 사람은 아래 글을 먼저 이해하자.

2024.06.09 - [L C++] - 내가 설계한 객체에 유효성 기능 지원하기

 

사용 동작 예를 한 번 알아보자.

Example
void f1(int* p) {}
void f2(char* p) {}

int main()
{
  nullptr_t mynullptr;
  f1(mynullptr); //1
  f2(mynullptr); //2
}

 

주석의 내부적 동작이 어떻게 되는지 확인해 보면 다음과 같다.

주석 1 : mynullptr => int* 변환 필요, mynullptr.operator int*()로 변환
주석 2 : mynullptr => char* 변환 필요, 아래처럼 동작 mynullptr.operator char*()로 변환

 

💬 앞선 예제에서 nullptr_t 타입의 변수가 포인터로 형변환이 된 이유가 바로 여기에 있다.

📜 C++11부터 표준에 포함되면서 "nullptr"는 키워드가 되었고 동작 원리는 위 코드와 동일한 규칙이다.

 


 

💬 nullptr는 널포인트 사용을 위해 잘 설계된 키워드라는 설명이 충분히 되었으면 좋겠다.

 

참고


리터럴

literal은 변수가 아닌 프로그램에서 직접 사용하는 값이다. 그리고 모든 리터럴은 타입이 있기에 아래 예제에서 각각의 타입을 생각해 보자.

Example
#include<cstddef>
int main()
{
  3;                           // 1
  3.4;                         // 2
  true;                        // 3
  nullptr;                     // 4

  std::nullptr_t a = nullptr;  // 5
  int* p1 = a;
  char* p2 = a;
}
  • 주석 1 : Int 타입
  • 주석 2 : double 타입
  • 주석 3 : bool 타입 - true는 값이고 컴파일가 인식하는 키워드이다.
  • 주석 4 : std::nullptr_t 타입 - nullptr도 값이고 컴파일러가 인식하는 키워드이다.
  • 주석 5 : 리터럴도 타입이 있기에 a처럼 nullptr 대신 사용 할 수도 있다.

 

C++ 핵심 가이드라인 한글화 프로젝트 링크

C++ Core Guidelines 원문 링크

 

 

 

728x90