C#中奇怪的增量行为

24

注意:请注意下面的代码基本上是毫无意义的,仅供示例展示。

根据赋值运算符右侧的表达式必须在其值分配给左侧变量之前进行评估的事实,以及自增和自减操作(如 ++ --)总是在评估后立即执行的事实,我不会预期以下代码能够正常工作:

string[] newArray1 = new[] {"1", "2", "3", "4"};
string[] newArray2 = new string[4];

int IndTmp = 0;

foreach (string TmpString in newArray1)
{
    newArray2[IndTmp] = newArray1[IndTmp++];
}
相反,我期望newArray1[0]被分配给newArray2[1]newArray1[1]被分配给newArray[2]等等,直到抛出System.IndexOutOfBoundsException。但是,令我大为惊讶的是,抛出异常的版本却是:
string[] newArray1 = new[] {"1", "2", "3", "4"};
string[] newArray2 = new string[4];

int IndTmp = 0;

foreach (string TmpString in newArray1)
{
    newArray2[IndTmp++] = newArray1[IndTmp];
}

我理解中,编译器首先评估右边的值,将其分配给左边,然后才进行递增,这对我来说是一种意外的行为。这是否真的是预期的,还是我明显地遗漏了什么?


3
+1 观察得好。不过,像这样的例子让我更加讨厌这些递增运算符。 - user180326
我认为这里有些人误解了问题,问题似乎是:“为什么在评估newArray1[IndTmp ++]之前要评估newArray2[IndTmp]?” 这是一个很好的问题,这必须意味着赋值的目标(LHS)在被赋值的值(RHS)之前得到评估。这似乎是更直观的行为,但如果规范似乎表明不同,那么我会对此感兴趣。值得一提的是,LHS引用一个变量,而RHS处理要分配给该变量的值。 - Justin Morgan
Justin,你说得很对。它的 RHS 在赋值之前当然会被评估,但索引首先被推送到堆栈上(如下所示)。 - Steve Morgan
6个回答

21

ILDasm有时可能是你最好的朋友 ;-)

我编译了两种方法并比较了它们生成的IL(汇编语言)。

不出所料,重要的细节在循环中。第一种方法的编译和运行方式如下:

Code         Description                  Stack
ldloc.1      Load ref to newArray2        newArray2
ldloc.2      Load value of IndTmp         newArray2,0
ldloc.0      Load ref to newArray1        newArray2,0,newArray1
ldloc.2      Load value of IndTmp         newArray2,0,newArray1,0
dup          Duplicate top of stack       newArray2,0,newArray1,0,0
ldc.i4.1     Load 1                       newArray2,0,newArray1,0,0,1
add          Add top 2 values on stack    newArray2,0,newArray1,0,1
stloc.2      Update IndTmp                newArray2,0,newArray1,0     <-- IndTmp is 1
ldelem.ref   Load array element           newArray2,0,"1"
stelem.ref   Store array element          <empty>                     
                                                  <-- newArray2[0] = "1"

这对于newArray1中的每个元素都会重复执行。重要的是,在增加IndTmp之前,源数组中元素的位置已被推入堆栈。

将其与第二种方法进行比较:

Code         Description                  Stack
ldloc.1      Load ref to newArray2        newArray2
ldloc.2      Load value of IndTmp         newArray2,0
dup          Duplicate top of stack       newArray2,0,0
ldc.i4.1     Load 1                       newArray2,0,0,1
add          Add top 2 values on stack    newArray2,0,1
stloc.2      Update IndTmp                newArray2,0     <-- IndTmp is 1
ldloc.0      Load ref to newArray1        newArray2,0,newArray1
ldloc.2      Load value of IndTmp         newArray2,0,newArray1,1
ldelem.ref   Load array element           newArray2,0,"2"
stelem.ref   Store array element          <empty>                     
                                                  <-- newArray2[0] = "2"

这里的IndTmp在源数组中的位置被推入栈之前已经自增了,因此导致行为不同(并引发后续异常)。

为了完整起见,让我们与之进行比较:

newArray2[IndTmp] = newArray1[++IndTmp];

Code         Description                  Stack
ldloc.1      Load ref to newArray2        newArray2
ldloc.2      Load IndTmp                  newArray2,0
ldloc.0      Load ref to newArray1        newArray2,0,newArray1
ldloc.2      Load IndTmp                  newArray2,0,newArray1,0
ldc.i4.1     Load 1                       newArray2,0,newArray1,0,1
add          Add top 2 values on stack    newArray2,0,newArray1,1
dup          Duplicate top stack entry    newArray2,0,newArray1,1,1
stloc.2      Update IndTmp                newArray2,0,newArray1,1  <-- IndTmp is 1
ldelem.ref   Load array element           newArray2,0,"2"
stelem.ref   Store array element          <empty>                     
                                                  <-- newArray2[0] = "2"

在这里,增量操作的结果先被推入栈中(并成为数组的索引),然后再更新IndTmp的值。

总之,赋值语句的目标似乎是先被评估,接着才是

感谢原帖作者提出这个非常发人深思的问题!


18

根据Eric Lippert的说法,这在C#语言中是明确定义的,很容易解释。

  1. 首先评估左侧须被引用和记住的表达式,并考虑副作用
  2. 然后完成右侧表达式

注意:代码的实际执行可能不是这样的,重要的是编译器必须创建与此等效的代码

因此,在第二段代码中所发生的情况是:

  1. 左侧:
    1. 评估newArray2并记住结果(即记住对应于我们想要存储东西的任何数组的引用,以防副作用后更改它)
    2. 评估IndTemp并记住结果
    3. IndTemp增加1
  2. 右侧:
    1. 评估newArray1并记住结果
    2. 评估IndTemp并记住结果(但这里为1)
    3. 通过从步骤2.1中索引到的数组中的索引获取数组项从步骤2.2
  3. 回到左侧
    1. 通过从步骤1.1中索引到的数组索引存储数组项从步骤1.2

正如您所看到的,第二次评估IndTemp(RHS)时,该值已经增加了1,但是这不会对LHS产生任何影响,因为它记住了在增加之前的值为0。

在第一段代码中,顺序略有不同:

  1. 左侧:
    1. 评估newArray2并记住结果
    2. 评估IndTemp并记住结果
  2. 右侧:
    1. 评估newArray1并记住结果
    2. 评估IndTemp并记住结果(但这里为1)
    3. IndTemp增加1
    4. 通过从步骤2.1中索引到的数组中的索引获取数组项从步骤2.2
  3. 回到左侧
    1. 通过从步骤1.1中索引到的数组索引存储数组项从步骤1.2

在这种情况下,步骤2.3中变量的增加对当前循环迭代没有影响,因此您将始终从索引N复制到索引N,而在第二段代码中,您将始终从索引N+1复制到索引N

Eric在他的博客文章中有一个标题为Precedence vs order, redux的文章,应该阅读。

这是一段示例代码,我基本上将变量转换为类的属性,并实现了一个自定义“数组”集合,它只是将正在发生的内容转储到控制台。

void Main()
{
    Console.WriteLine("first piece of code:");
    Context c = new Context();
    c.newArray2[c.IndTemp] = c.newArray1[c.IndTemp++];

    Console.WriteLine();

    Console.WriteLine("second piece of code:");
    c = new Context();
    c.newArray2[c.IndTemp++] = c.newArray1[c.IndTemp];
}

class Context
{
    private Collection _newArray1 = new Collection("newArray1");
    private Collection _newArray2 = new Collection("newArray2");
    private int _IndTemp;

    public Collection newArray1
    {
        get
        {
            Console.WriteLine("  reading newArray1");
            return _newArray1;
        }
    }

    public Collection newArray2
    {
        get
        {
            Console.WriteLine("  reading newArray2");
            return _newArray2;
        }
    }

    public int IndTemp
    {
        get
        {
            Console.WriteLine("  reading IndTemp (=" + _IndTemp + ")");
            return _IndTemp;
        }

        set
        {
            Console.WriteLine("  setting IndTemp to " + value);
            _IndTemp = value;
        }
    }
}

class Collection
{
    private string _name;

    public Collection(string name)
    {
        _name = name;
    }

    public int this[int index]
    {
        get
        {
            Console.WriteLine("  reading " + _name + "[" + index + "]");
            return 0;
        }

        set
        {
            Console.WriteLine("  writing " + _name + "[" + index + "]");
        }
    }
}

输出结果为:

first piece of code:
  reading newArray2
  reading IndTemp (=0)
  reading newArray1
  reading IndTemp (=0)
  setting IndTemp to 1
  reading newArray1[0]
  writing newArray2[0]

second piece of code:
  reading newArray2
  reading IndTemp (=0)
  setting IndTemp to 1
  reading newArray1
  reading IndTemp (=1)
  reading newArray1[1]
  writing newArray2[0]

13
newArray2[IndTmp] = newArray1[IndTmp++];

导致先给变量赋值,然后再对变量进行增量。

  1. newArray2 [0] = newArray1 [0]
  2. 增量
  3. newArray2 [1] = newArray1 [1]
  4. 增量

等等。

右手边的 ++ 运算符立即递增,但它返回的是递增前的值。用于数组索引的值是 RHS ++ 运算符返回的值,因此是未递增的值。

您所描述的(抛出异常)将是 LHS ++ 的结果:

newArray2[IndTmp] = newArray1[++IndTmp]; //throws exception

1
如果我理解你的意思,我必须反对。赋值不是首先发生的,而是在增加IndTmp之前评估newArray1[]的引用。 - Steve Morgan
所以,流程变成了:temp1 = IndTmp,temp2 = IndTmp,IndTmp增加,newArray2[temp1] = newArray1[temp2]。 - Steve Morgan

12
  • Figure out the storage location for the left-hand-side variable (which may have a side effect)
  • Evaluate the right-hand-side expression (which may have side effects)
  • Perform any side effects associated with the increment operator
  • Assign the value computed in step 2 to the storage location determined in step 1.
  • 左侧的副作用发生并产生一个变量
  • 右侧的副作用发生并产生一个
  • 该值会隐式转换为左侧的类型,这可能会产生第三个副作用
  • 赋值的副作用--将变量改变为正确类型的值--发生,并且会产生一个值--刚刚分配给左侧的值。

  • 4
    显然,假设右侧在左侧之前总是被评估是错误的。如果您在这里查看http://msdn.microsoft.com/en-us/library/aa691315(v=VS.71).aspx,似乎在索引器访问的情况下,索引器访问表达式的参数(即lhs)在rhs之前被评估。
    换句话说,首先确定存储rhs结果的位置,然后才评估rhs。

    我在这个帖子上会因为过于追求细节而声名狼藉,但实际上,在增量操作之前,赋值语句的索引,而不仅仅是结果索引被确定。 - Steve Morgan
    就我理解你的评论,这并不与我的解释相矛盾,而是补充了它。我认为原帖作者困惑的是为什么左手边的索引器访问表达式会先被评估。相比之下,我认为他真正期望的是在增量之前确定赋值源。 - Jonny Dee
    是的,我倾向于同意。 它确实有点违反常识,经过反思,这是一个非常好的问题。 - Steve Morgan

    3

    出现异常是因为你在索引newArray1时从索引1开始。由于你正在迭代newArray1中的每个元素,最后一次赋值会抛出异常,因为IndTmp等于newArray1.Length,即数组末尾的下一个位置。你在从newArray1中提取元素之前就增加了索引变量,这意味着你将崩溃并且还会错过newArray1中的第一个元素。


    有两个原因。第一个是你解释了第二种情况失败的原因,但没有解释为什么第一种情况能够成功。而在解释第二种情况时,你没有解释为什么 LHS 上的增量在 RHS 被评估之前被执行,这正是导致混淆的原因。你描述了 OP 观察到的行为,但没有回答 OP 提出的问题。我认为这在某种程度上贬低了你帖子的相关性。 - Steve Morgan
    @Steve:我想我也可以对你冗长而不必要详细解释一个简单的运算符优先级问题做出类似的回应,但好吧,公平的说。下次我处理IndexOutOfBounds异常时,我一定会使用ILDASM。 - Ed S.
    关于这件事情令人沮丧的是,它与运算符优先级无关,而是与评估顺序有关。我发现ILDASM是帮助理解原因的完美工具,并积极鼓励人们使用它,如果他们想要了解为什么他们的代码表现不如预期。既然基于相关性和准确性的批评显然是您关注的问题,我已经取消了我的反对票。很高兴能够帮忙。 - 更正,我试过了,但在您编辑答案之前无法撤消。如果机会出现,我一定会将其删除。 - Steve Morgan
    @Steve:是啊,我也不确定为什么我说了“运算符优先级”这个词...无论如何,我认为解释已经足够了,但是呃。 - Ed S.

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