Введение в MSIL - Часть 8: Конструкция for each

Дата публикации 23 май 2008

Введение в 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):
  1.  
  2. array<int>^ numbers = gcnew array<int> { 1, 2, 3 };
  3.  
  4. for each (int element in numbers)
  5. {
  6.     Console::WriteLine(element);
  7. }

numbers - это синтезированный ссылочный тип, расширающий встроенный тип System.Array. Он инициализируется тремя элементами, и конструкция for each запускает инструкции в своём блоке видимости для каждого из этих элементов. Достаточно логично. Я думаю, что вы можете представить эквивалентный код в любом выбранном вами языке. Достаточно предсказуемо, что это можно реализовать с помощью следующего набора инструкций.

Код (Text):
  1.  
  2.     .locals init (int32[] numbers,
  3.                   int32 index)
  4.    
  5. // Создаём массив
  6.                  
  7.     ldc.i4.3
  8.     newarr int32
  9.     stloc numbers
  10.    
  11. // Заполняем массив
  12.    
  13.     ldloc numbers
  14.     ldc.i4.0 // позиция
  15.     ldc.i4.1 // значение
  16.     stelem.i4
  17.  
  18.     ldloc numbers
  19.     ldc.i4.1 // позиция
  20.     ldc.i4.2 // значение
  21.     stelem.i4
  22.  
  23.     ldloc numbers
  24.     ldc.i4.2 // позиция
  25.     ldc.i4.3 // значение
  26.     stelem.i4
  27.    
  28.     br.s _CONDITION
  29.  
  30. _LOOP:
  31.  
  32.     ldc.i4.1
  33.     ldloc index
  34.     add
  35.     stloc index
  36.      
  37. _CONDITION:
  38.    
  39.     ldloc numbers
  40.     ldlen
  41.     ldloc index
  42.     beq _CONTINUE
  43.    
  44. // конструкция for each
  45.  
  46.     ldloc numbers
  47.     ldloc index
  48.     ldelem.i4
  49.     call void [mscorlib]System.Console::WriteLine(int32)
  50.    
  51.     br.s _LOOP
  52.  
  53. _CONTINUE:

Единственные инструкции, ещё не затрагивавшиеся в этом цикле, это те, которые относятся к массивами. Инструкция newarr достаёт целочисленное значение (integer), задающее количество элементов в массиве, из стека. Затем она создаёт массив заданного типа и помещает ссылку на него в массив. Инструкция stelem заменяет элемент в массиве указанным значением. В стек перед этим должно быть помещена ссылка на массив, позиция элемента в массиве и новое значение элемента. Наконец, инструкция ldelem загружает элемент из массива и помещает его в стек, предварительно взяв из последнего ссылку на массив и позицию элемента.

Как вы можете видеть, этот код похож на тот, который мог бы быть сгенерирован для конструкции for, пробегающей через те же элементы (см. главу 6). Конечно, конструкция for each можно использовать не только с типами, происходящими от System.Array. На самом деле, она может перебирать элементы любого типа, которые реализуют интерфейс IEnumerable или даже типы, предоставляющие ту же функциональность, не не реализующие вышеуказанный интерфейс. Степерь поддержки естественным образом зависит от решений, реализованных в вашем конкретном языке. Некоторые языки могут даже предоставлять поддержку для использования совершенно других контейнеров в конструкции for each. Рассмотрим следующий пример, используя класс контейнера вектора из стандартной библиотеки C++ (STL).

Код (Text):
  1.  
  2. std::vector<int> numbers;
  3. numbers.reserve(3);
  4. numbers.push_back(1);
  5. numbers.push_back(2);
  6. numbers.push_back(3);
  7.  
  8. for each (int element in numbers)
  9. {
  10.     std::cout << element << std::endl;
  11. }

В данном примере компилятор вызывает метод класса begin, чтобы получить итератор, указывающий на первый элемент контейнера. Затем этот итератор используется для перебора элементов. Значение итератора каждый раз увеличивается, пока не станет равно значению итератора, возвращённого методом end vector'а. При каждом проходе итератор разыменовывается, а получившееся значение помещается в соответствующий элемент. Элегантность заключается в том, что использование for each позволяет избежать многих распространённых ошибок, связанных с переполнением буфера и других, подобных этим.

Давайте вернёмся к управляемому коду. Если конструкция for each должна поддерживать коллекции, а не массивы, то, естественно, это ведёт к иной реализации, зависящей от использования конструкции. Рассмотрим следующий пример:

Код (Text):
  1.  
  2. Collections::ArrayList numbers(3);
  3. numbers.Add(1);
  4. numbers.Add(2);
  5. numbers.Add(3);
  6.  
  7. for each (int element in %numbers)
  8. {
  9.     Console::WriteLine(element);
  10. }

Держите в уме, что большая часть контейнеров STL, включающих конструктор, принимающий одно целочисленное значение, обычно устанавливают начальный размер контейнера, в кто время как .NET-коллекция используют конструктор для установки начальной вместимости.

Так что же делает for each в данном случае? Ну, тип ArrayList реализует интерфейс IEnumerable с единственным методом GetEnumerator. Конструкция for each вызывает метод GetEnumerator, чтобы получить реализация интерфейса IEnumerator, который затем используется для перебора коллекции. Давайте проверим простую реализацию для данного кода:

Код (Text):
  1.  
  2.     .locals init (class [mscorlib]System.Collections.ArrayList numbers,
  3.                   class [mscorlib]System.Collections.IEnumerator enumerator)
  4.    
  5. // Создаём массив
  6.                  
  7.     ldc.i4.3
  8.     newobj instance void [mscorlib]System.Collections.ArrayList::.ctor(int32)
  9.     stloc numbers
  10.    
  11. // Заполняем массив
  12.    
  13.     ldloc numbers
  14.     ldc.i4.1
  15.     box int32
  16.     callvirt instance int32 [mscorlib]System.Collections.ArrayList::Add(object)
  17.     pop
  18.  
  19.     ldloc numbers
  20.     ldc.i4.2
  21.     box int32
  22.     callvirt instance int32 [mscorlib]System.Collections.ArrayList::Add(object)
  23.     pop
  24.  
  25.     ldloc numbers
  26.     ldc.i4.2
  27.     box int32
  28.     callvirt instance int32 [mscorlib]System.Collections.ArrayList::Add(object)
  29.     pop
  30.    
  31. // Получаем перечислитель
  32.  
  33.     ldloc numbers
  34.  
  35.     callvirt instance class [mscorlib]System.Collections.IEnumerator
  36.         [mscorlib]System.Collections.IEnumerable::GetEnumerator()
  37.  
  38.     stloc enumerator
  39.    
  40.     br.s _CONDITION
  41.  
  42. _CONDITION:
  43.    
  44.     ldloc enumerator
  45.     callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
  46.     brfalse.s _CONTINUE
  47.    
  48. // конструкция for each
  49.  
  50.     ldloc enumerator
  51.     callvirt instance object [mscorlib]System.Collections.IEnumerator::get_Current()
  52.     call void [mscorlib]System.Console::WriteLine(object)
  53.    
  54.     br.s _CONDITION
  55.  
  56. _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):
  1.  
  2. Collections::Generic::List<int> numbers(3);
  3. numbers.Add(1);
  4. numbers.Add(2);
  5. numbers.Add(3);
  6.  
  7. for each (int element in %numbers)
  8. {
  9.     Console::WriteLine(element);
  10. }

В этом случае не возникает вопроса, должен ли перечислитель реализовывать IDisposable. Единственная остающаяся проблема - это найти надёжный способ вызвать метод Dispose несмотря на исключения. Вот подходящее решение, использующее конструкции обработки исключений, обсуждавшиеся в 5-ой части.

Код (Text):
  1.  
  2.     .locals init (class [mscorlib]System.Collections.Generic.'List`1'<int32> numbers,
  3.                   class [mscorlib]System.Collections.Generic.'IEnumerator`1'<int32> enumerator)
  4.    
  5. // Создаём массив
  6.                  
  7.     ldc.i4.3
  8.     newobj instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::.ctor(int32)
  9.     stloc numbers
  10.    
  11. // Заполняем массив
  12.    
  13.     ldloc numbers
  14.     ldc.i4.1 // value
  15.     callvirt instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::Add(!0)
  16.  
  17.     ldloc numbers
  18.     ldc.i4.2 // value
  19.     callvirt instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::Add(!0)
  20.  
  21.     ldloc numbers
  22.     ldc.i4.3 // value
  23.     callvirt instance void class [mscorlib]System.Collections.Generic.'List`1'<int32>::Add(!0)
  24.    
  25. // Получаем перечислитель
  26.    
  27.     ldloc numbers
  28.    
  29.     callvirt instance class [mscorlib]System.Collections.Generic.'IEnumerator`1'<!0>
  30.         class [mscorlib]System.Collections.Generic.'IEnumerable`1'<int32>::GetEnumerator()
  31.    
  32.     stloc enumerator
  33.    
  34.     .try
  35.     {
  36.        
  37.     _CONDITION:
  38.        
  39.         ldloc enumerator
  40.         callvirt instance bool class [mscorlib]System.Collections.Generic.'IEnumerator`1'<int32>::MoveNext()
  41.         brfalse.s _LEAVE
  42.        
  43.     _STATEMENTS:
  44.    
  45.         ldloc enumerator
  46.         callvirt instance !0 class [mscorlib]System.Collections.Generic.'IEnumerator`1'<int32>::get_Current()
  47.         call void [mscorlib]System.Console::WriteLine(int32)
  48.         br.s _CONDITION
  49.        
  50.     _LEAVE:
  51.        
  52.         leave.s _CONTINUE
  53.     }
  54.     finally
  55.     {
  56.         ldloc enumerator
  57.         callvirt instance void [mscorlib]System.IDisposable::Dispose()
  58.    
  59.         endfinally
  60.     }
  61.    
  62. _CONTINUE:

Учитывая комментарии и метки, код должен достаточно понятным. Давайте быстро пробежимся по нему. Запрошены две локальные переменные, одна для списка, а другая для перечислителя. Имена типов выглядят странно, так как должны поддерживать MSIL'овские дженерики. 'List`1' показывает, что используется тип List с одним параметром типа. Это позволяет отличать дженерик-типы с одним и тем же именем, но разным количество параметров. Тип List между кавычками указывает на реальные типы, используемые для конкретизации времени выполнения.

Определив локальные переменные, следующий шаг - это создать список, используя инструкцию newobj и заполнить его с помощью принадлежащего ему метода Add. Дженерики предназначены, в основном, для создания типизированных коллекций. Хорошим примером этого является код для добавления чисел в список. Предыдущий пример использовал ArrayList, поэтому числа сначала должны были пройти через инструкцию box, прежде чем быть добавленными в коллекцию. В данном случае мы можем просто положить числа в стек и вызвать метод Add. Нам просто нужно задать требуемую конкретизацию. Единственный параметр метода Add - это !0, который указывает на параметр типа, основанный на нуле. Теперь, когда коллекция готова, мы можем реализовать сами циклы. Чтобы начать перечисление, мы сначала получаем из коллекции реализацию IEnumerator<T> и сохраняем её во временной переменной. Далее вызывается метод перечислителя MoveNext, пока он не возвратит false. Обработчик исключения finally используется для вызова метода перечислителя Dispose. © Кенни Керр, пер. Aquila


0 1.767
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532