Last Updated: 2020-07-01
在阅读本文档前,请确保你已经知晓未定义行为的基本概念,详见 Expertise #01: Undefined Behavior and Unspecified Behavior 。只需看过概述部分知道什么是未定义行为即可,无需知晓各种具体的未定义行为。
请注意:本文是面向进阶读者的,在阅读本文档前,请确保你已经具备了完整的 C 语言基础。
如果你学过 C 语言的基本语法,你可能会觉得类似下面的代码并没有什么问题,只是有些晦涩难懂、并且代码风格糟糕。例如:
你或许会认为:既然后缀自增运算符是在主表达式计算之后被计算的,那么结果似乎就应当是 。然而事实却并非如此。例如在我的编译环境中,结果竟然是 。
你可能见过一些垃圾教学者夸夸其谈地分析其运行结果,并且看起来十分有道理。如果是这样的话,那么很不幸,你被坑了。事实上,这段看起来似乎“很正常只是代码风格很糟糕”的代码属于未定义行为。
我们知道,花费精力去研究未定义行为在不同编译环境下的运行结果是没有意义的。但如果想要深入学习 C/C++ 语言,你有必要去探究此类未定义行为的内在原因。
在 C 语言的 99 - Undefined Behavior and Unspecified Behavior 文档中,我仅对此类未定义行为作了一个表层的介绍,并且没有涉及其内在原因。本文将对此进行详细的介绍。阅读本文需要具备一定的 C 语言基础,且具备一定的汇编语言基础。
为了弄清此类未定义行为的内在原因,我们需要先掌握两个语言层面的基本概念,包括副作用和序列点。
C 语言规定,一个表达式(或是子表达式)被执行时产生的以下结果均属于 副作用 (side effect) :
修改一个对象的值;
修改一个文件;
访问一个 volatile 对象;
调用一个会产生以上结果之一的函数。
也就是说,副作用会改变程序的运行时刻环境。
下面是 C99 标准中对副作用的原文定义:
5.1.2.3 Program execution 2 Accessing a volatile object, modifying an object, modifying a file, or calling a function that does any of those operations are all side effects, which are changes in the state of the execution environment. Evaluation of an expression may produce side effects.
C 语言规定,在执行序列中某些特定的点上,此前触发的所有副作用都应被执行完毕,并且此后的所有副作用都不应被触发,这些特定的点被称为 序列点 (sequence point) 。
常见的序列点包括:
函数调用 ()
。也就是说,对于任意一个函数调用,在真正调用这个函数之前,其所有参数必须被执行完毕;
逻辑运算符 &&
和 ||
、三元运算符 ? :
、逗号运算符 ,
。也就是说,对于上述运算符,在逻辑运算和第二个操作数被执行之前,第一个操作数必须被执行完毕;
一个完整表达式的结束点。完整的表达式也包括初始化语句、逻辑控制结构、循环控制结构、返回语句等。
与每个格式化的输入/输出功能转换说明符关联的动作之后。
下面是 C99 标准中对序列点的原文定义:
5.1.2.3 Program execution 2 At certain specified points in the execution sequence called sequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations shall have taken place. (A summary of the sequence points is given in annex C.)
我们知道,对于程序执行过程中的各个序列点,此前触发的所有副作用都应被执行完毕,并且此后的所有副作用都不应被触发。这里面就隐藏了一个黑暗的秘密:反过来说,只要没有遇到序列点,副作用的执行顺序就是不确定的。
在两个序列点之间,如果有多个副作用,则它们的执行顺序事实上是不确定的!
这意味着 C 标准把这个执行顺序的决定权交给了编译器,也就是说,编译器可以自由地调整编译后得到的汇编指令的顺序,以便得到最优的执行效率。这对 C 语言的执行速度有一个不小的提高,但也因此埋下了一个陷阱。
到此你可能仍然不明白问题出在哪,因为弄清根源还必须深入到汇编的层次。接下来你将触及到 C 语言的黑暗角落。
我们先从最简单的自增表达式说起。考虑如下代码:
其汇编代码可能如下所示(在不同编译环境下可能会有不同的汇编语句,但主体逻辑是一致的):
由于 C 语言标准并未规定复制运算符 =
在将等号右侧的运算结果赋给等号左侧的地址之前存在序列点,因此汇编代码也可能如下所示:
根据常识可知,在转换成汇编代码后,单个语句 x = i ++
会被转换为一系列汇编语句,分别执行了读写的操作,而这个简单的事实正是陷阱之所在。请读者思考,当表达式中有多个自增表达式的时候会发生什么?例如:
其汇编代码可能如下所示:
由于 C 语言标准并未明确规定子表达式的执行顺序,其汇编代码也可能如下所示:
而这两份汇编代码的运行结果显然是不同的,前者 ,后者 。显然,问题出在对同一变量 的读写冲突上。
正因为如此,此类表达式被认为是未定义行为,因为不仅是不同的编译环境,哪怕是对于不同的程序上下文,编译器都可能会选择不同的执行顺序。极端地讲,可能你在上面插入了一个和 完全无关的函数调用,也会导致此表达式的结果发生变化,这样的危险代码无疑是不可接受的。
当然,下面的代码是没有问题的:
不过,考虑到代码风格和可读性的问题,即便没有错误,仍然强烈建议读者尽量不要在一个表达式内混用自增自减运算符。
特别补充一点,研究未定义行为的运行结果是没有意义的,本节中仅是为了让读者明白该行为产生的原因,才对其进行举例分析。请读者不要为了除学习以外的目的去研究未定义行为在不同情况下的运行结果,更不要试图去利用这些结果做些什么,那是非常愚蠢的。
了解了序列点的陷阱之后,我们当然更要知道如何避免掉入这个陷阱。
下面是 C99 标准中对此类未定义行为的原文定义:
Between two sequence points, an object is modified more than once, or is modified and the prior value is read other than to determine the value to be stored (6.5)
意思是说,在两个序列点之间,下列行为均属于未定义行为:
修改任何一个对象超过一次;
修改任何一个对象的同时在别处读取该对象的值。
也就是说,我们只要避免上述行为即可。
例1:
例2:
例1:
例2:
轻松一下,下面这个例子看起来很像是触发了未定义行为:
但是实际上没有,上述代码是完全合法的,因为 ||
运算符是有序列点的,左表达式 a = b
和右表达式 a ++ && ++ b
被分离,各自并不存在任何读写冲突。
不过即便如此,我们仍然强烈建议读者不要写出这种代码,这真的很无聊。
这个缺陷并非是不可接受的,虽然这给语言的使用者带来了麻烦,但这是为提高执行效率所付出的代价。而且这个陷阱也并不是什么秘密,在 C 语言标准中专门花费了不小的篇幅去阐明这个问题,按理说任何 C 语言课程都应该教授这个问题才对。
问题在于,许多国内的教学内容根本不提及此事,因而导致许多初学者深陷其中。老一辈所犯下的错误才是祸乱的根源, C 语言表示不背这个黑锅。早些年甚至在各大高校的 C 语言考试中出现了这些表达式,在考试中考察未定义行为的执行结果,这简直是可笑到了极点。
特别说明,我也并非是对国内的老一辈计算机学者不尊重,在那个年代,我们能做到这一步已经实属不易,先辈的付出与取得的成就无疑是令人尊敬的。但是时代已经变了,如今我们已经不再面临学习资源和人才匮乏的局面,实在不应当再保留着这些错误。有历史遗留错误并不可怕,可怕的是明知有错误而不改正,还要继续误导一代代的学生。
值得高兴的是,近年来的情况已经在逐步改善,误人子弟的事情已经少了不少。希望未来我们国内能和国外的顶尖高校一样,在 C 语言基础的教学大纲中就有关于这些知识的科学完整的教学吧。
更新时间 | 更新内容 |
---|---|
2020.3.1 | 为章节【避免掉入陷阱】追加小节【其实没问题的例子】 |