關於 yield 關鍵字(C#)

2023-07-24 18:06:41

〇、前言

yield 關鍵字的用途是把指令推遲到程式實際需要的時候再執行,這個特性允許我們更細緻地控制集合每個元素產生的時機。

對於一些大型集合,載入起來比較耗時,此時最好是先返回一個來讓系統持續展示目標內容。類似於在餐館吃飯,肯定是做好一個菜就上桌了,而不會全部的菜都做好一起上。

另外還有一個好處是,可以提高記憶體使用效率。當我們有一個方法要返回一個集合時,而作為方法的實現者我們並不清楚方法呼叫者具體在什麼時候要使用該集合資料。如果我們不使用 yield 關鍵字,則意味著需要把集合資料裝載到記憶體中等待被使用,這可能導致資料在記憶體中佔用較長的時間。

下面就一起來看下怎麼用 yield 關鍵字吧。

一、yield 關鍵字的使用

1.1 yield return:在迭代中一個一個返回待處理的值

如下範例,迴圈輸出小於 9 的偶數,並記錄執行任務的執行緒 ID:

class Program
{
    static async Task Main(string[] args)
    {
        foreach (int i in ProduceEvenNumbers(9))
        {
            ConsoleExt.Write($"{i}-Main");
        }
        ConsoleExt.Write($"--Main-迴圈結束");
        Console.ReadLine();
    }
    static IEnumerable<int> ProduceEvenNumbers(int upto)
    {
        for (int i = 0; i <= upto; i += 2)
        {
            ConsoleExt.Write($"{i}-ProduceEvenNumbers");
            yield return i;
            ConsoleExt.Write($"{i}-ProduceEvenNumbers-yielded");
        }
        ConsoleExt.Write($"--ProduceEvenNumbers-迴圈結束");
    }
}
public static class ConsoleExt
{
    public static void Write(object message)
    {
        Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
    }
    public static void WriteLine(object message)
    {
        Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
    }
    public static async void WriteLineAsync(object message)
    {
        await Task.Run(() => Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} "));
    }
}

輸出結果如下,可見整個迴圈是單執行緒執行,ProduceEvenNumbers()生產一個,然後Main()就操作一個,Main() 執行一次操作後,執行緒返回生產線,繼續沿著 return 往後執行;生產線迴圈結束後,Main() 也接著結束:

  

1.2 yield break:標識迭代中斷

 如下範例程式碼,通過條件中斷迴圈:

class Program
{
    static void Main()
    {
        ConsoleExt.Write(string.Join(" ", TakeWhilePositive(new[] { 2, 3, 4, 5, -1, 3, 4 })));
        ConsoleExt.Write(string.Join(" ", TakeWhilePositive(new[] { 9, 8, 7 })));
        Console.ReadLine();
    }
    static IEnumerable<int> TakeWhilePositive(IEnumerable<int> numbers)
    {
        foreach (int n in numbers)
        {
            if (n > 0) // 遇到負數就中斷迴圈
            {
                yield return n;
            }
            else
            {
                yield break;
            }
        }
    }
}
public static class ConsoleExt
{
    public static void Write(object message)
    {
        Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
    }
    public static void WriteLine(object message)
    {
        Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
    }
    public static async void WriteLineAsync(object message)
    {
        await Task.Run(() => Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} "));
    }
}

 輸出結果,第一個陣列中第五個數為負數,因此至此就中斷迴圈,包括它自己之後的數位不再返回:

  

1.3 返回型別為 IAsyncEnumerable<T> 的非同步迭代器

 實際上,不僅可以像前邊範例中那樣返回型別為 IEnumerable<T>,還可以使用 IAsyncEnumerable<T> 作為迭代器的返回型別,使得迭代器支援非同步。

 如下範例程式碼,使用 await foreach 語句對迭代器的結果進行非同步迭代:(關於 await foreach 還有另外一個範例可參考 3.2 await foreach() 範例

class Program
{
    public static async Task Main()
    {
        await foreach (int n in GenerateNumbersAsync(5))
        {
            ConsoleExt.Write(n);
        }
        Console.ReadLine();
    }
    static async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
    {
        for (int i = 0; i < count; i++)
        {
            yield return await ProduceNumberAsync(i);
        }
    }
    static async Task<int> ProduceNumberAsync(int seed)
    {
        await Task.Delay(1000);
        return 2 * seed;
    }
}
public static class ConsoleExt
{
    public static void Write(object message)
    {
        Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
    }
    public static void WriteLine(object message)
    {
        Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
    }
    public static async void WriteLineAsync(object message)
    {
        await Task.Run(() => Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")},  Thread {Thread.CurrentThread.ManagedThreadId}): {message} "));
    }
}

輸出結果如下,可見輸出的結果有不同執行緒執行:

  

1.4 迭代器的返回型別可以是 IEnumerator<T> 或 IEnumerator

以下範例程式碼,通過實現 IEnumerable<T> 介面、GetEnumerator 方法,返回型別為 IEnumerator<T>,來展現 yield 關鍵字的一個用法:

class Program
{
    public static void Main()
    {
        var ints = new int[] { 1, 2, 3 };
        var enumerable = new MyEnumerable<int>(ints);
        foreach (var item in enumerable)
        {
            Console.WriteLine(item);
        }
        Console.ReadLine();
    }
}
public class MyEnumerable<T> : IEnumerable<T>
{
    private T[] items;

    public MyEnumerable(T[] ts)
    {
        this.items = ts;
    }
    public void Add(T item)
    {
        int num = this.items.Length;
        this.items[num + 1] = item;
    }
    public IEnumerator<T> GetEnumerator()
    {
        foreach (var item in this.items)
        {
            yield return item;
        }
    }
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

1.5 不能使用 yield 的情況

  • yield return 不能套在 try-catch 中;
  • yield break 不能放在 finally 中;

    

  • yield 不能用在帶有 in、ref 或 out 引數的方法;
  • yield 不能用在 Lambda 表示式和匿名方法;
  • yield 不能用在包含不安全的塊(unsafe)的方法。

https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/statements/yield 

二、使用 yield 關鍵字實現惰性列舉

在 C# 中,可以使用 yield 關鍵字來實現惰性列舉。惰性列舉是指在使用列舉值時,只有在真正需要時才會生成它們,這可以提高程式的效能,因為在不需要使用列舉值時,它們不會被生成或儲存在記憶體中。

當然對於簡單的列舉,實際上還沒普通的 List<T> 有優勢,因為取列舉值也會對效能有損耗,所以只針對處理大型集合或延遲載入資料才能看到效果。

下面是一個簡單範例,展示瞭如何使用 yield 關鍵字來實現惰性列舉:

public static IEnumerable<int> enumerableFuc()
{
    yield return 1;
    yield return 2;
    yield return 3;
}

// 使用惰性列舉
foreach (var number in enumerableFuc())
{
    Console.WriteLine(number);
}

在上面的範例中,GetNumbers() 方法通過yield關鍵字返回一個 IEnumerable 物件。當我們使用 foreach 迴圈迭代這個物件時,每次迴圈都會呼叫 MoveNext() 方法,並執行到下一個 yield 語句處,返回一個元素。這樣就實現了按需生成列舉的元素,而不需要一次性生成所有元素。

三、通過 IL 程式碼看 yield 的原理

類比上一章節的範例程式碼,用 while 迴圈代替 foreach 迴圈,發現我們雖然沒有實現 GetEnumerator(),也沒有實現對應的 IEnumerator 的 MoveNext() 和 Current 屬性,但是我們仍然能正常使用這些函數。

static async Task Main(string[] args)
{
    // 用 while (enumerator.MoveNext()) 
    // 代替 foreach(int item in enumerableFuc())
    IEnumerator<int> enumerator = enumerableFuc().GetEnumerator();
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
    Console.ReadLine();
}
// 一個返回型別為 IEnumerable<int>,其中包含三個 yield return
public static IEnumerable<int> enumerableFuc()
{
    Console.WriteLine("enumerableFuc-yield 1");
    yield return 1;
    Console.WriteLine("enumerableFuc-yield 2");
    yield return 2;
    Console.WriteLine("enumerableFuc-yield 3");
    yield return 3;
}

輸出的結果:

  

下面試著簡單看一下 Program 類的原始碼

原始碼如下,除了明顯的 Main() 和 enumerableFuc() 兩個函數外,反編譯的時候自動生成了一個新的類 '<enumerableFuc>d__1'。

注:反編譯時,語言選擇:「IL with C#」,有助於理解。

然後看自動生成的類的實現,發現它繼承了 IEnumerable、IEnumerable<T>、IEnumerator、IEnumerator<T>,也實現了MoveNext()、Reset()、GetEnumerator()、Current 屬性,這時我們應該可以確認,這個新的類,就是我們雖然沒有實現對應的 IEnumerator 的 MoveNext() 和 Current 屬性,但是我們仍然能正常使用這些函數的原因了。

然後再具體看下 MoveNext() 函數,根據輸出的備註欄位,也能清晰的看到迭代過程,下圖中紫色部分:

  

  下邊是是第三、四次迭代,可以看到行標識可以對得上:

  

每次呼叫 MoveNext() 函數都會將「 <>1__state」加 1,一共進行了 4 次迭代,前三次返回 true,最後一次返回 false,代表迭代結束。這四次迭代對應被 3 個 yield return 語句分成4部分的 enumberableFuc() 中的語句。

用 enumberableFuc() 來進行迭代的真實流程就是:

  • 執行 enumberableFuc() 函數,獲取程式碼自動生成的類的範例;
  • 接著呼叫 GetEnumberator() 函數,將獲取的類自己作為迭代器,準備開始迭代;
  • 每次執行 MoveNext() 「 <>1__state」增加 1,通過 switch 語句可以讓每次呼叫 MoveNext() 的時候執行不同部分的程式碼;
  • MoveNext() 返回 false,結束迭代。

這也就說明了,yield 關鍵字其實是一種語法糖,最終還是通過實現 IEnumberable<T>、IEnumberable、IEnumberator<T>、IEnumberator 介面實現的迭代功能

 參考自:c# yield關鍵字的用法