修改IEnumerbale中的数据,却没有任何效果?

zeee 1,536 2019-09-18

某日, 余编码中。持一列者,于方法内改其值, 待毕, 复观列中物,安然无恙也。 苦思之, 久不得解, 乃复阅 Stackoverflow, 品四海友人之见, 徐徐悟之。

关于 C# 中对修改 IEnumerable 中的数据却没有生效的问,时不时困扰着很多 C# 的初学者,尤其是学会了 Linq 并经常去使用的朋友, 时不时就会碰到在某个接收的参数为 IEnumerbale 的方法中明明做了某些操作,但是方法结束后, 原来的数据却完全没有变化的情况。

之所以说“时不时”, 是因为,在没有弄懂原因之前,这个问题似乎是“不可稳定重现”的,可能自己在工作中遇到了这个问题,可是等回家想要复盘一遍时却发现问题又不会出现了? 像是玄学一般。 为什么不在上班的时候去复现? 因为上班要完成工作啊, 这个问题虽说“玄”, 但解决的办法却不难, 要么把方法定义为更具体的类型,如 List, Array 等, 对数据进行 ToList() 操作 —— 而且在等会儿弄明白原因之后你会发现, 这个做法就是正确的。

踩坑指北

可问题就出在这里(至少我当时是这样), 因为用 List 来代替 IEnumerbale 之后, 问题就可以解决了, 所以一直以为问题的原因出现在 IEumerable 身上, 于是在排查问题的时候就从这个地方下手, 于是就出现了类似这样的代码:

private static void Test()
{
    var data = new[] { new Score { Value = 1 } };
    Change(data);
    foreach(var d in data)
        Console.WriteLine(d.Value);
}
// 修改方法
private static void Change(IEnumerable<Score> data)
{
    foreach(var d in data)
        d.Value = 2;
}
// 数据定义, 由于不能直接修改foreach元素, 所以我们定义一个对象, 修改对象的属性
public class Score
{
    public int Value { get; set; }
}

按照预期, 如果问题出现在 IEnumerable 身上的话,此时 Change 方法应该不会生效,也就是说输出的的结果应该是1, 可是真正执行完输出的结果确是2。 What ?!! 怎么跟剧本不一样啊? 难道是层级的问题? 如果是一个 IEnumerbale 嵌套一个 IEnumerable ,然后修改里层的 IEnumerable 里面的数据的值的的话, 外面的对象回丢失里面对象的引用,导致修改失败? 于是,我们又写出了下面的代码:

public void Test ()
{
    var s1 = new List<Player>(){
        new Player{
            Name = "Zhouqi",
            Games = new []{new Game(12),new Game(10),new Game(8),new Game(13),new Game(2)}
        }
    };
    Print(s1);

    BeautifyData(s1);
    Print(s1);
}

private void BeautifyData(List<Player> students)
{
    foreach(var s in students)
        foreach (var sc in s.Games)
            sc.Score += 20;
}

private void Print(List<Player> students)
{
    foreach(var s in students)
        Console.WriteLine($"{s.Name}: {string.Join(',', s.Games.Select(sc => sc.Score))}");
}
// 数据定义, 运动员 & 比赛
public class Player
{
    public string Name { get; set; }
    public IEnumerable<Game> Games { get; set; }
}

public class Game
{
    public Game(int score)
    {
        Score = score;
    }
    public int Score { get; set; }
}

按照预期, 此时应该输出两次都是初始化的数据, 可是运行完代码结果如下

Zhouqi: 12,10,8,13,2

Zhouqi: 32,30,28,33,22

显然, 纵使再加一层, 但是数据还是被修改了。然后开始怀疑,时不时数据的初始化有问题, 因为虽然方法接受的是 IEnumerable 类型, 但是数据都是具体的某种列表类型**(恭喜前进了一大步)**,那如果我把数据初始化改成通过初始化呢?, 于是把s1初始化改为用Json反序列话的形式:

var s1 = "[{\"name\":\"Zhouqi\",\"games\":[{\"score\":12},{\"score\":10},{\"score\":8},{\"score\":13},{\"score\":2}]}]";

执行。 结果数据还是被改了。

真相大白

事实上, 最后一步已经十分接近答案了, 但是这个十分接近如果还是建立在**“问题出现在IEnumerbale身上”**之上的话, 却可能永远“可望而不可即”, 因为这个假设基础就是错误的。

废话不多说先揭晓答案: 问题不是出在 IEnumerable 上, 而是出在 Linq 上 ! 由于由于将 IEnumerable 的数据转成 List 之后问题解决,于是我想当然认为是 IEnumerable 的问题, 却忽略了这个 IEnumerable 的数据是通过 Linq 方法得到的。

既然如此, 重现的方法变得很简单了,我们自没有必要建立一个IEnumerable<IEnumerable <>>的复杂结构,也不用费尽心机跟风黑一下周琦了,周琦打的还是不错的(狗头)。

既如此,我们可以用第一个例子来最简单的浮现问题:

private static void Test()
{
    var data = (new[] {1}).Select(d => new Score { Value = d});
    foreach (var d in data)
        d.Value = 2;
    foreach (var d in data)
        Console.WriteLine(d.Value);
}

输出结果: 1

也就是说,上面的代码中, data 是经由 Linq 的扩展方法来初始化之后,第一个 foreach 遍历进行修改的内容并没有生效。

解析

首先我们要明确一个事情:IEnumerbale 本身只是一个接口,而接口本身是没有实现的, 这就足以说明出现问题的原因不会在接口身上, 而是接口中的某一种具体的实现, 这种实现在使用 Linq 的时候回出现, 而在 ToList() 之后就消除了。 那么我们下一步就是要分析这两者具体有什么区别就OK了。

ToList() 方法会把 IEnumerable 的数据转成 List(注意List依旧是 IEnumerbale 的一种实现) --- 在此之前这一批数据是 IEnumerable 的另一种实现, 也就是 Linq 方法执行完之后的实现方式。

到此,答案就浮出水面了:Linq 的一个最明显的特点就是延迟处理: 在 Linq 表达式创建的时候(包括比如上面的 Select 等扩展方法实际上也会创建一个Linq表达式),不会处理任何数据,也不会访问原始列表的数据, 而是在内存中生成了这个查询的表现形式。 只有在真正去访问数据(比如 foreach )的时候, **才开始去执行表达式,得到运算后的结果。**那么我们重新看一下上面的代码:

private static void Test()
{
    // 创建一个表达式
    var data = (new[] {1}).Select(d => new Score { Value = d});
    
    // 执行表达式, 每次运算的结果赋值给 d (此处是引用), 然后再令 d.Value = 2
    foreach (var d in data)
        d.Value = 2;
    
    // 再次执行表达式, 每次运算结果赋值给 d, 然后输出
    foreach (var d in data)
        Console.WriteLine(d.Value);
}

至此,我们可以看得出问题了, 两次 foreach 运行的时候,都会去重新运算表达是, 并赋值给各自对应的局部变量 d, 第一次的修改, 只是修改了其运算结果后赋值的 d 所引用的对象,但表达式本身是没有影响的, 所以第二次再运行之后, 得到的还是初始化的值。

而如果第一行创建 data 的时候调用了 ToList 方法, 那么这个表达式就会立即被执行一遍,并将结果保存在一块特定的堆内存中,内存地址赋值给 data.这样一来, 后面两次遍历,每次遍历的元素d, 都会直接根据地址找到这一批数据, 那么第一次的改动在第二次也会生效。

问题解决。

小小的总结

其实如果 VS 里面装有 ReSharper 插件, 并且编码的规范基本遵循其建议的话, 可能很久都不会遇到这个问题, 因为如果需要对 IEnumerable 进行多次遍历或者对内部的对象进行修改的时候, ReSharper 回提示你这样可能会引起多次遍历,导致性能或数据丢失的问题。

另外我们应知道,在定义接口或者方法时, 数据的传入类型定义的越抽象, 对于上层调用者来说会越友好,那么这个接口就会更好用, 但是这样做必须有一个绝对的前提那就是要保证在这样的抽象下方法是正确的,基于上面的结论,如果对一个 IEnumerbale 进行修改的话, 那么这个修改时不能保证绝对生效的, 因为 Linq 表达时对他的实现会导致每次遍历都会去重新执行表达式获取初始的值, 所以, 在一个接受列表作为参数的接口方法中, 如果需要对改列表数据进行修改或者多次遍历, 那么这个参数应该设置成更具体的类型, 比如 ICorrectable , IList 或者甚至 List, Array 等, 避免因为可能由于某种不同的实现导致程序出错的问题。

扩展

这个问题用 js 来重现一遍,发现通过 js 来理解会变得特别直观, 因为有下面两个前提:

  1. js 的数组时引用类型,这一点跟 C# 的具体列表数据一致
  2. js 中有一种迭代数据的方法叫生成器, 这类似于上面提到 Linq 创建表达时的形式,只有在真正去遍历该表达是的时候, 才会通过 yield 关键字返回指定的值。 但两者不一样的是 js 的生成器是不可复用的,当遍历完一次数据之后,在重新遍历数据, 会得到一个 null 的结果。

示例代码如下:

  1. 模拟普通数组(List)的重现:

    function print(player) {
        console.log(`${player.name}: ${player.games.map(g => g.score)}`)
    }
    
    function beautify(player){
        for(let game of player.games)
            game.score = 100
    }
    
    let player = {
        name: "zhouqi",
        games: [
            {score: 12},
            {score: 10},
            {score: 18},
            {score: 13},
            {score: 2},
        ]
    }
    print(player)	// zhouqi: 12, 10, 18, 13, 2
    
    beautify(player)
    print(player)	// zhouqi: 100, 100, 100, 100, 100
    
  2. 使用生成器来模拟表达式的实现:

    // 定义一个生成器
    function* g() {
        yield {score: 12}
        yield {score: 10}
        yield {score: 18}
        yield {score: 13}
        yield {score: 2}
    }
    
    function print(player) {
        let str = []
        for(let game of player.games{
            str.push(game.score)
        }
        console.log(`${player.name}: ${str}`)
    }
    
    
    function beautify(player){
        for(let game of player.games
            game.score = 100
    }
    
    let player = {
        name: "zhouqi",
        games: g()
    }
    
    // 线路1
    print(player)		// 输出: zhouqi: 12, 10, 18, 13, 2
    // --> 注意此时 g 生成器已经遍历结束, 后面的几个方法都会继续遍历, 但已经拿不到任何数据
    
    beautify(player)  	// do nothing
    print(player)		// 输出:zhouqi:
    
    
    // 线路2, 重现
    print(player)		// 输出: zhouqi: 12, 10, 18, 13, 2
    
    player.games = g();
    beautify(player)
    
    player.games = g();
    print(player)		// 输出:zhouqi: 12, 10, 18, 13, 2
    

可以看到, 由于 js 中的生成器不可复用, 所以我们如果要重现上面的效果,就需要在每次执行方法之前,对 games 进行一次重新赋值, 初始化一个新的生成器(路线2) ———— 这就可以理解为 C# 中的 Linq 表达式实际上就是这么干的, 而我们手动把它写出来之后, 问题就会变得很直观。


参考资料:


# csharp