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. 延伸阅读
- nullptr --- cppreference:
nullptr与std::nullptr_t的语义 - std::nullptr_t --- cppreference:这个独立类型的可 convertible 行为
- C++ Core Guidelines:ES.47 用 nullptr 而非 0 或 NULL:官方为什么推荐
- Compiler Explorer:对比
f(NULL)与f(nullptr)在重载下的汇编差异 - std::is_null_pointer --- cppreference:C++14 起的编译期空指针类型判断
- std::unique_ptr --- cppreference:看它的
operator bool为什么是explicit
10. 一句话总结
0 是 int、NULL 在多数实现里也只是 0,所以重载和模板推导里都会误选 int 版本;nullptr 是独立的 std::nullptr_t 类型,能正确选中指针重载、推导成 std::nullptr_t。标准保留 NULL 只为向后兼容------新代码一律写 nullptr,别再用 0 或 NULL 当空指针。