Почему компилятор c # в некоторых случаях выдает newobj / stobj, а не 'call instance .ctor' для инициализации структуры

вот какая-то тестовая программа на С #:

using System;


struct Foo {
    int x;
    public Foo(int x) {
        this.x = x;
    }
    public override string ToString() {
        return x.ToString();
    }
}

class Program {
    static void PrintFoo(ref Foo foo) {
        Console.WriteLine(foo);
    }
    
    static void Main(string[] args) {
        Foo foo1 = new Foo(10);
        Foo foo2 = new Foo(20);
        
        Console.WriteLine(foo1);
        PrintFoo(ref foo2);
    }
}

и вот дизассемблированная скомпилированная версия метода Main:

.method private hidebysig static void Main (string[] args) cil managed {
    // Method begins at RVA 0x2078
    // Code size 42 (0x2a)
    .maxstack 2
    .entrypoint
    .locals init (
        [0] valuetype Foo foo1,
        [1] valuetype Foo foo2
    )

    IL_0000: ldloca.s foo1
    IL_0002: ldc.i4.s 10
    IL_0004: call instance void Foo::.ctor(int32)
    IL_0009: ldloca.s foo2
    IL_000b: ldc.i4.s 20
    IL_000d: newobj instance void Foo::.ctor(int32)
    IL_0012: stobj Foo
    IL_0017: ldloc.0
    IL_0018: box Foo
    IL_001d: call void [mscorlib]System.Console::WriteLine(object)
    IL_0022: ldloca.s foo2
    IL_0024: call void Program::PrintFoo(valuetype Foo&)
    IL_0029: ret
} // end of method Program::Main

Я не понимаю, почему было создано newobj / stobj вместо простого вызова .ctor? Чтобы сделать его более загадочным, newobj + stobj оптимизирован jit-compiler в 32-битном режиме для одного вызова ctor, но не в 64-битном режиме ...

ОБНОВЛЕНИЕ:

Чтобы прояснить мое замешательство, ниже приведены мои ожидания.

выражение объявления типа значения, например

Foo foo = new Foo(10)

должен быть скомпилирован через

call instance void Foo::.ctor(int32)

выражение объявления типа значения, например

Foo foo = default(Foo)

должен быть скомпилирован через

initobj Foo

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

try{
    //foo invisible here
    ...
    Foo foo = new Foo(10);
    //we never get here, if something goes wrong
}catch(...){
    //foo invisible here
}finally{
    //foo invisible here
}

выражение присваивания вроде

foo = new Foo(10); // foo declared somewhere before

должен быть скомпилирован примерно так:

.locals init (
    ...
    valuetype Foo __temp,
    ...
)

...
ldloca __temp
ldc.i4 10
call instance void Foo::.ctor(int32)
ldloc __temp
stloc foo
...

так я понимаю, что говорит спецификация С #:

7.6.10.1 Выражения создания объекта

...

Обработка во время выполнения выражения-создания-объекта формы new T (A), где T - это тип-класс или тип-структура, а A - необязательный список-аргументов, состоит из следующих шагов:

...

Если T является структурным типом:

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

  • Конструктор экземпляра вызывается в соответствии с правилами вызова функций-членов (§7.5.4). Ссылка на вновь выделенный экземпляр автоматически передается в конструктор экземпляра, и к экземпляру можно получить доступ из этого конструктора как this.

я хочу сделать акцент на выделении временной локальной переменной. и, как я понимаю, инструкция newobj предполагает создание объекта в куче ...

Зависимость создания объекта от того, как он использовался, в данном случае меня расстраивает, поскольку foo1 и foo2 выглядят для меня одинаково.


person andrey.ko    schedule 04.03.2013    source источник
comment
Любопытство - это нормально, но это требует серьезных усилий. Загрузите SSCLI20 и посмотрите исходный код компилятора C #, метод csharp / sscomp / ilgen.cpp, ILGENREC :: genCall (). Думаю, что-то связано с возможным алиасингом.   -  person Hans Passant    schedule 04.03.2013
comment
@HansPassant: Вы правы; оптимизатор C # пропускает исключение копирования, потому что он обеспокоен возможным сглаживанием. В данном конкретном коде это слишком консервативное предположение; здесь фактически нет проблемы сглаживания. Но вместо того, чтобы выполнять этот анализ, компилятор C # обнаруживает ссылку и рано отказывается от оптимизации исключения.   -  person Eric Lippert    schedule 04.03.2013
comment
Re: ваше обновление: почему вы считаете, что инструкция newobj выделяет память в куче для типов значений? Это не задокументировано. Я не понимаю, почему вы можете поверить в то, что прямо противоречит документации.   -  person Eric Lippert    schedule 05.03.2013
comment
@Eric Lippert: да, действительно, спецификация CLI утверждает, что newobj для типа значения должен выделять память в стеке, спасибо, что указали на это   -  person andrey.ko    schedule 05.03.2013


Ответы (2)


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

https://ericlippert.com/2010/10/11/debunking-another-myth-about-value-types/

Хорошо, теперь, когда вы прочитали, что вы знаете, что спецификация C # утверждает, что создание экземпляра структуры имеет следующую семантику:

  • Создайте временную переменную для хранения значения структуры, инициализированной значением структуры по умолчанию.
  • Передайте ссылку на эту временную переменную в качестве this конструктора

Итак, когда вы говорите:

Foo foo = new Foo(123);

Это эквивалентно:

Foo foo;
Foo temp = default(Foo);
Foo.ctor(ref temp, 123); // "this" is a ref to a variable in a struct.
foo1 = temp;

Теперь вы можете спросить, зачем тратить время на выделение временного объекта, если у нас уже есть переменная foo, которая может быть this:

Foo foo = default(Foo);
Foo.ctor(ref foo, 123);

Эта оптимизация называется копированием. Компилятору C # и / или джиттеру разрешается выполнять исключение копии, когда они определяют с помощью своей эвристики, что это всегда невидимо. В редких случаях исключение копирования может вызвать заметное изменение в программе, и в этих случаях нельзя использовать оптимизацию. Например, предположим, что у нас есть структура из пары целых чисел:

Pair p = default(Pair);
try { p = new Pair(10, 20); } catch {}
Console.WriteLine(p.First);
Console.WriteLine(p.Second);

Мы ожидаем, что p здесь либо (0, 0), либо (10, 20), но никогда (10, 0) или (0, 20), даже если ctor выбрасывает на полпути. То есть, либо p было присвоено полностью сконструированное значение, либо p не было никаких изменений вообще. Копирование не может быть выполнено здесь; мы должны создать временное, передать временное в ctor, а затем скопировать временное в p.

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

Pair p = default(Pair);
p = new Pair(10, 20, ref p);
Console.WriteLine(p.First);
Console.WriteLine(p.Second);

Если компилятор C # выполняет исключение копирования, тогда this и ref p являются псевдонимами для p, что заметно отличается от того, если this является псевдонимом для временного! Контроллер мог заметить, что изменения в this вызывают изменения в ref p, если они являются псевдонимом одной и той же переменной, но не заметит этого, если они будут использовать псевдонимы для разных переменных.

Эвристика компилятора C # решает выполнить исключение копирования на foo1, но не на foo2 в вашей программе. Это значит увидеть, что в вашем методе есть ref foo2, и сразу же принять решение сдаться. Он мог бы провести более сложный анализ, чтобы определить, что это не в одной из этих безумных ситуаций с псевдонимом, но это не так. Самый дешевый и простой способ - просто пропустить оптимизацию, если есть хоть какой-то шанс, пусть даже маловероятный, что может возникнуть ситуация сглаживания, которая делает элизию видимой. Он генерирует newobj код и позволяет джиттеру решать, хочет ли он сделать исключение.

Что касается джиттера: у 64-битного и 32-битного джиттера совершенно разные оптимизаторы. По-видимому, один из них решает, что может ввести исключение копирования, которого не сделал компилятор C #, а другой - нет.

person Eric Lippert    schedule 04.03.2013
comment
спасибо, я нашел вашу статью очень познавательной, но я думаю, что должно быть различие между выражением присваивания и выражением объявления, и в последнем случае я не вижу никаких опасных случаев, я пишу обновление своего вопроса, как мне кажется - person andrey.ko; 05.03.2013
comment
Опасных случаев нет. Компилятор просто не обнаруживает оптимизацию, которую он мог бы выполнить. Компилятор не требуется для генерации оптимального кода, он нужен только для генерации правильного кода. - person Eric Lippert; 05.03.2013

Это потому, что переменные foo1 и foo2 разные.

Переменная foo1 - это просто значение, а переменная foo2 является одновременно значением и указателем, поскольку она используется в вызове с ключевым словом ref.

Когда переменная foo2 инициализируется, указатель устанавливается так, чтобы указывать на значение, и конструктор вызывается со значением указателя, а не с адресом значения.

Если вы настроили два PrintFoo метода с той лишь разницей, что в одном есть ключевое слово ref, и вызываете их с одной переменной каждый:

Foo a = new Foo(10);
Foo b = new Foo(20);
PrintFoo(ref a);
PrintFoo(b);

Если декомпилировать сгенерированный код, разница между переменными видна:

&Foo a = new Foo(10);
Foo b = new Foo(20);
Program.PrintFoo(ref a);
Program.PrintFoo(b);
person Guffa    schedule 04.03.2013
comment
Я не уверен, что слежу за твоим ходом мыслей. В сгенерированном IL в исходном вопросе типы локальных слотов ноль и один - это Foo; ни один из них не является управляемым указателем на Foo. Управляемый указатель создается кодом операции загрузки локального адреса. - person Eric Lippert; 04.03.2013
comment
@EricLippert: Да, но он явно проявляется как другой тип данных в декомпилированном коде, и если вы посмотрите на сгенерированный код, в стеке выделено два слота для foo2 и только один для foo1. - person Guffa; 04.03.2013
comment
Я предполагаю, что я имею в виду: вопрос в том, почему компилятор C # создает определенную последовательность IL. Обращение к тому, что производит какой-либо сторонний декомпилятор, учитывая, что IL не объясняет, почему компилятор C # выбирает сгенерировать этот IL в первую очередь. Суть вопроса в том, что нет необходимости инициализировать foo1 и foo2 по-разному. - person Eric Lippert; 04.03.2013