关于 yield 关键字(C#)

发布时间 2023-07-24 16:01:19作者: 橙子家

〇、前言

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关键字的用法