由C# yield return引發的思考

前言

    當我們編寫 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語法的本質,講解了它們的實現原理和底層機制。好在涉及到的知識整體比較簡單,仔細閱讀相關實現代碼的話相信會了解背後的實現原理,這裏就不過多贅述了。

    當你遇到挑戰和困難時,請不要輕易放棄。無論你面對的是什麼,只要你肯努力去嘗試,去探索,去追求,你一定能夠克服困難,走向成功。記住,成功不是一蹴而就的,它需要我們不斷努力和堅持。相信自己,相信自己的能力,相信自己的潛力,你一定能夠成爲更好的自己。

👇歡迎掃碼關注我的公衆號👇
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章