DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling params collections: where did my array go?

params is the oldest convenience in C#: write string.Format("{0}+{1}", a, b) and the compiler quietly builds the object[] that Format actually takes. For twenty years that desugaring had exactly one shape — a newarr, a run of stelem, and the call — and every decompiler learned to fold it back into loose arguments.

C# 13 generalised params to any collection type, and the interesting member of the new family, params ReadOnlySpan<T>, allocates nothing on the heap. The arguments go into a stack-allocated buffer and a span is wrapped around them. The method signature barely changes; the entire difference is at the call site. A decompiler that still only knows the newarr shape will show you a compiler-generated buffer type you can't name and a span constructor where the source had a plain call.

The signature barely moves; the call site moves a lot

Consider a logging method migrated from the classic form to the span form:

// Classic — unchanged since C# 1
public static void LogOld(params object[] args) { /* ... */ }

// C# 13 — one word of the type changes
public static void LogNew(params ReadOnlySpan<object> args) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Both are called identically: LogNew(id, name, DateTime.Now). But the IL the caller emits is completely different. The classic form is the familiar heap array:

// LogOld(id, name, now)
ldc.i4.3
newarr     [System.Runtime]System.Object     // allocate object[3] on the heap
dup
ldc.i4.0
ldarg.0
stelem.ref                                     // args[0] = id
dup
ldc.i4.1
ldarg.1
stelem.ref                                     // args[1] = name
// ... args[2] = now ...
call       void Log::LogOld(object[])
Enter fullscreen mode Exit fullscreen mode

The span form allocates no array. The compiler emits a compiler-generated inline-array type — a struct carrying [InlineArray(3)], the same mechanism C# uses for inline arrays and stackalloc — as a local in the caller's frame, stores each argument into it through the inline-array intrinsics, builds a ReadOnlySpan<object> over the buffer, and passes that:

// LogNew(id, name, now)
.locals init ([0] valuetype '<>y__InlineArray3`1'<object> buffer)
ldloca.s   buffer
initobj    valuetype '<>y__InlineArray3`1'<object>
// element 0
ldloca.s   buffer
ldc.i4.0
call       !0& InlineArrayElementRef<'<>y__InlineArray3`1'<object>,object>(ref, int)
ldarg.0
stind.ref                                      // buffer[0] = id
// ... buffer[1] = name, buffer[2] = now via the same intrinsic ...
ldloca.s   buffer
ldc.i4.3
call       ReadOnlySpan<object> InlineArrayAsReadOnlySpan<...>(ref, int)
call       void Log::LogNew(valuetype ReadOnlySpan`1<object>)
Enter fullscreen mode Exit fullscreen mode

No newarr, no heap object, no GC pressure. The buffer is a value type on the stack; it dies with the frame. Every bit of the difference is in the caller — the callee just received a span.

The three shapes a decompiler must know

params now has a small family of lowerings, chosen by the parameter's collection type. A decompiler reads the call site to tell them apart and must fold all of them back to loose arguments.

params T[] — the classic path, unchanged. Parameter type is an array, so the caller emits newarr + stelem + call. A decompiler folds it exactly as it always has. Nothing new here, and that's the point: old code keeps its old IL.

params ReadOnlySpan<T> / params Span<T> — the zero-alloc path. The <>y__InlineArrayN buffer, the element stores through InlineArrayElementRef, the InlineArrayAsReadOnlySpan call, then the target call. The recognition rule is: a compiler-generated inline-array local, populated element-by-element and wrapped in a span that immediately feeds a params call, is loose arguments. Render it as LogNew(id, name, now), not as a visible buffer and a span constructor — the <>y__InlineArray3 type can't be named in C# source, so emitting it literally produces code that won't recompile.

params List<T> / params IEnumerable<T> — the collection-builder path. For a non-span collection, the compiler reuses the collection-expression machinery: it builds the collection (a List<T> via its add path, or a type's [CollectionBuilder] method) and passes the finished collection. The decompiler must recognise the builder sequence and fold it back to loose arguments too.

All three collapse to the same source — foo(a, b, c) — so the decompiler's job is to erase the mechanism and recover the call.

The tell: a span fed by newarr

Here's the subtle case worth catching. Even a params ReadOnlySpan<T> parameter doesn't always get the stack buffer. The compiler stack-allocates only when it can prove the span won't escape; on the paths it can't, it falls back to a heap array and wraps the span around that. So you'll occasionally see:

ldc.i4.3
newarr     [System.Runtime]System.Object       // heap array, not an inline buffer
dup / stelem.ref ...                            // fill it
newobj     instance void ReadOnlySpan<object>::.ctor(!0[])   // span over the array
call       void Log::LogNew(valuetype ReadOnlySpan`1<object>)
Enter fullscreen mode Exit fullscreen mode

A newarr feeding a ReadOnlySpan constructor, rather than a <>y__InlineArray buffer, is the fingerprint of the fallback path. It's still a correct params call and still folds to LogNew(id, name, now) in the decompiled source — but it tells you this particular call did allocate, which is exactly the thing the span form was chosen to avoid. When you're reading decompiled output to understand an allocation profile, that distinction is the whole story: same source line, different cost, and only the IL shows which one you got.

What a good decompiler emits

Put the forms side by side and the obligation is clear. For the zero-alloc call, a decompiler that stops at the metal shows:

// Wrong: literal, and won't recompile — <>y__InlineArray3 isn't nameable
<>y__InlineArray3<object> buffer = default;
InlineArrayElementRef<...>(ref buffer, 0) = id;
// ...
LogNew(InlineArrayAsReadOnlySpan<...>(ref buffer, 3));
Enter fullscreen mode Exit fullscreen mode

while recovering the author's intent yields:

// Right: the call the author actually wrote
LogNew(id, name, DateTime.Now);
Enter fullscreen mode Exit fullscreen mode

Glass.NET folds all three params shapes — the classic array, the inline-array span, and the collection-builder forms — back to a call with loose arguments, and keeps an explicit span or list argument explicit when the source genuinely passed one rather than loose values. The feature moved the interesting work from the method signature to the call site, so that's where a decompiler has to look. Recover the call, drop the buffer, and note the allocation tell when you need it.

See inline arrays and stackalloc for the buffer mechanism this reuses, and collection expressions for the builder path the non-span forms share.

Top comments (0)