空指针这事。
在 C++ 圈子里。
真吵了很多年。
有人说。
写 0 就行。
反正大家都懂。
有人说。
别装。
老代码都写 NULL。
还有人更直接。
说这不是风格问题。
是类型系统的问题。
要解决就得给“空”一个类型。
后来 C++11 给了答案。
nullptr。
我第一次真正记住它。
不是因为它“新”。
是因为它让我少背了一口锅。
那会儿我们在改一个老系统。
一半是 C。
一半是 C++。
两边之间靠一堆“祖传约定”勉强对齐。
其中一个约定就是。
空指针写 NULL。
某天线上日志变得很怪。
明明传的是“空”。
却走进了 int 的重载。
输出还挺理直气壮。
调用点干净到让人不设防。
log(NULL);
我当时心里还在想。
这还能错?
然后我去翻那两个重载。
翻完就懂了。
这不是业务 bug。
这是历史。
更准确地说。
这是 C 和 C++ 一路演进。
留下的一笔旧账。
nullptr 的胜出也在这里。
它不是为了好看。
它是为了让“空”这件事。
别再靠猜。
很久以前:NULL 只是个宏
在 C 里。
“空指针”这个概念其实没那么神秘。
很多时候。
它就是 0。
你写 p == 0。
大家都明白你在问什么。
后来为了让代码更像人话。
头文件给 0 起了个外号。
叫 NULL。
但它的本质仍然很朴素。
一个宏。
展开成 0 或者 0L。
你自己去翻不同平台的头文件。
经常能看到类似的东西。
#define NULL 0
或者。
#define NULL 0L
这在 C 的年代很少出事。
因为 C 没有函数重载。
你也不会在一堆候选函数里。
让编译器替你“挑一个最合适的”。
但到了 C++。
语言开始变得“聪明”。
更准确地说。
C++98 时代。
标准就已经把“空指针常量”定义成了 0。
也就是。
语言层面承认了这件事。
“0 有时代表空指针。”
当时看起来合理。
因为大多数代码还很朴素。
大家也没把重载和模板玩到今天这个密度。
后来问题越积越多。
有些编译器甚至偷偷给 NULL 开过小灶。
比如用一个特殊的内部值。
让它“更像指针”。
但这种东西你一跨平台就失灵。
所以委员会最后还是走了正路。
给空指针一个关键字。
聪明的代价就是。
它得做选择。
而 NULL 这种东西。
在选择题里很容易变成坑。
你以为你在传“空”。
编译器以为你在传“0”。
最典型的坑:重载决议
你写两个重载。
一个收整数。
一个收指针。
这在工程里很常见。
比如日志接口。
你想兼容老代码。
又想支持指针参数。
#include <cstdio>
void log(int) {
std::puts("int");
}
void log(const char*) {
std::puts("ptr");
}
你心里想的是。
传 NULL 就走指针那条。
结果它很可能走 int。
int main() {
log(NULL); // 很可能打印 int
}
不是编译器跟你对着干。
是它按规则办事。
NULL 在很多实现里就是 0。
对 log(int) 来说。
这是一个“完美匹配”。
对 log(const char*) 来说。
它需要从 0 做一次“空指针常量到指针”的转换。
所以它输得很干脆。
于是你以为自己表达的是“空指针”。
实际表达的是“一个值为 0 的整数”。
最难受的是。
它不一定报错。
它可能是一个“合法但错误的选择”。
nullptr 的第一件事:把语义钉死
nullptr 是一个关键字。
它不是宏。
它有类型。
它表达的就是空指针。
int main() {
log(nullptr); // 打印 ptr
}
你不需要解释。
读代码的人也不需要猜。
编译器也不会再把“空”当成“0”。
你就把这一类历史遗留歧义。
直接切掉了。
nullptr 的类型:std::nullptr_t
nullptr 的类型叫 std::nullptr_t。
你在 <cstddef> 里能拿到它。
#include <cstddef>
#include <type_traits>
static_assert(std::is_same_v<decltype(nullptr), std::nullptr_t>);
你可以把它理解成。
标准库专门留出来的一个“空值类型”。
它不是整数。
也不想让你拿它去当整数。
int main() {
// int x = nullptr; // ❌ 不再允许把空指针当成整数
const char* p = nullptr; // ✅
(void)p;
}
这个类型很“专用”。
它能转换成任意的原始指针类型。
也能转换成任意的成员指针类型。
但它不再“顺手”转换成整数。
这就是设计的核心。
让你在需要的时候显式表态。
最先感受到的变化:代码终于不用“靠语境猜”
老代码里最常见的空指针写法。
不是 NULL。
是 0。
int* p = 0;
它当然能用。
但它也很像一段暗号。
你得靠上下文去猜。
这行 0 是长度?
是错误码?
还是“没有对象”?
后来大家改用 NULL。
int* p = NULL;
读起来像人话一点。
但本质还是那个宏。
你没法从语法上确定。
它到底是不是“空指针”语义。
C++11 之后我更喜欢一句话。
int* p = nullptr;
它不靠习惯。
也不靠团队约定。
它靠类型系统。
好接口的标准很朴素。
让正确的用法更自然。
让错误的用法更别扭。
auto 和 nullptr:你以为是指针,其实是“空值类型”
有个小细节。
很多新人第一次会踩。
auto p = nullptr;
这时候 p 不是 int*。
它就是 std::nullptr_t。
也就是“空值本身”。
你可以把它交给某个具体指针。
int* ip = p;
但你别把它当成“某种指针对象”。
这句写法更像是。
你先在桌上放了一个空盒子。
等会儿再决定要装哪一类东西。
如果你一开始就知道类型。
那还是把类型写出来。
更稳。
int* p = nullptr;
模板里的一点现实:nullptr 会暴露你的接口问题
很多人第一次把 nullptr 带进模板。
会遇到一个小惊讶。
这类惊讶通常发生在“我写了个很泛型的小工具”之后。
你觉得它什么都能吃。
结果它偏偏不吃 nullptr。
template <class T>
void takes_ptr(T*);
int main() {
takes_ptr(nullptr); // 这里通常会报错:无法推导 T
}
原因不复杂。
模板参数推导这一步。
不喜欢“猜”。
它不会因为 nullptr 能转成 T*。
就替你把 T 猜出来。
它只看一件事。
实参类型能不能直接对上 T*。
而 nullptr 的类型是 std::nullptr_t。
它不是 T*。
所以推导停在这里。
很多人第一反应是。
“怎么这么不智能。”
我反而觉得这挺诚实。
你传了空。
那你到底想让它是哪一种指针的空?
如果你自己都不说清楚。
编译器凭什么替你猜。
这也是 nullptr 给工程带来的洞见。
它会把那些“本来就模糊”的接口。
逼得更清楚一点。
你可以像很多库那样。
专门给空值一个入口。
#include <cstddef>
template <class T>
void takes_ptr(T*);
void takes_ptr(std::nullptr_t) {
// 专门处理“传了空指针”这件事
}
或者你在调用点自己把类型说出来。
takes_ptr<int>(nullptr);
总之。
nullptr 不会帮你把设计糊过去。
它会逼你把语义立起来。
智能指针时代:nullptr 更像“标准口令”
到今天。
很多项目已经不怎么手写 new 了。
指针更多是 unique_ptr、shared_ptr。
你会发现 nullptr 在这里特别自然。
#include <memory>
std::unique_ptr<int> make() {
return nullptr;
}
这个返回值的意思很明确。
没有对象。
也没有所有权。
再比如判断。
std::unique_ptr<int> p = nullptr;
if (!p) {
}
这里我更推荐写 if (!p)。
不是因为 p == nullptr 不对。
而是因为。
你在表达“有没有资源”。
而不是表达“跟哪个空值相等”。
写久了你会发现。
语义更干净。
你可能还会遇到:成员指针也有“空”
成员指针是 C++ 很老的一块语法。
很多人平时不写。
但它确实存在。
nullptr 也覆盖了它。
struct S {
int x;
};
int S::* pm = nullptr;
这也是 std::nullptr_t 的设计目标之一。
它不是“void* 的 0”。
它是“空指针语义”的统一入口。
小结:这不是“语法糖”,是给历史擦屁股
NULL 的问题不在于它不能用。
而在于它太像整数。
它让“空指针”这件事。
混在了“0”这件事里。
在没有重载的年代。
这还算能忍。
到了 C++。
重载。
模板。
类型推导。
这些东西一上来。
你就会发现。
模糊的语义会开始要利息。
nullptr 做的事很朴素。
把“空”从宏里拎出来。
变成一个类型。
让编译器有资格替你当坏人。
如果你在写 C++11 及之后的代码。
我不太建议再写 NULL。
更别写 0。
需要空指针的时候。
就写 nullptr。