nullptr 取代 NULL 和 0:空指针的三种写法和唯一正确解

0 是整数,NULL 在多数实现里也只是 0 的宏,而 nullptr 是 C++11 引入的、专门表示空指针的独立类型 std::nullptr_t。这一个差别,在函数重载和模板推导里能悄无声息地选错版本。这篇用 static_assert + std::is_same 把三种写法的真实类型钉出来,并说清那个经典陷阱 f(NULL)。

官方文档:nullptr、std::nullptr_t、C++ Core Guidelines:ES.47 用 nullptr

1. 引子:f(0) 调到了 f(int),不是 f(char*)

先来看为什么「用 0 当空指针」是隐患:

cpp 复制代码
void f(int)   { /* 处理整数 */ }
void f(char*) { /* 处理指针 */ }

// f(0);    // 你以为是「传空指针」,其实选中了 f(int)!

0 是 int,自然去匹配 f(int)。想传「空指针」却进了对整数的分支------这正是 nullptr 要解决的。

2. NULL 为何是陷阱:重载决议选中 int

NULL 的底层类型是实现定义的(implementation-defined):在把 NULL 定义成 0 的编译器里,它会静默选中 f(int);在把 NULL 定义成内建空指针常量的 gcc/clang 里,它甚至会让重载直接歧义(ambiguous) 。两种结果都说明同一件事:NULL 不可靠。先看 0 和 nullptr 的路由:

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <cstddef>

void f(int)   { std::cout << "f(int)\n"; }
void f(char*) { std::cout << "f(char*)\n"; }

int main() {
    f(0);        // 0 是 int,选中 f(int)
    f(nullptr);  // nullptr 是 std::nullptr_t,正确选中 f(char*)
}
text 复制代码
f(int)
f(char*)

那 f(NULL) 呢?在本文用的 gcc-13.2.0 上,它编译失败------调用歧义:

text 复制代码
prog.cc: 错误:对 'f(NULL)' 的调用有歧义(ambiguous)
  candidate: void f(int)
  candidate: void f(char*)

而在把 NULL 定义成 0 的老编译器里,f(NULL) 会静默选中 f(int) ,比歧义更隐蔽。0 和 NULL 都让「本想传空指针」的调用跑偏,只有 nullptr 这种独立的指针类型能正确路由到 f(char*)。

3. 模板推导下的差异:这才是 nullptr 的真正优势

重载还能靠「写对类型」规避,模板推导里 NULL 的坑几乎无法绕开。把实参交给模板,T 的推导结果说明一切:

cpp 复制代码
#include <iostream>
#include <type_traits>
#include <typeinfo>
#include <cstddef>

template <typename T>
void deduce(T) {
    std::cout << "  T 是 int       = " << std::is_same<T, int>::value << '\n';
    std::cout << "  T 是 nullptr_t = " << std::is_same<T, std::nullptr_t>::value << '\n';
    std::cout << "  T 实际类型名   = " << typeid(T).name() << '\n';
}

int main() {
    std::cout << "传入 0:\n";       deduce(0);
    std::cout << "传入 NULL:\n";    deduce(NULL);
    std::cout << "传入 nullptr:\n"; deduce(nullptr);
}
text 复制代码
传入 0:
  T 是 int       = 1
  T 是 nullptr_t = 0
  T 实际类型名   = i
传入 NULL:
  T 是 int       = 0
  T 是 nullptr_t = 0
  T 实际类型名   = l
传入 nullptr:
  T 是 int       = 0
  T 是 nullptr_t = 1
  T 实际类型名   = Dn

typeid(T).name() 打印的是编译器内部的「mangled 名」:i 即 int,Dn 即 decltype(nullptr)(也就是 std::nullptr_t),l 即 long。注意 NULL 既不是 i 也不是 Dn------在本编译器上它的类型是 long(gcc 把内建 __null 暴露成 long)。铁证:0 推导成 int、nullptr 推导成 std::nullptr_t,而 NULL 推导成 long------一个整数类型。既然 NULL 是整数,f(NULL) 在重载里既可能匹配 int、又因 __null 可转指针而歧义。如果模板里想根据「是不是空指针」做偏特化或分支,0/NULL 会让你整个分支失效。

4. 用 static_assert 把类型钉死

不靠打印,也能在编译期强制证明 nullptr 的类型:

cpp 复制代码
#include <iostream>
#include <type_traits>
#include <cstddef>

int main() {
    static_assert(std::is_same<decltype(nullptr), std::nullptr_t>::value, "");
    std::cout << "nullptr 的类型是 nullptr_t = "
              << std::is_same<decltype(nullptr), std::nullptr_t>::value << '\n';
}
text 复制代码
nullptr 的类型是 nullptr_t = 1

注:NULL 的底层类型依赖实现(多为 int 或编译器的内建空指针常量),所以不能用 std::is_same<decltype(NULL), std::nullptr_t> 来假设------这恰恰说明它不可靠。

5. 为什么标准不删掉 NULL:向后兼容

既然 NULL 有坑,为什么 C++ 不干脆删掉?因为删除会破坏海量已有代码 。无数老项目、老库、老头文件里写着 NULL,删掉它们就编译不过。C++ 的设计哲学是「不轻易破坏已有代码」,所以 NULL 被保留,但新代码一律用 nullptr。标准甚至保留了 NULL 的「实现定义空指针常量」语义,只是明明白白地建议你别再用它(见 Core Guidelines ES.47)。

text 复制代码
三种空指针写法对比
┌─────────┬───────────────────┬───────────────────────────────┐
│ 写法    │ 真实类型          │ 后果                          │
├─────────┼───────────────────┼───────────────────────────────┤
│ 0       │ int               │ 重载/模板里误选 int 版本      │
│ NULL    │ 多为 int(实现相关)│ 同上,且跨编译器行为不一致  │
│ nullptr │ std::nullptr_t    │ 专为空指针设计,正确选中指针  │
└─────────┴───────────────────┴───────────────────────────────┘

6. 完整示例:函数参数用 nullptr 才类型安全

把前面所有点落到一个真实接口:reset 接收指针,用 nullptr 判空才清晰、类型安全。

cpp 复制代码
#include <iostream>
#include <cstddef>

void reset(int* p) {
    if (p == nullptr) {
        std::cout << "p 是空指针\n";
    } else {
        std::cout << "p 指向 " << *p << '\n';
    }
}

int main() {
    int x = 5;
    reset(&x);       // 非空
    reset(nullptr);  // 正确表达「无」
    // reset(0);      // 反例,不要这么写:0 当空指针,类型意图模糊
    // reset(NULL);   // 反例,不要这么写:NULL 实现相关,易踩重载坑
}
text 复制代码
p 指向 5
p 是空指针

7. 空指针判空的实践规范

把「空指针」写对只是一半,判空怎么写 是另一半。if (p)、if (p != nullptr)、if (!p) 三者都能用,但表达力和适用范围并不相同:

写法 适用对象 实际语义 建议
if (p != nullptr) 裸指针 显式比较地址,意图最直白 首选,团队统一写法
if (p) 裸指针 靠指针到 bool 的转换,等价于上面 可用,短表达式里更简洁
if (!p) 裸指针 「非空取反」,容易读成「p 为假」 少用
if (p == 0) --- 又把 0 请回来当空指针常量 禁止,NULL 的坑原样重演
if (sp) 智能指针 调用 explicit operator bool 推荐
if (sp != nullptr) 智能指针 同样合法,语义最直白 推荐

智能指针之所以能直接 if (sp),是因为 unique_ptr / shared_ptr 都提供了 explicit operator bool。这个 explicit 是关键:它只在语境转换(contextual conversion) ------也就是 if、while、&&、||、! 的操作数位置------才会被调用,不能隐式转成 int 。于是 if (sp == 0) 这种「指针和整数比较」的老事故在智能指针上根本写不出来,编译器直接拒绝。这也顺带解释了为什么 explicit operator bool 要设计成 explicit:不是不让用,而是只在「当条件」这一个语境里放行。

还有一点值得记住:p != nullptr 和 !p 并不总是同义 。裸指针上两者等价(都判地址是否为零);但如果类型自定义了 operator bool 或 operator!------比如某些句柄类、延迟初始化类------!p 会走类型自己的逻辑,「非空」之外还可能带上「已初始化」「已打开」等额外含义。碰到这类类型,判空一律用它自己提供的语义,别退回去写 == nullptr。

8. nullptr 与模板、与 std::optional 的衔接

模板是 NULL 的坑最难绕开的地方:第 3 节已经实证 NULL 会被推导成 long 这样的整数类型,泛型代码里一旦按「是不是空指针」做分支或偏特化,用 NULL 传参的那条路就会整条失效。写泛型接口时,把「可能为空」的指针形参默认值一律写成 nullptr:

cpp 复制代码
template <typename T>
void use(T* ptr = nullptr);      // 正确:默认值是真正的空指针类型

// template <typename T>
// void use(T* ptr = NULL);      // 反例,不要这么写:NULL 实现相关,模板里更不可控

想在编译期判断「某个类型是不是空指针类型」,C++14 起有专门的 std::is_null_pointer,比手写 std::is_same<T, std::nullptr_t> 更达意:

设施 版本 作用 典型用途
std::nullptr_t C++11 空指针的独立类型 形参限定「只接受空指针」
decltype(nullptr) C++11 同一个类型,少打几个字符 模板里的类型标注
std::is_null_pointer<T> C++14 编译期判断 T 是否为空指针类型 类型分派、if constexpr 分支

那「可能没有值」这件事,到底该用空指针还是 std::optional<T>?原则很简单:「不拥有对象的、可选的引用」用指针 + nullptr;「可能缺失的值」用 std::optional 。optional 把「有没有值」编进了类型里,取值必须先判 has_value(),不会像裸指针那样一不小心就解引用;而指针天然表达「指向某个已存在的对象,也可能什么都不指」,还能直接参与多态。两者职责不同,不是替代关系。

把重载、模板推导、is_null_pointer 三件事放进一个程序里验证一遍:

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <type_traits>
#include <cstddef>

// 重载:三个版本分别接收「整数」「字符指针」「空指针」
void route(int)            { std::cout << "route(int)\n"; }
void route(char*)          { std::cout << "route(char*)\n"; }
void route(std::nullptr_t) { std::cout << "route(nullptr_t)\n"; }

// 模板:看 T 到底被推导成了什么
template <typename T>
const char* kind() {
    if (std::is_null_pointer<T>::value) return "空指针类型";
    if (std::is_same<T, int>::value)    return "整数类型";
    return "其他类型";
}

template <typename T>
void deduce(T) { std::cout << "  T 的类别: " << kind<T>() << '\n'; }

int main() {
    route(0);          // 0 是 int,精确匹配 route(int)
    route(nullptr);    // nullptr 是 nullptr_t,有专属重载时精确匹配胜出

    std::cout << "模板推导 0:\n";
    deduce(0);

    std::cout << "模板推导 nullptr:\n";
    deduce(nullptr);
}
text 复制代码
route(int)
route(nullptr_t)
模板推导 0:
  T 的类别: 整数类型
模板推导 nullptr:
  T 的类别: 空指针类型

注意 route(nullptr) 的结果:这里同时存在 route(int)、route(char*) 和 route(std::nullptr_t) 三个重载,nullptr 精确匹配 std::nullptr_t(恒等转换),比「转成 char*」的指针转换更优,所以胜出的是第三个。而 0 依旧一头扎进 route(int)------哪怕旁边就住着一个 char* 版本。同一件事写 0 和写 nullptr,走的是完全不同的两条路。

9. 延伸阅读

10. 一句话总结

0 是 int、NULL 在多数实现里也只是 0,所以重载和模板推导里都会误选 int 版本;nullptr 是独立的 std::nullptr_t 类型,能正确选中指针重载、推导成 std::nullptr_t。标准保留 NULL 只为向后兼容------新代码一律写 nullptr,别再用 0 或 NULL 当空指针。

相关推荐
大侠归来1 小时前
C++ 与 Lua 的协程交互:从原理到实战
c++·lua·交互
无名猿1 小时前
size_t 与 int 混用陷阱:有符号无符号比较与整数提升
c++·标准库·语法基础·踩坑记录
.道阻且长.1 小时前
C++ 11:可变参数模板
前端·c++·算法
m0_380743871 小时前
Qt实现调用相机进行拍照并进行图像处理
开发语言·c++
励志不掉头发的内向程序员3 小时前
【从零写一个CAD 03】三个 double 值得单独一个类吗:把视图变换抽成 View
开发语言·c++·qt·学习·系统架构
蒸蒸yyyyzwd10 小时前
秋招学习笔记 day46
c++·八股
码匠许师傅13 小时前
【C++三方组件】cpp-httplib:一个头文件起 HTTP 服务
开发语言·c++·http
无名猿14 小时前
new/delete 与 malloc/free:为什么绝对不能混用
c++·内存管理·现代c++·踩坑记录
2601_9622186116 小时前
C++中decltype关键字的实现
开发语言·c++