Наверняка почти каждому, кто имел дело с C#, известна подобная конструкция:
Вполне логично было-бы ожидать превращение этой конструкции в нечто подобное:
Увы и ах, на деле орех гораздо более морщинист, чем кажется с первого взгляда, и имеются некоторые тонкости, на которые будет указано позже. А до тех пор, наденем ношеную «IL freak» майку (у кого имеется) и погрузимся в недра реализации.
В конечном итоге, первая конструкция превратится компилятором в такую вот загогулину:
Что за диво в моём саду? Что это за <PrivateImplementationDetails>blablabla ерундовина? Прежде чем я расскажу что к чему, давайте бросим наш взгляд на Q::Main, где я указал значение в вершине стека перед каждой строкой кода:
Давайте теперь проведём построчный анализ:
IL_0001 и IL_0002 — создаётся новый массив типа System.Int32 и размерностью 3.
На IL_0007 нам попадается первый сюрприз в виде дублированной ссылки на массив. Почему? Предположим, что на IL_0008 и IL_0009 происходит инициализация массива (совсем скоро к этому месту мы вернёмся). А теперь посмотрим на IL_0012, где значение в вершине стека — опять массив — присвоен локальной переменной с индексом 0, т.е. переменной ints. А что если мы присвоим значение переменной ints на IL_0007? А произойдёт вот что:
Присвоение более не будет атомарным: с этого момента, внешний наблюдатель заметит массив в неинициализированном состоянии, без элементов. Именно этим и занимаются строки IL_0008 и IL_0009. Т.о. приведённый в самом начале код не эквивалентен конструкции:
А скорее представляет из себя нечто вроде этого:
Хотя реализация избегает создания двух локальных переменных. Это двигает нас к двум заумным строчкам кода:
Но ничего сложного и/или страшного в этом нет. По сути, мы наблюдаем вызов RuntimeHelpers.InitializeArray, который заполняет поле, токен которого помещён в стек на IL_0008, массивом, ссылка на который находится в вершине стека после выполнения IL_0007. Значение токена соотвествует картинке ниже:
На деле, выделенная линия являет собой статично поле в приватном и, очевидно, сгенерированном компилятором, классе с очевидно непроизносимым названием. Пара моментов, на которые следует обратить внимание. Во-первых, этот класс имеет вложенный класс, названный __StaticArrayInitTypeSize=12. Он являет собой массив фактическим размером в 12 байт (по 4 байта на каждый элемент System.Int32, размер каждого равен 4 байта, итого 12). Во-вторых, следует заметить что тип наследует System.ValueType (я всерьёз надеюсь на то, что читатели знакомы с судьбой экземпляров значимых типов после их создания в стеке, так что не будем на этом застрять внимания — прим. автора.). Но каким образом тип получает те самые 12 байт? Очевидно что просто подсунуть имя недостаточно для того, чтобы clr выделила необходимое количество памяти, так что если вы посмотрите на реализацию через ILDASM вы увидите вот что:
Директива .size как-бы говорит и нам, и clr о том, что необходимо выделить блок памяти в 12 байт в момент создания экземпляра этого типа. Если вам любопытно о роли директивы .pack, то суть проста: эта директива указывает выравнивание по указанной степени двойки (поддерживаются только значения от 2 до 128 (при значении 1 выравнивание, очевидно, отсутствует — прим. перев)). Необходимо для COM-совместимости. Вернёмся к полю:
Тип довольно простой, несмотря на то, что имя довольно длинное из-за вложенности типов. В нашем случае, '$$method0x6000001-1' это имя поля. Но самое интересное начинается после "at". Это т.н. data-label, который, в свою очередь, является куском данных где-то в PE-файле на данном смещении. Непосредственно в ILADSM вы увидите нечто подобное:
Это и есть обьявление data label, которое является, как уже видно, последовательностью байт конечного массива в little-endian. Теперь мы должны понимать как работает InitailizeArray:
Передаётся экземпляр массива (мы уже создали его командами IL_0001,IL_0002) и указатель на поле, указанное после ключевого слова "at", в которое завёрнуты данные массива. Т.о. среда исполнения способна посчитать необходимое количество байт для чтения по заданному адресу, конструируя таким образом массив. В свою очередь, смысл значения I_00002050 не являет собой никакой загадки — это преобыкновеннейший RVA. Вы можете в этом убедиться, используя dumpbin:
Но есть не менее занятная деталь: компилятор переиспользует тип __StaticArrayInitTypeSize когда массивы занимают одинаковое количество места в памяти. Т.о. листинг:
Заставляет компилятор использовать один и тот-же тип, ибо все массивы в памяти занимают по 32 байта:
Так-же, для массивов размером в 1 и 2 элемента, будет генерировать такой IL код:
А вот, собственно, и тот самый фокус с двумя локальными переменными: одна из них является временной, в которую и помещаются значения по мере заполнения массива, после чего ссылка на массив передаётся основной переменной. Причины же такого подхода (с отдельным методом для заполнения массива) очевидны: в случае наивной реализации мы бы имели по 4 команды на каждый элемент, что увеличивало бы обьём кода построения массива линейно пропорционально размеру массива, вместо этого обьём кода константен.
p.s. В статье приведено поведение компиляторов C# 2.0 и 3.0 версий от Microsoft. Поведение кода, сгенерированного компиляторами других версий или компиляторов от сторонних разработчиков (например, Mono), может отличаться от приведённого в статье.
int[] ints = new int[3] { 1,2,3 };//А если уж вдруг и не была известна, то отныне и впредь уж точно
Вполне логично было-бы ожидать превращение этой конструкции в нечто подобное:
int[] ints = new int[3];
ints[0] = 1;
ints[1] = 2;
ints[2] = 3;
Увы и ах, на деле орех гораздо более морщинист, чем кажется с первого взгляда, и имеются некоторые тонкости, на которые будет указано позже. А до тех пор, наденем ношеную «IL freak» майку (у кого имеется) и погрузимся в недра реализации.
В конечном итоге, первая конструкция превратится компилятором в такую вот загогулину:
Что за диво в моём саду? Что это за <PrivateImplementationDetails>blablabla ерундовина? Прежде чем я расскажу что к чему, давайте бросим наш взгляд на Q::Main, где я указал значение в вершине стека перед каждой строкой кода:
.method private hidebysig static void Main() cil managed
{
.entrypoint
// Code size 20 (0x14)
.maxstack 3
.locals init (int32[] V_0)
IL_0000: nop
// {}
IL_0001: ldc.i4.3
// {3}
IL_0002: newarr [mscorlib]System.Int32
// {&int[3]}
IL_0007: dup
// {&int[3], &int[3]}
IL_0008: ldtoken field valuetype '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'::'$$method0x6000001-1'
// {&int[3], &int[3], #'$$method0x6000001-1'}
IL_000d: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array,
valuetype [mscorlib]System.RuntimeFieldHandle)
// {&int[3]}
IL_0012: stloc.0
// {}
IL_0013: ret
} // end of method Q::Main
Давайте теперь проведём построчный анализ:
IL_0001 и IL_0002 — создаётся новый массив типа System.Int32 и размерностью 3.
На IL_0007 нам попадается первый сюрприз в виде дублированной ссылки на массив. Почему? Предположим, что на IL_0008 и IL_0009 происходит инициализация массива (совсем скоро к этому месту мы вернёмся). А теперь посмотрим на IL_0012, где значение в вершине стека — опять массив — присвоен локальной переменной с индексом 0, т.е. переменной ints. А что если мы присвоим значение переменной ints на IL_0007? А произойдёт вот что:
ldc.i4.3
newarr [mscorlib]System.Int32
stloc.0 //внимание сюда
ldloc.0 //и сюда
ldtoken field valuetype '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'::'$$method0x6000001-1'
call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array,
valuetype [mscorlib]System.RuntimeFieldHandle)
Присвоение более не будет атомарным: с этого момента, внешний наблюдатель заметит массив в неинициализированном состоянии, без элементов. Именно этим и занимаются строки IL_0008 и IL_0009. Т.о. приведённый в самом начале код не эквивалентен конструкции:
nt[] ints = new int[3];
ints[0] = 1;
ints[1] = 2;
ints[2] = 3;
А скорее представляет из себя нечто вроде этого:
int[] t = new int[3];
t[0] = 1;
t[1] = 2;
t[2] = 3;
int[] ints = t;
Хотя реализация избегает создания двух локальных переменных. Это двигает нас к двум заумным строчкам кода:
IL_0008: ldtoken field valuetype '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'::'$$method0x6000001-1'
IL_000d: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
Но ничего сложного и/или страшного в этом нет. По сути, мы наблюдаем вызов RuntimeHelpers.InitializeArray, который заполняет поле, токен которого помещён в стек на IL_0008, массивом, ссылка на который находится в вершине стека после выполнения IL_0007. Значение токена соотвествует картинке ниже:
На деле, выделенная линия являет собой статично поле в приватном и, очевидно, сгенерированном компилятором, классе с очевидно непроизносимым названием. Пара моментов, на которые следует обратить внимание. Во-первых, этот класс имеет вложенный класс, названный __StaticArrayInitTypeSize=12. Он являет собой массив фактическим размером в 12 байт (по 4 байта на каждый элемент System.Int32, размер каждого равен 4 байта, итого 12). Во-вторых, следует заметить что тип наследует System.ValueType (я всерьёз надеюсь на то, что читатели знакомы с судьбой экземпляров значимых типов после их создания в стеке, так что не будем на этом застрять внимания — прим. автора.). Но каким образом тип получает те самые 12 байт? Очевидно что просто подсунуть имя недостаточно для того, чтобы clr выделила необходимое количество памяти, так что если вы посмотрите на реализацию через ILDASM вы увидите вот что:
.class private auto ansi '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'
extends [mscorlib]System.Object
{
.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.class explicit ansi sealed nested private '__StaticArrayInitTypeSize=12'
extends [mscorlib]System.ValueType
{
.pack 1
.size 12 //внимание сюда и тут
} // end of class '__StaticArrayInitTypeSize=12'
.field static assembly valuetype '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'/'__StaticArrayInitTypeSize=12' '$$method0x6000001-1' at I_00002050
} // end of class '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'
Директива .size как-бы говорит и нам, и clr о том, что необходимо выделить блок памяти в 12 байт в момент создания экземпляра этого типа. Если вам любопытно о роли директивы .pack, то суть проста: эта директива указывает выравнивание по указанной степени двойки (поддерживаются только значения от 2 до 128 (при значении 1 выравнивание, очевидно, отсутствует — прим. перев)). Необходимо для COM-совместимости. Вернёмся к полю:
.field static assembly valuetype '<PrivateImplementationDetails>{8C802ECE-B24C-4A20-AE34-9303FE2DD066}'/'__StaticArrayInitTypeSize=12' '$$method0x6000001-1' at I_00002050
Тип довольно простой, несмотря на то, что имя довольно длинное из-за вложенности типов. В нашем случае, '$$method0x6000001-1' это имя поля. Но самое интересное начинается после "at". Это т.н. data-label, который, в свою очередь, является куском данных где-то в PE-файле на данном смещении. Непосредственно в ILADSM вы увидите нечто подобное:
.data cil I_00002050 = bytearray (
01 00 00 00 02 00 00 00 03 00 00 00)
Это и есть обьявление data label, которое является, как уже видно, последовательностью байт конечного массива в little-endian. Теперь мы должны понимать как работает InitailizeArray:
call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
Передаётся экземпляр массива (мы уже создали его командами IL_0001,IL_0002) и указатель на поле, указанное после ключевого слова "at", в которое завёрнуты данные массива. Т.о. среда исполнения способна посчитать необходимое количество байт для чтения по заданному адресу, конструируя таким образом массив. В свою очередь, смысл значения I_00002050 не являет собой никакой загадки — это преобыкновеннейший RVA. Вы можете в этом убедиться, используя dumpbin:
Но есть не менее занятная деталь: компилятор переиспользует тип __StaticArrayInitTypeSize когда массивы занимают одинаковое количество места в памяти. Т.о. листинг:
int[] ints = { 1, 2, 3, 4, 5, 6, 7, 8 };
long[] longs = { 1, 2, 3, 4 };
byte[] bytes = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 };
Заставляет компилятор использовать один и тот-же тип, ибо все массивы в памяти занимают по 32 байта:
.field static assembly valuetype '<PrivateImplementationDetails>{AA6C9D77-5FAD-47E0-8B55-1D8739074F1F}'/'__StaticArrayInitTypeSize=32' '$$method0x6000001-1' at I_00002050
.field static assembly valuetype '<PrivateImplementationDetails>{AA6C9D77-5FAD-47E0-8B55-1D8739074F1F}'/'__StaticArrayInitTypeSize=32' '$$method0x6000001-2' at I_00002070
.field static assembly valuetype '<PrivateImplementationDetails>{AA6C9D77-5FAD-47E0-8B55-1D8739074F1F}'/'__StaticArrayInitTypeSize=32' '$$method0x6000001-3' at I_00002090
.data cil I_00002050 = bytearray (
01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00
05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00)
.data cil I_00002070 = bytearray (
01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00
03 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00)
.data cil I_00002090 = bytearray (
01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10
11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 20)
Так-же, для массивов размером в 1 и 2 элемента, будет генерировать такой IL код:
.method private hidebysig static void Main() cil managed
{
.entrypoint
// Code size 19 (0x13)
.maxstack 3
.locals init (int32[] V_0,
int32[] V_1)
IL_0000: nop
IL_0001: ldc.i4.2
IL_0002: newarr [mscorlib]System.Int32
IL_0007: stloc.1
//
// V_1[0] = 1
//
IL_0008: ldloc.1
IL_0009: ldc.i4.0
IL_000a: ldc.i4.1
IL_000b: stelem.i4
//
// V_1[1] = 2
//
IL_000c: ldloc.1
IL_000d: ldc.i4.1
IL_000e: ldc.i4.2
IL_000f: stelem.i4
//
// V_0 = V_1
//
IL_0010: ldloc.1
IL_0011: stloc.0
IL_0012: ret
} // end of method Q::Main
А вот, собственно, и тот самый фокус с двумя локальными переменными: одна из них является временной, в которую и помещаются значения по мере заполнения массива, после чего ссылка на массив передаётся основной переменной. Причины же такого подхода (с отдельным методом для заполнения массива) очевидны: в случае наивной реализации мы бы имели по 4 команды на каждый элемент, что увеличивало бы обьём кода построения массива линейно пропорционально размеру массива, вместо этого обьём кода константен.
p.s. В статье приведено поведение компиляторов C# 2.0 и 3.0 версий от Microsoft. Поведение кода, сгенерированного компиляторами других версий или компиляторов от сторонних разработчиков (например, Mono), может отличаться от приведённого в статье.