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 当空指针」是隐患:
voidf(int){/* 处理整数 */}voidf(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的路由:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<cstddef>voidf(int){std::cout<<"f(int)\n";}voidf(char*){std::cout<<"f(char*)\n";}intmain(){f(0);// 0 是 int,选中 f(int)f(nullptr);// nullptr 是 std::nullptr_t,正确选中 f(char*)}f(int) f(char*)那f(NULL)呢?在本文用的 gcc-13.2.0 上,它编译失败——调用歧义:
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的推导结果说明一切:
#include<iostream>#include<type_traits>#include<typeinfo>#include<cstddef>template<typenameT>voiddeduce(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';}intmain(){std::cout<<"传入 0:\n";deduce(0);std::cout<<"传入 NULL:\n";deduce(NULL);std::cout<<"传入 nullptr:\n";deduce(nullptr);}传入 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 实际类型名 = Dntypeid(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的类型:
#include<iostream>#include<type_traits>#include<cstddef>intmain(){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';}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)。
三种空指针写法对比 ┌─────────┬───────────────────┬───────────────────────────────┐ │ 写法 │ 真实类型 │ 后果 │ ├─────────┼───────────────────┼───────────────────────────────┤ │ 0 │ int │ 重载/模板里误选 int 版本 │ │ NULL │ 多为 int(实现相关)│ 同上,且跨编译器行为不一致 │ │ nullptr │ std::nullptr_t │ 专为空指针设计,正确选中指针 │ └─────────┴───────────────────┴───────────────────────────────┘6. 完整示例:函数参数用 nullptr 才类型安全
把前面所有点落到一个真实接口:reset接收指针,用nullptr判空才清晰、类型安全。
#include<iostream>#include<cstddef>voidreset(int*p){if(p==nullptr){std::cout<<"p 是空指针\n";}else{std::cout<<"p 指向 "<<*p<<'\n';}}intmain(){intx=5;reset(&x);// 非空reset(nullptr);// 正确表达「无」// reset(0); // 反例,不要这么写:0 当空指针,类型意图模糊// reset(NULL); // 反例,不要这么写:NULL 实现相关,易踩重载坑}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:
template<typenameT>voiduse(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三件事放进一个程序里验证一遍:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<type_traits>#include<cstddef>// 重载:三个版本分别接收「整数」「字符指针」「空指针」voidroute(int){std::cout<<"route(int)\n";}voidroute(char*){std::cout<<"route(char*)\n";}voidroute(std::nullptr_t){std::cout<<"route(nullptr_t)\n";}// 模板:看 T 到底被推导成了什么template<typenameT>constchar*kind(){if(std::is_null_pointer<T>::value)return"空指针类型";if(std::is_same<T,int>::value)return"整数类型";return"其他类型";}template<typenameT>voiddeduce(T){std::cout<<" T 的类别: "<<kind<T>()<<'\n';}intmain(){route(0);// 0 是 int,精确匹配 route(int)route(nullptr);// nullptr 是 nullptr_t,有专属重载时精确匹配胜出std::cout<<"模板推导 0:\n";deduce(0);std::cout<<"模板推导 nullptr:\n";deduce(nullptr);}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当空指针。