C++ nullptr | "널 포인터" 가이드

C++ nullptr | "널 포인터" 가이드

이 글의 핵심

nullptr vs NULL vs 0, 오버로딩·nullptr_t, 레거시 마이그레이션까지 다룹니다.

nullptr이란?

nullptr 은 C++11에서 도입된 타입 안전한 널 포인터 리터럴입니다. 기존 NULL0의 문제점을 해결하고, 포인터 타입에만 사용할 수 있는 명확한 널 값을 제공합니다.

// 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 등)종종 정수 쪽의도와 다를 수 있음구현에 따라 가능 (경고)
nullptrstd::nullptr_t일반적으로 아님포인터/nullptr_t 오버로드와 매칭불가 (타입 안전)

NULL 정의가 (void*)0인 환경과 단순 0인 환경에서 오버로드 해석이 달라질 수 있으므로, 이식성과 가독성 모두 **nullptr**이 유리합니다.

함수 오버로딩에서의 문제 (심화)

intint*를 동시에 받는 API는 레거시에서 흔합니다. 이때 NULL0은 “정수” 쪽으로 붙기 쉽고, 의도는 “포인터 없음”인데 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 이전 코드 마이그레이션

  1. 포인터 초기화·비교: = NULL, == NULL, == 0nullptr (의미가 “포인터 널”일 때).
  2. 매크로 NULL 제거: 헤더 의존을 줄이려면 <cstddef>nullptr만 쓰면 됩니다.
  3. 오버로드 깨짐 확인: f(NULL)이 예전에 int를 탔다면, f(nullptr)로 바꾼 뒤 단위 테스트·호출부를 점검합니다.
  4. C 코드와의 경계: C 컴파일 유닛에는 NULL이 남을 수 있습니다. C++ 쪽만 점진적으로 nullptr로 통일합니다.
  5. 컴파일러 경고: -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 널 포인터 리터럴로, NULL0의 문제를 해결합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • 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 키워드 |