在“ k + = c + = k + = c;”中对内联运算符有解释吗?


89

以下操作的结果如何解释?

k += c += k += c;

我试图理解以下代码的输出结果:

int k = 10;
int c = 30;
k += c += k += c;
//k=80 instead of 110
//c=70

目前,我正在努力理解为什么“ k”的结果为80。为什么分配k = 40无效(实际上Visual Studio告诉我该值未在其他地方使用)?

为什么k为80,而不是110?

如果我将操作拆分为:

k+=c;
c+=k;
k+=c;

结果是k = 110。

我试图浏览CIL,但我对解释生成的CIL并不了解,无法获得一些细节:

 // [11 13 - 11 24]
IL_0001: ldc.i4.s     10
IL_0003: stloc.0      // k

// [12 13 - 12 24]
IL_0004: ldc.i4.s     30
IL_0006: stloc.1      // c

// [13 13 - 13 30]
IL_0007: ldloc.0      // k expect to be 10
IL_0008: ldloc.1      // c
IL_0009: ldloc.0      // k why do we need the second load?
IL_000a: ldloc.1      // c
IL_000b: add          // I expect it to be 40
IL_000c: dup          // What for?
IL_000d: stloc.0      // k - expected to be 40
IL_000e: add
IL_000f: dup          // I presume the "magic" happens here
IL_0010: stloc.1      // c = 70
IL_0011: add
IL_0012: stloc.0      // k = 80??????

3
由于拆分了函数,因此得到了不同的结果,因为k + = c + = k + = c = 80,因为k和c的值在所有和中保持相同,因此k + = c + = k + = c相等到10 + 30 + 10 + 30
若昂圣保罗阿莫林

78
有趣的练习,但是在实践中,除非您希望您的同事讨厌您,否则不要编写那样的代码链。:)
UnhandledExcepSean

3
@AndriiKotliarov因为K + = C + = K + = c是10 + 30 + 10 + 30,所以,K接收的所有值,和C得到仅最后3个参数30 + 10 + 30 = 70
若昂圣保罗阿莫林

6
另外值得一读-埃里克利珀的回答是什么与++ i我之间的区别++?
惠哈利

34
“医生,医生,当我这样做时会很痛!” “所以不要那样做。”
戴维·康拉德

Answers:


104

的操作a op= b;等效于a = a op b;。赋值可以用作语句或表达式,而表达式可以产生赋值。您的声明...

k += c += k += c;

由于赋值运算符是右关联的,因此...也可以写成

k += (c += (k += c));

或(扩展)

k =  k +  (c = c +  (k = k  + c));
     10       30       10  30   // operand evaluation order is from left to right
      |         |            
      |            40  10 + 30   // operator evaluation
         70  30 + 40
80  10 + 70

在整个评估过程中,使用所涉及变量的旧值。对于的价值尤其如此k(请参阅我对下面IL的评论以及Wai Ha Lee提供的链接)。因此,您不会得到70 + 40(的新值k)= 110,而是70 + 10(的旧值k)= 80。

的一点是,(根据C#规范“在表达操作数从左至右评价”(操作数为变量ck在我们的例子)。这与运算符优先级和关联性无关,在这种情况下,运算符优先级和关联性指示了从右到左的执行顺序。(请参阅本页上对Eric Lippert 答案的评论)。


现在让我们看一下IL。IL假定基于堆栈的虚拟机,即它不使用寄存器。

IL_0007: ldloc.0      // k (is 10)
IL_0008: ldloc.1      // c (is 30)
IL_0009: ldloc.0      // k (is 10)
IL_000a: ldloc.1      // c (is 30)

现在,堆栈看起来像这样(从左到右;堆栈顶部在右边)

10 30 10 30

IL_000b: add          // pops the 2 top (right) positions, adds them and pushes the sum back

10 30 40

IL_000c: dup

10 30 40 40

IL_000d: stloc.0      // k <-- 40

10 30 40

IL_000e: add

10 70

IL_000f: dup

10 70 70

IL_0010: stloc.1      // c <-- 70

10 70

IL_0011: add

80

IL_0012: stloc.0      // k <-- 80

需要注意的是IL_000c: dupIL_000d: stloc.0即第一分配k ,可能会被优化掉。在将IL转换为机器代码时,可能会通过抖动对变量进行此操作。

还要注意,计算所需的所有值要么在进行任何赋值之前被压入堆栈,要么从这些值计算得出。stloc在此评估期间,永远不会重复使用分配的值(by )。stloc弹出堆栈的顶部。


以下控制台测试的输出为(Release启用优化的模式)

评估k(10)
评估c(30)
评估k(10)
评估c(30)
40分配给k
70分配给c
80分配给k

private static int _k = 10;
public static int k
{
    get { Console.WriteLine($"evaluating k ({_k})"); return _k; }
    set { Console.WriteLine($"{value} assigned to k"); _k = value; }
}

private static int _c = 30;
public static int c
{
    get { Console.WriteLine($"evaluating c ({_c})"); return _c; }
    set { Console.WriteLine($"{value} assigned to c"); _c = value; }
}

public static void Test()
{
    k += c += k += c;
}

您可以将最终结果与公式中的数字相加,以获得更完整的信息:final是k = 10 + (30 + (10 + 30)) = 80,并且cfinal值在第一个括号中设置为c = 30 + (10 + 30) = 70
弗朗克

2
的确,如果k是本地的,则几乎可以肯定的是,如果启用了优化,则将删除死存储;如果不进行优化,则将保留死存储。一个有趣的问题是,如果是字段,属性,数组插槽等,是否允许抖动消除无效存储k?实际上,我相信并非如此。
埃里克·利珀特

发行版模式下的控制台测试确实显示,k如果它是一个属性,则分配两次。
奥利维尔·雅各布·德斯科姆斯

26

首先,亨克和奥利维尔的答案是正确的。我想用稍微不同的方式来解释它。具体来说,我想解决您提出的这一点。您具有以下一组语句:

int k = 10;
int c = 30;
k += c += k += c;

然后,您错误地得出结论,这应该产生与以下语句集相同的结果:

int k = 10;
int c = 30;
k += c;
c += k;
k += c;

查看您如何弄错了以及如何正确做对很有帮助。分解它的正确方法是这样的。

首先,重写最外面的+ =

k = k + (c += k += c);

其次,重写最外面的+。 我希望您同意x = y + z必须始终与“将y评估为临时值,将z评估为临时值,将临时项求和,将总和分配给x”相同。因此,让我们明确一点:

int t1 = k;
int t2 = (c += k += c);
k = t1 + t2;

确保清楚,因为这是您错了的步骤。将复杂的操作分解为更简单的操作时,必须确保缓慢而谨慎地进行操作,并且不要跳过步骤。跳过步骤是我们犯错的地方。

好的,现在,再次缓慢而仔细地将任务分配到t2。

int t1 = k;
int t2 = (c = c + (k += c));
k = t1 + t2;

分配将向t2分配与分配给c相同的值,所以说:

int t1 = k;
int t2 = c + (k += c);
c = t2;
k = t1 + t2;

大。现在分解第二行:

int t1 = k;
int t3 = c;
int t4 = (k += c);
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

太好了,我们正在进步。将任务分解为t4:

int t1 = k;
int t3 = c;
int t4 = (k = k + c);
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

现在分解第三行:

int t1 = k;
int t3 = c;
int t4 = k + c;
k = t4;
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

现在,我们可以看整个事情:

int k = 10;  // 10
int c = 30;  // 30
int t1 = k;  // 10
int t3 = c;  // 30
int t4 = k + c; // 40
k = t4;         // 40
int t2 = t3 + t4; // 70
c = t2;           // 70
k = t1 + t2;      // 80

因此,完成后,k为80,c为70。

现在让我们看一下如何在IL中实现这一点:

int t1 = k;
int t3 = c;  
  is implemented as
ldloc.0      // stack slot 1 is t1
ldloc.1      // stack slot 2 is t3

现在这有点棘手:

int t4 = k + c; 
k = t4;         
  is implemented as
ldloc.0      // load k
ldloc.1      // load c
add          // sum them to stack slot 3
dup          // t4 is stack slot 3, and is now equal to the sum
stloc.0      // k is now also equal to the sum

我们本可以实现为

ldloc.0      // load k
ldloc.1      // load c
add          // sum them
stloc.0      // k is now equal to the sum
ldloc.0      // t4 is now equal to k

但是我们使用了“ dup”技巧,因为它可以使代码更短并使抖动更容易,并且得到相同的结果。 通常,C#代码生成器会尝试将临时“临时”尽可能多地保留在堆栈上。如果你发现它更容易跟随IL用更少的短命,把优化关闭,代码生成器就会较少攻击性。

现在,我们必须做同样的技巧才能得到c:

int t2 = t3 + t4; // 70
c = t2;           // 70
  is implemented as:
add          // t3 and t4 are the top of the stack.
dup          
stloc.1      // again, we do the dup trick to get the sum in 
             // both c and t2, which is stack slot 2.

最后:

k = t1 + t2;
  is implemented as
add          // stack slots 1 and 2 are t1 and t2.
stloc.0      // Store the sum to k.

由于我们不需要这笔总和,因此我们不会将其重复。堆栈现在是空的,我们在语句的结尾。

这个故事的寓意是:当您试图理解一个复杂的程序时,总是一次分解一个操作。不要捷径;他们会让你误入歧途。


3
@ OlivierJacot-Descombes:规范的相关行在“运算符”部分中,并说:“表达式中的运算符从左到右进行求值。例如,在中F(i) + G(i++) * H(i)方法F的调用使用了i的旧值,然后方法G用旧值i调用,最后,方法H用新值i调用。这与运算符优先级是独立的并且不相关。” (添加了强调。)所以我想当我说没有地方会出现“使用旧值”时,我错了!它发生在一个示例中。但是规范位是“从左到右”。
埃里克·利珀特

1
这是缺少的链接。精髓在于我们必须区分操作数评估顺序和运算符优先级。操作数评估从左到右进行,在OP的情况下,操作员从右至左执行。
奥利维尔·雅各布·德斯科姆斯

4
@ OlivierJacot-Descombes:完全正确。优先级和关联性与子表达式的求值顺序无关,除了优先级和关联性确定子表达式边界在哪里的事实。子表达式从左到右评估。
埃里克·利珀特

1
糟糕,您似乎无法让赋值运算符重载:/
johnny

1
@ johnny5:是的。但是您可以重载+,然后您将+=免费获得,因为它x += y被定义为x = x + yexcept x,仅被评估一次。不管+是内置的还是用户定义的,都是如此。因此:尝试+对引用类型进行重载,看看会发生什么。
埃里克·利珀特


0

我用gcc和pgcc尝试了该示例,得到110。我检查了它们生成的IR,编译器的确将expr扩展为:

k = 10;
c = 30;
k = c+k;
c = c+k;
k = c+k;

在我看来,这很合理。


-1

对于这种链式分配,您必须从最右边开始分配值。您必须分配并计算并将其分配到左侧,然后一直进行到最终(最左侧的分配),请确保将其计算为k = 80。


请不要发布简单地重述已经回答过的其他答案的答案。
埃里克·利珀特

-1

简单的答案:将vars替换为您知道的值:

int k = 10;
int c = 30;
k += c += k += c;
10 += 30 += 10 += 30
= 10 + 30 + 10 + 30
= 80 !!!

这个答案是错误的。尽管此技术在这种特定情况下有效,但该算法通常无法正常工作。例如,k = 10; m = (k += k) + k;并不表示m = (10 + 10) + 10带有变异表达式的语言无法像渴望值替换那样被分析。对于突变,值替换按特定顺序发生,您必须考虑到这一点。
埃里克·利珀特

-1

您可以通过计数解决此问题。

a = k += c += k += c

有两个cS和两个kS

a = 2c + 2k

并且,由于该语言的运算符,k还等于2c + 2k

这适用于这种链式变量的任何组合:

a = r += r += r += m += n += m

所以

a = 2m + n + 3r

并且r将等于相同。

您可以仅计算最左端的赋值来计算其他数字的值。所以m等于2m + nn等于n + m

这表明,k += c += k += c;不同的是对k += c; c += k; k += c;,因此为什么你不同的答案。

评论中的某些人似乎担心您可能会尝试从此快捷方式过度概括到所有可能的加法类型。因此,我将明确指出该快捷方式仅适用于这种情况,即将内置数字类型的附加分配链接在一起。如果在中添加其他运算符,例如()+,或者调用函数或已重写+=,或者使用的不是基本数字类型,则此功能将(不一定)起作用。这仅是为了帮助解决问题中的特定情况


这无法回答问题
johnny

@ johnny5它解释了为什么得到结果的原因,即因为那是数学的原理。
马特·埃伦

2
数学和编译器评估语句的操作顺序是两件事。在你的逻辑下k + = c; c + = k; k + = c应该评估为相同的结果。
约翰尼

不,约翰尼5,这不是什么意思。从数学上来说,它们是不同的东西。这三个单独的运算的结果为3c + 2k。
马特·艾伦

2
不幸的是,您的“代数”解决方案恰好是正确的。您的技术通常无法正常工作。考虑x = 1;y = (x += x) + x;您是否争辩说“有三个x,所以y等于3 * x”?因为在这种情况下y等于4。现在y = x + (x += x);,您的论点是满足代数定律“ a + b = b + a”,并且它也是4?因为这是3。不幸的是,如果表达式中存在副作用C#不会遵循高中代数的规则。C#遵循副作用代数的规则。
埃里克·利珀特
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.