1. 这串字符到底在问什么:从一次代码评审的尴尬说起
前阵子帮一个刚入行的朋友看代码,他写了一个判断字符串是否为空的函数,里面密密麻麻塞了if (str == NULL || str == "" || str == "0")这样的条件。我当时就问他一句:你这三个判断,各自想拦住什么情况?他愣了半天,说“反正都是空的意思吧”。这个回答其实代表了很多人的真实状态——0、0x0、null、NULL、'0'、"0"、' '、" "这一堆东西,看起来都跟“零”或者“空”沾边,但它们在 C 语言里的类型、内存布局、语义、比较行为完全不同。混着用,轻则逻辑判断永远不成立,重则程序在某个边界输入下直接崩溃。
这篇内容就是把这八个写法彻底拆开讲清楚。它适合两类人:一类是刚学 C、被指针和字符数组绕晕的新手,另一类是有几年经验但一直靠“感觉”写判断、没系统梳理过这些字面量差异的开发者。我会从类型本质、内存表示、比较语义、常见误用四个层面逐个过一遍,中间穿插我实际踩过的坑和调试时用的验证手段。核心关键词就围绕C 语言字面量、空指针、空字符、字符串终止符、类型区分这几个点展开,读完之后你再看if条件里的这些写法,应该能一眼说出“这个比较到底在比什么”。
先说结论性的框架,方便你带着地图看后面:0和0x0是整型常量,值都是零,只是进制写法不同;null在标准 C 里根本不是关键字,它要么是某个库自定义的宏,要么是拼写错误;NULL是标准库定义的空指针常量宏,本质是个整数常量表达式;'0'是字符常量,值是 48;"0"是字符串字面量,占两个字节;' '是空格字符,值是 32;" "是含一个空格的字符串,同样占两个字节。这八者两两之间的比较,有的编译器直接报错,有的能编过但结果反直觉,有的在特定平台上行为还不一样。下面逐层展开。
2. 整型常量阵营:0 与 0x0 只是同一块石头的两种刻法
2.1 类型与值:进制不影响本质
0是十进制整型常量,0x0是十六进制整型常量,两者的类型都是int,值都是 0。这一点必须先钉死,因为很多人看到0x前缀就下意识觉得“这是地址”或者“这是指针”,其实十六进制只是一种书写方式,编译器在词法分析阶段就把它折算成同一个整数值了。你可以写int a = 0x0;,也可以写int b = 0;,反汇编出来完全一样。
那为什么代码里会同时出现这两种写法?多数场景是风格约定。比如做位运算、寄存器配置、内存偏移的时候,习惯用十六进制表达“这是二进制位模式”,0x0读起来比0更能提示读者“这里关注的是位”。而在普通计数、循环初值、返回值判断里,0更自然。我个人的习惯是:只要这个零参与的是位操作或地址偏移语义,就用0x0;纯数值语义就用0。这不是语法要求,是给未来读代码的人留线索。
2.2 和指针混用的经典陷阱
真正容易出事的地方,是把整型零和指针比较。C 标准里有一条特殊规则:值为 0 的整型常量表达式可以转换为任意类型的空指针,这个转换是合法的,而且得到的保证是“不等于任何对象或函数的地址”。所以if (p == 0)和if (p == NULL)在语义上是等价的,编译器都认。但反过来,int x = 0; if (p == x)就不一定了——这里x是变量不是常量表达式,标准不保证它被当作空指针处理,某些编译器会警告甚至生成错误比较。
我见过一段真实代码,作者为了“统一风格”,把所有空指针判断都写成if (ptr == 0x0)。在 32 位平台上跑得好好的,换到 64 位编译时告警刷屏,因为0x0虽然仍是常量表达式、理论上合法,但可读性极差,评审时被要求全部改回NULL。教训很直接:表达“空指针”就用NULL,表达“数值零”就用0,别用整型字面量去兼职指针语义,哪怕语法允许。语义清晰比省几个字符重要得多。
2.3 一个验证小实验
想亲眼确认类型,可以用sizeof和编译器告警来验证。写一段:
#include <stdio.h> int main(void) { printf("%zu %zu\n", sizeof(0), sizeof(0x0)); return 0; }输出两个相同的值(通常是 4,取决于平台int宽度)。再写int *p = 0x0;编译,多数编译器不报错但可能给个“整型转指针”的提示;写int *p = 0;则完全干净。这个对比能让你直观感受到:语法层面它们等价,但工程层面该分开就分开。
3. null 与 NULL:一个不是关键字,一个是宏,别搞混
3.1 null 在标准 C 里的真实身份
这是本篇最需要澄清的一点:标准 C 语言里没有null这个关键字。C 的关键字表里只有NULL对应的宏定义,没有小写的null。你在某些代码里看到null,来源通常有三种:一是从 Java、C#、JavaScript 这些语言带过来的肌肉记忆,手滑写错;二是某个项目自定义了#define null NULL或者#define null 0;三是某个第三方头文件里定义了自己的null宏。
这意味着什么?如果你在纯标准 C 环境里写if (p == null),而没有任何头文件定义null,编译器会直接报“未声明的标识符”。这不是“空指针判断失败”,是根本编不过。我调试过一个移植项目,代码从某托管语言翻译过来,满屏null,在原来环境跑得好好的,换到 C 编译器直接几百个错误,最后统一替换成NULL才过。所以第一条铁律:写 C 就用NULL,看到null先查它是不是被宏定义过。
3.2 NULL 的本质:一个整数常量表达式宏
NULL定义在<stddef.h>、<stdio.h>、<stdlib.h>、<string.h>等一堆头文件里,标准只规定它“展开为一个空指针常量”,具体展开成什么由实现决定。常见的有两种:
| 实现方式 | 典型写法 | 特点 |
|---|---|---|
| 整型零 | #define NULL 0 | 最常见,C++ 里也兼容 |
| 转为 void 指针 | #define NULL ((void*)0) | 类型更明确,但 C++ 里会有转换问题 |
关键点在于:NULL是宏,预处理阶段就被替换掉了,编译器看到的不是NULL这个符号,而是它展开后的东西。所以sizeof(NULL)的结果取决于实现——如果是0,得到sizeof(int);如果是((void*)0),得到指针大小。这也是为什么有些代码里sizeof(NULL)在 32 位和 64 位平台结果不同,别拿它当“指针大小”的可靠来源。
3.3 为什么空指针判断必须用 NULL
有人会问,既然NULL可能展开成0,那我直接写if (p == 0)不是一样吗?语法上确实一样,但工程上有三个理由坚持用NULL。第一,语义自解释:读代码的人一眼知道这是指针判空,不是数值比较。第二,避免类型混淆:在函数重载、可变参数、模板等场景(尤其 C 和 C++ 混编时),0可能被推导成int,而NULL的意图明确。第三,跨平台稳妥:某些实现把NULL定义成((void*)0),在指针上下文里类型匹配更干净。
我自己的习惯是:指针判空一律NULL,整数判零一律0,字符判终止符一律'\0'。这三者各司其职,代码评审时一眼能过。曾经有个同事图省事,全用0,结果在一个把NULL定义为((void*)0)的平台上,if (p == 0)触发了指针和整型的比较告警,虽然能跑,但被静态检查工具拦下来了,返工改了一下午。
4. 字符与字符串阵营:'0'、' '、'\0' 的值和内存完全不同
4.1 '0' 不是零,是 48
这是新手最容易栽的坑。'0'是字符常量,类型是int(C 里字符常量类型就是int),值是字符0在字符集里的编码。在 ASCII 和兼容 ASCII 的字符集里,'0'的值是48(十进制),十六进制是0x30。它和整数0差了整整 48。
所以下面这段代码是错的:
char c = '0'; if (c == 0) { /* 永远不成立 */ }c里存的是 48,和 0 比较当然不等。正确写法是if (c == '0')。这个坑在解析数字字符串时特别常见,比如手写一个atoi,判断当前字符是不是数字,得写c >= '0' && c <= '9',而不是c >= 0 && c <= 9。后者会把一堆控制字符和标点也算进去,逻辑直接崩。
4.2 ' ' 是空格,值是 32
' '是空格字符常量,值32(0x20)。它和'0'的区别是值不同,和0的区别是既不是零也不是终止符。空格在字符串处理里是个“合法但常被忽略”的字符,很多 trim 函数、分词逻辑都要专门处理它。注意' '和'\0'完全是两回事:前者是可见的空格,后者是字符串结束标志,值 0。
4.3 "0" 和 " ":字符串字面量,各占两个字节
"0"是字符串字面量,内容是一个字符'0'加一个字符串终止符'\0',所以占 2 个字节。" "同理,一个空格加终止符,也是 2 个字节。这里的关键认知是:字符串字面量的类型是char数组,不是单个字符。"0"的类型是char[2]," "的类型也是char[2]。
这带来一个经典错误:拿字符串和字符比较。
char c = '0'; if (c == "0") { /* 类型不匹配,编译器通常报错或警告 */ }c是char(提升为int),"0"是数组退化成char*指针,两者比较的是“字符值”和“地址”,毫无意义。正确做法要么c == '0',要么用strcmp比较字符串。我见过有人写if (str == "0")来判断字符串内容是不是"0",这也是错的——==比的是指针地址,两个不同的"0"字面量地址可能不同,结果不可靠。字符串内容比较必须用strcmp,这是铁律。
4.4 一张表把八个写法钉死
把前面所有内容压缩成一张对照表,建议截图存下来:
| 写法 | 类型 | 值/内容 | 字节数 | 语义 |
|---|---|---|---|---|
0 | int | 0 | 4(典型) | 整数零 |
0x0 | int | 0 | 4(典型) | 整数零(十六进制写法) |
null | 无标准定义 | 取决于是否被宏定义 | — | 非标准 C,通常是笔误或自定义宏 |
NULL | 宏,展开为整数常量表达式 | 0 或(void*)0 | 取决于实现 | 空指针常量 |
'0' | int(字符常量) | 48 | 4(典型) | 字符零 |
"0" | char[2] | {'0','\0'} | 2 | 含一个字符的字符串 |
' ' | int(字符常量) | 32 | 4(典型) | 空格字符 |
" " | char[2] | {' ','\0'} | 2 | 含一个空格的字符串 |
注意'\0'虽然没在标题里,但它是理解"0"和" "的关键——字符串字面量末尾自动补的那个零,就是'\0',值 0,类型char。它和整数0值相同但语义不同,写字符串处理时用'\0'表达“终止符”,别用0,理由和NULL一样:语义清晰。
5. 比较行为实测:哪些能编过,哪些结果反直觉
5.1 编译期就能拦住的错误
先看哪些组合编译器直接不让你过。'0' == "0"这种字符和字符串比较,多数编译器给警告(比较整型和指针),严格模式下报错。null未定义时直接报未声明标识符。int *p = "0";在 C 里能编过但警告(字符串字面量转指针类型不匹配),C++ 里直接报错。这些是“幸运的错误”,编译期就暴露了。
5.2 能编过但逻辑错的组合
危险的是那些语法合法、语义错误的写法。举几个我实际见过的:
if (c == 0)想判断字符是不是'0',实际判断的是不是终止符,逻辑反了。if (str == "0")想判断字符串内容,实际比的是指针地址,几乎永远不成立。if (p == 0x0)在部分平台告警,虽然语义上等价于判空,但可读性差。if (c == ' ' || c == 0)把空格和终止符混在一个条件里,本意可能是“空白字符”,但0是终止符不是空白,逻辑范围错了。
这些错误的共同点是:编译器不报错,运行时也不一定崩,但在特定输入下行为诡异。比如str == "0"在某些编译器优化下两个相同字面量可能被合并到同一地址,导致“有时候成立有时候不成立”,这种不确定性最难调。
5.3 用编译器告警当第一道防线
我的实操建议是:把告警级别拉满,把警告当错误处理。GCC 用-Wall -Wextra -Werror,Clang 类似。上面那些“能编过但逻辑错”的写法,在-Wall下大多会冒出comparison between pointer and integer、comparison with string literal results in unspecified behavior之类的提示。别忽略这些提示,它们就是编译器在帮你抓这类字面量混用的 bug。我带的项目里,CI 流水线强制-Werror,这类问题基本进不了主干。
6. 实战场景:这些区别在真实代码里怎么咬人
6.1 字符串判空的正确姿势
回到开头那个朋友的代码。判断一个字符串“是否为空”,得先明确“空”指什么:指针为空(NULL),还是内容为空串(""),还是内容为单个"0"字符?这三者语义完全不同。正确写法是分层判断:
int is_empty(const char *s) { if (s == NULL) return 1; /* 指针为空 */ if (s[0] == '\0') return 1; /* 内容为空串 */ return 0; }注意这里用s[0] == '\0'而不是strcmp(s, "") == 0,前者 O(1) 且不依赖库函数,后者也能用但多一次函数调用。至于"0",它不是空串,它是一个长度为 1 的合法字符串,判断空的时候不该把它算进去。如果业务上确实要把"0"当特殊值处理,那得单独写一条strcmp(s, "0") == 0,并且注释清楚为什么。
6.2 解析配置时的字符判断
写配置解析器时,经常要跳过空白、识别数字。这里' '、'0'、'\0'三者必须分清:
while (*p == ' ' || *p == '\t') p++; /* 跳过空白 */ if (*p >= '0' && *p <= '9') { /* 是数字 */ } if (*p == '\0') { /* 到末尾了 */ }如果把*p == '\0'误写成*p == 0,语法上等价('\0'值就是 0),但语义上不清晰;如果把*p == ' '误写成*p == 0,那就把“跳过空格”变成了“遇到终止符就停”,循环逻辑直接错。这类 bug 在解析短字符串时可能碰巧不触发,一旦输入带前导空格就暴露。
6.3 指针判空和整数判零的边界
有个场景特别容易混:函数返回int,用-1表示错误,用0表示成功,同时又有指针输出参数。这时候if (ret == 0)是判成功,if (out_ptr == NULL)是判指针有效,两者不能互换。我见过把if (ret == NULL)写出来的,编译器直接报错(整型和指针比较),算是幸运;也见过把if (out_ptr == 0)写出来的,能编过但评审被要求改成NULL。统一规范的价值就在这里:看到NULL就知道在说指针,看到0就知道在说数值,看到'\0'就知道在说字符终止,读代码时大脑不用做类型推断。
7. 几条能直接抄的编码规范
把上面的分析落成可执行的规则,我自己的项目里是这么定的,你可以直接拿去用或者改成团队版:
- 指针判空只用
NULL,禁止0、0x0、null。null出现即视为 bug,除非有明确的宏定义来源。 - 整数判零只用
0,位操作场景可用0x0但需注释说明是位模式。 - 字符判零(终止符)只用
'\0',禁止用0代替,哪怕值相同。 - 字符比较用字符常量,
'0'、' '该带引号就带引号,别写成裸数字 48、32。 - 字符串内容比较只用
strcmp系列,禁止用==比较字符串字面量或char*。 - 字符串字面量和字符常量不混比,
"0"和'0'是两种类型,编译器告警必须处理。 - 编译告警拉满并当错误处理,
-Wall -Wextra -Werror是底线。
提示:如果你的项目要和 C++ 混编,
NULL在 C++ 里展开成0可能引发重载歧义,那种场景下 C++ 侧用nullptr,C 侧继续用NULL,接口边界处做好类型转换和注释。
最后分享一个我调试这类问题的小习惯:遇到“判断永远不成立”或者“偶尔成立偶尔不成立”的诡异逻辑,第一反应不是加打印,而是把这行条件里的每个字面量的类型和值在纸上写一遍。c == 0写成“char 值 vs int 0”,str == "0"写成“char* 地址 vs char[2] 地址”,往往写到一半就发现问题了。这八个写法的区别,说到底就是类型、值、语义三件事,把这三件事对齐,C 语言里绝大多数“看起来像零”的坑都能绕过去。