出现这篇文章的初衷,是因为对教学内容的不解,在C Primer中也没有找到很好的解答,遂将自己找到的资料在这里做一个汇总,也聊表心意的分享给大家,觉得前面赘述的请从分割线处开始阅读。
从一个问题开始
对于下面这个程序的运行结果是什么?
int x = 1,y;
y = x++ + x++;
相信对于部分C语言初学者会给出y=2或者y=3这些不同的结果,那么问题就产生了到底此时的y的值到底是什么呢?
或许你还会疑惑:
- ++的优先级不是比=的优先级更高吗?先算++为什么又要在后面自增呢?那这样优先级的意义又在哪里呢?
- 对于这种式子结果到底是什么?
- 如果你尝试用不同编译器去编译,你又会发现结果有所不同?这又是为什么呢?
那么首先我将抛开问题从基本的几个概念讲起
这部分的内容参考了 Eternity的文章内容,如果对于我的讲解有疑惑也可以看看这篇文章。
主要要讲解的有:时序点(Sequence Point)、完整表达式、副作用、以及其与运算优先级的关系
-
第一点时序点(Sequence Point)副作用与完整表达式
时序点或者说序列点(整篇文章选择时序点这个翻译来讲解)在C Primer plus书中是这样描述的(对于在本书中的全部讲解,我将放在文末):
A sequence point is a point in program execution at which all side effects are evaluated before going on to the next step. In C, the semicolon in a statement marks a sequence point. That means all changes made by assignment operators, increment operators, and decrement operators in a statement must take place before a program proceeds to the next statement. Some operators that we’ll discuss in later chapters have sequence points. Also, the end of any full expression is a sequence point.
译文:
序列点(sequence point)是程序执行的点,在该点上,所有的副作用都在进入下一步之前发生。在 C语言中,语句中的分号标记了一个序列点。意思是,在一个语句中,赋值运算符、递增运算符和递减运算符对运算对象做的改变必须在程序执行下一条语句之前完成。后面我们要讨论的一些运算符也有序列点。另外,任何一个完整表达式的结束也是一个序列点。关于副作用、完整表达式会在稍后讲到
书中对于时序点的阐述并不是十分明确,在Eternity的文章当中这样讲解道:
首先为什么Sequence Point要叫做Sequence point
叫Sequence Point仅仅是因为它看起来帅气吗?还是会让人有“学术感”呢?当然不是。Sequence的解释是:
n. [数][计] 序列;顺序;续发事件
vt. 按顺序排好那么非Sequence的意思是什么呢?
是Paralleln. 平行线;对比
vt. 使…与…平行
adj. 平行的;类似的,相同的所以Sequence最恰当的翻译应该是被称作循序点
对于副作用(side-effect)这个名词,也将引用Primer Plus里面的介绍作为引入
Now for a little more C terminology: A side effect is the modification of a data object or file. For instance, the side effect of the statement
states = 50;
is to set the states variable to 50. Side effect? This looks more like the main intent! From the standpoint of C, however, the main intent is evaluating expressions. Show C the expression 4 + 6, and C evaluates it to 10. Show it the expression states = 50, and C evaluates it to 50. Evaluating that expression has the side effect of changing the states variable to 50. The Expressions and Statements
increment and decrement operators, like the assignment operator, have side effects and are used primarily because of their side effects.
Similarly, when you call the printf() function, the fact that it displays information is a side effect. (The value of printf(), recall, is the number of items displayed.)译文:
我们再讨论一个C语言的术语副作用(side effect)。副作用是对数据对象或文件的修改。例如,语句:
states = 50;
它的副作用是将变量的值设置为50。副作用?这似乎更像是主要目的!但是从C语言的角度看,主要目的是对表达式求值。给出表达式4 + 6,C会对其求值得10;给出表达式states = 50,C会对其求值得50。对该表达式求值的副作用是把变量states的值改为50。跟赋值运算符一样,递增和递减运算符也有副作用,使用它们的主要目的就是使用其副作用。
类似地,调用 printf()函数时,它显示的信息其实是副作用(printf()的返回值是待显示字符的个数)。“ i++”的副作用就是它的值会“偷偷的”+1,跟“=”这种马上+1的副作用不同
换言之它是在background(背后)被+1的,所以我们可以做这样一种想象在程序执行到“i++”的时候编译器开辟了一条通道去把i的值更新成了“i+1”。
这就是刚才所提到的非Sequence即Parallel。
既然是非线性即平行的执行的东西总需要一个汇合点进行汇合,这个交汇的地方就是所谓的Sequence Point,这就是平行模式回到循环执行模式的分界点。
在这里放一个不太贴切的示意图
从这里可以得出一个结论:所有的副作用都必须在时序点之前完成。(注意这里之前的意思并不是说刚好在这个时序点之前或者说到达时序点所有的副作用才开始全部一起产生)
前面看了在C Primer Plus里面对于副作用的解释,在这里我们再次介绍一下Side-effect
基本上只要是会对变量做改变的都算作Side-effect即副作用。譬如a=b这种会改变a的状态的行为就是“=”等号这个运算符的副作用,“i++”的副作用就是会把i的值做“+1”。同样的如果一个函数foo(i)会改变i,那么这个改变行为就是foo()这个函数式子的副作用,当然side-effect包括的不仅仅是对变量数值的改变。
在C语言执行标准(版本未知)中有如下定义:
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. 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.
译文:
访问易变对象,修改对象或文件,或者调用包含这些操作的函数都是副作用,它们都会改变执行环境的状态。计算表达式也会引起副作用。执行序列中某些特定的点被称为序列点。在序列点上,该点之前所有运算的副作用都应该结束,并且后继运算的副作用还没发生。
对于Side-effect的具体讲解会在后面的补充文章当中讲到,这里仅仅讲解对变量改变这一类型,也是为了能够简单的阐述清楚文章的主要内容——对于概念关系的理解。
那么讲到平行的程序最常见的BUG又是什么呢?当然是出现最开始讨论的问题的情况——出现了竞争。
回到平行程序设计的角度来看,开辟了一条“通道”去把一个变数+1,随后又遇到另一个函数在Sequence Point时序点之前,开辟了另一条通道去把这个变量+1,就像前面的示意图一样,那么最终的结果会是什么呢?你可能会认为结果就是加两次的结果但是,在C语言中这个答案真的不是这样的,或者说天晓得会有什么样的答案。
注:对于这里的答案你或许会有疑惑,笔者也同样对此抱有不解的态度,所以在后期会对这里的结果作出解释。
所以从这个例子我们尚且可以归纳出一条程序的撰写准则:不能在时序点前改变一个变量的数值两次
接下来看一个与文章开头比较类似的例子:
i++ + ++i;
首先我先告诉你在C语言标准当中“+”不是一个时序点 并且“;”分号是一个时序点标志(具体对于时序点的讲解也在后面一点会讲到)。因为“+”不是一个时序点所以这个程序会开辟出三个通道。(具体哪几个通道??待更新)
所以对于这个程序最后的结果会是多少也是不确定的。
或许此处也会有一个疑问都是开辟一个自增的变脸为什么i++会在之后自增,而++i则不会呢(文章更新后补充)
接下来可能会有人问:那i=i++
呢?
首先明确一点就是:“=”等号不是时序点。其次以平行程序的角度来看前面i++ + ++i
有三条通道,i=i++其实有两条通道,“=”等号在主通道里对i的值更新,而++则是在另一条通道里面对i的值做更新,所以当然是不行的,有两条通道对i的值做更新。
那么哪些是时序点呢?在C99标准中的Annex C确实有明确的整理出来:
The following are the sequence points described in 5.1.2.3:
——The call to a function, after the arguments have been evaluated (6.5.2.2).
——The end of the first operand of the following operators: logical AND && (6.5.13); logical OR || (6.5.14); conditional ? (6.5.15); comma , (6.5.17).
——The end of a full declarator: declarators (6.7.5);
The end of a full expression: an initializer (6.7.8); the expression in an expression statement (6.8.3); the controlling expression of a selection statement (if or switch) (6.8.4); the controlling expression of a while or do statement (6.8.5); each of the expressions of a for statement (6.8.5.3); the expression in a return statement (6.8.6.4).
——Immediately before a library function returns (7.1.4).
——After the actions associated with each formatted input/output function conversion specifier (7.19.6, 7.24.2).
——Immediately before and immediately after each call to a comparison function, and also between any call to a comparison function and any movement of the objects passed as arguments to that call (7.20.5).
第一点说的是,在foo(i++)
在控制程序真正跳进foo( )内部之前,i++的Side-effect必须完成。
特别需要注意个是,并不意味着传进去foo( )的会是“i+1”的的结果。
要知道传进去的是i++这条运算式的运算结果而不是,受附加效果影响后的值,因此传进去的还是i的原值。
现在看一个更具体的例子:首先假设i的初始值为1,那么写foo(i++,i++,i++)
会发生什么事呢?
首先说明foo( )里面的“,”逗号只是用于间隔函数参数,并不是上面第二条当中所说的“comma”“,”逗号运算符。所以第二条规则在此处是不适用的。
根据上一段,很容易得出结果是foo(1,1,1),但是在编译器中可能会得到foo(3,2,1)。
这里肯定会有人质疑说,不是前面讲过“i++”运算传入foo( )中的是函数的原始值?这的确是没错的但是请注意一点:
正如前面所说所有的副作用(Side-effect)必须在时序点之前完成这句话,并不代表着刚好在时序点(Sequence Point)之前才完成。
副作用(Side-efffect)完成时有一个时间范围的,也就是说从副作用开始到时序点结束这段时间内,都有可能完成这个副作用。
所以从概念上来说,上一个程序其实开辟了三条通道去更新i的值,而交汇点是在所有的函数参数全部求值完后,到进入foo( )中的这一瞬间。此外标准并没有规定函数参数的求值顺序,所有哪条通道先开启是个未知数。所以foo(3,2,1)只是刚好编译器倒着顺序求值,而更新i的时序点刚好落在进入foo( )之前了而已。