Most C# features leave a shape in IL you can read back. Optional and named arguments are unusual because the interesting part is absence: an optional argument you omit, a name you write. Neither survives as itself. The default value is filed away in a metadata table the method body never mentions, and the name is gone the moment the compiler has matched it to a parameter. What a decompiler recovers, and what it quietly admits it cannot, makes these two features a good lesson in where compilation is lossy.
This post reads the metadata and IL for both, and shows how Glass.NET handles each.
An optional parameter: the default lives in the Constant table
Here is a method with two optional parameters and a call that omits them:
public static string Format(string value, int width = 0, char pad = ' ')
=> value.PadLeft(width, pad);
static void Use() => Format("hi"); // width and pad omitted
The default values are nowhere in Format's body — Format just uses width and pad as parameters. They live in metadata on the parameters themselves. In ILDasm's rendering:
.method public hidebysig static string Format(string value,
[opt] int32 width,
[opt] char pad) cil managed
{
.param [2] = int32(0x00000000)
.param [3] = char(0x0020)
...
}
[opt] is the Optional flag on each parameter, and .param [2] = int32(0x00000000) is how ILDasm prints a row of the Constant metadata table. That table is a three-column structure — a type tag, a parent (here the Param row), and a blob of the literal bytes — and it is the same table that backs const fields and enum members. The parameter row also carries the HasDefault flag, which is the pointer that says "go look in the Constant table for my parent." So three pieces encode one default: Optional on the param, HasDefault on the param, and the value in the Constant table.
Crucially, width = 0 and pad = ' ' appear in the signature metadata of Format, not in Use. So to decompile Format and print its signature, a decompiler must read the Constant table rows for parameters 2 and 3, decode int32(0) and char(0x0020), and render int width = 0, char pad = ' '. This is a metadata read, not an inference, and it works with no PDB because the Constant table is part of the assembly the runtime itself relies on.
The call site: defaults are substituted, not referenced
Now the subtle half. Look at what Use actually compiled to:
.method private hidebysig static void Use() cil managed
{
IL_0000: ldstr "hi"
IL_0005: ldc.i4.0 // width — the default, baked in
IL_0006: ldc.i4.s 32 // pad — ' ' is 0x20 = 32, baked in
IL_0008: call string Format(string, int32, char)
IL_000d: pop
IL_000e: ret
}
You wrote Format("hi"). The compiler pushed three arguments. The defaults were not fetched from the Constant table at run time — the compiler read them at compile time and emitted ldc.i4.0 and ldc.i4.s 32 directly into Use. The call is a perfectly ordinary three-argument call; nothing distinguishes the two literals from arguments a programmer typed.
This has a real consequence that is worth decompiling to see: because the default was copied into the caller, changing Format's default from 0 to 1 and shipping a new library does not change Use's behavior until Use is recompiled — it still pushes ldc.i4.0. A decompiler reading only the old caller cannot even tell the default has moved on. This is the classic "optional parameters are a source-compatibility, not binary-compatibility, feature" rule, and it is visible right here in the baked-in literal.
For the decompiler it creates an ambiguity it cannot fully resolve: ldc.i4.0 where width goes is consistent with both Format("hi") and Format("hi", 0). The IL is identical. Glass.NET resolves the callee, sees that the trailing literal arguments equal the parameters' defaults, and prefers the shorter Format("hi") as the most likely source — but it keeps an argument when dropping it would change overload resolution, and it shows all arguments when the callee is in an assembly it cannot resolve to read the defaults from. It prints the call that is a valid source for the IL, while being honest that omitted-versus-written is one thing compilation genuinely erases.
Named arguments, in order: nothing at all
Named arguments are a compile-time matching device. When they are already in declaration order, they leave no trace whatsoever:
Format(value: "hi", width: 4, pad: '.');
compiles to exactly the same ldstr / ldc.i4.4 / ldc.i4.s 46 / call as the positional form. The names were used only to bind each expression to a parameter; once bound, the compiler emits positional pushes. There is nothing in the assembly that says a name was written, so a decompiler cannot — and should not pretend to — reconstruct width: and pad:. Glass.NET prints the positional call, which is behaviorally identical.
Named arguments, out of order: reordering, and sometimes spilling
The one case where named arguments touch codegen is when they are written out of parameter order, because the stack must still be built in parameter order. There are two sub-cases, and they look different in IL.
If every out-of-order argument is side-effect-free — a constant, a local, a field read the compiler deems safe — the compiler simply pushes them in parameter order:
Format(width: 4, value: "hi"); // 'pad' omitted → default
IL_0000: ldstr "hi" // value pushed first — parameter order
IL_0005: ldc.i4.4 // width
IL_0006: ldc.i4.s 32 // pad default
IL_0008: call string Format(string, int32, char)
Note the ldstr "hi" comes first even though width: 4 was written first: the observable order does not matter for a constant and a literal, so the compiler quietly reorders to parameter order and the source form is unrecoverable.
But when evaluating out of written order would be observable — a side effect, a getter, an increment — C#'s left-to-right evaluation guarantee forces the compiler to spill into temporaries:
int n = 0;
Format(width: Next(ref n), value: Read(ref n)); // both have side effects, written out of order
.locals init ([0] int32 n, [1] int32 V_1, [2] string V_2)
IL_...: ldloca.s n
IL_...: call int32 Next(int32&)
IL_...: stloc.1 // V_1 = Next(ref n) — evaluated FIRST (written first)
IL_...: ldloca.s n
IL_...: call string Read(int32&)
IL_...: stloc.2 // V_2 = Read(ref n) — evaluated SECOND
IL_...: ldloc.2 // now push in PARAMETER order: value …
IL_...: ldloc.1 // … then width
IL_...: ldc.i4.s 32 // pad default
IL_...: call string Format(string, int32, char)
The two temporaries V_1 and V_2 are the fingerprint. They are created in written order (Next first, because width: was written first) and consumed in parameter order (value first on the stack). That cross-over — evaluate in one order, push in another — is the only evidence that named arguments were reordered. And it is not enough to reconstruct the names: the exact same temporary-spill pattern is what the compiler emits for ordinary left-to-right evaluation of positional arguments that happen to need sequencing, and a programmer could have written those temporaries by hand. So Glass.NET renders the spill faithfully as temporaries and a positional call, rather than guessing at width:/value: syntax that the IL does not actually pin down.
What Glass.NET prints, and what it won't invent
Putting the two features together, the recovery splits cleanly along the line of what the metadata actually holds:
-
Optional defaults on a signature are recovered exactly, because they are in the Constant table: Glass.NET reads the
HasDefault/Optionalflags and the Constant row for each parameter and printsint width = 0, char pad = ' 'with the literal decoded by its type tag —char(0x0020)as' ', an enum default as the named member, adefaultstruct asdefault. - Omitted arguments at a call are recovered by resolving the callee and comparing the trailing literals to the defaults, then preferring the shortest form that still binds to the same overload — and falling back to showing every argument when the callee cannot be read or when an omission would change overload resolution.
-
Named-argument names are not invented. When the push order matches parameter order, the call is printed positionally. When a temporary-spill shows a written-order/parameter-order cross-over, the temporaries are shown as what they are, and the call stays positional. The names were erased at compile time, and a decompiler that fabricated
width:/value:would be dressing a guess as a fact.
That last point is the theme both features share. Decompilation recovers what the compiler stored — and for optional and named arguments, the compiler stored the default (in a table you can read) but threw away the name and the you-omitted-this (substituting a literal you can't distinguish). A good decompiler draws that line in the open: it prints the default because it is really there, and it declines to reconstruct the name because it really is not.
Top comments (0)