"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。 - João Paulo Amorim
78
有趣的练习,但在实际应用中,除非你想让同事讨厌你,否则不要写出这样链接的代码。 :) - UnhandledExcepSean
@JoãoPauloAmorim 那么为什么c是70? - Andrii Kotliarov
3
@AndriiKotliarov 因为 k += c += k += c 的结果是 10 + 30 + 10 + 30,所以 K 收到了所有的值,而 C 只收到了最后三个参数 30 + 10 + 30 = 70。 - João Paulo Amorim
6
值得一读的是Eric Lippert对“i++”和“++i”的区别的回答。链接在这里:https://dev59.com/tXA75IYBdhLWcg3wVniI#3346729 - Wai Ha Lee
34
医生,医生,我这样做会疼! 那就别这样做。 - David Conrad
7个回答

106

a op= b;这样的操作相当于a = a op b;。一个赋值语句可以用作语句或表达式,而作为表达式时将产生被赋的值。你的陈述...

k += c += k += c;

由于赋值运算符是从右至左结合的,因此也可以写成

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

或(展开)
k =  k +  (c = c +  (k = k  + c));
     10301030   // operand evaluation order is from left to right
      |         |        ↓    ↓
      |         ↓   4010 + 30   // operator evaluation
      ↓   7030 + 40
8010 + 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的第一次赋值可能会被优化掉。当JIT将IL转换为机器代码时,这可能是针对变量执行的。

另请注意,在进行任何分配之前,所有计算所需的值都将被推送到堆栈上或从这些值计算而来。在此评估过程中,通过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;
}

你可以将公式中的数字与最终结果相加以获得更完整的值:最终结果为 k = 10 + (30 + (10 + 30)) = 80,而 c 的最终值设置在第一个括号内,即 c = 30 + (10 + 30) = 70 - Franck
2
实际上,如果k是一个局部变量,如果开启了优化,则几乎可以肯定地删除死代码,如果没有开启,则保留。有趣的问题是,如果k是一个字段、属性、数组槽等,Jitter是否允许省略死代码;在实践中,我认为它不会这样做。 - Eric Lippert
在发布模式下进行的控制台测试确实显示,如果k是属性,则会分配两次。 - Olivier Jacot-Descombes

26

首先,Henk和Olivier的回答是正确的;我想用稍微不同的方式来解释一下。具体来说,我想解决你提出的这个问题。你有一组语句:

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”技巧是因为它可以使代码更短,对JIT编译器更友好,并且我们可以得到相同的结果。 一般来说,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
在规范的“运算符”章节中,相关行是“表达式中的操作数从左到右进行评估。例如,在F(i)+G(i ++)* H(i)中,方法F使用i的旧值调用,然后使用i的旧值调用方法G,最后使用i的新值调用方法H。这与运算符优先级无关且不相关。”(强调添加)。因此,当我说没有“使用旧值”的地方时,我想我是错了!它出现在一个例子中。但规范的关键是“从左到右”。 - Eric Lippert
1
这就是缺失的环节。要点在于我们必须区分操作数求值顺序和运算符优先级。操作数求值从左到右进行,在 OP 的情况下,运算符执行从右到左。 - Olivier Jacot-Descombes
4
“@OlivierJacot-Descombes:完全正确。优先级和结合性与子表达式求值的顺序没有任何关系,除了优先级和结合性决定了子表达式的边界之外。子表达式从左到右进行求值。” - Eric Lippert
1
哎呀,看起来你不能重载赋值运算符 :/ - johnny 5
1
@johnny5:没错。但你可以重载 +,然后你就会免费得到 +=,因为 x += y 被定义为 x = x + y,只是 x 只被计算一次。无论 + 是内置的还是用户定义的,这都是正确的。所以:尝试在引用类型上重载 +,看看会发生什么。 - Eric Lippert
显示剩余9条评论

14

归根结底:第一个+=应用于原始的k还是应用于更靠右计算得出的值?

答案是尽管赋值是从右到左绑定,但操作仍然是从左到右进行。

因此,最左边的+=执行的是10 += 70


1
这句话简明扼要地概括了问题。 - Aganju
实际上,是从左到右评估操作数。 - Olivier Jacot-Descombes

0

我尝试了使用gcc和pgcc的示例,并得到了110。我检查了它们生成的IR,编译器确实将表达式展开为:

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

这对我来说看起来很合理。


-1

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


请不要发布那些只是重复其他答案的回答。 - Eric Lippert

-1
你可以通过计数来解决这个问题。
a = k += c += k += c

这里有两个c和两个k,所以

a = 2c + 2k

由于语言的运算符,k也等于2c + 2k

这适用于此类链中任何变量的组合:

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

所以

a = 2m + n + 3r

r 将会等于相同的值。

你可以通过仅计算到最左边的赋值,计算出其他数字的值。因此,m 等于 2m + n,而且 n 等于 n + m

这表明,k += c += k += c;k += c; c += k; k += c; 是不同的,因此你会得到不同的答案。

评论区中的一些人似乎担心您可能试图将此快捷方式过度概括为所有可能类型的加法。所以,我要明确指出,此快捷方式仅适用于此情况,即将内置数字类型的加法分配链接在一起。如果加入其他运算符,例如 ()+,或者调用函数,或者覆盖了 +=,或者使用的不是基本数字类型,则它可能不起作用。它只是旨在帮助解决问题中的特定情况。


这并不回答问题。 - johnny 5
@johnny5,它解释了你得到的结果为什么是这样的,即因为这就是数学的工作方式。 - Matt Ellen
2
数学和编译器评估语句的操作顺序是两回事。根据你的逻辑,k+=c; c+= k; k+=c 应该得到相同的结果。 - johnny 5
感谢您的澄清,加一。 - johnny 5
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#遵循具有副作用的代数规则。 - Eric Lippert
显示剩余6条评论

-1
简单的回答:将变量替换为值,你就得到了它:
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。具有变异表达式的语言不能像具有急切值替换的语言那样进行分析。值替换按照与变异相关的特定顺序发生,您必须考虑到这一点。 - Eric Lippert

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