迭代器模式的一种应用场景以及C#对于迭代器的内置支持
迭代器模式
先放上gof中对于迭代器模式的介绍镇楼
- 意图
提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示。 - 别名
游标(Cursor)。 - 动机
一个聚合对象, 如列表(list), 应该提供一种方法来让别人可以访问它的元素,而又不需暴露它的内部结构. 此外,针对不同的需要,可能要以不同的方式遍历这个列表。但是即使可以预见到所需的那些遍历操作,你可能也不希望列表的接口中充斥着各种不同遍历的操作。有时还可能需要在同一个表列上同时进行多个遍历。迭代器模式都可帮你解决所有这些问题。这一模式的关键思想是将对列表的访问和遍历从列表对象中分离出来并放入一个迭代器(iterator)对象中。迭代器类定义了一个访问该列表元素的接口。迭代器对象负责跟踪当前的元素; 即, 它知道哪些元素已经遍历过了。
类图如下
工作中遇到的问题
在日常工作中,我们组负责的系统会经常与外部系统进行大量数据交互,大量数据交互的载体是纯文本文件,我们需要解析文件每一行的数据,处理后入库,所以在我们系统中就有了如下的代码了。
public void ParseFile(string filePath, Encoding fileEncoding) { FileStream fs = null; try { fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); using (var sr = new StreamReader(fs, fileEncoding)) { fs = null; string line = null; while ( (line = sr.ReadLine()) != null ) { //解析改行数据 } } } finally { if (fs != null) { fs.Close(); } } }
这样子的代码存在两个问题:1-无法进行单元测试 2-无法扩展。
来析一下问题的根源
实际上这两个问题的根源都是因为直接依赖了文件系统。在我们的业务处理逻辑中,我们实际关心的是内容,而不是内容从何而来。如果内容格式不发生更改,业务逻辑代码就应该保持不变。文件作为内容的载体,可能会变为socket或者nosql数据库,如果这种情况一旦发生,难道把业务代码copy一份出来,然后把从文件读取数据改为从socket或者nosql读取?在进行单元测试时,我希望可以提供一个字符串数组就能对我的业务逻辑进行测试,而不是要提供一个文件。那么好了,我们要做的事情是将具体的数据来源隐藏掉,给业务代码提供一组API,让业务代码使用这组API可以获取到它所关心的内容。换句话说,我要提供一种方法来让人访问数据载体的元素,但是我并不像把数据载体暴露出来,这个目的简直跟迭代器模式的动机一毛一样呀。
开始动手改造
在文件解析场景中,文件就是迭代器模式中提到的聚合对象,文件中的每一行就是聚合对象的内部元素。这样我们先定义出迭代器接口和具体的文件迭代器
1 public interface IIterator 2 { 3 void First(); 4 void Next(); 5 bool IsDone(); 6 string GetCurrentItem(); 7 }
1 class FileIterator : IIterator 2 { 3 private readonly StreamReader _reader = null; 4 private string _current = null; 5 public FileIterator(string filePath, Encoding encoding) 6 { 7 _reader = new StreamReader(new FileStream(filePath, FileMode.Open, FileAccess.Read), encoding); 8 } 9 10 public void First() 11 { 12 Next(); 13 } 14 15 public void Next() 16 { 17 _current = _reader.ReadToEnd(); 18 } 19 20 public bool IsDone() 21 { 22 return _current == null; 23 } 24 25 public string GetCurrentItem() 26 { 27 return _current; 28 } 29 }
而此时我们的业务代码变成了这样
1 public void ParseFile(IIterator iterator) 2 { 3 for (iterator.First(); !iterator.IsDone(); iterator.Next()) 4 { 5 var current = iterator.GetCurrentItem(); 6 Console.WriteLine(current); 7 //对数据进行处理 8 } 9 }
通过迭代器模式,业务代码对数据载体一无所知,按照给定的一组API,获取想要的数据即可,当进行单元测试时,我们可以提供一个基于数组的迭代器,对业务代码进行UT
class ArrayIterator:IIterator { private int _currentIndex = -1; private readonly string[] _array = null; public ArrayIterator(string[] array) { _array = array; } public void First() { Next(); } public void Next() { _currentIndex++; } public bool IsDone() { return _currentIndex >= _array.Length; } public string GetCurrentItem() { return _array[_currentIndex]; } }
问题并未完全解决
细心的读者已经发现了,在我上面实现的文件迭代器是存在问题的,因为我在构造函数里打开了文件流,但是并没有关闭它,所以按照C#里的标准做法,文件迭代器要实现 IDisposable接口,我们还要实现一个标准的Dispose模式,我们的文件迭代器就变成了这样。
1 class FileIterator : IIterator,IDisposable 2 { 3 private StreamReader _reader = null; 4 private string _current = null; 5 private bool _disposed = false; 6 private FileStream _fileStream = null; 7 private readonly string _filePath = null; 8 private readonly Encoding _encoding = null; 9 public FileIterator(string filePath, Encoding encoding) 10 { 11 _filePath = filePath; 12 _encoding = encoding; 13 } 14 15 public void First() 16 { 17 //原先在构造函数里实例化StreamReader不太合适,转移到First方法里 18 _fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read); 19 _reader = new StreamReader(_fileStream, _encoding); 20 _fileStream = null; 21 Next(); 22 } 23 24 public void Next() 25 { 26 _current = _reader.ReadToEnd(); 27 } 28 29 public bool IsDone() 30 { 31 return _current == null; 32 } 33 34 public string GetCurrentItem() 35 { 36 return _current; 37 } 38 39 public void Dispose() 40 { 41 Dispose(true); 42 GC.SuppressFinalize(this); 43 } 44 45 protected virtual void Dispose(bool disposing) 46 { 47 if (_disposed) 48 { 49 return; 50 } 51 if (disposing) 52 { 53 if (_reader != null) 54 { 55 _reader.Dispose(); 56 } 57 if (_fileStream != null) 58 { 59 _fileStream.Dispose(); 60 } 61 } 62 _disposed = true; 63 } 64 65 ~FileIterator() 66 { 67 Dispose(false); 68 } 69 }
配合这次改造,业务代码也要做一些改变
1 public void ParseFile(IIterator iterator) 2 { 3 try 4 { 5 for (iterator.First(); !iterator.IsDone(); iterator.Next()) 6 { 7 var current = iterator.GetCurrentItem(); 8 Console.WriteLine(current); 9 //对数据进行处理 10 } 11 } 12 finally 13 { 14 var disposable = iterator as IDisposable; 15 if (disposable != null) 16 { 17 disposable.Dispose(); 18 } 19 } 20 }
使用迭代器模式,成功解耦了对文件系统的依赖,我们可以随心所欲地进行单元测试,数据载体的变动再也影响不到业务代码。
C#早就看穿了一切
上面的章节,我实现了经典gof迭代器模式,实际上,迭代器模式的应用是如此的普遍,以至于有些语言已经提供了内置支持,在C#中,与迭代器有关的有foreach关键字,IEnumerable,IEnumerable<T>,IEnumerator,IEnumerator<T>四个接口,看起来有四个接口,实际上是2个,只是因为在 C#2.0版本之前未提供泛型支持,在这里仅对两个泛型接口进行讨论。
在C#中,接口IEnumerator<T>就是迭代器,对应上面的Iterator,而IEnumerable<T>接口就是聚合对象,对应上面的Aggregate。在IEnumerable<T>中只定义了一个方法
public Interface IEnumerable<T> { IEnumerator<T> GetEnumerator(); }
而foreach关键字c#专门为了遍历迭代器才出现的,我面试别人的时候,特别喜欢问这样一个问题:“满足什么条件的类型实例才可以被foreach遍历?"看起来正确答案应该是实现了IEnumerable<T>接口的类型,实际上C#并不要求类型实现IEnumerable<T>接口,只要类型中定义了public IEnumerator<T> GetEnumerator()接口即可。
对于IEnumerator<T>接口,微软已经想到了迭代器中可能会用到非托管对象(实际上微软刚开始忽略了这个事情,所以最初的非泛型接口IEnumerator并没有继承IDisposable接口,直到2.0后才让泛型接口IEnumerator<T>继承了IDisposable),所以它的定义是这样子的。
public interface IEnumerator<out T> : IDisposable, IEnumerator { new T Current {get;} } public interface IEnumerator { bool MoveNext(); Object Current {get;} void Reset(); }
在C#的IEnumerator<T>中,实际上将gof经典设计中的First(),IsDone()和Next()三个方法全都合并到了MoveNext()方法中,第一次迭代前现调用MoveNext(),并通过返回值判断迭代是否结束,还额外提供了一个Reset方法来重置迭代器。当我们使用foreach写出遍历一个对象的代码时,编译器会将我们的代码进行转换。比如我们现在要遍历一个32位整型List
List<int> list = new List<int> {0,1,2,3,4}; foreach (var item in list) { Console.WriteLine(item); }
编译时编译器会将代码变成类似下面这样
List<int> list = new List<int> {0,1,2,3,4}; using (var enumerator = list.GetEnumerator()) { while (enumerator.MoveNext()) { Console.WriteLine(enumerator.Current); } }
继续改造我们的代码
既然C#中已经内置了迭代器接口,我们就没有必要定义自己的IIterator接口了,直接使用IEnumerable<T>和IEnumerator<T>接口即可。
class FileEnumerable : IEnumerable<string> { private readonly string _filePath; private readonly Encoding _fileEncoding; public FileEnumerable(string filePath, Encoding fileEncoding) { _filePath = filePath; _fileEncoding = fileEncoding; } public IEnumerator<string> GetEnumerator() { return new FileEnumerator(_filePath,_fileEncoding); } IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } }
public class FileEnumerator : IEnumerator<string> { private string _current; private FileStream _fileStream; private StreamReader _reader; private readonly string _filePath; private readonly Encoding _fileEncoding; private bool _disposed = false; private bool _isFirstTime = true; public FileEnumerator(string filePath, Encoding fileEncoding) { _filePath = filePath; _fileEncoding = fileEncoding; } public string Current { get { return _current; } } object IEnumerator.Current { get { return Current; } } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { if (_reader != null) { _reader.Dispose(); } if (_fileStream != null) { _fileStream.Dispose(); } } _disposed = true; } public bool MoveNext() { if (_isFirstTime) { _fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read); _reader = new StreamReader(_fileStream, _fileEncoding); _fileStream = null; _isFirstTime = false; } return (_current = _reader.ReadLine()) != null; } public void Reset() { throw new NotImplementedException(); } ~FileEnumerator() { Dispose(false); } }
而此时我们的业务代码变成了这样子
public void ParseFile(IEnumerable<string> aggregate) { foreach (var item in aggregate) { Console.WriteLine(item); // //对数据进行处理 } }
在进行单元测试时,我可以直接传递一个字符串数组进去了。
最终版本
看起来我们对于代码的重构已经完美了,但是实际上C#对于迭代器的内置支持要更彻底,在上面,我们必须要自己写一个实现了IEnumerator<T>接口的类型,这个工作虽然不难,但是还是有点繁琐的,C# 针对迭代器模式,提供了yield return和yield break来帮助我们更快更好的实现迭代器模式。下面是代码重构的最终版本,我们无需自己定义FileEnumerator类了
class FileEnumerable : IEnumerable<string> { private readonly string _filePath; private readonly Encoding _fileEncoding; public FileEnumerable(string filePath, Encoding fileEncoding) { _filePath = filePath; _fileEncoding = fileEncoding; } public IEnumerator<string> GetEnumerator() { FileStream fileStream = null; try { fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read); using (var reader = new StreamReader(fileStream, _fileEncoding)) { fileStream = null; string line = null; while ((line = reader.ReadLine()) != null) { yield return line; } yield break; } } finally { if (fileStream != null) { fileStream.Dispose(); } } } IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } }
这里编译器会根据我们的代码,结合yield return和yield break来帮助我们生存一个实现了IEnumerator<string>接口的类型出来。
关于Dispose模式,和yield return,yield break本篇不做过多展开,有兴趣的可以找下资料,msdn会告诉你