枚举器

枚举器的作用

如果想遍历一个集合(数组、列表等),通常会用到 foreach。其背后依赖的就是枚举器。 枚举器本质上是一种状态机:提供统一的遍历方式,同时不暴露集合的内部结构。

先看一个简单示例:

public class MyIterator:IEnumerable
{
    public IEnumerator GetEnumerator()
    {
        return new MyEnumerator();
    }

    private class MyEnumerator : IEnumerator
    {
        private int _index = -1;

        public object Current
        {
            get
            {
                switch (_index)
                {
                    case 0: return 1;
                    case 1: return 2;
                    case 2: return 3;
                    default: throw new InvalidOperationException();
                }
            }
        }

        public bool MoveNext()
        {
            _index++;
            return _index < 3;
        }

        public void Reset()
        {
            _index = -1;
        }
    }
}

var iterator1 = new MyIterator();
foreach (var item in iterator1)
{
    Console.WriteLine(item);
}
// 输出:
// 1
// 2
// 3

在上面的代码中,我们定义了一个 MyIterator 类,它实现了 IEnumerable 接口。GetEnumerator 方法返回一个实现了 IEnumerator 接口的枚举器 MyEnumeratorMyEnumerator 类中包含了三个核心成员:

  • Current 属性:返回当前枚举器指向的元素。
  • MoveNext 方法:将枚举器推进到下一个元素。
  • Reset 方法:将枚举器重置到初始位置。

当我们使用 foreach 循环遍历 MyIterator 实例时,实际上是通过调用 GetEnumerator 方法获取枚举器,然后不断调用 MoveNext 方法来推进枚举器,并通过 Current 属性获取当前元素,直到 MoveNext 返回 false,表示枚举结束。 需要了解的是遍历并不是在集合对象上直接进行的,而是通过枚举器在不同状态下返回值的方式来推进的。 基于状态机编译可以实现延迟执行,只有在需要时才取下一条数据,而不是一次性把所有内容读进内存。在数据量很大的场景,能显著降低内存压力并且提高读取速度。

枚举器实战

写个文件读取的例子,对比一次性全部读入内存枚举器方式

先写一个枚举器:

public class FileLineEnumerator : IEnumerator<string>
{
    private StreamReader _reader;
    private string? _current;
    private bool _disposed = false;

    public FileLineEnumerator(string filePath)
    {
        _reader = new StreamReader(filePath);
    }

    public string Current => _current!;
    object System.Collections.IEnumerator.Current => Current;

    public bool MoveNext()
    {
        if (_reader.EndOfStream)
            return false;
        _current = _reader.ReadLine();
        return _current != null;
    }

    public void Reset()
    {
        _reader.BaseStream.Seek(0, SeekOrigin.Begin);
        _reader.DiscardBufferedData();
        _current = null;
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _reader.Dispose();
            _disposed = true;
        }
    }
}

封装一个有全部读入内存枚举器逐行读取这两种方式的类

public class ReadFile
{
    /// <summary>
    /// 一次性全部读入内存
    /// </summary>
    public static List<string> ReadLinesToList(string filePath)
    {
        return new List<string>(File.ReadAllLines(filePath));
    }

    /// <summary>
    /// 枚举器方式逐行读取大文件
    /// </summary>
    public static IEnumerator<string> ReadLinesEnumerator(string filePath)
    {
        return new FileLineEnumerator(filePath);
    }
}

现在用这两种方式来读取文件:

string filePath = "test.txt";

//生成的测试文件
if (!File.Exists(filePath))
{
    using var writer = new StreamWriter(filePath);
    for (int i = 0; i < 10000000; i++)
    {
        writer.WriteLine($"这是第 {i + 1} 行测试数据。");
    }

}

var sw = new Stopwatch();

//一次性全部读入内存
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long memBefore2 = GC.GetTotalMemory(true);
sw.Reset();
sw.Start();
var lines = ReadFile.ReadLinesToList(filePath);
int count2 = lines.Count;
sw.Stop();
long memAfter2 = GC.GetTotalMemory(true);
Console.WriteLine($"全部读入内存: {sw.ElapsedMilliseconds} ms, 行数: {count2}, 内存: {(memAfter2 - memBefore2) / 1024} KB");

//枚举器
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long memBefore3 = GC.GetTotalMemory(true);
sw.Reset();
sw.Start();
int count3 = 0;
using (var enumerator = ReadFile.ReadLinesEnumerator(filePath))
{
    while (enumerator.MoveNext())
    {
        count3++;
    }
}
sw.Stop();
long memAfter3 = GC.GetTotalMemory(true);
Console.WriteLine($"枚举器: {sw.ElapsedMilliseconds} ms, 行数: {count3}, 内存: {(memAfter3 - memBefore3) / 1024} KB");

// 输出:
//全部读入内存: 1173 ms, 行数: 10000000, 内存: 546796 KB
//枚举器: 201 ms, 行数: 10000000, 内存: 7 KB

可以看到,如果全部读入内存中处理,内存占用会非常高,而且速度很慢,这是因为需要为每一行数据分配字符串对象,还需要分配一个List,内存的分配和对象构建的开销很大,而枚举器是即用即取,处理完之后就释放了所以内存会明显更小,至于枚举器读取速度快的主要原因是枚举器是按需读取数据的,等于说是一边读取文件,一边处理器在处理数据,让CPU和IO并行工作了,所以速度明显更快。

推荐看一下这篇文章鸭子类型与 await 这篇文章的最后我提到了,其实IEnumerable接口的实现其实就是一个鸭子类型的实现,你只要有 GetEnumerator() 方法,且返回的类型有 MoveNext() 和 Current,就能被 foreach 使用,所以你可以尝试不实现IEnumerable和 IEnumerator接口,直接写这三个方法试试。

迭代器

上面了解了枚举器及其优势,但手写实现相对繁琐。更常用、更安全的方式是:迭代器(yield)。

首先,我们先到微软官方文档中找到关于yield的文档:C# yield 关键字,文档大致介绍了一下yield的作用以及

yield 关键字用于在迭代器块中提供对枚举器或生成器方法的值的逐个访问。迭代器块是包含 yield return 或 yield break 语句的方法、运算符或 get 访问器。迭代器块不需要显式地创建集合来保存要返回的值。相反,编译器会自动生成所需的状态机。

用迭代器来实现刚刚的例子:

public static IEnumerable<string> ReadLinesIterator(string filePath)
{
    using var reader = new StreamReader(filePath);
    string? line;
    while ((line = reader.ReadLine()) != null)
    {
        yield return line;
    }
}

没错,这已经实现了,我们只需要在方法中使用 yield return 语句来返回每一行数据即可,编译器会自动帮我们生成枚举器的实现,自动的实现了状态机。

再添加测试代码,与前两种方式做个对比:

using System.Diagnostics;

string filePath = "test.txt"; 

//生成的测试文件
if (!File.Exists(filePath))
{
    using var writer = new StreamWriter(filePath);
    for (int i = 0; i < 100000; i++)
    {
        writer.WriteLine($"这是第 {i + 1} 行测试数据。");
    }

}

var sw = new Stopwatch();

// yield
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long memBefore1 = GC.GetTotalMemory(true);
sw.Start();
int count1 = 0;
foreach (var line in ReadFile.ReadLinesIterator(filePath))
{
    count1++;
}
sw.Stop();
long memAfter1 = GC.GetTotalMemory(true);
Console.WriteLine($"yield: {sw.ElapsedMilliseconds} ms, 内存: {(memAfter1 - memBefore1) / 1024} KB");

//一次性全部读入内存
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long memBefore2 = GC.GetTotalMemory(true);
sw.Reset();
sw.Start();
var lines = ReadFile.ReadLinesToList(filePath);
int count2 = lines.Count;
sw.Stop();
long memAfter2 = GC.GetTotalMemory(true);
Console.WriteLine($"全部读入内存: {sw.ElapsedMilliseconds} ms, 行数: {count2}, 内存: {(memAfter2 - memBefore2) / 1024} KB");

//枚举器
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long memBefore3 = GC.GetTotalMemory(true);
sw.Reset();
sw.Start();
int count3 = 0;
using (var enumerator = ReadFile.ReadLinesEnumerator(filePath))
{
    while (enumerator.MoveNext())
    {
        count3++;
    }
}
sw.Stop();
long memAfter3 = GC.GetTotalMemory(true);
Console.WriteLine($"枚举器: {sw.ElapsedMilliseconds} ms, 行数: {count3}, 内存: {(memAfter3 - memBefore3) / 1024} KB");

// 输出:
// yield: 304 ms, 内存: 0 KB
// 全部读入内存: 978 ms, 行数: 10000000, 内存: 546796 KB
// 枚举器: 201 ms, 行数: 10000000, 内存: 7 KB

可以看到,迭代器方式同样具备按需取数的优势,写法更简洁,性能也与手写枚举器差不多,但是肯定是慢一点的,因为编译器生成的状态机会比手写的多一些开销,不过在大多数场景下,这个开销是可以忽略不计的。

待添加部分

  • IAsyncEnumerable 与异步迭代器
  • yield break 的使用场景
  • 迭代器与 LINQ 的结合使用