4

由C# yield return引发的思考

 1 year ago
source link: https://www.cnblogs.com/wucy/p/17443749.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

前言#

    当我们编写 C# 代码时,经常需要处理大量的数据集合。在传统的方式中,我们往往需要先将整个数据集合加载到内存中,然后再进行操作。但是如果数据集合非常大,这种方式就会导致内存占用过高,甚至可能导致程序崩溃。

    C# 中的yield return机制可以帮助我们解决这个问题。通过使用yield return,我们可以将数据集合按需生成,而不是一次性生成整个数据集合。这样可以大大减少内存占用,并且提高程序的性能。

    在本文中,我们将深入讨论 C# 中yield return的机制和用法,帮助您更好地理解这个强大的功能,并在实际开发中灵活使用它。

使用方式#

上面我们提到了yield return将数据集合按需生成,而不是一次性生成整个数据集合。接下来通过一个简单的示例,我们看一下它的工作方式是什么样的,以便加深对它的理解

foreach (var num in GetInts())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

IEnumerable<int> GetInts()
{
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine("内部遍历了:{0}", i);
        yield return i;
    }
}

首先,在GetInts方法中,我们使用yield return关键字来定义一个迭代器。这个迭代器可以按需生成整数序列。在每次循环时,使用yield return返回当前的整数。通过1foreach循环来遍历 GetInts方法返回的整数序列。在迭代时GetInts方法会被执行,但是不会将整个序列加载到内存中。而是在需要时,按需生成序列中的每个元素。在每次迭代时,会输出当前迭代的整数对应的信息。所以输出的结果为

内部遍历了:0
外部遍历了:0
内部遍历了:1
外部遍历了:1
内部遍历了:2
外部遍历了:2
内部遍历了:3
外部遍历了:3
内部遍历了:4
外部遍历了:4

可以看到,整数序列是按需生成的,并且在每次生成时都会输出相应的信息。这种方式可以大大减少内存占用,并且提高程序的性能。当然从c# 8开始异步迭代的方式同样支持

await foreach (var num in GetIntsAsync())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

async IAsyncEnumerable<int> GetIntsAsync()
{
    for (int i = 0; i < 5; i++)
    {
        await Task.Yield();
        Console.WriteLine("内部遍历了:{0}", i);
        yield return i;
    }
}

和上面不同的是,如果需要用异步的方式,我们需要返回IAsyncEnumerable类型,这种方式的执行结果和上面同步的方式执行的结果是一致的,我们就不做展示了。上面我们的示例都是基于循环持续迭代的,其实使用yield return的方式还可以按需的方式去输出,这种方式适合灵活迭代的方式。如下示例所示

foreach (var num in GetInts())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

IEnumerable<int> GetInts()
{
    Console.WriteLine("内部遍历了:0");
    yield return 0;

    Console.WriteLine("内部遍历了:1");
    yield return 1;

    Console.WriteLine("内部遍历了:2");
    yield return 2;
}

foreach循环每次会调用GetInts()方法,GetInts()方法的内部便使用yield return关键字返回一个结果。每次遍历都会去执行下一个yield return。所以上面代码输出的结果是

内部遍历了:0
外部遍历了:0
内部遍历了:1
外部遍历了:1
内部遍历了:2
外部遍历了:2

探究本质#

上面我们展示了yield return如何使用的示例,它是一种延迟加载的机制,它可以让我们逐个地处理数据,而不是一次性地将所有数据读取到内存中。接下来我们就来探究一下神奇操作的背后到底是如何实现的,方便让大家更清晰的了解迭代体系相关。

foreach本质#

首先我们来看一下foreach为什么可以遍历,也就是如果可以被foreach遍历的对象,被遍历的操作需要满足哪些条件,这个时候我们可以反编译工具来看一下编译后的代码是什么样子的,相信大家最熟悉的就是List<T>集合的遍历方式了,那我们就用List<T>的示例来演示一下

List<int> ints = new List<int>();
foreach(int item in ints)
{
    Console.WriteLine(item);
}

上面的这段代码很简单,我们也没有给它任何初始化的数据,这样可以排除干扰,让我们能更清晰的看到反编译的结果,排除其他干扰。它反编译后的代码是这样的

List<int> list = new List<int>();
List<int>.Enumerator enumerator = list.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    ((IDisposable)enumerator).Dispose();
}

可以反编译代码的工具有很多,我用的比较多的一般是ILSpydnSpydotPeek和在线c#反编译网站sharplab.io,其中dnSpy还可以调试反编译的代码。

通过上面的反编译之后的代码我们可以看到foreach会被编译成一个固定的结构,也就是我们经常提及的设计模式中的迭代器模式结构

Enumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
   var current = enumerator.Current;
}

通过这段固定的结构我们总结一下foreach的工作原理

  • 可以被foreach的对象需要要包含GetEnumerator()方法
  • 迭代器对象包含MoveNext()方法和Current属性
  • MoveNext()方法返回bool类型,判断是否可以继续迭代。Current属性返回当前的迭代结果。

我们可以看一下List<T>类可迭代的源码结构是如何实现的

public class List<T> : IList<T>, IList, IReadOnlyList<T>
{
    public Enumerator GetEnumerator() => new Enumerator(this);
 
    IEnumerator<T> IEnumerable<T>.GetEnumerator() => Count == 0 ? SZGenericArrayEnumerator<T>.Empty : GetEnumerator();
 
    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<T>)this).GetEnumerator();

    public struct Enumerator : IEnumerator<T>, IEnumerator
    {
        public T Current => _current!;
        public bool MoveNext()
        {
        }
    }
}

这里涉及到了两个核心的接口IEnumerable<IEnumerator,他们两个定义了可以实现迭代的能力抽象,实现方式如下

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

public interface IEnumerator
{
    bool MoveNext();
    object Current{ get; }
    void Reset();
}

如果类实现IEnumerable接口并实现了GetEnumerator()方法便可以被foreach,迭代的对象是IEnumerator类型,包含一个MoveNext()方法和Current属性。上面的接口是原始对象的方式,这种操作都是针对object类型集合对象。我们实际开发过程中大多数都是使用的泛型集合,当然也有对应的实现方式,如下所示

public interface IEnumerable<out T> : IEnumerable
{
    new IEnumerator<T> GetEnumerator();
}

public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    new T Current{ get; }
}

可以被foreach迭代并不意味着一定要去实现IEnumerable接口,这只是给我们提供了一个可以被迭代的抽象的能力。只要类中包含GetEnumerator()方法并返回一个迭代器,迭代器里包含返回bool类型的MoveNext()方法和获取当前迭代对象的Current属性即可。

yield return本质#

上面我们看到了可以被foreach迭代的本质是什么,那么yield return的返回值可以被IEnumerable<T>接收说明其中必有蹊跷,我们反编译一下我们上面的示例看一下反编译之后代码,为了方便大家对比反编译结果,这里我把上面的示例再次粘贴一下

foreach (var num in GetInts())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

IEnumerable<int> GetInts()
{
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine("内部遍历了:{0}", i);
        yield return i;
    }
}

它的反编译结果,这里咱们就不全部展示了,只展示一下核心的逻辑

//foeach编译后的结果
IEnumerator<int> enumerator = GetInts().GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine("外部遍历了:{0}", current);
    }
}
finally
{
    if (enumerator != null)
    {
        enumerator.Dispose();
    }
}

//GetInts方法编译后的结果
private IEnumerable<int> GetInts()
{
    <GetInts>d__1 <GetInts>d__ = new <GetInts>d__1(-2);
    <GetInts>d__.<>4__this = this;
    return <GetInts>d__;
}

这里我们可以看到GetInts()方法里原来的代码不见了,而是多了一个<GetInts>d__1 l类型,也就是说yield return本质是语法糖。我们看一下<GetInts>d__1类的实现

//生成的类即实现了IEnumerable接口也实现了IEnumerator接口
//说明它既包含了GetEnumerator()方法,也包含MoveNext()方法和Current属性
private sealed class <>GetIntsd__1 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;
    //当前迭代结果
    private int <>2__current;
    private int <>l__initialThreadId;
    public C <>4__this;
    private int <i>5__1;

    //当前迭代到的结果
    int IEnumerator<int>.Current
    {
        get{ return <>2__current; }
    }

    //当前迭代到的结果
    object IEnumerator.Current
    {
        get{ return <>2__current; }
    }

    //构造函数包含状态字段,变向说明靠状态机去实现核心流程流转
    public <GetInts>d__1(int <>1__state)
    {
        this.<>1__state = <>1__state;
        <>l__initialThreadId = Environment.CurrentManagedThreadId;
    }

    //核心方法MoveNext
    private bool MoveNext()
    {
        int num = <>1__state;
        if (num != 0)
        {
            if (num != 1)
            {
                return false;
            }
            //控制状态
            <>1__state = -1;
            //自增 也就是代码里循环的i++
            <i>5__1++;
        }
        else
        {
            <>1__state = -1;
            <i>5__1 = 0;
        }
        //循环终止条件 上面循环里的i<5
        if (<i>5__1 < 5)
        {
            Console.WriteLine("内部遍历了:{0}", <i>5__1);
            //把当前迭代结果赋值给Current属性
            <>2__current = <i>5__1;
            <>1__state = 1;
            //说明可以继续迭代
            return true;
        }
        //迭代结束
        return false;
    }

    //IEnumerator的MoveNext方法
    bool IEnumerator.MoveNext()
    {
        return this.MoveNext();
    }

    //IEnumerable的IEnumerable方法
    IEnumerator<int> IEnumerable<int>.IEnumerable()
    {
        //实例化<GetInts>d__1实例
        <GetInts>d__1 <GetInts>d__;
        if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId)
        {
            <>1__state = 0;
            <GetInts>d__ = this;
        }
        else
        {
            //给状态机初始化
            <GetInts>d__ = new <GetInts>d__1(0);
            <GetInts>d__.<>4__this = <>4__this;
        }
        //因为<GetInts>d__1实现了IEnumerator接口所以可以直接返回
        return <GetInts>d__;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        //因为<GetInts>d__1实现了IEnumerator接口所以可以直接转换
        return ((IEnumerable<int>)this).GetEnumerator();
    }

    void IEnumerator.Reset()
    {
    }

    void IDisposable.Dispose()
    {
    }
}

通过它生成的类我们可以看到,该类即实现了IEnumerable接口也实现了IEnumerator接口说明它既包含了GetEnumerator()方法,也包含MoveNext()方法和Current属性。用这一个类就可以满足可被foeach迭代的核心结构。我们手动写的for代码被包含到了MoveNext()方法里,它包含了定义的状态机制代码,并且根据当前的状态机代码将迭代移动到下一个元素。我们大概讲解一下我们的for代码被翻译到MoveNext()方法里的执行流程

  • 首次迭代时<>1__state被初始化成0,代表首个被迭代的元素,这个时候Current初始值为0,循环控制变量<i>5__1初始值也为0。
  • 判断是否满足终止条件,不满足则执行循环里的逻辑。并更改装填机<>1__state为1,代表首次迭代执行完成。
  • 循环控制变量<i>5__1继续自增并更改并更改装填机<>1__state为-1,代表可持续迭代。并循环执行循环体的自定义逻辑。
  • 不满足迭代条件则返回false,也就是代表了MoveNext()以不满足迭代条件while (enumerator.MoveNext())逻辑终止。

上面我们还展示了另一种yield return的方式,就是同一个方法里包含多个yield return的形式

IEnumerable<int> GetInts()
{
    Console.WriteLine("内部遍历了:0");
    yield return 0;

    Console.WriteLine("内部遍历了:1");
    yield return 1;

    Console.WriteLine("内部遍历了:2");
    yield return 2;
}

上面这段代码反编译的结果如下所示,这里咱们只展示核心的方法MoveNext()的实现

private bool MoveNext()
{
    switch (<>1__state)
    {
        default:
            return false;
        case 0:
            <>1__state = -1;
            Console.WriteLine("内部遍历了:0");
            <>2__current = 0;
            <>1__state = 1;
            return true;
        case 1:
            <>1__state = -1;
            Console.WriteLine("内部遍历了:1");
            <>2__current = 1;
            <>1__state = 2;
            return true;
        case 2:
            <>1__state = -1;
            Console.WriteLine("内部遍历了:2");
            <>2__current = 2;
            <>1__state = 3;
            return true;
        case 3:
            <>1__state = -1;
            return false;
    }
}

通过编译后的代码我们可以看到,多个yield return的形式会被编译成switch...case的形式,有几个yield return则会编译成n+1case,多出来的一个case则代表的MoveNext()终止条件,也就是返回false的条件。其它的case则返回true表示可以继续迭代。

IAsyncEnumerable接口#

上面我们展示了同步yield return方式,c# 8开始新增了IAsyncEnumerable<T>接口,用于完成异步迭代,也就是迭代器逻辑里包含异步逻辑的场景。IAsyncEnumerable<T>接口的实现代码如下所示

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    ValueTask<bool> MoveNextAsync();
    T Current { get; }
}

它最大的不同则是同步的IEnumerator包含的是MoveNext()方法返回的是boolIAsyncEnumerator接口包含的是MoveNextAsync()异步方法,返回的是ValueTask<bool>类型。所以上面的示例代码

await foreach (var num in GetIntsAsync())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

所以这里的await虽然是加在foreach上面,但是实际作用的则是每一次迭代执行的MoveNextAsync()方法。可以大致理解为下面的工作方式

IAsyncEnumerator<int> enumerator = list.GetAsyncEnumerator();
while (enumerator.MoveNextAsync().GetAwaiter().GetResult())
{
   var current = enumerator.Current;
}

当然,实际编译成的代码并不是这个样子的,我们在之前的文章<研究c#异步操作async await状态机的总结>一文中讲解过async await会被编译成IAsyncStateMachine异步状态机,所以IAsyncEnumerator<T>结合yield return的实现比同步的方式更加复杂而且包含更多的代码,不过实现原理可以结合同步的方式类比一下,但是要同时了解异步状态机的实现,这里咱们就不过多展示异步yield return的编译后实现了,有兴趣的同学可以自行了解一下。

foreach增强#

c# 9增加了对foreach的增强的功能,即通过扩展方法的形式,对原本具备包含foreach能力的对象增加GetEnumerator()方法,使得普通类在不具备foreach的能力的情况下也可以使用来迭代。它的使用方式如下

Foo foo = new Foo();
foreach (int item in foo)
{
    Console.WriteLine(item);
}

public class Foo
{
    public List<int> Ints { get; set; } = new List<int>();
}

public static class Bar
{
    //给Foo定义扩展方法
    public static IEnumerator<int> GetEnumerator(this Foo foo)
    {
        foreach (int item in foo.Ints)
        {
            yield return item;
        }
    }
}

这个功能确实比较强大,满足开放封闭原则,我们可以在不修改原始代码的情况,增强代码的功能,可以说是非常的实用。我们来看一下它的编译后的结果是啥

Foo foo = new Foo();
IEnumerator<int> enumerator = Bar.GetEnumerator(foo);
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    if (enumerator != null)
    {
        enumerator.Dispose();
    }
}

这里我们看到扩展方法GetEnumerator()本质也是语法糖,会把扩展能力编译成扩展类.GetEnumerator(被扩展实例)的方式。也就是我们写代码时候的原始方式,只是编译器帮我们生成了它的调用方式。接下来我们看一下GetEnumerator()扩展方法编译成了什么

public static IEnumerator<int> GetEnumerator(Foo foo)
{
    <GetEnumerator>d__0 <GetEnumerator>d__ = new <GetEnumerator>d__0(0);
    <GetEnumerator>d__.foo = foo;
    return <GetEnumerator>d__;
}

看到这个代码是不是觉得很眼熟了,不错和上面yield return本质这一节里讲到的语法糖生成方式是一样的了,同样的编译时候也是生成了一个对应类,这里的类是<GetEnumerator>d__0,我们看一下该类的结构

private sealed class <GetEnumerator>d__0 : IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;
    private int <>2__current;
    public Foo foo;
    private List<int>.Enumerator <>s__1;
    private int <item>5__2;

    int IEnumerator<int>.Current
    {
        get{ return <>2__current; }
    }

    object IEnumerator.Current
    {
        get{ return <>2__current; }
    }

    public <GetEnumerator>d__0(int <>1__state)
    {
        this.<>1__state = <>1__state;
    }

    private bool MoveNext()
    {
        try
        {
            int num = <>1__state;
            if (num != 0)
            {
                if (num != 1)
                {
                    return false;
                }
                <>1__state = -3;
            }
            else
            {
                <>1__state = -1;
                //因为示例中的Ints我们使用的是List<T>
                <>s__1 = foo.Ints.GetEnumerator();
                <>1__state = -3;
            }
            //因为上面的扩展方法里使用的是foreach遍历方式
            //这里也被编译成了实际生产方式
            if (<>s__1.MoveNext())
            {
                <item>5__2 = <>s__1.Current;
                <>2__current = <item>5__2;
                <>1__state = 1;
                return true;
            }
            <>m__Finally1();
            <>s__1 = default(List<int>.Enumerator);
            return false;
        }
        catch
        {
            ((IDisposable)this).Dispose();
            throw;
        }
    }

    bool IEnumerator.MoveNext()
    {
        return this.MoveNext();
    }

    void IDisposable.Dispose()
    {
    }

    void IEnumerator.Reset()
    {
    }

    private void <>m__Finally1()
    {
    }
}

看到编译器生成的代码,我们可以看到yield return生成的代码结构都是一样的,只是MoveNext()里的逻辑取决于我们写代码时候的具体逻辑,不同的逻辑生成不同的代码。这里咱们就不在讲解它生成的代码了,因为和上面咱们讲解的代码逻辑是差不多的。

总结#

    通过本文我们介绍了c#中的yield return语法,并探讨了由它带来的一些思考。我们通过一些简单的例子,展示了yield return的使用方式,知道了迭代器来是如何按需处理大量数据。同时,我们通过分析foreach迭代和yield return语法的本质,讲解了它们的实现原理和底层机制。好在涉及到的知识整体比较简单,仔细阅读相关实现代码的话相信会了解背后的实现原理,这里就不过多赘述了。

    当你遇到挑战和困难时,请不要轻易放弃。无论你面对的是什么,只要你肯努力去尝试,去探索,去追求,你一定能够克服困难,走向成功。记住,成功不是一蹴而就的,它需要我们不断努力和坚持。相信自己,相信自己的能力,相信自己的潜力,你一定能够成为更好的自己。

👇欢迎扫码关注我的公众号👇
2042116-20200622133425514-1420050576.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK