Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilationC#

Decompiling C# 12: collection expressions and primary constructors

Collection expressions and primary constructors look like single syntactic ideas, but the compiler lowers them to very different IL depending on the target type and whether a parameter is captured. Here is exactly what `[1, 2, 3]`, spreads and a primary constructor emit, and where a decompiler can fold the sugar back versus where it can only show the lowered form.

C# 12 added two features that read like single ideas in source but fan out into several very different shapes in IL. A collection expression — [1, 2, 3], or [.. first, .. second] — has no opcode of its own; the compiler chooses a lowering from the target type, and the choice ranges from a plain array to a stack-allocated buffer to a call into a builder method. A primary constructor on a class or struct looks structurally like a record’s, but lowers to far less. Walking both lowerings precisely explains what a decompiler such as Glass.NET can fold back into the sugar, and where it can only honestly show the lowered form.

Collection expressions: one syntax, several lowerings

The key fact about [1, 2, 3] is that it is target-typed. The same three characters compile to different IL depending on what they are assigned to.

Array target. int[] a = [1, 2, 3]; is exactly an array initializer. For all-constant primitive elements the compiler stores the bytes in a field in <PrivateImplementationDetails> and calls RuntimeHelpers.InitializeArray:

ldc.i4.3
newarr     [System.Runtime]System.Int32
dup
ldtoken    field ... '<PrivateImplementationDetails>'::...    // the 1,2,3 blob
call       void RuntimeHelpers::InitializeArray(Array, RuntimeFieldHandle)

For non-constant elements it emits newarr followed by stelem per slot. Either way this is byte-for-byte what new int[] { 1, 2, 3 } produces, so a decompiler shows an array creation — and may render it as [1, 2, 3] or as new int[] { ... }; both are faithful because the IL is identical.

List<T> target. List<int> a = [1, 2, 3]; becomes a construction plus Add calls (with a capacity preset when the length is known):

newobj     instance void List`1<int32>::.ctor()
dup
ldc.i4.1
callvirt   instance void List`1<int32>::Add(!0)
dup
ldc.i4.2
callvirt   instance void List`1<int32>::Add(!0)
...

This is the single most important ambiguity in the feature: a collection expression to List<T> lowers to the same IL as the classic collection initializer new List<int> { 1, 2, 3 }. There is no marker that separates them. A decompiler therefore cannot know which you wrote and will print one of the two — most show the collection-initializer form because it is the older, more widely compatible syntax. Nothing is lost; the surface syntax simply may not match your source.

Span<T>/ReadOnlySpan<T> target. This is where collection expressions earn their keep, and where the IL stops looking like anything you could hand-write. For a ReadOnlySpan<int> of constants, the compiler stores the data in a readonly RVA field and builds the span with RuntimeHelpers.CreateSpan<int> — no heap allocation at all. For a Span<T> of non-constant elements, it synthesizes a buffer type decorated with [InlineArray(N)], stack-allocates it, writes each element, and takes a span over it:

.locals ( valuetype '<>y__InlineArray3`1'<int32> buffer )
// zero-init buffer, then for each element:
ldloca.s   buffer
call       !0& InlineArrayElementRef<...>(ref buffer, index)
stind.i4                                   // buffer[i] = value
// then:
ldloca.s   buffer
call       Span<int> InlineArrayAsSpan<...>(ref buffer, 3)

The synthesized <>y__InlineArrayN<T> type has an unspeakable name and the [InlineArray] attribute, which together are a reliable fingerprint. A C#-12-aware decompiler recognizes the pattern and folds it back to [...]; a decompiler that does not will surface the inline-array struct and the span call verbatim — correct, but not pretty.

[CollectionBuilder] target. Types such as ImmutableArray<T> advertise a factory with [CollectionBuilder(typeof(ImmutableArray), "Create")]. The compiler materializes a ReadOnlySpan<T> of the elements (using the same inline-array or RVA technique as above) and passes it to the builder:

// ... build ReadOnlySpan<int> of {1,2,3} as above ...
call       valuetype ImmutableArray`1<int32> ImmutableArray::Create<int32>(ReadOnlySpan`1<int32>)

The call to the designated builder method, fed by a span the method never saw constructed in source, is the fingerprint. A decompiler that knows the convention reconstructs ImmutableArray<int> x = [1, 2, 3];; otherwise it shows the explicit ImmutableArray.Create(span) call.

[1, 2, 3]target-typedtarget decidesint[]newarr / InitializeArrayList<T>new List + Add, Add, …Span<T>stackalloc inline arrayImmutableArray<T>[CollectionBuilder] callIL shapesper targetfold back[1, 2, 3]recognized

Spreads: length first, then copies

A spread element (..) inside a collection expression flattens another sequence into this one. The lowering splits on whether the operand’s count is cheap to learn.

For int[] all = [.. first, .. second]; where both operands are arrays, the compiler sums the lengths, allocates the destination once, and copies each operand in with an index cursor — no intermediate list:

ldloc first
ldlen                        // first.Length
ldloc second
ldlen                        // second.Length
add                          // total
newarr    int32              // allocate destination once
// first.CopyTo(dest, 0); second.CopyTo(dest, first.Length);

The same shape applies to any operand exposing a Length/Count, with CopyTo or Array.Copy moving the bulk. When an operand is a bare IEnumerable<T> whose count is unknown, the compiler cannot pre-size, so it falls back to iterating that operand with a foreach and adding to a growable builder. A decompiler reconstructs [.. a, .. b] when it recognizes the sum-allocate-copy skeleton; when the fallback iteration is involved, or the copies were reordered by optimization, it may instead show the explicit length arithmetic and CopyTo calls, which is the honest reading of that IL.

Primary constructors: a constructor, and capture fields only if needed

A primary constructor moves parameters onto the type declaration:

public class Logger(ILog log, string prefix)
{
    public void Info(string m) => log.Write(prefix + m);
}

The lowering is deliberately minimal. The compiler emits one ordinary instance constructor taking (ILog log, string prefix). Then — and this is the subtle part — it synthesizes a private field only for the parameters that a member actually uses outside the constructor and field initializers. Here both log and prefix are read inside Info, so both are captured and get backing fields with unspeakable, compiler-generated names of the form <parameter>P:

.field private initonly class ILog '<log>P'
.field private initonly string '<prefix>P'
.method ... .ctor(class ILog log, string prefix)
  ldarg.0
  call   instance void object::.ctor()
  ldarg.0  ldarg.1  stfld  ILog Logger::'<log>P'       // capture log
  ldarg.0  ldarg.2  stfld  string Logger::'<prefix>P'  // capture prefix
  ret

Info then reads this.<log>P and this.<prefix>P. If a parameter were used only to initialize a field or property — public string Prefix = prefix; — it would not get a capture field; the constructor would read it once to set Prefix and discard it. Unused parameters generate nothing at all. And a base call, class Audited(int id) : Base(id), simply passes the argument through in the synthesized constructor.

The contrast with records is the whole point. A record’s primary constructor generates public init-only properties, value equality, GetHashCode, ToString/PrintMembers, a clone method and Deconstruct — a rich, recognizable fingerprint. A class primary constructor generates none of that. It produces a constructor and, at most, some private capture fields. That minimalism is exactly why it is hard to recover: a constructor that assigns parameters to private readonly fields is precisely what people have hand-written for decades.

So a decompiler reconstructs class Logger(ILog log, string prefix) only when it recognizes the <parameter>P capture-field naming convention and the constructor’s one-to-one parameter-to-field assignment. When it does, the primary-constructor syntax comes back cleanly. When it does not — or when the captured fields were renamed by obfuscation, or you genuinely wrote the explicit constructor — it shows the equivalent class Logger { private readonly ILog log; … public Logger(ILog log, string prefix) { … } }. The two forms compile to the same IL, so the explicit rendering is not a failure; it is the faithful reading when the sugar’s one distinguishing marker is absent.

What to take from the decompiled view

Both features reinforce the same lesson about reading compiled .NET: the surface syntax is a choice over the IL, not a property of it. A collection expression is whichever of four lowerings its target demanded, and only some of those carry a fingerprint strong enough to rebuild the brackets; to List<T> it is literally indistinguishable from a collection initializer. A class primary constructor is a constructor plus capture fields named <x>P, with none of the record machinery that makes records easy to spot. A good decompiler folds back what it can prove and shows the lowered code where it cannot — and the IL view is where you confirm which happened. Open the assembly in Glass.NET, switch a method to IL, and watch [1, 2, 3] become a newarr or an inline-array buffer. For the full workflow, see how to decompile a .NET DLL to C#.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.