位置:首页 > C > C23 引入 nullptr 之后,C 语言里的 NULL 该怎么看

C23 引入 nullptr 之后,C 语言里的 NULL 该怎么看

时间:2026-08-24  |  作者:深海捕梦者  |  阅读:0

目录

  1. NULL 到底是什么
  2. C++ 当年为什么一定要引入 nullptr
  3. C23 把 nullptr 带进 C,主要想解决什么
  4. C 里的 nullptr 和 nullptr_t 有哪些语义
  5. 它有用,但真的有必要吗
  6. 项目里到底该怎么选

前言

看到 nullptr,很多人第一反应会把话题归到 C++。但这次讨论的重点其实是 C23:它正式把 nullptr 关键字和对应的 nullptr_t 类型引入了 C 语言。问题也随之而来:这项改动到底是在补真实短板,还是只是把 C++ 的做法搬了过来? 要回答这个问题,不能只看新语法本身,还得回到 NULL、空指针、空指针常量这些老概念,再结合 C++ 当年为什么要发明 nullptr。把这些背景理顺之后,才能判断在自己的项目里该不该用、值不值得用。

看到 nullptr,很多人第一反应会把话题归到 C++。但这次讨论的重点其实是 C23:它正式把 nullptr 关键字和对应的 nullptr_t 类型引入了 C 语言。问题也随之而来:这项改动到底是在补真实短板,还是只是把 C++ 的做法搬了过来?

要回答这个问题,不能只看新语法本身,还得回到 NULL、空指针、空指针常量这些老概念,再结合 C++ 当年为什么要发明 nullptr。把这些背景理顺之后,才能判断在自己的项目里该不该用、值不值得用。

NULL 到底是什么

NULL 是 C 语言里预定义的“空指针常量”,通常定义在 以及其他多个头文件中。

展示 C23 之前和 C23 之后空指针常量定义变化,以及 NULL 常见实现形式的信息图
C 语言空指针常量的三种来源用一张图先厘清“空指针”“空指针常量”与 C23 新增定义的关系。

这里先区分两个容易混淆的概念:

  • 空指针:某种指针类型的一个特殊值,表示它不指向任何对象或函数。
  • 空指针常量:一种常量表达式,它可以转换成任意指针类型,并得到该类型的空指针。

换句话说,空指针本身一定是指针类型;空指针常量则不一定是指针类型,但它必须能生成空指针。

在 C23 之前,C 对“空指针常量”的定义有两种:

  1. 值为零的整数常量表达式
  2. 值为零的整数常量表达式,并显式转换为 void * 类型

从 C23 开始,第三种形式也被纳入标准:

  1. nullptr

因为第一种定义只要求“值为零的整数常量表达式”,并没有限定具体类型,所以 00L0U(char)0 都算;进一步说,1 - 1-0 这样的表达式也符合定义。

POSIX 只提到了第二种定义。至于具体实现,主流标准库常见的写法是把 NULL 定义为 ((void*)0),例如 gcc 和 clang 常见环境就是如此;但也有实现会为了兼容 C++ 等原因把 NULL 定义为 0

C++ 当年为什么一定要引入 nullptr

nullptr 最早来自 C++11。要理解它为什么出现,关键不是语法,而是 C++ 对空指针常量和类型转换的规定与 C 不同。

展示 C++ 中 NULL 引发重载歧义,以及 nullptr 如何切断与整数关系的信息图
C++ 中 NULL 的歧义从哪来这一节最关键的不是语法,而是类型系统差异。图里把 C++ 重载选择过程和 nullptr。

在 C++11 之前,C++ 中“空指针常量”只有一种标准定义:值为零的整数字面量。这比 C 更严格,像 1 - 1-0 这样的表达式在 C++ 里就不算空指针常量。

更关键的是,C++ 没有沿用 C 中那条“void * 也能作为空指针常量”的思路。因为在 C++ 里,任意对象指针都可以隐式转换为 void *,但反过来不行。像下面这样的代码,在 C++ 中不能通过编译:

int *p = (void *)0;

这意味着 void * 类型的零值,在 C++ 里失去了“可无条件变成任意指针”的资格,也就不适合继续承担空指针常量的角色。

于是很多 C++ 实现把 NULL 定义成 0。而在 gcc 和 clang 中,还存在一个非标准扩展 __null:它被视作字面量,值为零,类型是整数,大小与 void * 相同;在 LP64 数据模型中,它等同于 0L。不过当 __null 用于算术运算时,编译器会给出警告。

真正把问题暴露出来的是 C++ 的函数重载。看下面这段代码:

#include 

#ifdef NULL
#undef NULL
#endif
#define NULL 0

void fn(int arg) {
    std::cout << "int: " << arg << std::endl;
}

void fn(const char *arg) {
    std::cout << "string: " << (arg  arg : "nil") << std::endl;
}

int main() {
    const char *s = NULL;
    fn(s);
    fn(NULL);
    return 0;
}

fn(s) 会调用指针版本,这没有争议。但 fn(NULL) 按直觉像是在传空指针,标准却会把它当成整数常量,结果更匹配 fn(int)。也就是说,“直接传 NULL” 和 “传一个空指针变量” 在重载解析里可能不是一回事。如果用 gcc/clang 的 __null,这里通常又会变成“调用有歧义”。

这正是 nullptr 诞生的直接原因:它专门表示空指针常量,可以隐式转换成任意指针类型,但不能再和整数混用,从根上避开重载与类型推断里的歧义。

nullptr 是关键字,不需要包含任何头文件;它还有一个专属类型 nullptr_t,定义在 中:

using nullptr_t = decltype(nullptr);

C23 把 nullptr 带进 C,主要想解决什么

回到 C 语言,其实要先看 NULL 在 C 里究竟有没有现实问题。

展示 C 语言里 NULL 为 0 时在 _Generic 和可变参数函数中可能出现的问题,以及工程选择建议的信息图
C 里 NULL=0 的两个典型风险C23 引入 nullptr 的现实价值主要体现在少数类型敏感场景。
  • 如果 NULL 定义为 ((void*)0),那么在 C 中通常不会出现 C++ 那类问题,因为 C 允许 void * 与其他对象指针之间进行隐式转换。
  • 如果 NULL 定义为 0,那在绝大多数普通代码里也没事;但只要场景对“类型”和“实参宽度”足够敏感,就可能出问题。

这也是 C23 引入 nullptr 的背景:它不是为了修复所有 C 代码,而是为那些“NULL 恰好被定义为整数零,且代码又依赖精确类型语义”的场景提供更明确的写法。

场景一:_Generic 会把 NULL 当成整数

先看 _Generic

#include 

#ifdef NULL
#undef NULL
#endif
#define NULL 0

void fn_int(int arg) {
    printf("int: %dn", arg);
}

void fn_str(const char *arg) {
    printf("string: %sn", arg  arg : "nil");
}

#define FN(X) _Generic((X), 
    int: fn_int, 
    void *: fn_str, 
    char *: fn_str, 
    const char *: fn_str 
)(X);

int main() {
    const char *s = NULL;
    FN(s);
    FN(NULL);
    return 0;
}

这里的 _Generic 模拟了“按类型分派”的效果:intfn_int,字符串指针走 fn_str。问题在于,如果 NULL 被定义成 0,那么 FN(NULL) 匹配到的就是 int,而不是指针分支。

这和 C++ 重载里的问题本质类似:语义上你想表达空指针,类型系统却先把它认成了整数

场景二:可变参数函数里的宽度风险

另一个更值得警惕的场景是可变参数函数:

#include 
#include 

#ifdef NULL
#undef NULL
#endif
#define NULL 0

void fn(int count, ...) {
    va_list ptr;
    va_start(ptr, count);
    for (int iter = 0; iter < count; iter++) {
        const char *str = va_arg(ptr, char *);
        puts(str  str : "nil");
    }
    va_end(ptr);
}

int main(void) {
    const char *s = NULL;
    fn(3, s, NULL, "hello, world");
    return 0;
}

LP64 数据模型中,int32 位,指针是 64 位。可变参数调用会发生默认参数提升,但整数提升最多只是把比 int 更小的整数提升到 int;如果你传入的本来就是 int,那它仍然是 32 位整数。

于是当 NULL 实际上传进去的是 0,而读取时你却用 va_arg(ptr, char *) 把它当作 64 位指针取出,就产生了类型和宽度不匹配的问题。即便在某些平台上由于调用约定或对齐规则,程序看起来还能“正常工作”,这依然是带有内存安全风险的未定义或不可靠行为。

从这个角度看,C23 的 nullptr 确实解决了一个真实问题:它让“这是空指针而不是整数零”这件事变得更明确,也能避免在某些边角场景里把整数当指针传递。

C 里的 nullptr 和 nullptr_t 有哪些语义

在 C23 中,nullptr 的类型是 nullptr_t,定义在 中。它可以隐式转换成任意指针类型,从而得到对应类型的空指针;同时它不能和整数自由互转。

标准还给了它几个很关键的性质:

  • nullptr_t 的大小和对齐方式与 void * 相同。
  • nullptr_t 只有一个合法值,也就是 nullptr
  • 值为 nullptr 的对象,与值为 (void *)0 的对象具有相同的数据格式。

这意味着,至少从对象表示的角度看,它与传统空指针表示保持了非常强的兼容关系。也正因为这样,很多人会觉得它本质上就是“加了类型限制的 ((void*)0)”。

它有用,但真的有必要吗

如果把“是否有用”和“是否有必要”拆开看,结论会更清楚。

先说有用。 从类型约束的角度,nullptr 的确更严格,也更明确。它专门表示空指针,不容易被误用成整数零;在 _Generic、可变参数、类型推导等敏感场景里,确实能减少歧义。

但再问有没有必要,答案就未必一样。

  • 从功能上看,很多需要 nullptr 的地方,本来就可以通过 ((void*)0) 避开问题。
  • 从兼容性看,标准委员会选择新增关键字和新类型,而不是直接统一 NULL 的定义,这保住了编译器对旧代码的兼容,却牺牲了新代码对旧编译器的兼容。使用 nullptr 的 C23 代码,无法在老编译器上直接通过。
  • 从收益看,问题只会出现在 NULL 恰好是 0 且代码又依赖特殊类型语义的少数场景,很多项目可能长期都碰不到。
  • 从语言设计看,C 一向强调简单和灵活。一个特性能不能进入语言,不只是看它“好不好”,还要看它是否值得增加新的复杂度。
  • 从实际开发看,很多 C 程序员本来就更关注编译器和平台行为,而不是最新标准特性;有些代码甚至不会直接使用 NULL,而是按项目约束选择更底层的写法。

所以,一个比较稳妥的判断是:nullptr 在 C23 里有现实意义,但它解决的问题范围并不大,远不到“没有它就不行”的程度。

项目里到底该怎么选

如果只看语言语义,nullptr 的确更干净,也更不容易引发误判。但工程上做选择,不能只看语义,还得看编译器版本、目标平台和团队约束。

比较实际的建议是:

  • 如果你用的是较新的工具链,项目本身也明确以 C23 为基线,那么使用 nullptr 没问题,尤其是在 _Generic、可变参数等对类型很敏感的代码里,它能表达得更直接。
  • 如果你做的是公司项目、公共开源软件,或者目标平台上的编译器普遍偏旧,那么应优先考虑兼容性,此时不建议贸然引入 nullptr
  • 如果项目只是常规 C 代码,且 NULL 在当前实现里本来就定义为 ((void*)0),那么为了这点收益专门切到新特性,价值通常不高。

归根结底,C23 引入 nullptr 不等于它马上会成为 C 项目的默认写法。对大多数人来说,更重要的不是“立刻改用它”,而是知道 NULL 在哪些边角情况下会暴露出类型问题,然后在需要时做出有依据的选择。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多