前言
可能有些朋友看到nullptr
会以为我在讲C++,然而正如标题所示这是“C语言篇”中的一篇文章,自然是要讲C语言的。没错,正如大家所料, C23 不幸地从 C++ 引入了nullptr
关键字和与之对应的nullptr_t
类型。虽然我的目标是讲C语言,但是为了搞明白这件事的来龙去脉,还是要涉及一些C++的内容。
什么是NULL
NULL是C语言预定义的一个“空指针常量”,定义在<stddef.h>
以及其他多个头文件里。
想要继续深究下去,要先弄懂 “空指针” 以及 “空指针常量” 的概念。
从定义上讲,任何类型的指针都有一个叫做“空指针”的值,表示不指向任何对象或者函数。空指针在比较时不与任何指向具体对象或函数的指针相等,但空指针与空指针始终是相等的,即便它们的类型不同。而创造空指针的方法就是使用“空指针常量”,它可以转换成任意指针类型,形成该类型的空指针。
从以上描述可以得出,“空指针”必须是指针类型,可以是常量也可以是变量;“空指针常量”显然必须是常量,但不必是指针类型。
在C23之前, “空指针常量” 有两种定义:
- 值为零的整数常量表达式
- 值为零的整数常量表达式,并显式转换为
void *
类型
从C23开始,有了第三种定义:
nullptr
第一种定义只说是整数,没规定具体类型,所以0
、0L
、0U
、(char)0
都是空指针常量。由于它只要求是个表达式,所以1 - 1
、-0
也是空指针常量。
POSIX 标准只提到了第二种定义。
主流编译器(严格来说是标准库),比如gcc和clang,把NULL
定义为((void*)0)
。可能有的编译器出于对兼容C++以及其他各种因素的考虑,把NULL
定义为0
。
为什么C++需要nullptr
nullptr
是C++11率先引入的关键字,专门用于表示“空指针常量”,所以我们还得从C++说起。
上文提到了C语言对“空指针常量”的定义,但C++有所不同,在C++11之前,C++的 “空指针常量” 只有一种定义:
- 值为零的整数字面量
这个定义就严格了不少,像1 - 1
和-0
这样的表达式就不行了。
最关键的是,它去掉了类型为void *
的定义,这又是为什么呢?
在C语言中任何指针类型都可以和void *
相互隐式转换,但是C++明确规定了任何类型的指针都可以隐式转换为void *
类型,反之则不行。像int *p = (void *)0;
这样的代码在C++中是不能通过编译的,所以void *
类型的值就失去了在C++中作为空指针常量的资格。
至于为什么C++要禁止void *
向其他指针类型的隐式转换,这又是一个能让程序员争得面红耳赤的问题了。
C++编译器通常把NULL
定义为0
。但对于gcc和clang,则是把NULL
定义为__null
,这是一个非标准的编译器扩展,它被视为字面量,值是零,类型是整数,大小与void *
相同,在 LP64 数据模型中它等同于0L
,当__null
被用于算术运算时编译器会给出警告。
然而这样的定义又引发了其他的问题。C++有一个叫“函数重载”的功能,多个函数可以共用一个函数名,调用时根据参数数量和类型自动选择合适的函数。思考下面一段代码:
#include <iostream>
#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)
传入了一个空指针常量,当然是要调用第二个版本,但实际情况是,按照标准,NULL
是整数,第一个版本更匹配。直接传NULL
和传空指针竟然有不同的行为!就算是gcc和clang的__null
,编译器也会报错说有歧义。
为了让程序员更安心地使用空指针常量,nullptr
就诞生了,它是一个常量,可以隐式地转换成任意指针类型,形成该类型的空指针,并且只能转换成指针,彻底跟整数切割,它的大小与void *
相同。nullptr
是关键字,可以直接用,不需要包含任何头文件。nullptr
有自己的专属类型nullptr_t
,定义在<cstddef>
和<stddef.h>
中。
using nullptr_t = decltype(nullptr);
C语言中的nullptr有必要吗
我们先来看一下C语言中的NULL
有什么问题。
- 如果
NULL
定义为((void*)0)
,C语言没有C++对void *
的限制,所以不会出现任何问题。 - 如果
NULL
定义为0
,C语言没有C++的函数重载,绝大多数情况下也没有问题,只在个别对类型和大小敏感的场景下可能有问题。
假设NULL
定义为0
,考虑以下场景:
场景一,_Generic
:
#include <stdio.h>
#ifdef NULL
#undef NULL
#endif
#define NULL 0
void fn_int(int arg) {
printf("int: %d\n", arg);
}
void fn_str(const char *arg) {
printf("string: %s\n", 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;
}
C语言的_Generic
可以实现类似C++函数重载的效果,尽管匹配方式必须手动指定。在这个例子里,我们指定了void *
和const char *
都走fn_str
,而int
走fn_int
。由于NULL
是0
,直接传NULL
和传空指针有不同的行为。
场景二,可变参数函数:
#include <stdio.h>
#include <stdarg.h>
#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 数据模型中,int
是 32位 ,指针是 64位 ,尽管“可变参数函数”在调用时会进行“默认参数提升”,但对于整数而言,也只是把小于int
的整数提升为int
,如果传入的是int
,则不会发生提升。所以在上面的代码中,我们实际上用64位的char *
解引用了一个32位的int
对象,尽管由于内存对齐等因素结果可能不受影响,但终究是引入了内存安全问题。
为了解决NULL
的类型问题,标准委员会的这帮人很自然地就想到了C++中的nullptr
,于是就把它引入到C语言了。nullptr
的类型是nullptr_t
,定义在<stddef.h>
中,它可以隐式地转换成任意指针类型,形成该类型的空指针,并且不能和整数互转。
nullptr_t
的大小和对齐方式都与void *
相同,nullptr_t
只有一个合法值nullptr
,值为nullptr
的对象和值为(void *)0
的对象拥有相同的数据格式,也就是说用指针去解引用nullptr
对象是安全的。
现在我们回到标题的问题,nullptr
是否有必要?
你会发现nullptr
实际上就是个限制了类型转换的((void*)0)
,除了类型以外它们两个几乎没有任何区别。如果要解决解决NULL
的类型问题,直接把它定义为((void*)0)
就行了。但是标准委员会选择引入新类型和关键字而不是修改已有规则,这满足了编译器的向后兼容(使用NULL
的旧代码仍然可以用新编译器编译),但放弃了代码的向后兼容(使用nullptr
的新代码无法使用旧的编译器编译)。
不考虑兼容性的情况下,nullptr
是否比((void*)0)
更好?我觉得是的。
nullptr
更严格、更有针对性,更不容易被滥用。
但nullptr
是否有必要?我觉得不是。
- 从功能上讲,所有需要
nullptr
的地方都可以用((void*)0)
代替。 - 从兼容性上讲,C语言程序员们对最新标准并不热衷,使用
nullptr
必然会导致大量兼容性问题。 - 从收益率上讲,仅在
NULL
定义为0
,并且仅在极个别场景里是有问题的,大多数人可能一辈子也不会遇到上面提到的问题,而使用nullptr
却会带来实实在在的成本。 - 从设计上讲,C语言应该是简单和灵活的,不能是个好东西就往里面加,不然会和C++一样臃肿。如果
nullptr
是有必要的,那么异常处理要不要加,模板要不要加,名字空间要不要加。所有改动都应该被严格审核,必须保证好处远大于坏处,并且不能显著增加复杂度。 - 从实用性上讲,众所周知C语言标准的地位一直比较尴尬,大部分C程序员都是面向编译器编程,甚至很多程序员压根不用
NULL
,而是根据实际情况选择对应的底层表示,nullptr
根本不会对他们有任何帮助。
考虑到C23同时引入的各种不向后兼容的改动,如果说标准委员会真的很在意兼容性我会觉得好笑。在我看来他们在C语言引入nullptr
的真正原因是为了让C语言看起来更像C++。
我该如何选择
综上所述,nullptr
是有用的,但不多,并且有兼容性问题。
如果你是个modern boy,不用最新标准浑身难受;或者你只是做做个人项目,做做习题,那么你自然想用什么就用什么。
如果你在做公司项目,或者公共开源软件,或者针对特定平台开发(这些平台的编译器通常较旧),那么我强烈不建议你使用nullptr
,你只需要知道NULL
在什么情况下可能有问题就行。