C的优先级、求值顺序和表达式副作用

又到一年开学的时候,又有一堆初学者要被国内的垃圾教科书坑爹了,这里写一下C里面这几个东西到底是怎么样的。

在C语言里,表达式之间的运算会出现一些反人类直觉的情况,下面将详细解释和说明。

表达式

首先介绍表达式,表达式大约是个模糊的概念,我在维基百科上并没有过多的找到关于表达式的定义。

基础的表达式有三种

  • 变量
  • 常量
  • 函数调用

说明

  • 变量名本身是一个表达式,表达式的值是变量当前的值。
  • 常量名本身是一个表达式,字面常量也是表达式。对于这两者,表达式的值是常量当前的值。
  • 对于返回值不为void的函数,对它的正确调用也是表达式。表达式的值为函数的返回值。
  • 对于用操作符连接或者操作的以上三种,我们也将其视为一个表达式。

例如 a++ 、 a+b 、g() + f() 都可以视为表达式。

表达式会给定一个值,拥有一种类似于返回一个值的效果。

比如a = 1 + 2,那么这里的 1 + 2 可以视为一个表达式,这个表达式会给出一个值,此时就是1+2的计算结果,为3。

说到这个你可能会觉得白痴,因为每个人都知道1+2=3,但是明白这一点是有意义的,在于表达式中拥有++a或者调用函数的子表达式,那么这个子表达式所给定的值是值得稍微探讨的问题。

表达式副作用

有些表达式在给出值的时候,会引发操作符所带来的表达式副作用(下称副作用)。

例1

例如a=2,我们将整个式子看做一个整体的表达式,那么我们知道赋值操作会给出右值的值,在这里也就是2,那么这个表达式所带来的副作用其实就是a被赋值为了2。

例2

a++,这个表达式会先给出a自身的值,然后其所带来的副作用是,a=a+1。

例3

++a,这个表达式会给出a+1的值,其带来的副作用为a=a+1。

当然这里,当它产生副作用效果的时候,也就是赋值为a+1的时候,很可能编译器不再去重复运算a+1这个表达式,这里只是说它达到了这种效果的副作用。

 

就是说,对于有副作用的操作符,我们将其视为两步:

  1. 给出 受这个操作符影响后的值(也有可能在第一步的时候操作符不影响原值,例如a++)
  2. 执行副作用

注意:副作用产生的时间,不一定紧随第一步。

这个后面会详细说明。

优先级与求值顺序

需要说明的是,优先级虽然要遵循,但是细分后的子表达式的求值顺序却不是固定的。

但是虽然求值顺序不固定,但是如果不出意外的话(语句中不含有未定义行为(undefined behavior)),是和正常顺序计算出来的结果肯定是一致的,但是我们既然学习语言,就要深究,要明白他的具体逻辑,才会在一些极端情况下知道自己的错误。

首先我们要明白什么叫编译器,初学者可以理解为让我们代码可以变成运行的程序的软件,这个软件当然也是人写出来的,那么就有可能出现不同的人做出来的编译器软件虽然都是为了实现同一个效果,但是具体实现的方法确是不一样的情况,所以对于编译器这里要引入一个编译器编写中的概念

应用序求值 与 正则序求值

通常情况下他们得到的结果是一样的,有些特殊情况下,会得出不同的结果

  • 应用序求值 -> 所有被传入的实际参数都会立即被求值 而后应用
  • 正则序求值 -> 完全展开 后归约

大多数C的编译器都会使用正则序求值,所以我们就不对应用序求值做过多的解释了。

完全展开就是说,优先计算所有子表达式的值,最后再依据语言的规则合并出结果。

举例1

a = ++b + 3 *4 + 4 * 5;

完全展开后:a = b+1 + 12 + 20;

编译器会根据优先级,来展开子表达式,最后会到一个无法展开的状态。

展开的时候会根据优先级的从高到低,来依次展开,这固然没什么问题。

但是,要说明的是,同级优先级的子表达式展开顺序,其实不是固定的,就是说例如刚才的式子,

可能会出现4*5在3*4之前运算的情况。

 

 举例2

a = ++i + ++k;

按照一般人臆断,会觉得先运算++i再运算k++。

但是实际上并不是,优先级和求值顺序往往不具有对应关系。

这里有可能发生的是:

  1. 先运算++i 后运算++k
  2. 先运算++k 后运算++i

这一点看似无意义,我知道大家会觉得无所谓啊,反正最后结果都是一样的,但实际上其实和对运算顺序有要求的函数、表达式副作用结合在一起看其实是有意义的,下文会描述。

 

结合律(又称结合性)

那么有人会问明明某种运算符的结合律会明确指出这个表达式的结合性,那么为什么还会出现运算顺序不定的问题呢。

结合律其实是指导了这个操作符左边的值和右边的值以何种方式进行运算。

例如 1 – 2 – 3,如果在减号意义不变的情况下,那如果我们假想减号是个具有从右向左的结合性的运算符,那么就会发生:

1 – 2 = 1(理解为 2 – 1 = 1)

1 – 3 = 2(理解为 3 – 1 = 2)

就会发生这种可笑的结果,那么所以说结合性实际上指导了操作符与被操作符之间的运算关系,那么对于:

1 + 2 + 3

如果计算顺序变为 1 + (2 + 3)

对于:

1 – 2 – 3

因为1 – 2 – 3实际上为 1 + -2 + -3,如果先算-2 + -3 也没有任何的问题。

那么实际上我们可以看到,这个表达式的结果是不会变动的,而且这种做法也是符合结合律的做法。

号外:请注意结合律和交换律是两码事。

当求值顺序的不定性和副作用结合

前文说,副作用产生的时间,不一定紧随第一步(给出子表达式的值),如果不搞清楚这个问题,如果代码不当,就容易会发生一些莫名其妙的问题。

举例

int i = 3;

i=i++ + i++;

首先说明,这个表达式是错的,在C语言中规定在两序列点(,和;为序列点)之间,同一变量有超过一次被修改值的行为是未定义行为,未定义行为是可以由编译器随意处理的情况,所以可能会引发不明的错误。

J.2 Undefined behavior

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).

——ISO/IEC 9899:1999 – Programming languages — C

下面用这个例子来举证,为什么这样做是错的,是未定义行为。

我们将表达式的副作用视为这个表达式在这个序列点(又称顺序点)结束之前所必须完成的事情。

换句话说,只要在序列点没结束之前,在何处处理该表达式的副作用都是被允许的。

情况1

计算第一个i++,得3

计算第二个i++,得3

将i+i赋予i,此时为6

将i+1所得的结果赋予i,为4

将i再次+1的结果赋予i,为5

这种是大多数人臆想的理想情况

 

情况2

计算第一个i++,得3

将i自增1,这时的i已经从原来的i变成了i+1,此时的i变成了4

计算第二个i++,得4

将i再次自增1,此时的i变成了5

将i+i赋予i,此时的i为7

 

 

这时你会发现,完全和情况1所得的结果,完全不是同一个值。

而这种情况,在不同编译器中,处理所使用的方式都可能不一样,粗略算起来可能有十多种情况,而每种情况得到的结果可能都不一样,所以,就会发生,同样的代码在不同的环境里出现不同的结果的情况,这是我们不想也不愿意看到的。

所以这是一种未定义行为,是实际代码中绝对要避免的代码。

引申

同样的,对于函数的调用,也是类似。

我们假设这里具有 g() f() h()三个函数,这三个函数会影响同一个变量,并返回一个值。

对于表达式:

a = g() + f () + h();

我们一般会希望先运算g,再运算f,再运算h。

但是由于实际运算顺序的不确定性,是有可能导致完全和我们预期结果不一致的,这也是一种类似于我们上面所描述的情况。

就是说,当求值顺序不确定,我们不能依赖使用结合性和优先级来确立表达式副作用的生效时间。

本文完。

如有不当之处,请指出,谢谢,转载请声明来源:作者璨,来源:http://blog.cuican.name/?p=453。

发表评论

电子邮件地址不会被公开。 必填项已用*标注