Подтвердить что ты не робот

Почему MSFT С# компилирует Исправленный "массив для разложения указателя" и "адрес первого элемента" по-другому?

Компилятор .NET С# (.NET 4.0) компилирует оператор fixed довольно своеобразным образом.

Вот короткая, но полная программа, чтобы показать вам, о чем я говорю.

using System;

public static class FixedExample {

    public static void Main() {
        byte [] nonempty = new byte[1] {42};
        byte [] empty = new byte[0];

        Good(nonempty);
        Bad(nonempty);

        try {
            Good(empty);
        } catch (Exception e){
            Console.WriteLine(e.ToString());
            /* continue with next example */
        }
        Console.WriteLine();
        try {
            Bad(empty);
        } catch (Exception e){
            Console.WriteLine(e.ToString());
            /* continue with next example */
        }
     }

    public static void Good(byte[] buffer) {
        unsafe {
            fixed (byte * p = &buffer[0]) {
                Console.WriteLine(*p);
            }
        }
    }

    public static void Bad(byte[] buffer) {
        unsafe {
            fixed (byte * p = buffer) {
                Console.WriteLine(*p);
            }
        }
    }
}

Скомпилируйте его с помощью "csc.exe FixedExample.cs/unsafe/o +", если вы хотите следовать.

Здесь генерируемый IL для метода Good:

Хорошо()

  .maxstack  2
  .locals init (uint8& pinned V_0)
  IL_0000:  ldarg.0
  IL_0001:  ldc.i4.0
  IL_0002:  ldelema    [mscorlib]System.Byte
  IL_0007:  stloc.0
  IL_0008:  ldloc.0
  IL_0009:  conv.i
  IL_000a:  ldind.u1
  IL_000b:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_0010:  ldc.i4.0
  IL_0011:  conv.u
  IL_0012:  stloc.0
  IL_0013:  ret

Здесь сгенерированный ИЛ для метода Bad:

Bad()

  .locals init (uint8& pinned V_0, uint8[] V_1)
  IL_0000:  ldarg.0
  IL_0001:  dup
  IL_0002:  stloc.1
  IL_0003:  brfalse.s  IL_000a
  IL_0005:  ldloc.1
  IL_0006:  ldlen
  IL_0007:  conv.i4
  IL_0008:  brtrue.s   IL_000f
  IL_000a:  ldc.i4.0
  IL_000b:  conv.u
  IL_000c:  stloc.0
  IL_000d:  br.s       IL_0017
  IL_000f:  ldloc.1
  IL_0010:  ldc.i4.0
  IL_0011:  ldelema    [mscorlib]System.Byte
  IL_0016:  stloc.0
  IL_0017:  ldloc.0
  IL_0018:  conv.i
  IL_0019:  ldind.u1
  IL_001a:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_001f:  ldc.i4.0
  IL_0020:  conv.u
  IL_0021:  stloc.0
  IL_0022:  ret

Здесь Good делает:

  • Получить адрес буфера [0].
  • Разделить этот адрес.
  • Вызовите WriteLine с этим разыменованным значением.

Здесь что-то плохое:

  • Если буфер имеет значение null, GOTO 3.
  • Если buffer.Length!= 0, GOTO 5.
  • Сохраните значение 0 в локальном слоте 0,
  • GOTO 6.
  • Получить адрес буфера [0].
  • Относитесь к этому адресу (в локальном слоте 0, который может быть 0 или буфером сейчас).
  • Вызовите WriteLine с этим разыменованным значением.

Когда buffer является не нулевым и непустым, эти две функции выполняют одно и то же. Обратите внимание, что Bad просто перескакивает через несколько обручей, прежде чем перейти к вызову функции WriteLine.

Когда buffer равно null, Good выдает NullReferenceException в деклараторе фиксированного указателя (byte * p = &buffer[0]). Предположительно, это желаемое поведение для фиксации управляемого массива, поскольку, как правило, любая операция внутри фиксированного оператора будет зависеть от действительности фиксируемого объекта. Иначе зачем этот код находиться внутри блока fixed? Когда Good передается нулевая ссылка, он не работает сразу в начале блока fixed, обеспечивая соответствующую и информативную трассировку стека. Разработчик увидит это и поймет, что он должен проверять buffer перед его использованием, или, возможно, его логику неправильно присвоила null buffer. В любом случае, явно вводить блок fixed с управляемым массивом null нежелательно.

Bad обрабатывает этот случай по-разному, даже нежелательно. Вы можете видеть, что Bad на самом деле не генерирует исключение, пока p не будет разыменован. Он делает это в обходном порядке назначения нула в тот же локальный слот, который содержит p, а затем бросает исключение, когда оператор блока fixed разыгрывает p.

Обработка null таким образом имеет то преимущество, что объектная модель в С# соответствует. То есть внутри блока fixed p по-прежнему обрабатывается семантически как своего рода "указатель на управляемый массив", который не будет, когда null, вызывает проблемы до тех пор, пока (или если) не будет разыменован. Согласованность - все хорошо и хорошо, но проблема в том, что p не является указателем на управляемый массив. Это указатель на первый элемент buffer, и любой, кто написал этот код (Bad), интерпретирует его семантический смысл как таковой. Вы не можете получить размер buffer из p, и вы не можете вызвать p.ToString(), так почему же относитесь к нему так, как если бы это был объект? В случаях, когда buffer является нулевым, очевидно, что ошибка кодирования, и я считаю, что было бы гораздо более полезно, если Bad выдавал исключение в деклараторе фиксированного указателя, а не внутри метода.

Итак, кажется, что Good обрабатывает null лучше, чем Bad. Что относительно пустых буферов?

Когда buffer имеет длину 0, Good бросает IndexOutOfRangeException в деклараторе фиксированного указателя. Это кажется вполне разумным способом обработки доступа к границам границ. В конце концов, код &buffer[0] должен обрабатываться так же, как &(buffer[0]), который должен явно бросать IndexOutOfRangeException.

Bad обрабатывает этот случай по-разному и снова нежелательно. Точно так же, как если бы buffer были null, когда buffer.Length == 0, Bad не генерирует исключение до тех пор, пока p не будет разыменован, и в это время он выкинет NullReferenceException, а не IndexOutOfRangeException! Если p никогда не разыменовывается, тогда код даже не генерирует исключение. Опять же, кажется, что идея здесь состоит в том, чтобы дать p семантический смысл "указателя на управляемый массив". Опять же, я не думаю, что любой, кто пишет этот код, будет думать о p таким образом. Код был бы намного полезнее, если бы он бросил IndexOutOfRangeException в декларатор фиксированного указателя, тем самым уведомив разработчика о том, что массив прошел в, был пуст, а не null.

Похоже, что fixed(byte * p = buffer) должен быть скомпилирован с тем же кодом, что и fixed (byte * p = &buffer[0]). Также обратите внимание, что хотя buffer могло быть любым произвольным выражением, тип type (byte[]) известен во время компиляции, поэтому код в Good будет работать для любого произвольного выражения.

Edit

На самом деле обратите внимание, что реализация Bad выполняет проверку ошибок на buffer[0] дважды. Он делает это явно в начале метода, а затем делает это неявно в инструкции ldelema.


Итак, мы видим, что Good и Bad семантически разные. Bad длиннее, вероятно, медленнее и, конечно же, не дает нам желательных исключений, когда у нас есть ошибки в нашем коде и даже не удается намного позже, чем в некоторых случаях.

Для любопытных в разделе 18.6 спецификации (С# 4.0) говорится, что в обоих случаях отказа используется поведение "Реализация":

Инициализатор с фиксированным указателем может быть одним из следующих:

• Символ "&" а затем переменная-ссылка (§5.3.3) на подвижную переменную (§18.3) неуправляемого типа T, если тип T * неявно конвертируется в тип указателя, заданный в фиксированном утверждении. В этом случае инициализатор вычисляет адрес данной переменной, и гарантируется, что переменная останется на фиксированном адресе на протяжении фиксированного оператора.

• Выражение типа массива с элементами неуправляемого типа T, если тип T * неявно конвертируется в тип указателя, заданный в фиксированном утверждении. В этом случае инициализатор вычисляет адрес первого элемента в массиве, и весь массив, как гарантируется, останется на фиксированном адресе на протяжении фиксированного оператора. Поведение фиксированного оператора определяется реализацией, если выражение массива равно null или массив имеет нулевые элементы.

... другие случаи...

Последняя точка, документация MSDN предполагает, что эти два являются "эквивалентными":

//Следующие два присваивания эквивалентны...

fixed (double * p = arr) {/.../}

fixed (double * p = & arr [0]) {/.../}

Если эти два должны быть "эквивалентными", то зачем использовать другую семантику обработки ошибок для прежнего утверждения?

Также кажется, что дополнительные усилия были добавлены в запись путей кода, сгенерированных в Bad. Скомпилированный код в Good отлично работает для всех случаев сбоев и совпадает с кодом в Bad в случаях отсутствия отказа. Зачем внедрять новые пути кода, а не просто использовать более простой код, созданный для Good?

Почему это реализовано таким образом?

4b9b3361

Ответ 1

Вы могли заметить, что код IL, который вы включили, реализует спецификацию почти line-for-line. Это включает в себя явно реализацию двух исключений, перечисленных в спецификации, в случае, когда они являются релевантными, и не включает код в том случае, если они не являются. Итак, простейшая причина, по которой компилятор ведет себя так, как он делает, - "потому что спецификатор сказал так".

Конечно, это просто приводит к двум дополнительным вопросам, которые мы можем задать:

  • Почему языковая группа С# решила написать спецификацию таким образом?
  • Почему команда компилятора выбрала конкретное поведение, определяемое реализацией?

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

Напомним, что спецификация говорит, что в случае подачи массива в инициализатор с фиксированным указателем, который

Поведение фиксированного оператора определяется реализацией, если выражение массива равно null или массив имеет нулевые элементы.

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

В этом случае команда компилятора решила, что " выдает исключение в том месте, где ваш код делает что-то неправильно". Посмотрите, что будет делать код, если бы он не был внутри инициализатора с фиксированным указателем и не думал о том, что еще происходит. В примере "Хороший" вы пытаетесь взять адрес объекта, который не существует: первый элемент в пустом/пустом массиве. Это не то, что вы действительно можете сделать, так что это приведет к исключению. В примере "Плохой" вы просто назначаете адрес параметра переменной указателя; byte * p = null - совершенно законное утверждение. Только при попытке WriteLine(*p) произойти ошибка. Поскольку инициализатору с фиксированным указателем разрешено делать все, что он хочет в этом случае исключения, самое простое дело в том, чтобы просто разрешить назначение, такое же бессмысленное, как и оно.

Ясно, что два утверждения не являются точно эквивалентными. Мы можем сказать это тем, что стандарт рассматривает их по-разному:

  • &arr[0]: "токен" & ", за которым следует переменная-ссылка", и поэтому компилятор вычисляет адрес arr [0]
  • arr: "выражение типа массива", и поэтому компилятор вычисляет адрес первого элемента массива с оговоркой, что массив с нулевой или нулевой длиной создает поведение, определяемое реализацией, re смотри.

Эти два результата дают эквивалентные результаты, если в массиве есть элемент, который является точкой, которую пытается найти документация MSDN. Задавая вопросы о том, почему явно undefined или поведение, определяемое реализацией, действует так, как это делается, на самом деле не поможет вам решить какие-либо конкретные проблемы, потому что вы не можете полагаться на это, чтобы быть правдой в будущем. (Сказав это, мне, конечно, было бы интересно узнать, что такое процесс мышления, поскольку вы, очевидно, не можете "исправить" нулевое значение в памяти...)

Ответ 2

Итак, мы видим, что хорошие и плохие семантически разные. Почему?

Потому что Good - это случай 1, а bad - случай 2.

Good не присваивает "выражение типа массива". Он назначает "токен" & ", за которым следует переменная-ссылка", так что это случай 1. Плохо присваивает "выражение типа массива", делающее это случай 2. Если это так, документация MSDN неверна.

В любом случае это объясняет, почему компилятор С# создает два разных (и во втором случае специализированных) шаблонов кода.

Почему случай 1 генерирует такой простой код? Я размышляю здесь: Принимая адрес элемента массива, вероятно, скомпилирован таким же образом, как использование array[index] в ref -выражении. На уровне CLR параметры и выражения ref - это только управляемые указатели. Итак, выражение &array[index]: оно скомпилировано управляемым указателем, который не закреплен, а "внутренним" (этот термин исходит из Managed С++, я думаю). GC исправляет это автоматически. Он ведет себя как нормальная ссылка на объект.

Итак, случай 1 получает обычную обработку управляемого указателя, в то время как случай 2 получает специальное, реализованное (не undefined) поведение.

Это не отвечает на все ваши вопросы, но, по крайней мере, это дает некоторые причины для ваших наблюдений. Я как бы надеялся, что Эрик Липперт добавит свой ответ в качестве инсайдера.