果子李 发表于 2023-5-31 09:49:58

由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集合的遍历方式了,那我们就用List的示例来演示一下
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();
}可以反编译代码的工具有很多,我用的比较多的一般是ILSpy、dnSpy、dotPeek和在线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类可迭代的源码结构是如何实现的
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
页: [1]
查看完整版本: 由C# yield return引发的思考