由C# yield return引發的思考

2023-05-31 12:02:07

前言

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

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