最近,我无意中引发了一场大辩论,议题是当 P 是一个空指针时,在 C/C++ 中使用 &P->m_foo 表达式是否合法。程序员社区分成了两大阵营。一方自信地宣称这不合法,而另一方则同样肯定地说这合法。双方都给出了各种论据和链接,到某个时刻,我意识到我必须把事情说清楚。为此,我通过一个封闭的邮件列表联系了微软 MVP 专家和 Visual C++ 微软开发团队。他们帮助我准备了这篇文章,现在欢迎所有感兴趣的人阅读。对于那些等不及想知道答案的人:那段代码是“不”正确的。
这一切都始于一篇关于 PVS-Studio 分析器对 Linux 内核进行检查的文章。但问题与检查本身无关。关键在于,在那篇文章中,我引用了 Linux 代码中的以下片段:
|
|
我称这段代码是危险的,因为我认为它会导致未定义行为。
之后,我收到了大量的电子邮件和评论,读者们反对我的这个观点,我甚至差点就被他们有说服力的论点说服了。例如,为了证明那段代码是正确的,他们指出了 offsetof 宏的实现,通常是这样的:
#define offsetof(st, m) ((size_t)(&((st *)0)->m))
这里我们处理了空指针解引用,但代码仍然工作得很好。还有一些其他的邮件论证说,既然没有通过空指针进行访问,那就没有问题。
尽管我容易轻信,但我还是会努力复核任何我可能怀疑的信息。我开始研究这个主题,最终写了一篇小文章:“关于空指针解引用问题的反思”。
一切都表明我是对的:不能那样写代码。但我没能为我的结论提供有说服力的证据,也没能引用标准中的相关摘录。
发表那篇文章后,我再次收到了大量抗议邮件的轰炸,所以我认为我应该一劳永逸地把这一切搞清楚。我向语言专家提出了一个问题,以了解他们的意见。这篇文章是他们回答的总结。
当 'podhd' 是一个空指针时,'&podhd->line6' 表达式在 C 语言中是未定义行为。
C99 标准关于 '&' 取地址运算符的规定如下 (6.5.3.2 "Address and indirection operators"):
一元 & 运算符的操作数应该是一个函数指示符,一个 [] 或一元 * 运算符的结果,或者是一个指定了非位域且未使用 register 存储类说明符声明的对象的左值。
表达式 'podhd->line6' 显然不是函数指示符,也不是 [] 或 * 运算符的结果。它“是”一个左值表达式。然而,当 'podhd' 指针为 NULL 时,该表达式并不指定一个对象,因为 6.3.2.3 "Pointers" 中说:
如果一个空指针常量被转换成指针类型,得到的指针,称为空指针,保证与任何指向对象或函数的指针比较结果为不相等。
当“一个左值在求值时未指定一个对象,其行为是未定义的” (C99 6.3.2.1 "Lvalues, arrays, and function designators")
左值是具有对象类型或除 void 之外的不完整类型的表达式;如果一个左值在求值时未指定一个对象,其行为是未定义的。
所以,简而言之,是同样的想法:
当 -> 运算符作用于该指针时,它求值为一个不存在对象的左值,结果是未定义行为。
在 C++ 语言中,情况完全相同。当 'podhd' 是一个空指针时,'&podhd->line6' 表达式是未定义行为。
我在前一篇文章中提到的 WG21 的讨论(232. Is indirection through a null pointer undefined behavior?)带来了一些困惑。参与讨论的程序员坚持认为这个表达式不是未定义行为。然而,没有人能在 C++ 标准中找到任何条款允许在 "poldh" 为空指针时使用 "poldh->line6"。
"polhd" 指针未能满足基本约束 (5.2.5/4, 第二点),即它必须指定一个对象。没有任何 C++ 对象的地址是 nullptr。
struct usb_line6 *line6 = &podhd->line6;
当 podhd 指针等于 0 时,这段代码在 C 和 C++ 中都是不正确的。如果指针等于 0,就会发生未定义行为。
程序能正常运行纯属运气。未定义行为可能以不同形式出现,包括程序完全按照程序员预期的方式执行。这只是未定义行为的一种特例,仅此而已。
你不能这样写代码。指针在解引用之前必须进行检查。
这篇文章的问世,得益于那些我毫无理由怀疑其能力的专家们。我要感谢以下人士帮助我撰写本文: