C/C++ 语言中的表达式求值

经常可以在一些讨论组里看到下面的提问:“谁知道下面C语句给n赋什么值?”
 m = 1; n = m+++m++;
 最近有位不相识的朋友发email给我,问为什么在某个C++系统里,下面表达式打印出两个4,而不是4和5:
 a = 4; cout << a++ << a;
 C++ 不是规定 << 操作左结合吗?是C++ 书上写错了,还是这个系统的实现有问题?
 要弄清这些,需要理解的一个问题是:如果程序里某处修改了一个变量(通过赋值、增量/减量操作等),什么时候从该变量能够取到新值?有人可能说,“这算什么问题!我修改了变量,再从这个变量取值,取到的当然是修改后的值!”其实事情并不这么简单。
 C/C++ 语言是“基于表达式的语言”,所有计算(包括赋值)都在表达式里完成。“x = 1;”就是表达式“x = 1”后加表示语句结束的分号。要弄清程序的意义,首先要理解表达式的意义,也就是:1)表达式所确定的计算过程;2)它对环境(可以把环境看作当时可用的所有变量)的影响。如果一个表达式(或子表达式)只计算出值而不改变环境,我们就说它是引用透明的,这种表达式早算晚算对其他计算没有影响(不改变计算的环境。当然,它的值可能受到其他计算的影响)。如果一个表达式不仅算出一个值,还修改了环境,就说这个表达式有副作用(因为它多做了额外的事)。a++ 就是有副作用的表达式。这些说法也适用于其他语言里的类似问题。
 现在问题变成:如果C/C++ 程序里的某个表达式(部分)有副作用,这种副作用何时才能实际体现到使用中?为使问题更清楚,我们假定程序里有代码片段“...a[i]++ ... a[j] ...”,假定当时i与j的值恰好相等(a[i] 和a[j] 正好引用同一数组元素);假定a[i]++ 确实在a[j] 之前计算;再假定其间没有其他修改a[i] 的动作。在这些假定下,a[i]++ 对 a[i] 的修改能反映到 a[j] 的求值中吗?注意:由于 i 与 j 相等的问题无法静态判定,在目标代码里,这两个数组元素访问(对内存的访问)必然通过两段独立代码完成。现代计算机的计算都在寄存器里做,问题现在变成:在取 a[j] 值的代码执行之前,a[i] 更新的值是否已经被(从寄存器)保存到内存?如果了解语言在这方面的规定,这个问题的答案就清楚了。
 程序语言通常都规定了执行中变量修改的最晚实现时刻(称为顺序点、序点或执行点)。程序执行中存在一系列顺序点(时刻),语言保证一旦执行到达一个顺序点,在此之前发生的所有修改(副作用)都必须实现(必须反应到随后对同一存储位置的访问中),在此之后的所有修改都还没有发生。在顺序点之间则没有任何保证。对C/C++ 语言这类允许表达式有副作用的语言,顺序点的概念特别重要。
 现在上面问题的回答已经很清楚了:如果在a[i]++ 和a[j] 之间存在一个顺序点,那么就能保证a[j] 将取得修改之后的值;否则就不能保证。
 C/C++语言定义(语言的参考手册)明确定义了顺序点的概念。顺序点位于:
 1. 每个完整表达式结束时。完整表达式包括变量初始化表达式,表达式语句,return语句的表达式,以及条件、循环和switch语句的控制表达式(for头部有三个控制表达式);
 2. 运算符 &&、||、?: 和逗号运算符的第一个运算对象计算之后;
 3. 函数调用中对所有实际参数和函数名表达式(需要调用的函数也可能通过表达式描述)的求值完成之后(进入函数体之前)。
 假设时刻ti和ti+1是前后相继的两个顺序点,到了ti+1,任何C/C++ 系统(VC、BC等都是C/C++系统)都必须实现ti之后发生的所有副作用。当然它们也可以不等到时刻ti+1,完全可以选择在时段 [t, ti+1] 之间的任何时刻实现在此期间出现的副作用,因为C/C++ 语言允许这些选择。
 前面讨论中假定了a[i]++ 在a[i] 之前做。在一个程序片段里a[i]++ 究竟是否先做,还与它所在的表达式确定的计算过程有关。我们都熟悉C/C++ 语言有关优先级、结合性和括号的规定,而出现多个运算对象时的计算顺序却常常被人们忽略。看下面例子:
 (a + b) * (c + d) fun(a++, b, a+5)
 这里“*”的两个运算对象中哪个先算?fun及其三个参数按什么顺序计算?对第一个表达式,采用任何计算顺序都没关系,因为其中的子表达式都是引用透明的。第二个例子里的实参表达式出现了副作用,计算顺序就非常重要了。少数语言明确规定了运算对象的计算顺序(Java规定从左到右),C/C++ 则有意不予规定,既没有规定大多数二元运算的两个对象的计算顺序(除了&&、|| 和 ,),也没有规定函数参数和被调函数的计算顺序。在计算第二个表达式时,首先按照某种顺序算fun、a++、b和a+5,之后是顺序点,而后进入函数执行。
 不少书籍在这些问题上有错(包括一些很流行的书)。例如说C/C++ 先算左边(或右边),或者说某个C/C++ 系统先计算某一边。这些说法都是错误的!一个C/C++ 系统可以永远先算左边或永远先算右边,也可以有时先算左边有时先算右边,或在同一表达式里有时先算左边有时先算右边。不同系统可能采用不同的顺序(因为都符合语言标准);同一系统的不同版本完全可以采用不同方式;同一版本在不同优化方式下,在不同位置都可能采用不同顺序。因为这些做法都符合语言规范。在这里还要注意顺序点的问题:即使某一边的表达式先算了,其副作用也可能没有反映到内存,因此对另一边的计算没有影响。
 回到前面的例子:“谁知道下面C语句给n赋什么值?”
 m = 1; n = m++ +m++;
 正确回答是:不知道!语言没有规定它应该算出什么,结果完全依赖具体系统在具体上下文中的具体处理。其中牵涉到运算对象的求值顺序和变量修改的实现时刻问题。对于:
 cout << a++ << a;
 我们知道它是
 (cout.operator <<(a++)).operator << (a);
 的简写。先看外层函数调用,这里需要算出所用函数(由加下划线的一段得到),还需要计算a的值。语言没有规定哪个先算。如果真的先算函数,这一计算中出现了另一次函数调用,在被调函数体执行前有一个顺序点,那时a++的副作用就会实现。如果是先算参数,求出a的值4,而后计算函数时的副作用当然不会改变它(这种情况下输出两个4)。当然,这些只是假设,实际应该说的是:这种东西根本不该写,讨论其效果没有意义。
 有人可能说,为什么人们设计 C/C++时不把顺序规定清楚,免去这些麻烦?C/C++ 语言的做法完全是有意而为,其目的就是允许编译器采用任何求值顺序,使编译器在优化中可以根据需要调整实现表达式求值的指令序列,以得到效率更高的代码。像Java那样严格规定表达式的求值顺序和效果,不仅限制了语言的实现方式,还要求更频繁的内存访问(以实现副作用),这些可能带来可观的效率损失。应该说,在这个问题上,C/C++和Java的选择都贯彻了它们各自的设计原则,各有所获(C/C++ 潜在的效率,Java更清晰的程序行为),当然也都有所失。还应该指出,大部分程序设计语言实际上都采用了类似C/C++的规定。
 讨论了这么多,应该得到什么结论呢?C/C++ 语言的规定告诉我们,任何依赖于特定计算顺序、依赖于在顺序点之间实现修改效果的表达式,其结果都没有保证。程序设计中应该贯彻的规则是:如果在任何“完整表达式”(形成一段由顺序点结束的计算)里存在对同一“变量”的多个引用,那么表达式里就不应该出现对这一“变量”的副作用。否则就不能保证得到预期结果。注意:这里的问题不是在某个系统里试一试的问题,因为我们不可能试验所有可能的表达式组合形式以及所有可能的上下文。这里讨论的是语言,而不是某个实现。总而言之,绝不要写这种表达式,否则我们或早或晚会某种环境中遇到麻烦。
 后记:去年参加一个学术会议,看到有同行写文章讨论某个C系统里表达式究竟按什么顺序求值,并总结出一些“规律”。从讨论中了解到某“程序员水平考试”出了这类题目。这使我感到很不安。今年给一个教师学习班讲课,发现许多专业课教师也对这一基本问题也不甚明了,更觉得问题确实严重。因此整理出这篇短文供大家参考。
 后后记:4年多过去了,许多新的和老的教科书仍然在不厌其烦地讨论在C语言里原本并无意义的问题(如本文所指出的)。希望学习和使用C语言的人不要陷入其中。 

很多新手对于C的表达式求值(如i++ + ++i)这一问题感到十分的疑惑,不能很好地理解,今日小轩献丑,准备为新手们解释一下,如有不通之处,请各位同道指出。
 
 
其次,是小轩认为要掌握这个问题需要的一些基础知识:
 

1.变量的访问种类与副作用的产生
 在程序设计中经常用到“数”这一概念,在程序设计语言中,“数”被抽象为“量”,在C语言中,“量”分为“变量”和“常量”两种。其中与变量相关的内容较多,主要为访问类型和属性两大类(本文解释表达式求值问题,此问题与变量的属性无过多牵连,所以会在另外一篇文章中向大家介绍变量的属性)。C语言中访问类型有两种:透明引用和真引用;透明引用指不产生副作用的访问,而真引用与之恰好相反,指产生副作用的访问。
 那么,什么是副作用呢?副作用指编译器系统对一个变量实行的数值改变的过程,简单来说,就是改变一个变量的数值。
 
第一个基础知识介绍完了,现在举个例子说明一下:
 printf("%d",a+1);这里编译器对变量a的访问属于透明引用,没有修改变量a的值(即没有产生副作用)
 if(++a) a=0;7 这里的对变量a的两个访问都属于真引用,都修改了变量a的值(即都产生了副作用)
 

2.序列点的定义,作用及其存在位置
 序列点,也称顺序点,对于编译器系统而言,它是一个瞬间,一个在此序列点之前的全部的副作用都必须要实现的瞬间,也就是说,编译器系统处理到存在序列点的地方时,前面的所有副作用都已产生(即被真引用的变量都已改变它的数值),序列点存在的意义是为了保证编译工作的有序性和有效性。
 有序性:多个不同的序列点的存在使源程序中处于不同序列点之间并被多次真引用的变量的副作用的产生有序化。
 有效性:序列点的存在可以使编译器各模块之间相互通信,序列点就是他们的通信方式,可以保证编译器各模块工作的有效性。
 
那么,都什么地方存在序列点呢?C FAQs中明确写道:
 1.完整表达式(表达式语句或不为任何其他表达式的子表达式的表达式)的尾部,即“;”处
 2.“||”、“&&”、“?:”或逗号操作符处
 3.函数调用处(参数求值完毕,函数被实际调用之前)
 
关于序列点的基础知识就这么多,现在让我们看几个例子:
 a=1;本例中有一个序列点,位于结束符处,有一个副作用,在唯一的序列点之前有效。
 a=1,b=2,c=3;本例中有三个序列点,分别位于两个逗号操作符处和结束符处,变量a的副作用在第一个序列点之前有效,变量b的副作用在第二个序列点之前,第一个序列点之后有效,变量c的副作用在第二个序列点之后,第三个序列点之前有效。
 if(a==1 && ++b>0)本例中有两个序列点,分别位于"&&"处和语句结束符处(由于if语句的特殊性,没有结束符,但if内的表达式是一个完整表达式,所以也存在序列点),变量a被透明引用,没有产生副作用,即第一个序列点之前没有副作用产生,变量b被真引用,在第一个序列点之后,第二个序列点之前实现其副作用。
 printf("%d",a);本例中有两个序列点,分别位于函数调用处和结束符处(函数中的“,”是分隔符,用来分割参数,不是逗号操作符),两个序列点之前及其相对应的序列点之后均没有副作用
 a=0xffffffff,a++,c=malloc(a),*c=1;                本例中有五个序列点,分别位于三个逗号操作符处、函数调用处和结束符处,变量a的第一个副作用在第一个序列点之前有效,第二个副作用在第一个序列点之后,第二个序列点之前有效,变量c的第一个副作用在第二个序列点之后,第三个序列点(第三个序列点是函数表达式内的那个)之前有效,第三个序列点与第四个序列点之间没有副作用,变量c的第二个副作用在地四个序列点之后,第五个序列点之前有效。
 

3.对于前缀运算和后缀运算的解释
 前缀运算,主要包括前缀自增运算(++i)和前缀自减运算(--i),相对应的,后缀运算主要包括后缀自增运算(i++)和后缀自减运算(i--)。
 前缀运算与后缀运算的差别主要在于其副作用实现依据的序列点的不同。
 前缀运算的副作用在本句结束符处的序列点之前有效,而后缀运算的副作用在本句结束符处的序列点之后有效。
 
举例:
 a++;本例中有一个副作用和一个序列点,其中副作用由后缀运算产生,所以在本句结束之前,变量a的值都不改变,在本句结束之后,变量a的值改变。
 --a;本例中有一个副作用和一个序列点,其中副作用由前缀运算产生,所以在本句结束之前,变量a的值就已经改变。
 

4.C语言一个重要的规则
 ANSI/ISO C标准中有这样的描述:在上一个和下一个序列点之间,一个对象所保存的值至多只能被表达式的求值修改一次,而且只有在确定将要保存的值的时候才能访问前一个值。
 
我做具体解释如下:
 在上一个和下一个序列点之间指的就是相邻的两个序列点,一个对象所保存的值至多只能被表达式求值修改一次指的是一个变量的值只能修改一次,只有在确定将要保存的值的时候才能访问前一个值指的是将要用来计算表达式计算的每个变量的值都应该是确定的;整合化简后,可以改述为:在两个相邻的序列点之间,对于同一个变量只能进行一次真引用。
 
现举例如下:
 a=1,b=2,a=b;本例中有三个序列点和副作用,且相邻序列点之间对同一变量进行真引用的次数没有超过一次,所以本表达式合法。
 a=a+1;本例中有一个序列点和一个副作用,变量a在寻列点之间出现两次,但对变量a的真引用的次数只有一次,没有违反我们刚才的规则,所以本表达式同样合法。
 a[b=2]=++b;本例中有一个序列点和两个副作用,变量b的第一个副作用对变量b就行了一次真引用,就变量b的值修改为2,变量b的第二个副作用源自于前缀自增表达式,两个副作用都在同两个序列点之间,所以编译器不知道是先进行第一个副作用还是先进行第二个副作用了,所以就有了四种结果(a[2]=2、a[2]=3、a[3]=2、a[3]=3)了,这是我们不希望看到的,所以本表达式不合法。
 a[b=2]=b--;本例中有两个序列点和两个副作用,但是变量b的第二个副租用源自于后缀运算,这个副作用在本句结束符处的序列点之后有效,所以本句还可以解释为a[2]=2,b=1;这样的表达式符合我们的规则,所以本表达式合法。
 
注:我这里指的表达式不合法不是说表达式不能通过编译器的编译,指的是可以通过编译但结果未知(就像a[b=2]=++b这个例子一样有四种解释方案,结果自然未知),我们习惯上统称此类问题为UB问题(未定义问题)。未定义问题主要是因为C语言标准没有明确规定遇到这种问题时编译器应如何处理,所以,对于未定义问题不同编译器给出的结果可能不同,可能相同,例如char c="asdf";在VC6.0和LCC-WIN32的结果就是不同的。
 

下面回到主题,我们终于可以看看i++ + ++i了!在本例中,只有一个序列点(未写出全句),但在这个序列点之前访问了两次变量i,其中有一次访问为真引用,这样就使表达式产生了二义性(后面的i自加后,前面的i是自加前的i还是自加后的i?),所以这个表达式就成为了未定义问题。
 


ok,写到这里,这篇文章就差不多该结稿了,我希望新手们将本文多读几遍,就算不能全部理解,也要能记下多少就记多少,以后多练练,就回了;至于老手们,看后如果找到了什么不恰当的地方,还请指出,我一定会努力修改,使本文日臻完美的

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值