《征服 C 指针》摘录1:什么是空指针?区分 NULL、0 和 '\0'

时间:2024-01-17 23:07:26

一、什么是空指针?

空指针 是一个特殊的指针值。

空指针 是指可以确保没有向任何一个对象的指针。通常使用宏定义 NULL 来表示空指针常量值。

空指针 确保它和任何非空指针进行比较都不会相等,因此经常作为函数发生异常时的返回值使用。另外,对于第 5 章的链表来说,也经常在数据的末尾放上一个空指针来提示:“请注意,后面已经没有元素了哦。”

在如今的操作系统下,应用程序一旦试图通过空指针引用对象,就会马上招致一个异常并且当前应用程序会被操作系统强制终止。因此,如果每次都使用 NULL 来初始化指针变量,在错误地使用了无效(未初始化)的指针时,
我们就可以马上发现潜在的 bug。

通常,我们可以根据指针指向的数据类型来明确地区别指针的类型。如果将“指向 int 的指针”赋给“指向 double 的指针”,如今的编译器会报前面提到的警告。但是,只有 NULL,无论对方指向什么类型的变量,都可以被赋值和比较。

偶尔会见到先将空指针强制转型,然后进行赋值、比较操作的程序,这不但是徒劳的,甚至还会让程序变得难以阅读。

二、区分 NULL、'\0' 和 0

经常有一种错误的程序写法:使用 NULL 来结束字符串。

/*
 * 通常,C 的字符串使用 '\0' 结尾,可是因为 strncpy() 函数在 src 的长度大于 len 
 * 的情况下没有使用 '\0' 来结束,所以一板一眼写了一个整理成 C 的字符串形成的函数(企图)
 */
void my_strncpy(char *dest, char *src, int len)
{
    strncpy(dest, src, len);
    dest[len] = NULL;    /* 错误:使用 NULL 来结束字符串!! */
}

上面的代码,尽管在某些运行环境下能跑起来,但无论怎样它就是错误的。因为字符串是使用“空字符”来结束的,而不是用空指针来结束。

在 C 语言标准中,空字符的定义为“所有的位为 0 的字节称为 空字符(null character)”。也就是说,空字符是值为 0 的字符。

空字符在表现上通常使用'\0'。因为'\0'是常量,所以实际上它等同于 0。也许有些吓到你了,'\0'呀'a'呀什么的,它们的数据类型其实并不是 char,而是 int。

另外,在我的环境下,NULL 在 stdio.h 里的定义如下:

#define NULL 0

看到这个,你可能会说:“说来说去,那还不都是 0 嘛。”确实在大部分的情况下是这样的,但背后的事情却异常复杂。

正如前面说的那样,写成 '\0' 和 写成常量的 0 其实是一样的。使用 '\0' 只不过是习惯使然。如果想让代码容易读,遵从习惯是非常重要的。

将 0 当作空指针来使用,除了极其例外的情况,通常是不会发生错误的。

但是,如果在字符串的最后使用 NULL,就必然会发生错误。

标准允许将 NULL 定义成 (void*)0,所以在 NULL 被定义成 (void*)的时候,如果使用 NULL 来结束字符串,编译器必然会提示警告。

看到刚才的关于 NULL 的定义,可能有人会产生下面的推测:

啥呀?所谓的空指针,不就是为 0 的地址嘛。

在 C 中,为 0 的地址上应该是不能保存有效数据的吧?放什么都起不到任何作用,这没什么大不了的。

这种推测好像颇有道理,但也是有问题的。

确实在大多数的环境中,空指针就是为 0 的地址。但是,由于硬件状况等原因,世上也存在值不为 0  的空指针。

偶尔会有人在获得一个结构体之后,先使用 memset() 将它的内存区域清零后再使用。此外,虽然 C 语言提供了动态分配函数 malloc() 和 calloc(),但是抱着“清零后比较好”的观点,偏爱 calloc() 的倒有很多。这样也许可以避免一些难以再现的 bug。

使用 memset() 和 calloc() 将内存区域清零,其实就是单纯地使用 0 来填位。通过这种处理,当结构体的成员中包含指针的时候,这个指针能不能作为空指针来使用,最终是由运行环境来决定的。

顺便说一下,对于 浮点数,即使它的位模式为 0,值也不一定为 0。

说到这里,

哦,原来这样啊,所以要使用宏定义的 NULL  呢。对于空指针的值不为 0 的运行环境,NULL 的值应该被 #define 成别的值吧。

可能会有人产生以上的想法。实际上,这种想法也是有偏差的,这涉及问题的内部根源。

比如,尝试编译下面的代码

int *p = 3;

在我的环境里,会出现以下警告:

warning: initialization makes pointer from integer without a cast

因为 3 无论怎么说都是 int 型,指针和 int 型是不一样的,所以编译器会提示警告。尽管在我的环境里指针和 int 的长度都是 4字节,但还是出现了警告。如今的编译器,几乎都是这样的。

继续,让我们尝试编译下面的代码:

int *p = 0;

这一次没有警告。

如果说将 int 型的值赋予指针就会得到一个警告,那么为什么值为 3 的时候出现警告,值为 0 的时候却没有警告呢?简直匪夷所思!

这是因为在 C 语言中,“当常量 0 处于应该作为指针使用的上下文时,它就作为空指针使用”。上面的例子中,因为接受赋值的对象指针,编译器根据上下文判断出“0 应该作为指针使用”,所以将常数 0 作为空指针来读取。

无论如何,编译器都会针对性地对待“需要将 0 作为指针进行处理的上下文”,所以即便是空指针的值不为 0 的情况下,使用常量 0 来代替空指针也是合法的。

此外,如上所述,有的环境中像下面这样定义 NULL

#define NULL ((void*)0)

ANSI C 中,根据“应该将 0 作为指针进行处理的上下文”的原则,将常量 0 作为指针来处理。因此,显式将 0 强制转型成 void* 是没有意义的。但是在某些情况下,编译器也可能会理解不了“应该将 0 作为指针进行处理的上下文”。

这些情况是:

(1)、没有原型声明的函数的参数

(2)、可变长参数函数中的可变部分的参数

ANSI C 中,因为引入了原型声明,只有在你确实做了原型声明的情况下,编译器才能知道你“想要传递指针”。

可是,对于以 printf() 为代表的可变长参数函数,其可变部分的参数的类型编译器是不能理解的。另外糟糕的是,在可变长参数的函数中,还经常使用常量 NULL 来表示参数的结束(比如 UNIX 的系统调用 execl()函数)。

以上情况下,简单地传递常量 0,会降低程序的可移植性。

因此,通常使用宏定义 NULL 来将 0 强制转型成 void*,可以显式地告之编译器当前的 0 为指针。

延伸阅读:

《征服 C 指针》摘录1:什么是空指针?区分 NULL、0 和 '\0'

《征服 C 指针》摘录2:C变量的 作用域 和 生命周期(存储期)

《征服 C 指针》摘录3:数组 与 指针

《征服 C 指针》摘录4:函数 与 指针

《征服 C 指针》摘录5:函数形参 和 空的下标运算符[]

《征服 C 指针》摘录6:解读 C 的声明

《征服 C 指针》摘录7:练习——挑战那些复杂的声明