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

Все статьи

Введение в 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. Как я сказал ранее, я не хочу сосредотачиваться исключительно на одной реализации компилятора, но скорее продемонстрировать техники, которые могут быть использованы компиляторами в общем случае.

Давайте начнём с простого примера.

array<int>^ numbers = gcnew array<int> { 1, 2, 3 };

for each (int element in numbers)
{
    Console::WriteLine(element);
}

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

    .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).

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 должна поддерживать коллекции, а не массивы, то, естественно, это ведёт к иной реализации, зависящей от использования конструкции. Рассмотрим следующий пример:

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, который затем используется для перебора коллекции. Давайте проверим простую реализацию для данного кода:

    .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. Рассмотрим следующий пример:

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-ой части.

    .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.

2002-2013 (c) wasm.ru