C语言篇:NULL与nullptr

179 阅读9分钟

前言

可能有些朋友看到nullptr会以为我在讲C++,然而正如标题所示这是“C语言篇”中的一篇文章,自然是要讲C语言的。没错,正如大家所料, C23 不幸地C++ 引入了nullptr关键字和与之对应的nullptr_t类型。虽然我的目标是讲C语言,但是为了搞明白这件事的来龙去脉,还是要涉及一些C++的内容。

什么是NULL

NULL是C语言预定义的一个“空指针常量”,定义在<stddef.h>以及其他多个头文件里。

想要继续深究下去,要先弄懂 “空指针” 以及 “空指针常量” 的概念。

从定义上讲,任何类型的指针都有一个叫做“空指针”的值,表示不指向任何对象或者函数。空指针在比较时不与任何指向具体对象或函数的指针相等,但空指针与空指针始终是相等的,即便它们的类型不同。而创造空指针的方法就是使用“空指针常量”,它可以转换成任意指针类型,形成该类型的空指针。

从以上描述可以得出,“空指针”必须是指针类型,可以是常量也可以是变量;“空指针常量”显然必须是常量,但不必是指针类型。

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

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

从C23开始,有了第三种定义:

  1. nullptr

第一种定义只说是整数,没规定具体类型,所以00L0U(char)0都是空指针常量。由于它只要求是个表达式,所以1 - 1-0也是空指针常量。

POSIX 标准只提到了第二种定义。

主流编译器(严格来说是标准库),比如gcc和clang,把NULL定义为((void*)0)。可能有的编译器出于对兼容C++以及其他各种因素的考虑,把NULL定义为0

为什么C++需要nullptr

nullptrC++11率先引入的关键字,专门用于表示“空指针常量”,所以我们还得从C++说起。

上文提到了C语言对“空指针常量”的定义,但C++有所不同,在C++11之前,C++的 “空指针常量” 只有一种定义:

  1. 值为零的整数字面量

这个定义就严格了不少,像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,而intfn_int。由于NULL0,直接传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 数据模型中,int32位 ,指针是 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在什么情况下可能有问题就行。