Введение в MSIL - Часть 6: Стандартные языковые конструкции — Архив WASM.RU

Все статьи

Введение в MSIL - Часть 6: Стандартные языковые конструкции — Архив WASM.RU

В части с 1 по 5 "Введения..." мы рассматривали MSIL и конструкции, предоставляемые им для написания управляемого кода. В следующих секциях мы изучим некоторые стандартные языковые возможности, которые не являются особенностями подобных MSIL'у языков, основанных на инструкциях. Иметь представление, какой код генерится для обычных конструкций языка программирования очень важно, чтобы понимать, как улучшить качество вашего кода и быстрее находить трудноотлавливаемые баги.

В этой части мы рассмотрим несколько стандартных конструкций, которые встречаются во многих языках. Я буду приводить примеры на C#, хотя соответствующие объяснения применим ко многих языкам программирования, имеющим эквиваленты этих конструкций. Так как я буду стараться объяснить общий принцип, то не всегда могу предоставить точный код, который сгенерирует компилятор. Идея в том, чтобы лучше понять, что как он работает. Я призываю вас сравнивать инструкции, генерируемые разными компиляторам с помощью таких инструментов как ILDASM.

Такие языки программирования как C и C++ позволяют более быструю разработку программного обеспечения в сравнении с классическим программированием на ассемблере. Значительное количество языковых средств играет в этом немалую роль. Такие концепции как выражения, условные конструкции (например, if и switch), а также циклы (while и for) - это то, что делает эти портабельные языки такими мощными. Ещё большая скорость была достигнута с помощью новых концеций вроде объектов, но прежде чем они были признаны, конструкции, упомянутые выше, дали огромное преиущество ассемблерному программисту, которому требовалось писать большие приложения и операционные системы. Теперь, давайте, перейдём к делу.

Конструкция if-else

Следующий пример проверяет перед выполнением основного кода, не равен ли аргумент null.

void Send(string message)
{
    if (null == message)
    {
        throw new ArgumentNullException("message");
    }
  
    /* impl */
}

Конструкция if выглядит довольно безобидно. Если выражение равно true, управление переходит к блоку внутри фигурных скобок и бросается исключение. Если выражение равно false, управление переходит к следующей строке после конструкции if. Описание else я оставляю вам в качестве домашнего упражнения.

Даже если вы никогда не использовали C# раньше, этот код для вас должен быть вполне ясен (если вы программист). Тогда как компилятор превращает эту несложную, маленькую конструкцию if в что-то понимаемое средой выполнения? Как и случае с большинством программистских задач, есть несколько путей решить проблему.

Во многих случаях компилятору необходимо создавать временные объекты. Обычный их источник - это выражения. На практике компиляторы избегают создание временных объектов настолько, насколько это возможно. Гипотетический неоптимизирующий компилятор превратит код выше в что-то вроде следующего:

bool isNull = null == message;

if (isNull)
{
    throw new ArgumentNullException("message");
}

Я не хочу сказать, что оптимизирующий компилятор не будет использовать временные объекты подобным образом. Они могут часто помочь сгенерировать более эффективный код - это зависит от обстоятельств. Здесь обрабатывается результат выражения, который вначале преобразуется в булевое значение, передающееся затем выражению if. Это ещё не все отличия от предыдущего примера, но после прочтения последних 5 частей этого цикла, должно быть ясно, как это применяется в языке вроде MSIL. Вся идея в том, чтобы разбить конструкции и выражения на простые инструкции, которые могут быть выполнены один за другим. Давайте рассмотрим одну из реализаций метода Send.

.method void Send(string message)
{
    .maxstack 2
     
    ldnull
    ldarg message
    ceq
     
    ldc.i4.0
    ceq
     
    brtrue.s _CONTINUE
    
    ldstr "message"
    newobj instance void [mscorlib]System.ArgumentNullException::.ctor(string)
    throw
    
    _CONTINUE: 
    
    /* impl */
    
    ret
}

Если вы изучили материал из прошлых глав цикла, то должны многое понять из объявления метода. Давайте рассмотрим его по-быстрому. Временный объект, который может молча сгенерировать компилятор, явно объявлен в MSIL'е, хоть он и безымянный и проживёт очень короткую жизнь в стеке. (Думаю, явный он или нет - вопрос спорный). Можете ли вы его найти? Сначала мы сравниваем сообщение с null. Инструкция ceq берёт два значения из стека, сравнивает их, а затем помещает на стек 1, если они равны и 0, если нет (это временно). Код может показаться излишне усложнённым. Причина этого состоит в том, что в MSIL'е нет cneq, то есть "compare-not-equal-to", операции противоположной предыдущей. Поэтому мы сначала сравниваем сообщение с null, а затем полученный результат с нулём, таким образом инвертируя предыдущее сравнение.

Теперь, когда мы получили результат выражения, инструкция brtrue.s делает примерно тоже, что и конструкция if. Она передает управление на заданную метку, если верхний элемент стека не равен нулю, пропуская в этом случае связанный с условием блок кода.

Конечно, это не единственный путь. Вышеприведённый пример очень похож на то, что генерирует мой компилятор C# с той разницей, что он использует локальную переменную для сохранения результата выражения. Эта реализация кажется немного неуклюжей. Как правило, это не играет роли, так как JIT-компилятор, вероятно, нивелирует эту разницу в результате компиляции. Тем не менее, будет полезно потренироваться, упростив эту реализацию. Я упомянул, что нет инструкции "cneq". С другой стороны, есть обратная версия инструкции brtrue.s. Как и следовало ожидать, она называется brfalse.s. Используя её, можно полностью убрать необходимость во втором сравнении.

Наконец, как C++-программист вы могли бы ожидать, что компилятор использует оператор равенства, так как один из операндов является типом System.String, для которого такой оператор определён, и это именно то, что делает C++-компилятор.

.method void Send(string message)
{
    .maxstack 2
    
    ldnull
    ldarg message
    call bool string::op_Equality(string, string)
    
    brfalse.s _CONTINUE
    
    ldstr "message"
    newobj instance void [mscorlib]System.ArgumentNullException::.ctor(string)
    throw
    
    _CONTINUE: 
    
    /* impl */
    
    ret
}

JIT-компилятор оптимизирует код настолько хорошо, насколько может, применяя по возможности инлайн-методы. Вас не должно удивлять, что две разные реализации могут приводить к одному и тому же машинному коду.

Конструкция for

Прежде чем мы перейдём к реализации этой конструкции, которую часто называют "циклом", давайте бысто проведём обзор того, как она работает. Если вашим основным занятием является Visual Basic или даже C#, то вы можете быть вовсе незнакомы с этой очень полезной конструкцией. Даже C++-программистам рекомендуют по возможности избегать её в пользу более безопасного алгоритма std::for_each из стандартной библиотеки C++. Тем не менее, у цикла есть много интересных применений, которые не имеют ничего общего с пробегом по контейнеру от начала до конца.

Следующий просто псевдокод демонстрирует данную конструкцию.

for ( начальное выражение ; выражение условия ; выражение цикла )
{
    код
}

Она состоит из трёх выражений, разделённых точкой с запятой, за которыми следует связанный блок кода. Вы можете использовать любое выражение, дающее результат, который может быть интерпретирован как истина или ложь. Первое выражение выполняется в самом начале и только один раз. Оно обычно используется для инициализации индексов цикла или других переменных, которые могут потребоваться данной конструкции. Выражение условия запускается следующим и выполняется перед каждой итерацией. Если его результат истинен, то выполняется прикреплённый блок код, если нет, то управление передаётся на первую строку за ним. Выражение цикла выполняется после каждой итерацией. Оно может использоваться для увеличения значения индексов цикла, движения курсоров или чего угодно другого, подходящего по контексту. После этого снова выполняется выражение условия и так далее. Для более подробного разъяснения обратитесь к документации по вашему языку программирования. Вот простой пример, который отображает числа от нуля до девяти в отладочном режиме.

for (int index = 0; 10 != index; ++index)
{
    Debug.WriteLine(index);
}

Мы уже говорил о том, как конструкция if может быть реализована с помощью MSIL. Давайте теперь используем это знание, чтобы разобрать for на более мелкие конструкции, которые могут быть интерпретированы компьютером, пока что используя C#.

    int index = 0;
    goto _CONDITION;
 
_LOOP:
 
    ++index;
 
_CONDITION:
 
    if (10 != index)
    {
        // for statements
        Debug.WriteLine(index);
 
        goto _LOOP;
    }

Это выглядит ничем иным, как конструкцией for! Здесь я использую печально известный goto, который чаще упоминают как инструкцию ветвления. Она неизбежна в языках, которые не поддерживают конструкции выбора и цикла, но не имеет значительного смысла в таких языках как C++ и C#, кроме, разве что, запутывания кода. (Если вы сторонник использования данной конструкции, то, пожалуйста, не надо делиться этим со мной. Напишите собственную статью о её достоинствах.) Кодогенераторы, тем не менее, довольно часто её используют, так как для них использовать эту конструкцию гораздо легче, нежели, например, for.

Из примера выше вы можете понять, что мы реализуем конструкцию for, сначала проверяя условие, а затем переходя к выражению цикла, чтобы сделать следующую итерацию. Теперь давайте посмотрим, как мы можем это реализовать в MSIL.

    .locals init (int32 index)
    br.s _CONDITION
 
_LOOP:
 
    ldc.i4.1
    ldloc index
    add
    stloc index 
     
_CONDITION:
    
    ldc.i4.s 10
    ldloc index
    beq _CONTINUE
    
    // for
    ldloc index
    box int32
    call void [System]System.Diagnostics.Debug::WriteLine(object)
    
    br.s _LOOP
 
_CONTINUE:

Инициализовав локальную переменную index мы переходим к метке _CONDITION. Чтобы выполнить условие, я поместил в стек значение 10, за которым последовало значение index. Инструкция beq (или branch on equal) достаёт из стека два значения и сравнивает их. Если они рвны, то она передает контроль коду по метке _CONTINUE, заканчивая, таким образом, цикл. В противном случае контроль передаётся блоку кода, относящемуся к циклу. Чтобы послать значение index в вывод отладки, я поместил его в стек, использовал box и вызвал статический метод WriteLine ссылочного типа System.Diagnostics.Debug из сборки System. После этого используется инструкция br.s для передачи управления выражению цикла для проведения следующей итерации.

2002-2013 (c) wasm.ru