为什么IEnumerable没有被消费?/C#中的生成器与Python比较如何?

19 投票
6 回答
2579 浏览
提问于 2025-04-18 05:00

我原以为C#中的yield return和Python中的yield差不多,我觉得我理解了这两者。我认为编译器会把一个函数变成一个对象,这个对象里有一个指针指向执行应该从哪里继续。当请求下一个值时,这个对象会运行到下一个yield的位置,更新指针,然后返回一个值。

在Python中,这种方式有点像懒惰求值,它会根据需要生成值,但一旦这些值被使用过,如果没有保存到其他变量中,就可以被垃圾回收了。如果你尝试两次遍历这样的函数的结果,除非把结果转成列表,否则会得到一个空的可迭代对象。

举个例子:

def y():
    list = [1,2,3,4]

    for i in list:
        yield str(i)

ys = y()
print "first ys:"
print ",".join(ys)
print "second ys:"
print ",".join(ys)

输出结果:

first ys:
1,2,3,4
second ys:

直到最近,我还以为C#也是这样,但在dotnetfiddle上试了一下却失败了。

http://dotnetfiddle.net/W5Cbv6

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

public class Program
{
    public static IEnumerable<string> Y()
    {
        var list = new List<string> {"1","2","3","4","5"};
        foreach(var i in list)
        {
            yield return i;
        }
    }

    public static void Main()
    {


        var ys = Y();
        Console.WriteLine("first ys");
        Console.WriteLine(string.Join(",", ys));
        Console.WriteLine("second ys");
        Console.WriteLine(string.Join(",", ys));

    }
}

输出结果:

first ys
1,2,3,4,5
second ys
1,2,3,4,5

这里发生了什么?它是在缓存结果吗?这不对吧,否则File.ReadLines在处理大文件时会出问题吧?难道它只是第二次从头开始运行这个函数吗?

注意:我对生成器和协程的一些术语有点不确定,所以我尽量避免使用这些标签。

6 个回答

1

编译器会创建一个对象,这个对象实现了你在Y方法中定义的IEnumerable接口。

这个对象其实就像一个状态机,它会记录当前的状态,当你在遍历时,它会向前移动。你可以看看从你的Y方法返回的IEnumerable所创建的Enumerator的MoveNext方法的IL代码:

        IL_0000: ldarg.0
        IL_0001: ldfld int32 Program/'<Y>d__1'::'<>1__state'
        IL_0006: stloc.1
        IL_0007: ldloc.1
        IL_0008: switch (IL_001e, IL_00e8, IL_00ce)

        IL_0019: br IL_00e8

        IL_001e: ldarg.0
        IL_001f: ldc.i4.m1
        IL_0020: stfld int32 Program/'<Y>d__1'::'<>1__state'
        IL_0025: ldarg.0
        IL_0026: ldarg.0
        IL_0027: newobj instance void class [mscorlib]System.Collections.Generic.List`1<string>::.ctor()
        IL_002c: stfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0031: ldarg.0
        IL_0032: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0037: ldstr "1"
        IL_003c: callvirt instance void class [mscorlib]System.Collections.Generic.List`1<string>::Add(!0)
        IL_0041: ldarg.0
        IL_0042: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0047: ldstr "2"
        IL_004c: callvirt instance void class [mscorlib]System.Collections.Generic.List`1<string>::Add(!0)
        IL_0051: ldarg.0
        IL_0052: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0057: ldstr "3"
        IL_005c: callvirt instance void class [mscorlib]System.Collections.Generic.List`1<string>::Add(!0)
        IL_0061: ldarg.0
        IL_0062: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0067: ldstr "4"
        IL_006c: callvirt instance void class [mscorlib]System.Collections.Generic.List`1<string>::Add(!0)
        IL_0071: ldarg.0
        IL_0072: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0077: ldstr "5"
        IL_007c: callvirt instance void class [mscorlib]System.Collections.Generic.List`1<string>::Add(!0)
        IL_0081: ldarg.0
        IL_0082: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<>g__initLocal0'
        IL_0087: stfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<list>5__2'
        IL_008c: ldarg.0
        IL_008d: ldarg.0
        IL_008e: ldfld class [mscorlib]System.Collections.Generic.List`1<string> Program/'<Y>d__1'::'<list>5__2'
        IL_0093: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> class [mscorlib]System.Collections.Generic.List`1<string>::GetEnumerator()
        IL_0098: stfld valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<string> Program/'<Y>d__1'::'<>7__wrap4'
        IL_009d: ldarg.0
        IL_009e: ldc.i4.1
        IL_009f: stfld int32 Program/'<Y>d__1'::'<>1__state'
        IL_00a4: br.s IL_00d5

        IL_00a6: ldarg.0
        IL_00a7: ldarg.0
        IL_00a8: ldflda valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<string> Program/'<Y>d__1'::'<>7__wrap4'
        IL_00ad: call instance !0 valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<string>::get_Current()
        IL_00b2: stfld string Program/'<Y>d__1'::'<i>5__3'
        IL_00b7: ldarg.0
        IL_00b8: ldarg.0
        IL_00b9: ldfld string Program/'<Y>d__1'::'<i>5__3'
        IL_00be: stfld string Program/'<Y>d__1'::'<>2__current'
        IL_00c3: ldarg.0
        IL_00c4: ldc.i4.2
        IL_00c5: stfld int32 Program/'<Y>d__1'::'<>1__state'
        IL_00ca: ldc.i4.1
        IL_00cb: stloc.0
        IL_00cc: leave.s IL_00f3

        IL_00ce: ldarg.0
        IL_00cf: ldc.i4.1
        IL_00d0: stfld int32 Program/'<Y>d__1'::'<>1__state'

        IL_00d5: ldarg.0
        IL_00d6: ldflda valuetype        [mscorlib]System.Collections.Generic.List`1/Enumerator<string> Program/'<Y>d__1'::'<>7__wrap4'
        IL_00db: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<string>::MoveNext()
        IL_00e0: brtrue.s IL_00a6

        IL_00e2: ldarg.0
        IL_00e3: call instance void Program/'<Y>d__1'::'<>m__Finally5'()

        IL_00e8: ldc.i4.0
        IL_00e9: stloc.0
        IL_00ea: leave.s IL_00f3

当Enumerator对象处于初始状态时(也就是刚通过GetEnumerator调用创建出来),这个方法会生成一个内部列表,里面包含了所有的返回值。之后每次调用MoveNext时,都是在操作这个内部列表,直到它被用完为止。这就意味着每次有人开始遍历返回的IEnumerable时,都会创建一个新的Enumerator,重新开始。

File.ReadLines也是一样。每次你开始遍历文件时,都会创建一个新的文件句柄,每调用一次MoveNext或Current,就会从底层流中返回一行。

2

当编译器看到“yield”这个关键词时,它会在程序类里面创建一个嵌套的私有类,这个类会实现一个叫做IEnumerator的东西。(在C#有“yield”之前,我们得自己手动实现这个功能)

这里有一个稍微简化、易读的版本:

private sealed class EnumeratorWithSomeWeirdName : IEnumerator<string>, IEnumerable<string>
{
private string _current;
private int _state = 0;
private List<string> list_;
private List<string>.Enumerator _wrap;

public string Current
{
    get { return _current; }
}

object IEnumerator.Current
{
    get { return _current; }
}

public bool MoveNext()
{
    switch (_state) {
        case 0:
            _state = -1;
            list_ = new List<string>();
            list_.Add("1");
            list_.Add("2");
            list_.Add("3");
            list_.Add("4");
            list_.Add("5");
            _wrap = list_.GetEnumerator();
            _state = 1;
            break;
        case 1:
            return false;
        case 2:
            _state = 1;
            break;
        default:
            return false;
    }
    if (_wrap.MoveNext()) {
        _current = _wrap.Current;
        _state = 2;
        return true;
    }
    _state = -1;
    return false;
}

IEnumerator<string> GetEnumerator()
{
    return new EnumeratorWithSomeWeirdName();
}

IEnumerator IEnumerator.GetEnumerator()
{
    return new EnumeratorWithSomeWeirdName();
}

void IDisposable.Dispose()
{
    _wrap.Dispose();
}

void IEnumerator.Reset()
{
    throw new NotSupportedException();
}

}

Y()方法也会有所改变。它会简单地返回这个嵌套类的一个实例:

public static IEnumerable<string> Y()
{
    return new EnumeratorWithSomeWeirdName();
}

注意,这个时候什么都还没发生。你只是得到了这个类的一个实例。只有当你开始遍历(用foreach循环)时,实例上的MoveNext()方法才会被调用。这个方法会一次返回一个项目。(这一点很重要)

foreach循环其实是个语法糖;它实际上是调用GetEnumerator()方法:

using(IEnumerator<string> enumerator = list.GetEnumerator()) {
    while (enumerator.MoveNext()) yield return enumerator.Current;
}

如果你调用ys.GetEnumerator(),你会看到它有一个MoveNext()方法和一个Current属性,这正是IEnumerator应该有的。

如果你的Main方法里有一行像这样的代码:

foreach (string s in ys) Console.WriteLine(s);

然后你用调试器逐步执行,你会看到调试器在Main和Y方法之间来回跳动。通常情况下,像这样进出一个方法是不可能的,但因为实际上它是一个类,所以这样是可行的。(因为string.Join会遍历整个内容,所以你的例子不会显示这个。)

现在,每次你调用

Console.WriteLine(string.Join(",", ys));

时,都会调用另一个foreach循环,因此又会创建一个新的Enumerator。这是可能的,因为这个内部类也实现了IEnumerable(他们在实现yield关键词时考虑得非常周到)。所以这里有很多编译器的魔法在运作。一行带有yield return的代码变成了一个完整的类。

3

代码在不同情况下表现不一样的原因是因为在Python中,你使用了同一个 IEnumerator 实例两次,但第二次的时候它已经被遍历过了(它不能重复使用,所以就不再用了)。而在C#中,每次调用 GetEnumerator() 都会返回一个新的 IEnumerator,这个新的实例会从头开始遍历集合。每个枚举器实例之间互不影响。枚举器不会自动锁定集合,所以两个枚举器都可以遍历整个集合。然而,你的Python例子只使用了一个枚举器,所以如果不重置,它只能遍历一次。

yield 操作符是一个方便的工具,用来更轻松地返回 IEnumerableIEnumerator 实例。它实现了接口,每次调用 yield return 时都会向返回的迭代器中添加一个元素。每次调用 Y() 时,都会构建一个新的可枚举对象,但每个可枚举对象可以有多个枚举器。 每次调用 String.Join 时,内部会调用 GetEnumerator,这会为每次调用创建一个新的枚举器。因此,每次调用 String.Join 时,你都会从头到尾遍历整个集合。

7

经过仔细查看代码的每个部分,我觉得问题可能和IEnumerable<>有关。如果我们去看一下MSDN,会发现IEnumerable本身并不是一个枚举器,而是每次调用GetEnumerator()时都会创建一个新的枚举器。如果我们再看看GetEnumerator,我们会发现foreach(我想string.Join也是这样)会调用GetEnumerator(),每次调用时都会创建一个新的状态。举个例子,这里有一段代码使用了枚举器:

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

public class Program
{
    public static IEnumerable<string> Y()
    {
        var list = new List<string> {"1","2","3","4","5"};
        foreach(var i in list)
        {
            yield return i;
        }
    }
    
    public static void Main()
    {
        
        
        var ys = Y();
        Console.WriteLine("first ys");
        Console.WriteLine(string.Join(",", ys));
        IEnumerator<string> i = ys.GetEnumerator();
        Console.WriteLine(""+i.MoveNext()+": "+i.Current);
        Console.WriteLine(""+i.MoveNext()+": "+i.Current);
        Console.WriteLine(""+i.MoveNext()+": "+i.Current);
        Console.WriteLine(""+i.MoveNext()+": "+i.Current);
        Console.WriteLine(""+i.MoveNext()+": "+i.Current);
        Console.WriteLine(""+i.MoveNext()+": "+i.Current);
    }
}

(dotnetfiddle)

当MoveNext到达末尾时,它的行为就像Python那样,正如预期的那样。

14

你已经非常接近了。IEnumerable 是一种可以创建迭代器(IEnumerator)的对象。IEnumerator 的行为正如你所描述的那样。

所以,IEnumerable 就是 生成生成器

除非你特别去做一些事情,让生成的迭代器之间共享某种状态,否则 IEnumerator 对象不会相互影响,不管它们是来自不同的迭代器块调用,还是由同一个 IEnumerable 生成的另一个 IEnumerator

撰写回答