Почему MSFT C# компилирует фиксированный массив, чтобы по-разному указывать затухание и адрес первого элемента?

Компилятор .NET c# (.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

Вот сгенерированный IL для метода 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:

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

Вот что делает Bad:

  1. Если буфер пуст, ПЕРЕЙТИ к 3.
  2. Если buffer.Length != 0, ПЕРЕЙТИ К 5.
  3. Сохраните значение 0 в локальном слоте 0,
  4. ПЕРЕЙТИ К 6.
  5. Получить адрес буфера[0].
  6. Уважайте этот адрес (в локальном слоте 0, который сейчас может быть 0 или буфером).
  7. Вызовите WriteLine с этим разыменованным значением.

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

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

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

Преимущество такой обработки null заключается в сохранении согласованности объектной модели в C#. То есть внутри блока fixed p по-прежнему семантически обрабатывается как своего рода указатель на управляемый массив, который при нулевом значении не вызовет проблем до тех пор, пока (или пока) не будет разыменован. Согласованность — это хорошо, но проблема в том, что p не является указателем на управляемый массив. Это указатель на первый элемент buffer, и любой, кто писал этот код (Bad), интерпретировал бы его семантическое значение как таковое. Вы не можете получить размер buffer из p и не можете вызвать p.ToString(), так зачем обращаться с ним, как с объектом? В тех случаях, когда buffer имеет значение null, это явно ошибка кода, и я считаю, что было бы намного полезнее, если бы 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 могло быть любым произвольным выражением, его тип (byte[]) известен во время компиляции, поэтому код в Good будет работать для любого произвольного выражения.

Изменить

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


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

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

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

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

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

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

И последний пункт: документация MSDN предлагает что они эквивалентны:

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

фиксированный (double* p = arr) { /.../ }

фиксированный (двойной* p = &arr[0]) { /.../ }

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

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

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


person Michael Graczyk    schedule 03.08.2012    source источник
comment
@pst Я удалил SoWhat и его объяснение для краткости. Фиксированный.   -  person Michael Graczyk    schedule 04.08.2012
comment
Вы проверили, что спецификация C# говорит о arr[0]? Я почти уверен, что arr[0] выдает исключение, когда arr является нулевым или пустым в любом контексте, независимо от любого оператора fixed, который его окружает.   -  person    schedule 04.08.2012
comment
@hvd Да, именно поэтому fixed(byte *p = buffer) следует рассматривать как fixed(byte *p = &buffer[0]). Последний обеспечивает более четкую проверку ошибок. Я добавлю это к вопросу.   -  person Michael Graczyk    schedule 04.08.2012
comment
Забавно, я всегда думал, что это поведение было самым интуитивным...   -  person user541686    schedule 04.08.2012
comment
Интересно, как это определяется (или утверждается как UB/IB) в C .. чтобы добавить в смесь немного апельсинов.   -  person    schedule 04.08.2012
comment
C не имеет управляемой памяти, поэтому вся концепция описанного здесь специального fixed поведения не существует. Кроме этого: в C поведение undefined для ссылки на элемент массива, который не существует, поэтому &arr[0] не определено, когда arr равно NULL. Я не думаю, что стандарт C или C++ в настоящее время допускает массивы 0-длины (кажется, C++0x при использовании new[]); GCC работает как расширение, но я понятия не имею, что там за поведение.   -  person Michael Edenfield    schedule 04.08.2012
comment
@MichaelEdenfield Не совсем так. &arr[0] действительно недействителен в C, когда arr имеет значение null, но это потому, что &arr[0] является сокращением от &*(arr+0), а арифметика недействительна для нулевых указателей. &*arr, с другой стороны, действителен, даже если arr имеет значение null, как и &arr[1], когда arr является массивом длины 1.   -  person    schedule 04.08.2012
comment
Я всегда считал, что это единственное место, где *(arr + 1) и arr[1] различаются: если arr не имеет элемента в [1], то первый является допустимым указателем, находящимся за концом массива, а второй не определен. Я больше не делаю достаточно C, чтобы отслеживать :)   -  person Michael Edenfield    schedule 04.08.2012


Ответы (2)


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

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

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

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

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

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

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

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

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

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

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

person Michael Edenfield    schedule 03.08.2012
comment
В соответствии с реализацией спецификации определенное поведение разрешено только во втором случае, но Good реализует первый случай. &array[0] не является выражением массива. - person usr; 04.08.2012
comment
нам придется надеяться, что кто-то из команды компилятора С# встретится. Это моя надежда :) - person Michael Graczyk; 04.08.2012
comment
@usr правда, я пытался донести эту мысль в посте, но буду более откровенен :) - person Michael Edenfield; 04.08.2012
comment
@MichaelEdenfield В этом случае стандарт действительно рассматривает их одинаково. Только при проверке ошибок они различаются. Во втором случае он говорит, что инициализатор вычисляет адрес первого элемента в массиве. Это то же самое, что и первый случай (поскольку данная переменная является первым элементом массива). - person Michael Graczyk; 04.08.2012
comment
Стандарт четко различает их; тот факт, что они дают один и тот же результат, не делает их одинаковыми, о чем свидетельствует тот факт, что случай 2 имеет особый случай, определяемый реализацией, а случай 1 - нет. - person Michael Edenfield; 04.08.2012
comment
@MichaelEdenfield Это только для случаев ошибок. Также вы говорите, что byte * p = null - совершенно законное утверждение. Этот оператор на самом деле определяется реализацией. Я спрашиваю, почему их реализация определила это нежелательно. - person Michael Graczyk; 04.08.2012

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

Потому что хорошо — это случай 1, а плохо — это случай 2.

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

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

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

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

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

person usr    schedule 03.08.2012
comment
Если это правда, документация MSDN неверна. Не совсем, просто немного вводит в заблуждение, как я понимаю. Эти две формы эквивалентны для тех буферов, для которых обе формы имеют определенное поведение. - person ; 04.08.2012
comment
@hvd, да, для таких случаев. Но не для всех. Документация не уточняет свое утверждение так, как это сделали вы. - person usr; 04.08.2012
comment
@hvd Я должен ожидать, что поведение, определяемое реализацией, определено в документах MSDN. Ясно, что не в этом случае. - person Michael Graczyk; 04.08.2012
comment
Обратите внимание, что MS всегда прикрывает свои собственные задницы, добавляя это предостережение на свои страницы языка C#: Для получения дополнительной информации см. Спецификацию языка C#. Спецификация языка является исчерпывающим источником информации о синтаксисе и использовании C#. - person Michael Edenfield; 04.08.2012
comment
@usr Я раньше не слышал термина интерьер, но в этом случае фиксирующая часть кода одинакова как для Good, так и для Bad. Только проверка ошибок в начале отличается. GC и JIT одинаково видят две фиксирующие части (управляемый ref преобразуется в собственный int). По крайней мере, если JIT-хаков в данном случае нет (сомневаюсь, что они есть). - person Michael Graczyk; 04.08.2012
comment
@MichaelGraczyk Я не уверен, где MS обычно перечисляет поведение, определяемое реализацией C #, я вообще не могу его найти, не только для вашего конкретного вопроса. - person ; 04.08.2012
comment
Как ни странно, соответствие спецификации ECMA требует от вас документировать эти варианты, но спецификация Microsoft C# 4.0 явно опускает приложение о проблемах переносимости :) - person Michael Edenfield; 07.08.2012