“k += c += k += c;”中的内联运算符是不是有解释?

Posted

技术标签:

【中文标题】“k += c += k += c;”中的内联运算符是不是有解释?【英文标题】:Is there an explanation for inline operators in "k += c += k += c;"?“k += c += k += c;”中的内联运算符是否有解释? 【发布时间】:2019-07-07 13:18:41 【问题描述】:

以下操作结果的解释是什么?

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??????

【问题讨论】:

你得到了不同的结果,因为你拆分了函数,k += c += k += c = 80 因为 k 和 c 的值在所有和中保持相同,所以 k += c += k += c 等于 10 + 30 + 10 + 30 有趣的练习,但是,在实践中,除非你想让你的同事讨厌你,否则永远不要写这样的代码链。 :) @AndriiKotliarov 因为 k += c += k += c 是 10 + 30 + 10 + 30,所以,K 接收所有值,而 C 只得到最后 3 个参数 30 + 10 + 30 = 70 也值得一读 - Eric Lippert 的 answer 到 What is the difference between i++ and ++i? “医生,医生,我这样做的时候好痛!” “所以不要那样做。” 【参考方案1】:

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 的评论以及提供的link Wai Ha Lee)。因此,您得到的不是 70 + 40(k 的新值)= 110,而是 70 + 10(k 的旧值)= 80。

重点是(根据 C# spec)“表达式中的操作数是从左到右计算的”(操作数是我们的变量 ck案子)。这与运算符优先级和关联性无关,在这种情况下,它们决定了从右到左的执行顺序。 (请参阅本页上 Eric Lippert 的answer 的 cmets)。


现在让我们看看 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)在此评估期间永远不会重复使用。 stloc 弹出栈顶。


以下控制台测试的输出是(Release mode with optimizations)

评估 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,而c 最终值设置在第一个括号中,即c = 30 + (10 + 30) = 70 确实,如果k 是本地的,那么如果启用优化,死存储几乎肯定会被删除,如果不启用,则会保留。一个有趣的问题是,如果k 是字段、属性、数组槽等,是否允许 抖动来删除死存储;在实践中,我相信它不会。 Release 模式下的控制台测试确实表明k 是一个属性,它被分配了两次。【参考方案2】:

首先,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”技巧,因为它使代码更短,更容易处理抖动,我们得到相同的结果。 一般来说,C# 代码生成器会尽可能地在堆栈上保持临时“临时”。如果您发现使用更少临时代码更容易遵循 IL,请关闭优化 em>,代码生成器的攻击性会降低。

我们现在必须做同样的技巧来获得 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.

由于我们不需要其他任何东西的总和,所以我们不会重复它。堆栈现在是空的,我们在语句的末尾。

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

【讨论】:

@OlivierJacot-Descombes:规范的相关行在“运算符”部分中,并说“表达式中的操作数从左到右计算。例如,在F(i) + G(i++) * H(i)方法F用i的旧值调用,然后用i的旧值调用方法G,最后用i的新值调用方法H。运算符优先级。” (添加了重点。)所以我想我错了,当我说“使用旧值”的地方没有出现时!它发生在一个例子中。但规范位是“从左到右”。 这是缺少的链接。精髓是我们必须区分操作数的求值顺序和运算符的优先级。操作数评估从左到右进行,在 OP 的情况下,运算符执行从右到左。 @OlivierJacot-Descombes:完全正确。除了优先级和关联性确定子表达式边界的位置之外,优先级和关联性与评估子表达式的顺序没有任何关系。子表达式从左到右计算。 哎呀看起来你不能重载赋值运算符:/ @johnny5:没错。但是您可以重载+,然后您将免费获得+=,因为x += y 被定义为x = x + y,但x 仅被评估一次。无论+ 是内置的还是用户定义的,都是如此。所以:尝试在引用类型上重载+,看看会发生什么。【参考方案3】:

归结为:第一个+= 是应用于原始k 还是应用于更右侧计算的值?

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

所以最左边的+= 正在执行10 += 70

【讨论】:

这很好地说明了它。 它实际上是从左到右计算的操作数。【参考方案4】:

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

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

我觉得这很合理。

【讨论】:

【参考方案5】:

对于这种链式分配,您必须从最右侧开始分配值。你必须赋值计算赋值给左边,一直到最后(最左边赋值),当然计算为k=80。

【讨论】:

请不要发布简单地重新陈述许多其他答案已经陈述的答案。【参考方案6】:

简单的答案:用值替换 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不能分析具有可变表达式的语言,就好像它们具有急切的值替换。值替换以关于突变的特定顺序发生,您必须考虑到这一点。【参考方案7】:

你可以通过数数来解决这个问题。

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; 不同,因此您会得到不同的答案。

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

【讨论】:

这不能回答问题 @johnny5 它解释了为什么你会得到你得到的结果,即因为这就是数学的运作方式。 数学和编译器评估语句的操作顺序是两个不同的东西。根据你的逻辑 k+=c; c + = k; k+=c 的计算结果应该相同。 不,约翰尼 5,这不是它的意思。从数学上讲,它们是不同的东西。这三个单独的操作计算为 3c + 2k。 不幸的是,您的“代数”解决方案只是巧合正确。 你的技术一般都行不通。考虑x = 1;y = (x += x) + x; 你的论点是“有三个x,所以y 等于3 * x”吗?因为在这种情况下y 等于4。现在y = x + (x += x); 是不是你的论点是满足代数定律“a + b = b + a”并且这也是 4?因为这是 3。不幸的是,如果表达式中有副作用,C# 不遵循高中代数规则。 C# 遵循副作用代数的规则。

以上是关于“k += c += k += c;”中的内联运算符是不是有解释?的主要内容,如果未能解决你的问题,请参考以下文章

什么是内联运算符C [重复]

在声明 es2018 期间内联扩展运算符

这些在 React 中使用三元运算符有条件地应用内联样式的方法在性能上是不是有任何差异?

深入探讨 内联函数和宏定义的区别

为啥我们需要python中的运算符函数?

F#中的空合并运算符?