Сообщений 5 Оценка 420 Оценить |
Общие сведения Реализация паттерна «итератор» своими руками Итераторы в языке C# И напоследок... блок finally Заключение Список литературы |
Шаблон проектирования «Итератор» предназначен для последовательного доступа ко всем элементам коллекции (агрегата), не раскрывая ее внутренней структуры. Это один из классических шаблонов проектирования, описанный в знаменитой книге «банды четырех», который подтвердил свою эффективность и жизнеспособность за длительный период применения. Важность и особенности реализации этого шаблона сильно зависят от конкретного языка программирования, но в том или ином виде, он присутствует в большинстве современных языках и библиотеках.
Общий вид шаблона проектирования «Итератор» приведен на рисунке 1.
Рисунок 1. Общий вид паттерна проектирования «Итератор»
В разных языках и средах итераторы поддерживают разную функциональность. Существуют однонаправленные и двунаправленные итераторы, некоторые итераторы позволяют удалять или модифицировать элементы коллекции; в большинстве языков итератор становится недействительным, если после его получения коллекция будет изменена (например, при добавлении или удалении элементов; хотя это зависит не столько от языка, сколько от типа коллекции).
Однако терминология, принятая компанией Майкрософт в языке программирования C# и платформе .Net, несколько отличается от общепринятой. Так, в качестве основного инструмента реализации паттерна проектирования «Однонаправленный итератор» используются интерфейсы IEnumerable (простраство имен System.Collections) и обобщенная версия этого же интерфейса IEnumerable<T> (пространство имен System.Collections.Generics). С другой стороны, начиная со второй версии в языке программирования C# появилась новая возможность языка, под названием «итератор». Эта возможность реализуется в языке программирования C# с помощью блока итераторов (Iterator Block), однако на самом деле эта возможность может быть использована как для реализации паттерна проектирования «Итератор», так и паттерна проектирования «Генератор». Поэтому далее в статье, если не сказано обратное, под термином «итератор» будет подразумеваться именно возможность языка программирования, а не паттерн проектирования.
ПРИМЕЧАНИЕ |
Для реализации паттерна «Однонаправленный итератор»на языке C# нужно выполнить одно из двух условий. Во-первых, можно просто реализовать интерфейс IEnumerable или его «обобщенный» вариант – IEnumerable<T>. Во-вторых, коллекция может просто содержать метод GetEnumerable, который, возвращает объект, содержащий свойство Current и метод MoveNext.
Давайте начнем с менее распространенного варианта – реализации паттерна «Итератор», на основе идиомы именования, а не на основе интерфейсов IEnumerable или IEnumerable<T>:
Использование идиомы именования потребовалось разработчикам языка C# 1.0 для того, чтобы реализовать типизированные коллекции без использования обобщений (который на тот момент еще не было). Интерфейс IEnumerable возвращает object, а это значит, что было бы невозможно реализовать эффективное итерирование типизированной коллекции целых чисел, поскольку каждый раз при получении элемента коллекции происходила бы упаковка и распаковка текущего элемента. Кроме того, поскольку при использовании идиомы наименования можно избежать виртуальных вызовов, такая реализация работает несколько быстрее. |
class CustomContainer { publicintthis[int idx] { ... } publicint Count { ... } publicvoid Add(intvalue) { ... } public CustomIterator GetEnumerator() { returnnew CustomIterator(this); } publicstruct CustomIterator { internal CustomIterator(CustomContainer container) { this.container = container; currentIndex = -1; } publicint Current { get { if (currentIndex == -1 || currentIndex == container.Count) { thrownew InvalidOperationException(); } return container[currentIndex]; } } publicbool MoveNext() { if (currentIndex != container.Count) { currentIndex++; } return currentIndex < container.Count; } // При реализации паттерна «Итератор»без интерфейса IEnumerator // этого метода может и не бытьpublicvoid Reset() { currentIndex = -1; } privatereadonly CustomContainer container; privateint currentIndex; } |
Использование паттерна «Итератор» в языке C# всегда было простым и удобным; оператор foreach упрощает работу с итераторами, самостоятельно вызывая MoveNext до тех пор, пока эта функция не вернет false:
var container = GetCustomContainer(); foreach (var i in container) Console.WriteLine("{0} ", i); |
Отделение класса, реализующего паттерн «Итератор», от класса коллекции в нашей реализации обусловлено не только принципом единственной ответственности (SRP – Single Responsibility Principle), но и банальным здравым смыслом. Очевидно, что процесс итерирования физически не связан с самой коллекцией, но еще более важным фактором является то, что можно использовать более одного объекта-итератора для разных, независимых операций перебора элементов, именно поэтому в нашей реализации метод GetEnumerator всегда возвращает новый объект.
Рисунок 2. Контейнер с двумя объектами итераторами
Для реализации этого паттерна проектирования мы создали вложенный класс, который получает коллекцию в качестве параметра конструктора и сохраняет ее в одном из своих полей. Кроме этого, этот класс содержит текущий индекс (currentIndex), указывающий на текущий элемент коллекции, который можно получить с помощью свойства Current. Согласно идиоме, принятой в .NET, объект-итератор после создания должен указывать на элемент, предшествующий первому элементу коллекции (в нашем случае это означает, что текущий индекс должен равняться -1), и должен указывать на первый элемент коллекции после первого вызова MoveNext. Метод MoveNext должен возвращать true, если перемещение на следующий элемент коллекции выполнено успешно, в противном случае (если мы уже прошли всю коллекцию), этот метод должен возвращать false (при этом объект-итератор должен указывать на элемент, расположенный за последним элементом коллекции). Метод Reset должен возвращать объект-итератор в первоначальное состояние, а обращение к текущему элементу (к свойству Current) в случае, если объект-итератор указывает на некорректный элемент, должно приводить к генерации исключения InvalidOperationException. Объект-итератор также должен позаботиться о том, чтобы после его создания коллекция не была изменена, и, в случае обращения к текущему элементу после изменения коллекции, также должно генерироваться исключение InvalidOperationException (это поведение в приведенном выше примере не отражено).
Приведенная выше реализация не слишком сложна с технической точки зрения, но достаточно объемна (учитывая, что она не отслеживает изменение коллекции), да и допустить ошибки при работе с индексами очень просто. Поэтому не удивительно, что далеко не все пользовательские коллекции в C# 1.0 поддерживали этот паттерн проектирования, многие из них просто предоставляли специфический интерфейс доступа к своему содержимому. Во второй версии разработчики C# упростили процесс реализации паттерна «Итератор», введя возможность языка, именуемую «Итераторы» (Iterators, см. http://msdn.microsoft.com/en-us/library/dscyy5s0%28VS.80%29.aspx).
Начиная с версии 2.0, в языке C# появилась возможность реализации паттерна «Итератор» с помощью новой возможности языка «Итераторы» (Iterators). В результате предыдущий пример можно переписать следующим образом:
public class CustomContainer { // Остальной код аналогиченpublic IEnumerator<int> GetEnumerator() { for (int i = 0; i < list.Count; i++) yieldreturn list[i]; } } |
Впечатляет! Вместо 40 строк кода мы получили всего 2. Но, прежде чем делать какие-то выводы, стоит взглянуть на то, во что преобразует этот код компилятор:
class CustomContainer { // Остальной код аналогиченpublic IEnumerator<int> GetEnumerator() { __GetEnumeratorIterator iterator = new __GetEnumeratorIterator(0); /*state = “before”*/ iterator.__this = this; return iterator; } [CompilerGenerated] privatesealedclass __GetEnumeratorIterator : IEnumerator<int>, IEnumerator, IDisposable { // Fieldsprivateint __state; privateint __current; public CustomContainer __this; publicint __i; // Methods [DebuggerHidden] public __GetEnumeratorIterator(int __state) { this.__state = __state; } bool IEnumerator.MoveNext() { switch (this.__state) { case 0: /*состояние: "до" */this.__state = -1; /*состояние: "выполняется" */this.__i = 0; while (__i < this.__this.list.Count) { this.__current = this.__this.list[__i]; __state = 1; /*состояние: "приостановлен" */returntrue; Label_0056: __state = -1; /*состояние: "выполняется" */ __i++; } break; case 1: goto Label_0056; } returnfalse; } [DebuggerHidden] void IEnumerator.Reset() { thrownew NotSupportedException(); } void IDisposable.Dispose() { } // Propertiesint IEnumerator<int>.Current { [DebuggerHidden] get { returnthis.__current; } } object IEnumerator.Current { [DebuggerHidden] get { returnthis.__current; } } } } |
Блок итератора преобразовывается во вложенный private-класс, реализующий интерфейсы IEnumerator, IEnumerator<T> и IDisposable, причем, если ваш метод будет возвращать интерфейс IEnumerator (т.е. необобщенный интерфейс), то в любом случае будут реализованы все три интерфейса, при этом обобщенным интерфейсом будет IEnumerator<object>. В случае возврата интерфейса IEnumerable (или IEnumerable<T>), к этим трем интерфейсам добавятся еще два: IEnumerable и IEnumerable<T>.
Автоматически сгенерированный класс содержит несколько обязательных и несколько необязательных дополнительных полей. Каждый сгенерированный класс реализует конечный автомат, который отслеживает текущее состояние объекта-итератора и переходит к выполнению очередного блока кода внутри блока итератора после вызова метода MoveNext. Данный класс содержит поле __state (состояние конечного автомата), ссылку на внешний класс (__this), а также поле __current, тип которого соответствует типу элемента, возвращаемого объектом-итератором . Необязательными полями являются поля, соответствующие локальным переменным метода GetEnumerator (в данном случае __i), а также все параметры этого метода (поскольку в данном примере метод GetEnumerator не содержит параметров, то соответствующих полей нет).
Конечно же, имена вложенного класса и всех его переменных и методов не являются такими "благозвучными". Для устранения конфликта имен компилятор генерирует имена, которые являются некорректными с точки зрения языка C#, например, реальное имя сгенерированного класса может быть таким: <GetEnumerator>d__0. |
Большинство сгенерированных методов достаточно просты. Метод GetEnumerator каждый раз просто создает экземпляр итератора и в параметре конструктора передает целочисленное значение, которое является начальным значением состояния (важность этого решения будет понятна при рассмотрении классов, реализующих IEnumerator), а также устанавливает свойство __this, давая возможность итератору получить доступ к самому контейнеру и всему его содержимому; свойство Current возвращает текущее значение итератора (переменную __current), метод Reset не реализован (причем это не особенность реализации, об этом явно сказано в спецификации языка C#), метод Dispose является пустым (позднее я приведу пример, когда это не так), а вся основная работа делается методом MoveNext.
Именно метод MoveNext содержит основной код, который до этого находился в методе GetEnumerator, а также именно в нем находится реализация конечного автомата, отвечающего за изменение текущего значения, возвращаемого итератором. Конечный автомат содержит некоторое количество "предустановленных" состояний (которые описаны в спецификации языка C#), а также ряд дополнительных состояний, количество которых зависит от реализации (точнее, от количества операторов yield return).
Предыдущий пример достаточно показателен, но для рассмотрения внутреннего устройства сгенерированного кода давайте все же рассмотрим еще более простой код:
static IEnumerator<int> GetNumbers() { string padding = "\t\t"; Console.WriteLine(padding + "Первая строка метода GetNumbers()"); // 1 Console.WriteLine(padding + "Сразу перед yield return 7"); // 2yieldreturn 7; // 3 Console.WriteLine(padding + "Сразу после yield return 7"); // 4 Console.WriteLine(padding + "Сразу перед yield return 42"); // 5 yieldreturn 42; // 6 Console.WriteLine(padding + "Сразу после yield return 42"); //7 } publicstaticvoid Main() { Console.WriteLine("Вызываем GetNumbers()"); IEnumerator<int> iterator = GetNumbers(); Console.WriteLine("Вызываем MoveNext()..."); // Прежде чем обратиться к первому элементу коллекции// нужно вызвать метод MoveNextbool more = iterator.MoveNext(); Console.WriteLine("Result={0}; Current={1}", more, iterator.Current); Console.WriteLine("Снова вызываем MoveNext()..."); more = iterator.MoveNext(); Console.WriteLine("Result={0}; Current={1}", more, iterator.Current); Console.WriteLine("Снова вызываем MoveNext()..."); more = iterator.MoveNext(); Console.WriteLine("Result={0} (stopping)", more); } |
Результат выполнения этого кода:
Вызываем GetNumbers() Вызываем MoveNext()... Первая строка метода GetNumbers() Сразу перед yield return 7 Result=True; Current=7 Снова вызываем MoveNext()... Сразу после yield return 7 Сразу перед yield return 42 Result=True; Current=42 Снова вызываем MoveNext()... Сразу после yield return 42 Result=False (stopping) |
Метод MoveNext сгенерированного класса:
private bool MoveNext() { switch (this.__state) { case 0: /*состояние: "до"*/this.__state = -1; /*состояние: "выполняется"*/ Console.WriteLine(Test.padding + "Первая строка метода GetNumbers()"); // 1 Console.WriteLine(Test.padding + "Сразу перед yield return 7"); // 2this.__current = 7; // 3this.__state = 1; /*состояние: "приостановлен после первого yield return"*/returntrue; case 1: /*состояние: "приостановлен после первого yield return"*/this.__state = -1; /* состояние: "выполняется"*/ Console.WriteLine(Test.padding + "Сразу после yield return 7"); // 4 Console.WriteLine(Test.padding + "Сразу перед yield return 42"); // 5this.__current = 42; // 6this.__state = 2; /*состояние: "приостановлен после второго yield return"*/returntrue; case 2: /*состояние: "приостановлен после второго yield return"*/this.__state = -1; /*состояние: "после"*/ Console.WriteLine(Test.padding + "Сразу после yield return 42"); //7break; } returnfalse; } |
Поскольку весь код метода GetEnumerator расположен в методе MoveNext сгенерированного класса, то этот код вызовется не сразу после создания объекта итератора, а лишь после вызова метода MoveNext. При этом даже при вызове метода MoveNext этот код не будет вызван целиком, как мы привыкли думать о коде обычного метода, вместо этого он будет вызываться по частям. При первом вызове метода MoveNext, будет выполнена часть кода, с начала метода до первого оператора yield return (будут выполнены строки 1 и 2). После чего в текущее значение итератора будет сохранено значение 7, текущее состояние итератора будет сохранено путем установки значения __state в 1 (состояние: «приостановлен после первого yield return»), а метод MoveNext вернет true (что скажет вызывающему коду о том, что получен следующий элемент коллекции).
ПРИМЕЧАНИЕ Можно сказать, что каждый блок кода, между операторами yield return выполняется отложенно (lazily) только после очередного вызова метода MoveNext. Именно на этом основана ленивость библиотеки LINQ, которая построена в виде методов расширения интерфейсов IEnumerable и IEnumerable<T>. |
При следующем вызове метода MoveNext выполнение будет продолжено сразу же после предыдущего оператора yield return (выполнятся строки 4 и 5), текущее значение итератора станет равным 42, а текущее состояние итератора станет равным 2 (состояние: "приостановлен после второго yield return") и, опять же, метод MoveNext вернет true.
Следующий вызов метода MoveNext «продолжит» выполнение со строки 7, после чего состояние итератора станет равным -1 (состояние: "после"), а метод MoveNext вернет false, что скажет вызывающему коду о том, что перебор завершен.
При генерации конечного автомата в сгенерированном коде нет различий между состояниями before, running и after (каждому из них соответствует состояние, равное -1), поскольку поведение кода в эти моменты времени является одинаковым (согласно спецификации, попытка обращения к свойству Current приводит к неопределенному поведению). Для каждого yield return вводится отдельное состояние, которому соответствует уникальное целочисленное значение.
При возвращении интерфейса IEnumerable или IEnumerable<T> компилятор генерирует код, очень похожий на рассмотренный ранее, но с некоторыми модификациями. Главной особенностью в этом случае является то, что сгенерированный класс, помимо реализации интерфейсов IEnumerable и IEnumerable<T>, также реализует интерфейсы IEnumerator, IEnumerator<T> и IDisposable. Такое решение принято, по всей видимости, в целях экономии памяти и времени – ведь каждое создание объекта приводит к выделению памяти в управляемой куче, что далеко не бесплатно. В результате в одном классе реализуется и интерфейсы, возвращающие итератор, и сам объект-итератор. Но поскольку возможность независимых проходов по коллекции все равно необходима, разработчики пошли на следующий шаг: при первом вызове метода GetEnumerator возвращается тот же объект, а при последующих вызовах (эта проверка является потокобезопасной) возвращается новый объект, содержащий первоначальное состояние параметров. В связи с этим появляется новое состояние (-2), которое можно назвать "до вызова GetEnumerator", а также появляются поля, содержащие первоначальные значения параметров (поскольку эти параметры могут изменяться после создания enumerable-объекта).
Давайте изменим предыдущий пример таким образом, чтобы функция GetNumbers возвращала IEnumerable<int>, и посмотрим на код, генерируемый компилятором:
static IEnumerable<int> GetNumbers() { yieldreturn 7; yieldreturn 42; } |
Код, сгенерированный компилятором:
private static IEnumerable<int> GetNumbers() { returnnew GetNumbersIterator(-2); /*состояние: "до вызоваGetEnumerator"*/ } privatesealedclass GetNumbersIterator : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable { // Fieldsprivateint __state; privateint __current; privateint l__initialThreadId; // Methodspublic GetNumbersIterator(int __state) { this.__state = __state; this.l__initialThreadId = Thread.CurrentThread.ManagedThreadId; } bool IEnumerator.MoveNext() { switch (this.__state) { case 0: /*состояние: "до"*/this.__state = -1; /*состояние: "выполняется" */this.__current = 7; this.__state = 1; /*состояние: "приостановлен" */returntrue; case 1: /*состояние: "приостановлен" */this.__state = -1; /*состояние: "выполняется"*/this.__current = 42; this.__state = 2; /*состояние: "приостановлен"*/returntrue; case 2: /*состояние: "приостановлен"*/this.__state = -1; /*состояние: "после"*/break; } returnfalse; } IEnumerator<int> IEnumerable<int>.GetEnumerator() { if ((Thread.CurrentThread.ManagedThreadId == this.l__initialThreadId) && (this.__state == -2)) /*"до вызова GetEnumerator"*/ { this.__state = 0; /*"до" */returnthis; } returnnew GetNumbersIterator(0); /* "после"*/ } // Остальной код опущен } |
Теперь становится понятным причина, по которой первоначальное состояние итератора передается в конструкторе, в этом случае у нас может быть два первоначальных состояния итератора: -2 ("до вызова GetEnumerator") и 0 (before). Метод GetEnumerator отслеживает, состояние -2, возвращая this. В противном случае создается дополнительная копия.
Последним моментом, о котором нужно обязательно сказать при рассмотрении итераторов в C#, являются проблемы, связанные с блоком finally внутри блока итераторов. Давайте в последний пример добавим блок try/finally и, даже не глядя на сгенерированный код, подумаем о его поведении и возможных последствиях:
public static IEnumerable<int> GetNumbers() { try { yieldreturn 7; // 1// 2: обработка первого элемента внешним кодомyieldreturn 42; // 3// 4: обработка второго элемента внешним кодом } finally { Console.WriteLine("Внутри блока finally метода GetNumbers"); } } |
Как уже было сказано ранее, блок итератора не выполняется последовательно, а разворачивается в конечный автомат, реализация которого находится в методе MoveNext. Очевидно, блок finally должен выполняться не после каждого вызова метода MoveNext, а только один раз на полную итерацию последовательности, поскольку в противном случае мы можем, например, освободить ресурсы, которые потребуются при следующей итерации цикла. А раз так, то кто сможет гарантировать, что пользователь захочет пройти последовательность целиком? Почему, получив итератор из метода GetNumbers, пользователь обязательно должен вызвать MoveNext более одного раза?
Но, несмотря на то, что компилятор не может гарантировать вызов блока finally итератора, он делает все возможное, чтобы свести такую вероятность к минимуму. Давайте рассмотрим сгенерированный код:
private sealed class GetNumbersIterator : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable { // Остальные методы остаются такими же, поэтому пропущеныprivatevoid m__Finally3() { this.__state = -1; /*состояние: "после" */ Console.WriteLine("Внутри блока finally метода GetNumbers"); } privatebool MoveNext() { try { switch (this.__state) { case 0: /*состояние: "до" */this.__state = -1; /*состояние: "выполняется" */this.__state = 1; /*состояние: "выполняется; может быть вызван блок finilize*/this.__current = 7; this.__state = 2; /*состояние: "приостановлен" */returntrue; case 2: /*состояние: "приостановлен"*/this.__state = 1; /*состояние: "выполняется"; может быть вызван блок finilize*/this.__current = 42; this.__state = 3; /*состояние: "приостановлен" */returntrue; case 3: /*состояние: "приостановлен" */this.__state = 1; /*состояние: "выполняется" */// Нормальное завершение блока итератораthis.m__Finally3(); break; } returnfalse; } fault { // Возникло исключение в блоке try ((System.IDisposable)this).Dispose(); } } void IDisposable.Dispose() { switch (this.__state) { case 1: case 2: case 3: try { } finally { // Явный вызов метода Disposethis.m__Finally3(); } break; } } } |
Если блок итератора содержит блок finally, то весь код, расположенный в этом блоке, помещается в отдельный метод (в нашем случае в метод m_Finally3()), который будет вызван в следующих случаях:
На последнем случае давайте остановимся подробнее и попытаемся понять, для чего вообще итератор реализует интерфейс IDisposalbe. Итак, что произойдет, если исключение произойдет не в коде итератора, а в коде, его использующем, при обработке первого элемента коллекции? Давайте снова вернемся к последнему примеру:
public static IEnumerable<int> GetNumbers() { try { yieldreturn 7; // 1// 2: обработка первого элемента внешним кодомyieldreturn 42; // 3// 4: обработка второго элемента внешним кодом } finally { Console.WriteLine("Внутри блока finally метода GetNumbers"); } } |
Разработчик вправе предполагать, что если между строками 1 и 3 (т.е. при обработке первого элемента коллекции пользовательским кодом) произойдет исключение, то блок finally должен быть выполнен точно так же, как и при возникновении исключения непосредственно в блоке итератора, например, в строке 1. Такое поведение становится более очевидным, если вместо возвращения двух магических чисел блок итератора будет выполнять более осмысленную работу, например, открывать файл и возвращать строки по одной:
public static IEnumerable<string> ReadFile(string filename) { using (TextReader reader = File.OpenText(filename)) { string line; while ((line = reader.ReadLine()) != null) yieldreturn line; } } |
В этом фрагменте блок try/finally генерирует за нас компилятор, но это никак не влияет на поведение. Давайте еще раз зададимся вопросом: вправе ли разработчик рассчитывать на корректное освобождение ресурсов, если пользовательский код сгенерирует исключение?
К сожалению, ответ на этот вопрос будет утвердительным в том случае, если пользователь будет следовать общепринятым идиомам использования итераторов: воспользуется оператором foreach, либо реализует аналогичную функциональность самостоятельно:
foreach (var s in ReadFile(filename)) { // Обрабатываем очередную строку, // при этом при обработке может возникнуть исключение Console.WriteLine(s); } |
Компилятор преобразовывает оператор foreach следующим образом:
IEnumerator<string> enumerator = ReadFile(filename).GetEnumerator(); try { while (enumerator.MoveNext()) { var s = enumerator.Current; // Обрабатываем очередную строку, // при этом при обработке может возникнуть исключение Console.WriteLine(s); } } finally { IDisposable disposable = enumerator as System.IDisposable; if (disposable != null) disposable.Dispose(); } |
В таком случае, если при обработке элемента коллекции возникнет исключение, то автоматически будет вызван метод Dispose итератора, что приведет к вызову блока finally блока итератора.
Подобные знания внутренностей языка программирования могут показаться излишним, и в большинстве случаев, скорее всего, так оно и есть. Но итераторы играют весьма важную роль в языке программирования C#, так, например, большая часть особенностей использования LINQ 2 Objects основана на итераторах и на их «ленивом» выполнении. И это касается не только разработки и применения некоторых библиотек, это также касается реализации многих повседневных задач. В большинстве случаев прикладной программист может найти другие пути решения и не использовать блоки итераторов, но существует ряд задач, которые очень просто и элегантно решаются именно с их помощью (например, работа с деревьями), поэтому если вы все же столкнетесь с необходимостью применения этого инструмента, желательно обладать достаточным опытом и знаниями, чтобы случайно не прострелить себе ногу.
Сообщений 5 Оценка 420 Оценить |