使用 foreach 遍历 IEnumerable 会跳过一些元素

Iterating over IEnumerable using foreach skips some elements

我遇到过遍历 enumerable 和遍历 enumerable.ToList() 之间的行为差​​异。

public static void Kill(Point location)
{
    Wound(location);
    foreach(var point in GetShipPointsAndTheirNeighbors(location).ToList())
    {
        CellsWithShips[point.X, point.Y] = false;
    }
}

/// <summary>
/// This version does not work for strange reasons, it just skips a half of points. See TestKill_DoesNotWork_1 test case
/// </summary>
/// <param name="location"></param>
public static void Kill_DoesNotWork(Point location)
{
    Wound(location);
    foreach(var point in GetShipPointsAndTheirNeighbors(location))
    {
        CellsWithShips[point.X, point.Y] = false;
    }
}

如您所见,这些方法之间的唯一区别是第一个迭代 List 个点,而 Kill_DoesNotWork 迭代 IEnumerable<Point> 个点。但是,最后一种方法有时会跳过元素 (Ideone example).

有完整的代码(170行代码抱歉,不能再压缩了)

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace SampleAi
{
    [DebuggerDisplay("Pont({X}, {Y})")]
    public class Point
    {
        #region Constructors

        public Point(int x, int y)
        {
            X = x;
            Y = y;
        } 

        #endregion // Constructors

        #region Properties

        public int X
        {
            get;
            private set;
        }

        public int Y
        {
            get;
            private set;
        }

        #endregion // Properties

        #region Methods

        public Point Add(Point point)
        {
            return new Point(X + point.X, Y + point.Y);
        }

        #endregion // Methods

        #region Overrides of Object

        /// <summary>
        /// Returns a string that represents the current object.
        /// </summary>
        /// <returns>
        /// A string that represents the current object.
        /// </returns>
        public override string ToString()
        {
            return string.Format("Point({0}, {1})", X, Y);
        }

        #endregion
    }

    public static class Map
    {
        #region Properties

        private static bool[,] CellsWithShips
        {
            get;
            set;
        }

        #endregion // Properties

        #region Methods

        public static IEnumerable<Point> GetAllShipPoints()
        {
            return Enumerable.Range(0, CellsWithShips.GetLength(0))
                             .SelectMany(x => Enumerable.Range(0, CellsWithShips.GetLength(1)).Select(y => new Point(x, y)))
                             .Where(p => CellsWithShips[p.X, p.Y]);
        }

        public static void Init(int width, int height)
        {
            CellsWithShips = new bool[width, height];
        }

        public static void Wound(Point location)
        {
            CellsWithShips[location.X, location.Y] = true;
        }

        public static void Kill(Point location)
        {
            Wound(location);
            foreach(var point in GetShipPointsAndTheirNeighbors(location).ToList())
            {
                CellsWithShips[point.X, point.Y] = false;
            }
        }

        /// <summary>
        /// This version does not work for strange reasons, it just skips a half of points. See TestKill_DoesNotWork_1 test case
        /// </summary>
        /// <param name="location"></param>
        public static void Kill_DoesNotWork(Point location)
        {
            Wound(location);
            foreach(var point in GetShipPointsAndTheirNeighbors(location))
            {
                CellsWithShips[point.X, point.Y] = false;
            }
        }

        private static IEnumerable<Point> GetShipPointsAndTheirNeighbors(Point location)
        {
            return GetShipPoints(location).SelectMany(Near);
        }

        private static IEnumerable<Point> Near(Point location)
        {
            return new[]
            {
                location.Add(new Point(0, -1)),
                location.Add(new Point(0, 0))
            };
        }

        private static IEnumerable<Point> GetShipPoints(Point location)
        {
            var beforePoint = new[]
            {
                location,
                location.Add(new Point(0, -1)),
                location.Add(new Point(0, -2)),
                location.Add(new Point(0, -3))
            };
            return beforePoint.TakeWhile(p => CellsWithShips[p.X, p.Y]);
        }

        #endregion // Methods
    }

    public static class Program
    {
        private static void LoadMap()
        {
            Map.Init(20, 20);

            Map.Wound(new Point(1, 4));
            Map.Wound(new Point(1, 5));
            Map.Wound(new Point(1, 6));
        }

        private static int TestKill()
        {
            LoadMap();
            Map.Kill(new Point(1, 7));
            return Map.GetAllShipPoints().Count();
        }

        private static int TestKillDoesNotWork()
        {
            LoadMap();
            Map.Kill_DoesNotWork(new Point(1, 7));
            return Map.GetAllShipPoints().Count();
        }

        private static void Main()
        {
            Console.WriteLine("Test kill: {0}", TestKill());
            Console.WriteLine("Test kill (does not work): {0}", TestKillDoesNotWork());
        }
    }
}

由于这是压缩代码,所以大部分功能并不完全符合它们的要求。如果你想削减更多,你可以使用 this gist for sharing your code (gist with unit tests).

我正在使用 MSVS 2013(12.0.30110.00 更新 1)和 .NET Framework v4.5.51650

ToList() 的调用将实现项目的结果选择,因为 它查看了那个时间点。迭代 IEnumerable 将评估为每个项目给出的表达式并逐个产生它们,因此现实可能在迭代之间发生变化。事实上,很可能会发生这种情况,因为您在迭代之间更改了项的属性。

在您的迭代主体中,您设置

CellsWithShips[point.X, point.Y] = false;

在选择你的方法时,你查询

things.Where(p => CellsWithShips[p.X, p.Y]);

这意味着此类查询的固有动态结果将发生变化,因为您已将其中一些结果设置为 false。但只是因为它会根据需要逐一评估每个项目。这称为延迟执行,最常用于优化大型查询或长运行、动态大小的操作。