Компилятор .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
?
Почему это реализовано таким образом?