C++ nullptr | "널 포인터" 가이드
이 글의 핵심
nullptr vs NULL vs 0, 오버로딩·nullptr_t, 레거시 마이그레이션까지 다룹니다.
nullptr이란?
nullptr 은 C++11에서 도입된 타입 안전한 널 포인터 리터럴입니다. 기존 NULL과 0의 문제점을 해결하고, 포인터 타입에만 사용할 수 있는 명확한 널 값을 제공합니다.
// C++03 이전
int* p1 = NULL; // 매크로 (보통 0)
int* p2 = 0; // 정수 0
// C++11 이후
int* p3 = nullptr; // 타입 안전
왜 필요한가?:
- 타입 안전: 정수와 포인터 혼동 방지
- 오버로딩 해결: 함수 오버로딩에서 명확한 선택
- 템플릿 안전: 템플릿에서 타입 추론 문제 해결
- 명확성: 코드 의도를 명확하게 표현
// ❌ NULL의 문제: 정수로 해석됨
#define NULL 0
void func(int x) { std::cout << "int\n"; }
void func(int* ptr) { std::cout << "pointer\n"; }
func(NULL); // "int" 출력 (의도와 다름!)
// ✅ nullptr: 포인터로 명확히 해석됨
func(nullptr); // "pointer" 출력 (의도대로)
nullptr, NULL, 0 — 무엇이 다른가?
표준적으로 nullptr만 “포인터 널”에 특화된 리터럴입니다. **0은 정수 리터럴이고, NULL**은 보통 0으로 정의된 매크로라 정수 오버로드와 우선순위가 겹칩니다.
| 표현 | 의미 | 정수 f(int) 선택 | 포인터 쪽 의도 | 정수 변수에 대입 |
|---|---|---|---|---|
0 | 정수 0 또는 널 포인터 상수 | 가능 | 문맥에 따라 포인터로 변환 | int x = 0 OK |
NULL | 구현 정의 (0, 0L 등) | 종종 정수 쪽 | 의도와 다를 수 있음 | 구현에 따라 가능 (경고) |
nullptr | std::nullptr_t | 일반적으로 아님 | 포인터/nullptr_t 오버로드와 매칭 | 불가 (타입 안전) |
NULL 정의가 (void*)0인 환경과 단순 0인 환경에서 오버로드 해석이 달라질 수 있으므로, 이식성과 가독성 모두 **nullptr**이 유리합니다.
함수 오버로딩에서의 문제 (심화)
int와 int*를 동시에 받는 API는 레거시에서 흔합니다. 이때 NULL과 0은 “정수” 쪽으로 붙기 쉽고, 의도는 “포인터 없음”인데 int 오버로드가 호출되는 버그가 납니다. nullptr은 **std::nullptr_t**로 전달되므로 포인터 계열 오버로드와 잘 맞습니다.
std::nullptr_t 전용 오버로드를 두면 “널 리터럴만” 다루는 분기를 분리할 수 있습니다.
#include <cstddef>
#include <iostream>
void f(int) { std::cout << "int\n"; }
void f(int*) { std::cout << "int*\n"; }
void f(std::nullptr_t) { std::cout << "nullptr_t\n"; }
int main() {
f(0); // 보통 int
f(NULL); // 보통 int (구현 의존)
f(nullptr); // nullptr_t 오버로드
int* p = nullptr;
f(p); // int*
}
nullptr의 타입:
nullptr의 타입은 std::nullptr_t입니다. 이는 모든 포인터 타입으로 암시적 변환이 가능하지만, 정수 타입으로는 변환할 수 없습니다.
#include <cstddef>
// nullptr의 타입
using nullptr_t = decltype(nullptr);
int* p1 = nullptr; // OK: 포인터로 변환
char* p2 = nullptr; // OK: 포인터로 변환
void* p3 = nullptr; // OK: 포인터로 변환
// int x = nullptr; // 에러: 정수로 변환 불가
nullptr의 동작 원리:
nullptr은 특별한 타입 std::nullptr_t의 prvalue입니다. 컴파일러는 nullptr을 모든 포인터 타입으로 암시적 변환하지만, 정수 타입으로는 변환하지 않습니다.
// 개념적 구현
struct nullptr_t {
// 모든 포인터 타입으로 변환 가능
template<typename T>
operator T*() const { return 0; }
// 멤버 포인터로도 변환 가능
template<typename C, typename T>
operator T C::*() const { return 0; }
// 정수로는 변환 불가
operator int() const = delete;
};
const nullptr_t nullptr = {};
NULL의 문제점
#define NULL 0
void func(int x) {
cout << "int: " << x << endl;
}
void func(int* ptr) {
cout << "pointer" << endl;
}
int main() {
func(NULL); // 모호함! int 버전 호출됨
func(nullptr); // pointer 버전 호출
}
nullptr의 장점
// 1. 타입 안전
int* p = nullptr; // OK
int x = nullptr; // 에러: 정수에 할당 불가
// 2. 오버로딩 해결
void func(int);
void func(char*);
func(0); // int 버전
func(NULL); // int 버전 (문제!)
func(nullptr); // char* 버전 (의도대로)
// 3. 템플릿에서 안전
template<typename T>
void process(T* ptr) {
if (ptr == nullptr) {
// ...
}
}
기본 사용법
int* ptr = nullptr;
// nullptr 체크
if (ptr == nullptr) {
cout << "널 포인터" << endl;
}
if (!ptr) {
cout << "널 포인터" << endl;
}
// 함수 인자
void func(int* p = nullptr) {
if (p) {
*p = 10;
}
}
실전 예시
예시 1: 안전한 포인터 사용
class Node {
public:
int data;
Node* next;
Node(int d) : data(d), next(nullptr) {}
};
class LinkedList {
private:
Node* head;
public:
LinkedList() : head(nullptr) {}
~LinkedList() {
while (head != nullptr) {
Node* temp = head;
head = head->next;
delete temp;
}
}
void append(int data) {
Node* newNode = new Node(data);
if (head == nullptr) {
head = newNode;
return;
}
Node* current = head;
while (current->next != nullptr) {
current = current->next;
}
current->next = newNode;
}
void print() const {
Node* current = head;
while (current != nullptr) {
cout << current->data << " -> ";
current = current->next;
}
cout << "nullptr" << endl;
}
};
int main() {
LinkedList list;
list.append(1);
list.append(2);
list.append(3);
list.print();
}
예시 2: 옵셔널 파라미터
class Logger {
private:
ofstream* file;
public:
Logger(const string* filename = nullptr) : file(nullptr) {
if (filename != nullptr) {
file = new ofstream(*filename);
}
}
~Logger() {
if (file != nullptr) {
file->close();
delete file;
}
}
void log(const string& message) {
if (file != nullptr) {
*file << message << endl;
} else {
cout << message << endl;
}
}
};
int main() {
// 파일 로깅
string filename = "log.txt";
Logger fileLogger(&filename);
fileLogger.log("파일에 기록");
// 콘솔 로깅
Logger consoleLogger;
consoleLogger.log("콘솔에 출력");
}
예시 3: 팩토리 패턴
class Shape {
public:
virtual void draw() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
void draw() const override {
cout << "Circle" << endl;
}
};
class Rectangle : public Shape {
public:
void draw() const override {
cout << "Rectangle" << endl;
}
};
Shape* createShape(const string& type) {
if (type == "circle") {
return new Circle();
} else if (type == "rectangle") {
return new Rectangle();
}
return nullptr; // 알 수 없는 타입
}
int main() {
Shape* shape = createShape("circle");
if (shape != nullptr) {
shape->draw();
delete shape;
} else {
cout << "알 수 없는 도형" << endl;
}
}
예시 4: 스마트 포인터와 함께
#include <memory>
class Resource {
public:
Resource() {
cout << "Resource 생성" << endl;
}
~Resource() {
cout << "Resource 소멸" << endl;
}
void use() {
cout << "Resource 사용" << endl;
}
};
int main() {
unique_ptr<Resource> ptr1 = nullptr;
if (ptr1 == nullptr) {
cout << "ptr1은 nullptr" << endl;
}
ptr1 = make_unique<Resource>();
if (ptr1 != nullptr) {
ptr1->use();
}
// nullptr로 리셋
ptr1 = nullptr; // Resource 소멸
}
nullptr_t 타입
#include <cstddef>
// nullptr의 타입
using nullptr_t = decltype(nullptr);
void func(nullptr_t) {
cout << "nullptr 받음" << endl;
}
void func(int*) {
cout << "포인터 받음" << endl;
}
int main() {
func(nullptr); // nullptr 버전
int* p = nullptr;
func(p); // 포인터 버전
}
nullptr_t와 템플릿·자동 변환
std::nullptr_t는 <cstddef>에 정의되어 있으며, **decltype(nullptr)**과 같습니다. 템플릿 인자나 auto 추론에서 “널 포인터만” 받고 싶을 때 유용합니다.
#include <cstddef>
#include <type_traits>
template<class T>
void reset(T* p) {
delete p;
}
// 포인터가 아닌 nullptr_t 전용 처리가 필요하면 오버로드 분리
void take(std::nullptr_t) { /* no-op */ }
void take(int* p) { /* ... */ }
static_assert(std::is_same<decltype(nullptr), std::nullptr_t>::value, "");
// C++17 이상: std::is_same_v<decltype(nullptr), std::nullptr_t>
멤버 포인터에도 nullptr은 동일하게 쓰이며, 정수로의 암시적 변환이 없다는 점이 0/NULL 대비 큰 이점입니다.
실전 예제: API 설계에서 nullptr
반환값: “객체 없음”을 나타낼 때 optional/expected(C++23)와 함께 쓰는 패턴도 많지만, Raw 포인터 API라면 nullptr이 유일한 실패 값임을 문서화하는 것이 일반적입니다.
// 조회 실패 시 nullptr — 호출자는 항상 검사
const Widget* findWidget(int id) const;
// 기본 인자: “옵션 없음”
void connect(const Options* opts = nullptr);
스마트 포인터: std::unique_ptr<T>/shared_ptr<T>는 nullptr로 리셋할 때 의미가 명확하고, **if (ptr)**는 operator bool과 자연스럽게 연결됩니다.
C++11 이전 코드 마이그레이션
- 포인터 초기화·비교:
= NULL,== NULL,== 0→nullptr(의미가 “포인터 널”일 때). - 매크로
NULL제거: 헤더 의존을 줄이려면<cstddef>의nullptr만 쓰면 됩니다. - 오버로드 깨짐 확인:
f(NULL)이 예전에int를 탔다면,f(nullptr)로 바꾼 뒤 단위 테스트·호출부를 점검합니다. - C 코드와의 경계: C 컴파일 유닛에는
NULL이 남을 수 있습니다. C++ 쪽만 점진적으로nullptr로 통일합니다. - 컴파일러 경고:
-Wzero-as-null-pointer-constant(GCC/Clang)로0널 상수 사용을 잡아 **nullptr**로 고칩니다.
// Before (C++03 스타일)
void bar(int* p);
bar(0);
bar(NULL);
// After (C++11+)
void bar(int* p);
bar(nullptr);
자주 발생하는 문제
문제 1: NULL 사용
// ❌ C++11 이후에도 NULL 사용
void func(int* ptr) {
if (ptr == NULL) { // 구식
// ...
}
}
// ✅ nullptr 사용
void func(int* ptr) {
if (ptr == nullptr) {
// ...
}
}
문제 2: 정수와 혼동
// ❌ 0을 널 포인터로 사용
int* ptr = 0;
if (ptr == 0) {
// ...
}
// ✅ nullptr 사용
int* ptr = nullptr;
if (ptr == nullptr) {
// ...
}
문제 3: 오버로딩 문제
void process(int x) {
cout << "int" << endl;
}
void process(int* ptr) {
cout << "pointer" << endl;
}
// ❌ NULL 사용 (모호함)
process(NULL); // int 버전 호출 (의도와 다름)
// ✅ nullptr 사용
process(nullptr); // pointer 버전 호출
nullptr 체크 방법
int* ptr = nullptr;
// 방법 1: 명시적 비교
if (ptr == nullptr) {
cout << "널" << endl;
}
// 방법 2: 암시적 변환
if (!ptr) {
cout << "널" << endl;
}
// 방법 3: 논리 연산
if (ptr) {
*ptr = 10;
} else {
cout << "널" << endl;
}
NULL vs nullptr 비교
// NULL (C++03)
#define NULL 0 // 또는 ((void*)0)
int* p1 = NULL;
int x = NULL; // OK (문제!)
// nullptr (C++11)
int* p2 = nullptr;
// int y = nullptr; // 에러 (타입 안전)
// 템플릿에서
template<typename T>
void func(T value) {
if (value == nullptr) { // OK
// ...
}
}
func(NULL); // 컴파일 에러 (NULL은 int)
func(nullptr); // OK
실무 패턴
패턴 1: 안전한 포인터 반환
template<typename T>
class Repository {
std::map<int, T> storage_;
public:
// nullptr을 사용한 안전한 반환
T* find(int id) {
auto it = storage_.find(id);
if (it != storage_.end()) {
return &it->second;
}
return nullptr; // 찾지 못함
}
// 사용
void process(int id) {
T* item = find(id);
if (item != nullptr) {
// 안전하게 사용
item->process();
} else {
std::cout << "항목을 찾을 수 없음\n";
}
}
};
패턴 2: 옵셔널 콜백
class EventHandler {
using Callback = void(*)();
Callback onSuccess_ = nullptr;
Callback onError_ = nullptr;
public:
void setOnSuccess(Callback cb) {
onSuccess_ = cb;
}
void setOnError(Callback cb) {
onError_ = cb;
}
void execute() {
try {
// 작업 수행
if (onSuccess_ != nullptr) {
onSuccess_();
}
} catch (...) {
if (onError_ != nullptr) {
onError_();
}
}
}
};
// 사용
EventHandler handler;
handler.setOnSuccess( { std::cout << "성공\n"; });
handler.execute();
패턴 3: RAII 가드
template<typename T>
class ScopedPtr {
T* ptr_;
public:
explicit ScopedPtr(T* p = nullptr) : ptr_(p) {}
~ScopedPtr() {
if (ptr_ != nullptr) {
delete ptr_;
}
}
// 복사 금지
ScopedPtr(const ScopedPtr&) = delete;
ScopedPtr& operator=(const ScopedPtr&) = delete;
// 이동 허용
ScopedPtr(ScopedPtr&& other) noexcept : ptr_(other.ptr_) {
other.ptr_ = nullptr;
}
ScopedPtr& operator=(ScopedPtr&& other) noexcept {
if (this != &other) {
if (ptr_ != nullptr) {
delete ptr_;
}
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
return *this;
}
T* get() const { return ptr_; }
T* operator->() const { return ptr_; }
T& operator*() const { return *ptr_; }
explicit operator bool() const {
return ptr_ != nullptr;
}
};
// 사용
ScopedPtr<int> ptr(new int(42));
if (ptr) {
std::cout << *ptr << '\n';
}
FAQ
Q1: nullptr vs NULL?
A:
- nullptr: 타입 안전, C++11 이상, 포인터 전용
- NULL: 매크로 (보통
0), 타입 불안전, 정수로 해석될 수 있음
// NULL의 문제
void func(int x) { std::cout << "int\n"; }
void func(int* ptr) { std::cout << "pointer\n"; }
func(NULL); // "int" 출력 (문제!)
func(nullptr); // "pointer" 출력 (의도대로)
권장: C++11 이상에서는 항상 nullptr 사용
Q2: nullptr vs 0?
A:
- nullptr: 포인터 전용, 타입 안전
- 0: 정수와 포인터 모두 가능, 혼동 가능
int* p1 = 0; // OK (하지만 혼동 가능)
int* p2 = nullptr; // OK (명확함)
int x = 0; // OK
// int y = nullptr; // 에러: 정수에 할당 불가
Q3: nullptr은 어떤 타입인가요?
A: std::nullptr_t 타입입니다. 이는 모든 포인터 타입으로 암시적 변환이 가능하지만, 정수 타입으로는 변환할 수 없습니다.
#include <cstddef>
std::nullptr_t null = nullptr;
int* p1 = null; // OK
char* p2 = null; // OK
// int x = null; // 에러
Q4: nullptr을 정수에 할당할 수 있나요?
A: 불가능합니다. 컴파일 에러가 발생합니다. 이것이 nullptr의 타입 안전성입니다.
int* ptr = nullptr; // OK
// int x = nullptr; // 에러: 정수에 할당 불가
// 명시적 캐스팅도 불가
// int y = static_cast<int>(nullptr); // 에러
Q5: 언제 nullptr을 사용해야 하나요?
A: 항상 사용하세요. C++11 이상에서는 NULL이나 0 대신 nullptr을 사용하는 것이 권장됩니다.
// ❌ 구식
int* p1 = NULL;
int* p2 = 0;
// ✅ 현대적
int* p3 = nullptr;
Q6: nullptr은 false로 평가되나요?
A: 예. nullptr은 불리언 컨텍스트에서 false로 평가됩니다.
int* ptr = nullptr;
if (ptr) {
// 실행 안됨
}
if (!ptr) {
std::cout << "널 포인터\n"; // 실행됨
}
// 명시적 비교
if (ptr == nullptr) {
std::cout << "널 포인터\n"; // 실행됨
}
Q7: nullptr을 함수 오버로딩에서 어떻게 사용하나요?
A: nullptr은 포인터 오버로드를 명확하게 선택합니다.
void process(int x) {
std::cout << "int: " << x << '\n';
}
void process(int* ptr) {
std::cout << "pointer\n";
}
void process(std::nullptr_t) {
std::cout << "nullptr\n";
}
process(0); // "int: 0"
process(NULL); // "int: 0" (문제!)
process(nullptr); // "nullptr" 또는 "pointer"
int* p = nullptr;
process(p); // "pointer"
Q8: nullptr 학습 리소스는?
A:
- “Effective Modern C++” (Item 8: Prefer nullptr to 0 and NULL) by Scott Meyers
- cppreference.com - nullptr
- “C++ Primer” (5th Edition) by Stanley Lippman
관련 글: Pointer Basics, Smart Pointers.
한 줄 요약: nullptr은 타입 안전한 C++11 널 포인터 리터럴로, NULL과 0의 문제를 해결합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ nullptr vs NULL | “널 포인터” 가이드
- C++ enum class | “강타입 열거형” 가이드
- C++ 범위 기반 for | “Range-based for” 가이드
관련 글
- C++ nullptr vs NULL |
- C++ async & launch |
- C++ Atomic Operations |
- C++ Attributes |
- C++ auto 키워드 |