Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling static abstract interface members: how generic math compiles

C# 11 let interfaces declare static abstract members, and the BCL used it to build generic math — write Sum<T>(span) once and it runs over int, double, or your own type. But the feature leans on a corner of the ECMA spec most decompilers never had to handle: a constrained call to a *static* interface method, resolved to the type argument at runtime. Get that wrong and the decompiler emits a call to a method that looks like it has no receiver, or invents a cast that is not there. Here is exactly what the compiler emits for T.Zero and a + b over a generic constraint, why the constrained. prefix on a non-virtual static call is the whole trick, and how Glass.NET reads it back into the operators and static members you actually wrote.

C# 11 added a small-sounding feature with large consequences: interfaces can declare static abstract members. That is what made generic math possible — you can now write Sum<T>(ReadOnlySpan<T>) once, constrain T to INumber<T>, and have it run over int, double, decimal, or a type you defined yourself, with the + operator resolving to each type’s own implementation. It reads like ordinary generics. Underneath, it depends on a dispatch mechanism that did not exist before: calling a static interface method through a type parameter, resolved to the type argument at runtime.

That mechanism is where decompilers earn their keep. The IL for it reuses an old prefix in a new way, and a tool that learned the old rule — “constrained. precedes callvirt” — reads the new shape wrong. This post walks the exact IL the compiler emits for T.Zero and a + b over a generic constraint, explains why the constrained. prefix sits on a plain call, and shows how Glass.NET turns it back into the operators and static members you wrote.

The source, and what makes it tricky

Here is the canonical generic-math method. There is no mention of any concrete numeric type; everything flows through the constraint:

using System.Numerics;

public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
    T total = T.Zero;            // static abstract property on the interface
    foreach (T v in values)
        total += v;              // static abstract operator op_Addition
    return total;
}

T.Zero and total += v look like member access and an operator. But T is a type parameter — at compile time there is no concrete type to bind the call to. The runtime has to resolve Zero and op_Addition to whatever T turns out to be. That resolution is the job of the constrained. prefix.

instance member (since generics shipped)constrained. !!T · callvirt IFoo::Bar()dispatch on receiver; avoids boxing a value typestatic abstract member (C# 11)constrained. !!T · call T::op_Addition(..)no receiver; type arg carried into static dispatchnaive decompiler assumesconstrained. ⇒ callvirt only→ mis-reads the static call: phantomreceiver, or an invented cast

The two rows are the crux. The prefix is identical, but what it decorates — and what it means — is not.

The IL the compiler emits

Build in Release and the body of Sum<T> reduces to this (annotated). Note there is no callvirt for the operator or the property; both are plain calls with a constrained. prefix carrying !!T, the method’s own type parameter:

.method public hidebysig static !!T Sum<(class System.Numerics.INumber`1<!!T>) T>(
        valuetype System.ReadOnlySpan`1<!!T> values) cil managed
{
  // T total = T.Zero;
  constrained. !!T
  call       !!T System.Numerics.INumberBase`1<!!T>::get_Zero()
  stloc.0                                   // total

  // foreach (T v in values) total += v;
  ...                                        // span enumeration elided
  ldloc.0                                    // total
  ldloc.2                                    // v
  constrained. !!T
  call       !!T System.Numerics.IAdditionOperators`3<!!T,!!T,!!T>::op_Addition(!0, !1)
  stloc.0                                    // total = total + v
  ...
  ldloc.0
  ret
}

Two things to read off this. First, T.Zero is a call to get_Zero — the getter of a static property declared on INumberBase<T> — prefixed with constrained. !!T. Second, total += v is a call to op_Addition, the special-named operator method on IAdditionOperators<T,T,T>, again prefixed with constrained. !!T. The prefix is what tells the runtime: do not look for a static method on the interface itself; resolve it against the concrete type bound to !!T at this instantiation. Strip the prefix mentally and the instruction is nonsense — a call to an abstract interface method has no body to run. The prefix is the entire mechanism.

A decompiler that carries the old assumption — that constrained. only ever decorates a callvirt, as it has since C# 2 for value-type instance receivers — hits this call and has no rule for it. The typical failure modes are a phantom receiver (it tries to render the first stack operand as the thing being called into) or an invented cast to the interface (it “explains” the prefix as a conversion). Either way the output no longer matches the source and no longer recompiles.

Reading it back

Glass handles the shape generically. When it sees constrained. !!X followed by a call to a method that is static abstract (or static virtual) on an interface, it records that the effective receiver type is the type parameter !!X and that dispatch is static. From there it resolves the spelling from the method’s metadata:

  • get_Zero / get_One carry the specialname flag and the property-accessor shape, so they read back as the Zero / One property: T.Zero.
  • op_Addition, op_Subtraction, op_Equality, and the rest carry specialname static and a reserved operator name, so they read back as the operator: total + v.

Applying both, the recovered source is the method you started with:

public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (T v in values)
        total += v;
    return total;
}

The constraint itself comes straight from the generic parameter’s metadata — (class System.Numerics.INumber1<!!T>) Tin the.methodsignature becomeswhere T : INumber. The foreachreconstructs from the span-enumeration pattern (its own decompilation problem, handled separately), but the generic-math core —T.Zeroandtotal += v` — hinges entirely on reading the constrained static call correctly.

It is not just the BCL

Generic math is the famous customer, but static abstract members are a general feature, and the same IL shape shows up wherever you use one. A factory constraint, for instance:

public interface IFactory<TSelf> where TSelf : IFactory<TSelf>
{
    static abstract TSelf Create();
}

public static TSelf MakeOne<TSelf>() where TSelf : IFactory<TSelf>
    => TSelf.Create();          // constrained. !!TSelf · call !!TSelf IFactory::Create()

TSelf.Create() compiles to the identical pattern — constrained. !!TSelf on a call to the static abstract Create. Because Glass keys off the instruction shape and the member flags, not a hard-coded list of numeric interfaces, your own static abstract members decompile exactly the way the framework’s do: a static member access on the type parameter, spelled the way you wrote it. A tool that special-cased INumber<T> to pass its own test suite would render this one wrong; one that handles the ECMA mechanism handles all of them.

What to take away

Static abstract interface members made generic math possible, and they did it by reusing the constrained. prefix on a plain call to carry a type argument into static dispatch — a shape that did not exist before C# 11. The naive rule “constrained. precedes callvirt” mis-reads it, producing phantom receivers or invented casts, so a correct decompiler has to recognise the constrained static call as its own pattern: receiver type is the type parameter, dispatch is static, and the spelling comes from the member’s special name — get_Zero to T.Zero, op_Addition to +. Do that generically, off the instruction and the metadata flags rather than a list of known interfaces, and both the BCL’s numeric stack and your own factory- or parser-style static abstract members read back into the source you wrote. For a related dispatch puzzle — recovering calls that carry no target token at all — see decompiling function pointers and calli; for the metadata tables these flags live in, .NET metadata tables explained.

Try Nebula.NET

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