运算符优先级和求值顺序之间有什么关系?

51

'运算符优先级'和'求值顺序'这两个术语在编程中非常常见,对于程序员来说非常重要。就我所理解的而言,这两个概念紧密相连;在讨论表达式时,一个离不开另一个。

我们来看一个简单的例子:

int a=1;  // Line 1
a = a++ + ++a;  // Line 2
printf("%d",a);  // Line 3

现在很明显,Line 2会导致未定义行为,因为C和C ++中的序列点包括:

  1. 在逻辑运算符&& (逻辑与)、|| (逻辑或)和逗号运算符的左右操作数计算之间。例如,在表达式*p++ !=0 && *q++ !=0中,子表达式*p++ !=0的所有副作用都在尝试访问q之前完成。

  2. 在三元"问号"运算符的第一个操作数计算和第二个或第三个操作数之间。例如,在表达式a=(*p++)?(*p++):0中,第一个*p++之后有一个序列点,这意味着它已经被递增了,当执行第二个实例的时候。

  3. 在完整表达式的结尾。此类别包括表达式语句(如赋值a=b;)、返回语句、if、switch、while或do-while语句的控制表达式以及for语句中的所有三个表达式。

  4. 在进入函数调用之前。参数的计算顺序未指定,但是这个序列点意味着它们的所有副作用在进入函数之前都已完成。在表达式f(i++) + g(j++) + h(k++)中,fi的原始值作为参数调用,但在进入f体之前,i已经递增。同样,jk分别在进入gh之前更新。但是,并未指定按照哪个顺序执行f()g()h(),也未指定按照哪个顺序递增ijk。因此,f主体中jk的值是未定义的。3请注意,函数调用f(a,b,c)不是逗号运算符的使用,abc的计算顺序未指定。

  5. 在函数返回后,在将返回值复制到调用上下文之后。(这个序列点仅在C++标准中指定;它只隐含地存在于C中。)

  6. 在初始化程序的结尾;例如,在声明int a=5;中评估5之后。

因此,按照第三点的标准:

在完整表达式的结尾处。该类别包括表达式语句(例如赋值a=b;)、返回语句、if、switch、while或do-while语句的控制表达式以及for语句中的所有三个表达式。

第2行 明显会导致未定义行为。这展示了未定义行为序列点是如何紧密关联的。

现在让我们看另一个例子:

int x=10,y=1,z=2; // Line 4
int result = x<y<z; // Line 5

现在很明显,Line 5将使变量result存储1

现在在Line 5中,表达式x<y<z可以被计算为:

x<(y<z) 或者 (x<y)<z。 在第一种情况下,result的值将为0,而在第二种情况下,result将为1。但是我们知道,当运算符优先级相同时-关联性发挥作用,因此评估为(x<y)<z

这是在这篇MSDN文章中所说的:

C运算符的优先级和关联性影响表达式中操作数的分组和计算。仅当存在具有更高或更低优先级的其他运算符时,运算符的优先级才有意义。具有更高优先级的运算符的表达式首先进行评估。优先权也可以用单词“绑定”来描述。优先级更高的运算符被称为具有更紧密绑定力。

现在,关于上面的文章:

它提到“具有更高优先级的运算符的表达式首先进行评估。”

这可能听起来不正确。但是,如果我们考虑()也是一个运算符,则x<y<z(x<y)<z相同。我的理解是,如果关联性不发挥作用,则整个表达式的计算将变得模糊,因为<不是序列点

此外,我发现另一个链接在运算符优先级和关联性方面说:

此页面按优先级(从最高到最低)列出C运算符。它们的关联性指示在表达式中等优先级的操作符以什么顺序应用。

因此,针对第二个示例int result=x<y<z,我们可以看到总共有3个表达式,即xyz。由于表达式的最简单形式包括单个文字常量或对象,因此表达式xyz的结果将是其rvalues,即分别为1012。因此,现在我们可以将x<y<z解释为10<1<2
现在,由于我们有两个要评估的表达式,即10<11<2,并且运算符的优先级相同,所以关联性是否起作用?答案是它们从左到右依次计算
采用上述最后一个示例作为我的论点:
int myval = ( printf("Operator\n"), printf("Precedence\n"), printf("vs\n"),
printf("Order of Evaluation\n") );

在上面的例子中,由于逗号运算符具有相同的优先级,表达式从左到右求值,并将最后一个printf()的返回值存储在myval 中。

SO / IEC 9899:201xJ.1未指定行为中提到:

除了函数调用(),&&,||,?:和逗号运算符(6.5)指定的之外,子表达式的求值顺序以及副作用发生的顺序。

现在我想知道,是否说:评估顺序取决于运算符的优先级,留下未指定行为的情况。是错误的呢?

如果在我的问题中有任何错误,请指出并纠正。我发布这个问题的原因是受到MSDN文章的困惑。它是在错误中还是不是?


1
我完全同意你的观点,但是我不明白你从哪里得出了未指定的行为。 - Bo Persson
8
在我阅读标准时,(x<y)<z 只是澄清了结合性/优先级。子表达式 (x<y)z 可以以任意顺序进行评估。 - Erik
2
@pmg:: 运算符的优先级没有直接指定,但可以从语法中推导出来。 - Sadique
1
@Acme:对于C语言我不清楚。但对于C++,我非常确定我的初始评论是正确的。 - Erik
1
@Acme 请阅读我的回答。优先级和结合规则仅定义表达式树的形状,而不是它将被评估的顺序。 - Šimon Tóth
显示剩余10条评论
6个回答

55

是的,至少在标准C和C++中,MSDN文章存在错误1

话虽如此,我想先解释一下术语:在C++标准中,它们(大多数情况下——有一些失误)使用“evaluation”来指代对操作数进行评估,“value computation”则用于指代执行操作。因此,当您执行例如a + b时,会对每个ab进行评估,然后执行value computation以确定结果。

显然,value computations的顺序(大部分情况下)由优先级和结合性控制——控制value computations基本上就是优先级和结合性的定义。本答案其余部分使用“evaluation”来指代操作数的评估,而不是value computations。

现在,至于评估顺序是否由优先级决定,不是的!就像这样简单。举个例子,让我们考虑你的例子x<y<z。根据结合规则,这被解析为(x<y)<z。现在,在堆栈机器上评估这个表达式。它完全可以这样做:

 push(z);    // Evaluates its argument and pushes value on stack
 push(y);
 push(x);
 test_less();  // compares TOS to TOS(1), pushes result on stack
 test_less();

这个表达式在比较 x<y 的结果与 z 时,会先计算 z,再计算 xy。虽然表达式中 z 的位置在前,但计算顺序并不受结合性的影响。

总结:运算顺序与结合性无关。

优先级也是如此。我们可以把表达式改为 x*y+z,同样会在比较 x 的结果与 z 时,先计算 z,再计算 xy

<code>push(z);
push(y);
push(x);
mul();
add();
</code>

总结:评估顺序独立于优先级。

即使考虑到副作用,这个结论仍然成立。我认为,将副作用视为由单独的执行线程执行,并在下一个序列点(例如表达式结束处)进行join,这种思路很有教育意义。因此,像a=b++ + ++c;这样的表达式可以按以下方式执行:

push(a);
push(b);
push(c+1);
side_effects_thread.queue(inc, b);
side_effects_thread.queue(inc, c);
add();
assign();
join(side_effects_thread);

这也说明了为什么一个表面上的依赖关系并不一定会影响评估顺序。虽然a是分配的目标,但是这仍然在评估bc之前评估a此外请注意,尽管我在上面写成了“线程”,但这也可以是一个线程池,所有线程都并行执行,因此您不能保证一个增量与另一个增量的顺序。

除非硬件直接(且廉价)支持线程安全排队,否则这可能不会在实际实现中使用(即使有也不太可能)。将某些东西放入线程安全队列通常比进行单个递增具有更大的开销,因此很难想象任何人在现实中会这样做。但是,在概念上,这个想法符合标准的要求:当您使用前/后递增/递减操作时,您正在指定一个操作,该操作将在评估该表达式部分之后的某个时间发生,并且在下一个序列点完成。

编辑:虽然这不完全是线程处理,但某些体系结构确实允许这种并行执行。例如,Intel Itanium和某些DSP之类的VLIW处理器允许编译器指定要并行执行的指令数量。大多数VLIW机器都有一个特定的指令“数据包”大小,限制了并行执行的指令数量。Itanium也使用指令包,但指定指令包中的位来表示当前包中的指令可以与下一个包中的指令并行执行。使用这样的机制,您可以获得在多个线程上使用大多数人熟悉的体系结构中的指令并行执行。

摘要:评估顺序与表面依赖无关

在下一个序列点之前尝试使用值会导致未定义的行为——特别是,“其他线程”(潜在地)在那段时间内修改该数据,而您无法与另一个线程同步访问。任何尝试使用它都会导致未定义的行为。

只是为了(诚然现在相当牵强的)例子,想象一下您的代码在64位虚拟机上运行,但是实际的硬件是8位处理器。当您递增64位变量时,它执行类似以下步骤的序列:

load variable[0]
increment
store variable[0]
for (int i=1; i<8; i++) {
    load variable[i]
    add_with_carry 0
    store variable[i]
}
如果你在序列的中间某个位置读取值,你可能会得到一些字节已经被修改的东西,所以你得到的既不是旧值也不是新值。
这个例子可能有点牵强,但一个稍微不那么极端的版本(例如,在32位机器上的64位变量)实际上相当普遍。
结论:求值顺序不取决于优先级、结合性或者(必要的话)表面上的依赖关系。试图在表达式的任何其他部分使用已应用了前缀/后缀递增/递减的变量确实会导致完全未定义的行为。虽然真正崩溃的可能性很小,但你绝对不能保证得到旧值或新值 -- 你可能得到完全不同的结果。
1 我没有检查这篇文章,但相当多的 MSDN 文章谈论 Microsoft 的 Managed C++ 和/或 C++/CLI (或专门针对它们的 C++ 实现),但很少或根本没有指出它们不适用于标准 C 或 C++。这可能会给人造成假象,认为它们声称自己决定适用于其自身语言的规则实际上适用于标准语言。在这些情况下,这些文章并非技术上错误 -- 它们只是与标准 C 或 C++ 没有任何关系。如果你试图将这些语句应用于标准的 C 或 C++,结果就会是错误的。

3
由于优先级和结合规则定义了表达式树的形状,它们也定义了部分排序,因为树必须从叶子节点到根节点进行评估。当然,叶子节点之间没有任何排序,但声称它们完全没有排序是很奇怪的。 - Šimon Tóth
@Let_Me_Be:编译器可以自由地将类似ab+ac的表达式转换为a*(b+c)(假设a、b和c都是整数)。在原始表达式中,有两个乘法运算先于加法运算,但转换后的版本在加法运算之后只有一个乘法运算。最终,它必须从叶子节点到根节点评估某些树形结构,但它评估的树形结构通常不会直接反映您编写的内容。 - Jerry Coffin
1
优先级肯定在某种程度上影响了评估顺序。如果你说 (a++ + b) * c,那么加法必须在乘法之前计算,因为乘法的结果取决于加法的结果。这就是表达式树的样子。这就是 f().g() 首先执行 f 然后执行 g 的整个原因,因为在调用 g 之前会先评估 f(),因为存在明显的依赖关系。 - Johannes Schaub - litb
@Johannes:我不认为那是真的。虽然在现实中似乎不太可能发生,编译器可能将其评估为(a++c)+(bc)。然而,通常的警告仍然适用--特别是在浮点情况下进行这种重新排序要困难得多。然而,我的观点与执行操作的顺序无关,而与评估操作数的顺序有关。 - Jerry Coffin
1
@Johannes:不,但它仍然可以执行temp=c,然后在更改后的表达式中使用temp。我们剩下的很简单:根据超出序列点定义的评估顺序是一个非常糟糕的想法。是的,你可能能够推断出部分时间内可能发生的事情,但依赖它仍然是一个非常糟糕的想法——几乎任何你认为已经确定的东西都有可能是错误的——即使你是正确的,依赖它也会导致脆弱性(即,知道比你少的人很可能会打破它)。 - Jerry Coffin
显示剩余13条评论

12

优先级影响计算顺序的唯一方式是创建依赖,除此之外二者是独立的。您已经精心选择了一些简单的例子来展示优先级所创建的依赖完全定义了计算顺序,但这并不是普遍适用的。此外,请不要忘记许多表达式具有两个效果:它们产生一个值,并且它们有副作用。这两个效果没有必要同时发生,因此即使依赖关系强制执行特定的评估顺序,这仅影响值的计算顺序; 它对副作用没有影响。


有效地意味着在foo(a) + foo(b)中,foo(b)本可以比foo(a)更早被评估出来。 - ninjalj
1
我喜欢你描述这个的方式。 - Johannes Schaub - litb
@ninjalj 在你的例子中,如何执行强制评估顺序? - Orestis Kapar

7

一个好的理解方式是通过表达式树。

如果你有一个表达式,比如说 x+y*z,你可以将其重写成一个表达式树:

应用优先级和结合律规则:

x + ( y * z )

应用优先级和结合规则后,您可以安全地忘记它们。

以树形式表示:

  x
+
    y
  *
    z

现在这个表达式的叶子是xyz。这意味着您可以按任何顺序评估xyz,也意味着您可以按任何顺序评估*x的结果。
现在,由于这些表达式没有副作用,您并不真正关心。但如果它们有副作用,顺序可能会改变结果,由于顺序可以是编译器决定的任何内容,因此您会遇到问题。
现在,序列点为这种混乱带来了一点秩序。它们有效地将树划分为几个部分。 x + y * z, z = 10, x + y * z 优先级和结合性之后: x + ( y * z ) , z = 10, x + ( y * z) 树:
      x
    +
        y
      *
        z
  , ------------
      z
    =
      10     
  , ------------
      x
    +
        y
      *
        z   

树的顶部将在中间之前被评估,而中间部分在底部之前被评估。

@Acme 嗯,这篇文章基本上是正确的。或者更加委婉地说,我知道作者的意思。对于 MSVC 来说,它可能完全正确(编译器可能会强制执行额外的顺序)。但是再次强调,优先级和结合规则只定义了表达式树的形状,而不是评估的顺序。 - Šimon Tóth
1
@Acme 这取决于你的看法。首先,优先级定义了部分顺序。x+y*z 意味着在计算 + 时需要 * 的结果。这是由运算符优先级定义的顺序。现在,评估顺序需要遵循这个部分顺序,但除此之外它可以做任何想做的事情。所以你不能真正地说它们是完全无关的。但它们也不是互相定义的。 - Šimon Tóth
@Let_Me_Be:是的!那你的观点是什么?表达式是被计算的,操作数是表达式的一部分。我看不出有什么区别。你所说的“用于计算+”是什么意思?有人怎么可能计算“+”呢? - Prasoon Saurav
1
@Prasoon 对不起,但你显然有困难理解他的意思。他在谈论表达式树叶子节点上的表达式顺序。 - Šimon Tóth
@Let_Me_Be:还要看看Jerry Coffin的回答。 - Prasoon Saurav
显示剩余11条评论

5

它提到“具有更高优先级运算符的表达式首先进行评估。”

我只是要重复我这里所说的。就标准C和C++而言,那篇文章是有缺陷的。优先级仅影响哪些标记被视为每个运算符的操作数,但它不以任何方式影响评估顺序。

因此,该链接仅解释了Microsoft如何实现事情,而不是语言本身的工作原理。


1
1+2*3的计算顺序完全由运算符优先级定义。 - user207421
@EJP那是一个特殊情况。看一下1 + 2 + 3 * 4,其中1 + 2可以在3 * 4之前计算,因为*的优先级高于+。 - Slava
@user207421:1+2*3无论如何都会在编译时进行求值... 但是a() + b() * c()是一个更好的例子:*可能会在+之前进行求值,但是操作数的求值顺序是未指定的。我使用可能是因为这个表达式的上下文可能允许编译器省略对+和/或*的求值。 - undefined

5

优先级与求值顺序无关,反之亦然。

优先级规则描述了当一个未用括号明确表达操作符的表达式中混合不同类型的操作符时,应如何加括号。例如,乘法的优先级高于加法,因此2 + 3 x 4等价于2 + (3 x 4),而不是(2 + 3) x 4

求值顺序规则描述了表达式中每个操作数求值的顺序。

以一个例子为例:

y = ++x || --y;   

根据运算符优先级规则,表达式将被括号化为(++/--的优先级高于||,后者的优先级又高于=):
y = ( (++x) || (--y) )   

逻辑或运算符“||”的求值顺序规定为(C11 6.5.14):“||”运算符保证从左到右进行求值。这意味着首先会对左操作数,即子表达式“(x++)”进行求值。由于短路行为,“如果第一个操作数与0不相等,则不会评估第二个操作数”,因此尽管“(--y)”在“(++x) || (--y)”之前加上了括号,但右操作数将不会被求值。

-1

我认为这只是

a++ + ++a

表达式有问题,因为

a = a++ + ++a;

首先符合第3条规则,但随后符合第6条规则:在赋值之前进行完整的评估。

因此,

a++ + ++a

对于a=1的gets完全求值为:

1 + 3   // left to right, or
2 + 2   // right to left

结果是相同的 = 4。

一个

a++ * ++a    // or
a++ == ++a

会产生未定义的结果。不是吗?


1
不行。因为a++ + ++a在序列点之间修改了a两次,所以其行为是未定义的;没有正确的结果(或每个结果都是正确的,即使答案是-2,000,000,000)。 - Jonathan Leffler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接