Why c# compiler in some cases emits newobj/stobj rather than 'call instance .ctor' for struct initialization

First off, you should read my article on this subject. It does not address your specific scenario, but it has some good background information:

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

OK, so now that you've read that you know that the C# specification states that constructing an instance of a struct has these semantics:

  • Create a temporary variable to store the struct value, initialized to the default value of the struct.
  • Pass a reference to that temporary variable as the "this" of the constructor

So when you say:

Foo foo = new Foo(123);

That is equivalent to:

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

Now, you might ask why go through all the trouble of allocating a temporary when we already have a variable foo right there that could be this:

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

That optimization is called copy elision. The C# compiler and/or the jitter are permitted to perform a copy elision when they determine using their heuristics that doing so is always invisible. There are rare circumstances in which a copy elision can cause an observable change in the program, and in those cases the optimization must not be used. For example, suppose we have a pair-of-ints struct:

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

We expect that p here is either (0, 0) or (10, 20), never (10, 0) or (0, 20), even if the ctor throws halfway through. That is, either the assignment to p was of the completely constructed value, or no modification was made to p at all. The copy elision cannot be performed here; we have to make a temporary, pass the temporary to the ctor, and then copy the temporary to p.

Similarly, suppose we had this insanity:

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

If the C# compiler performs the copy elision then this and ref p are both aliases to p, which is observably different than if this is an alias to a temporary! The ctor could observe that changes to this cause changes to ref p if they alias the same variable, but would not observe that if they aliased different variables.

The C# compiler heuristic is deciding to do the copy elision on foo1 but not foo2 in your program. It is seeing that there is a ref foo2 in your method and deciding right there to give up. It could do a more sophisticated analysis to determine that it is not in one of these crazy aliasing situations, but it doesn't. The cheap and easy thing to do is to just skip the optimization if there is any chance, however remote, that there could be an aliasing situation that makes the elision visible. It generates the newobj code and let the jitter decide whether it wants to make the elision.

As for the jitter: the 64 bit and 32 bit jitters have completely different optimizers. Apparently one of them is deciding that it can introduce the copy elision that the C# compiler did not, and the other one is not.

Tags:

C#

.Net

Il