Введение в MSIL - Часть 8: Конструкция for each — Архив WASM.RU
Конструкция for each: приобрёвшая популярность благодаря Visual Basic, едва признанная С++ и ставшая бессмертной из-за ECMA-334 (некотоыре люди называют это просто C#).
В этой главе я будую говорить об одном из самых популярных конструкций в коммерчески успешных языка. Будете ли программировать на Visual Basic, COM, Standard C++ или .NET (или на всех вышеперечисленных), вы неизбежно встретитесь с какой-либо формой этой конструкции.
Вы можете заметить определённую тенденцию в этом цикле. По мере того, как мы начинаем копать более интересные вещи, фокус всё больше смещается с собственно инструкций MSIL на дизайн языка и реализации компилятора. В инструкциях, с помощью которых реализуется конструкция for each, нет ничего особенно нового. Тем не менее, полезно понимать, как она работает и в какие команды MSIL в каком контексте она переводится. Вы можете поблагодарить вашего начальника, что он позволяет вам писать код на языке, где конструкции абстрагированы от грязных секретов, скрывающихся за их красивым фасадом. for each - как раз такая инструкция.
Те или иные варианты for each, когда дело касается .NET, могут быть найдены в C#, Visual Basic .NET и C++/CLI. Могут быть и другие языки, мне неизвестные, где применяются похожие конструкции. В примерах этой главы будет использоваться только C++/CLI, так как мы уже достаточно много говорили о C# и VB. Как я сказал ранее, я не хочу сосредотачиваться исключительно на одной реализации компилятора, но скорее продемонстрировать техники, которые могут быть использованы компиляторами в общем случае.
Давайте начнём с простого примера.
Код (Text):
array<int>^ numbers = gcnew array<int> { 1, 2, 3 }; for each (int element in numbers) { Console::WriteLine(element); }numbers - это синтезированный ссылочный тип, расширающий встроенный тип System.Array. Он инициализируется тремя элементами, и конструкция for each запускает инструкции в своём блоке видимости для каждого из этих элементов. Достаточно логично. Я думаю, что вы можете представить эквивалентный код в любом выбранном вами языке. Достаточно предсказуемо, что это можно реализовать с помощью следующего набора инструкций.
Код (Text):
.locals init (int32[] numbers, int32 index) // Создаём массив ldc.i4.3 newarr int32 stloc numbers // Заполняем массив ldloc numbers ldc.i4.0 // позиция ldc.i4.1 // значение stelem.i4 ldloc numbers ldc.i4.1 // позиция ldc.i4.2 // значение stelem.i4 ldloc numbers ldc.i4.2 // позиция ldc.i4.3 // значение stelem.i4 br.s _CONDITION _LOOP: ldc.i4.1 ldloc index add stloc index _CONDITION: ldloc numbers ldlen ldloc index beq _CONTINUE // конструкция for each ldloc numbers ldloc index ldelem.i4 call void [mscorlib]System.Console::WriteLine(int32) br.s _LOOP _CONTINUE:Единственные инструкции, ещё не затрагивавшиеся в этом цикле, это те, которые относятся к массивами. Инструкция newarr достаёт целочисленное значение (integer), задающее количество элементов в массиве, из стека. Затем она создаёт массив заданного типа и помещает ссылку на него в массив. Инструкция stelem заменяет элемент в массиве указанным значением. В стек перед этим должно быть помещена ссылка на массив, позиция элемента в массиве и новое значение элемента. Наконец, инструкция ldelem загружает элемент из массива и помещает его в стек, предварительно взяв из последнего ссылку на массив и позицию элемента.
Как вы можете видеть, этот код похож на тот, который мог бы быть сгенерирован для конструкции for, пробегающей через те же элементы (см. главу 6). Конечно, конструкция for each можно использовать не только с типами, происходящими от System.Array. На самом деле, она может перебирать элементы любого типа, которые реализуют интерфейс IEnumerable или даже типы, предоставляющие ту же функциональность, не не реализующие вышеуказанный интерфейс. Степерь поддержки естественным образом зависит от решений, реализованных в вашем конкретном языке. Некоторые языки могут даже предоставлять поддержку для использования совершенно других контейнеров в конструкции for each. Рассмотрим следующий пример, используя класс контейнера вектора из стандартной библиотеки C++ (STL).
Код (Text):
std::vector<int> numbers; numbers.reserve(3); numbers.push_back(1); numbers.push_back(2); numbers.push_back(3); for each (int element in numbers) { std::cout << element << std::endl; }В данном примере компилятор вызывает метод класса begin, чтобы получить итератор, указывающий на первый элемент контейнера. Затем этот итератор используется для перебора элементов. Значение итератора каждый раз увеличивается, пока не станет равно значению итератора, возвращённого методом end vector'а. При каждом проходе итератор разыменовывается, а получившееся значение помещается в соответствующий элемент. Элегантность заключается в том, что использование for each позволяет избежать многих распространённых ошибок, связанных с переполнением буфера и других, подобных этим.
Давайте вернёмся к управляемому коду. Если конструкция for each должна поддерживать коллекции, а не массивы, то, естественно, это ведёт к иной реализации, зависящей от использования конструкции. Рассмотрим следующий пример:
Код (Text):
Collections::ArrayList numbers(3); numbers.Add(1); numbers.Add(2); numbers.Add(3); for each (int element in %numbers) { Console::WriteLine(element); }Держите в уме, что большая часть контейнеров STL, включающих конструктор, принимающий одно целочисленное значение, обычно устанавливают начальный размер контейнера, в кто время как .NET-коллекция используют конструктор для установки начальной вместимости.
Так что же делает for each в данном случае? Ну, тип ArrayList реализует интерфейс IEnumerable с единственным методом GetEnumerator. Конструкция for each вызывает метод GetEnumerator, чтобы получить реализация интерфейса IEnumerator, который затем используется для перебора коллекции. Давайте проверим простую реализацию для данного кода:
Код (Text):
.locals init (class [mscorlib]System.Collections.ArrayList numbers, class [mscorlib]System.Collections.IEnumerator enumerator) // Создаём массив ldc.i4.3 newobj instance void [mscorlib]System.Collections.ArrayList::.ctor(int32) stloc numbers // Заполняем массив ldloc numbers ldc.i4.1 box int32 callvirt instance int32 [mscorlib]System.Collections.ArrayList::Add(object) pop ldloc numbers ldc.i4.2 box int32 callvirt instance int32 [mscorlib]System.Collections.ArrayList::Add(object) pop ldloc numbers ldc.i4.2 box int32 callvirt instance int32 [mscorlib]System.Collections.ArrayList::Add(object) pop // Получаем перечислитель ldloc numbers callvirt instance class [mscorlib]System.Collections.IEnumerator [mscorlib]System.Collections.IEnumerable::GetEnumerator() stloc enumerator br.s _CONDITION _CONDITION: ldloc enumerator callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext() brfalse.s _CONTINUE // конструкция for each ldloc enumerator callvirt instance object [mscorlib]System.Collections.IEnumerator::get_Current() call void [mscorlib]System.Console::WriteLine(object) br.s _CONDITION _CONTINUE:Ок, выглядит неплохо, но чего-то нехватает. Можете сказать, чего именно? Так как при использовании конструкции for each создаётся новый объект, когда вызывается метод GetEnumerator, то разумно предположить, что этот объекту требуется в определённый момент "подчистить", чтобы избежать утечек ресурсво. Перечислитель (enumerator) делает это, реализуя интерфейс IDisposable. К сожалению, комплиятор не всегда может распознать это во время компиляции, хотя если у него есть достаточно статической информации о типах, то есть определённый смысл в том, чтобы извлечь из неё пользу и оптимизировать сгенерированные инструкции. Получается, что перечислитель, предоставляемый типом ArrayList, не реализует интерфейс IDisposable. Конечно, это может измениться в будущем, так что "оптимизирование" вашего кода в подобных случаях - не слишком мудрый шаг. Компиляторы могут использовать инструкцию isinst, чтобы определить, реализует ли перечислитель интерфейс IDisposable. Возможно, что это упущение в дизайне интерфейсов IEnumerable и IEnumerator.
Чтобы решить эту проблему, оригинальный интерфейс IEnumerator, представленный в NET Framework 2.0, наследуется от IDisposable, поэтому реализациям необходимо предоставить метод Dispose, даже если он ничего не делает. Это очевидным образом упрощает дела для того, кто совершает вызов. Collections.Generic.IEnumerator<T> - это тип значения, возвращаемого методом GetEnumerator, который относится к интерфейсу Collections.Generic.IEnumerable<T>, реализуемого новой базовой коллекцией типов. Для совместимости с существующим кодом, реализации перечислителя так реализуют старый интерфейс IEnumerator. Рассмотрим следующий пример:
Код (Text):
Collections::Generic::List<int> numbers(3); numbers.Add(1); numbers.Add(2); numbers.Add(3); for each (int element in %numbers) { Console::WriteLine(element); }В этом случае не возникает вопроса, должен ли перечислитель реализовывать IDisposable. Единственная остающаяся проблема - это найти надёжный способ вызвать метод Dispose несмотря на исключения. Вот подходящее решение, использующее конструкции обработки исключений, обсуждавшиеся в 5-ой части.
Код (Text):
.locals init (class [mscorlib]System.Collections.Generic.'List`1'<int32> numbers, class [mscorlib]System.Collections.Generic.'IEnumerator`1'<int32> enumerator) // Создаём массив ldc.i4.3 newobj instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::.ctor(int32) stloc numbers // Заполняем массив ldloc numbers ldc.i4.1 // value callvirt instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::Add(!0) ldloc numbers ldc.i4.2 // value callvirt instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::Add(!0) ldloc numbers ldc.i4.3 // value callvirt instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::Add(!0) // Получаем перечислитель ldloc numbers callvirt instance class [mscorlib]System.Collections.Generic.'IEnumerator`1'<!0> class [mscorlib]System.Collections.Generic.'IEnumerable`1'<int32>::GetEnumerator() stloc enumerator .try { _CONDITION: ldloc enumerator callvirt instance bool class [mscorlib]System.Collections.Generic.'IEnumerator`1'<int32>::MoveNext() brfalse.s _LEAVE _STATEMENTS: ldloc enumerator callvirt instance !0 class [mscorlib]System.Collections.Generic.'IEnumerator`1'<int32>::get_Current() call void [mscorlib]System.Console::WriteLine(int32) br.s _CONDITION _LEAVE: leave.s _CONTINUE } finally { ldloc enumerator callvirt instance void [mscorlib]System.IDisposable::Dispose() endfinally } _CONTINUE:Учитывая комментарии и метки, код должен достаточно понятным. Давайте быстро пробежимся по нему. Запрошены две локальные переменные, одна для списка, а другая для перечислителя. Имена типов выглядят странно, так как должны поддерживать MSIL'овские дженерики. 'List`1' показывает, что используется тип List с одним параметром типа. Это позволяет отличать дженерик-типы с одним и тем же именем, но разным количество параметров. Тип List между кавычками указывает на реальные типы, используемые для конкретизации времени выполнения.
Определив локальные переменные, следующий шаг - это создать список, используя инструкцию newobj и заполнить его с помощью принадлежащего ему метода Add. Дженерики предназначены, в основном, для создания типизированных коллекций. Хорошим примером этого является код для добавления чисел в список. Предыдущий пример использовал ArrayList, поэтому числа сначала должны были пройти через инструкцию box, прежде чем быть добавленными в коллекцию. В данном случае мы можем просто положить числа в стек и вызвать метод Add. Нам просто нужно задать требуемую конкретизацию. Единственный параметр метода Add - это !0, который указывает на параметр типа, основанный на нуле. Теперь, когда коллекция готова, мы можем реализовать сами циклы. Чтобы начать перечисление, мы сначала получаем из коллекции реализацию IEnumerator<T> и сохраняем её во временной переменной. Далее вызывается метод перечислителя MoveNext, пока он не возвратит false. Обработчик исключения finally используется для вызова метода перечислителя Dispose. © Кенни Керр, пер. Aquila
Введение в MSIL - Часть 8: Конструкция for each
Дата публикации 23 май 2008