DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling default interface methods: a method body inside an interface, the class that never overrode it, and the cast you cannot drop

For as long as there have been .NET decompilers, one rule held: interfaces declare signatures, classes supply bodies. A default interface method, introduced in C# 8 on .NET Core 3.0, breaks that rule cleanly — an interface can now carry a working implementation, and a class that implements the interface can supply nothing and still get the behaviour. That leaves Glass.NET with three things to reconstruct that older decompilers never had to model, and getting any of them wrong produces code that looks plausible but will not compile.

A body where interfaces never had one

Start with the source:

public interface ILogger
{
    string Prefix => "[log]";                       // default property getter

    void Log(string message)                        // default method
        => Console.WriteLine($"{Prefix}: {message}");
}

public sealed class ConsoleLogger : ILogger { }     // overrides nothing
Enter fullscreen mode Exit fullscreen mode

ILogger.Log has a body, and ConsoleLogger implements the interface without writing a single line. Compile it and look at the interface in IL:

.class interface public abstract auto ansi ILogger
{
  .method public hidebysig newslot virtual instance void
          Log(string message) cil managed
  {
    .maxstack  3
    ldarg.0
    callvirt   instance string ILogger::get_Prefix()
    ldarg.1
    ...
    call       void [System.Console]System.Console::WriteLine(string)
    ret
  }
  // get_Prefix is likewise newslot virtual instance with a body
}
Enter fullscreen mode Exit fullscreen mode

The method is newslot virtual — the same shape that makes any interface member dispatchable — but, crucially, it is not marked abstract, and it has a body. That single combination (non-abstract, virtual, body present, declared on an interface) is the tell. An abstract interface member would be .method public hidebysig newslot abstract virtual instance void Log(string) cil managed { } — the abstract flag, no IL. When Glass.NET walks the interface and finds code attached to a non-abstract virtual, it knows it is looking at a default implementation and renders the body on the interface rather than treating the interface as a pile of signatures.

Note what the body can touch: an interface has no instance fields, so this inside Log can only reach other interface members, and it does so virtually — callvirt ILogger::get_Prefix(). Glass reads that back as Prefix, dispatched through the interface, which is exactly what the language means.

The class that overrode nothing

Now ConsoleLogger. The temptation — and the trap a naive decompiler falls into — is to show it with a Log method "so the behaviour makes sense." But the metadata is adamant: the class has no Log method, and there is no MethodImpl row mapping a class method onto ILogger::Log. It is genuinely empty:

.class public auto ansi sealed ConsoleLogger
       extends [System.Runtime]System.Object
       implements ILogger
{
  .method public hidebysig specialname rtspecialname
          instance void .ctor() cil managed { ... }   // just the ctor
}
Enter fullscreen mode Exit fullscreen mode

The behaviour lives entirely on the interface; the runtime finds it through interface dispatch at the call site. So Glass.NET renders the class exactly as it is — public sealed class ConsoleLogger : ILogger { } — and does not synthesise a method to explain anything. A MethodImpl appears only when the class actually overrides the default, and then Glass shows the override. Absence of a MethodImpl is information: it means the default was accepted.

The cast you cannot drop

Here is the call site, and the part that trips reconstruction:

ILogger logger = new ConsoleLogger();
logger.Log("started");          // compiles

// new ConsoleLogger().Log("started");   // does NOT compile
Enter fullscreen mode Exit fullscreen mode

A default interface method is a member of the interface, not of the class. If you hold the receiver as ConsoleLogger, Log is not in that type's member list and the call does not compile. In IL this is why the call is always a callvirt against the interface method with the receiver typed as the interface:

newobj     instance void ConsoleLogger::.ctor()
callvirt   instance void ILogger::Log(string)
Enter fullscreen mode Exit fullscreen mode

When Glass.NET reconstructs this, it cannot drop the cast or re-type the variable as ConsoleLogger to tidy the code up — doing so would emit source that fails to compile. The interface-typed receiver is a load-bearing part of the language rule, not leftover noise. So Glass keeps the variable at ILogger (or inserts the ((ILogger)x) cast where the original value was concrete), preserving the one shape in which the call is legal. This is the difference between a decompiler that reproduces behaviour and one that reproduces compilable behaviour.

The diamond, where the class stops being empty

The one case where an implementing class is forced to carry a method is ambiguity. If a class implements two interfaces that each provide a default for the same signature, the language refuses to pick for you — the class must override, and that override carries a MethodImpl row mapping the class method onto the interface slot:

interface IA { void M() => Console.WriteLine("A"); }
interface IB { void M() => Console.WriteLine("B"); }

class C : IA, IB
{
    public void M() => ((IA)this).M();   // explicit base-interface call → IA's default
}
Enter fullscreen mode Exit fullscreen mode

That explicit base call compiles to a non-virtual call to IA::M on a this constrained to the interface, which is how you name a specific interface's default implementation. Glass.NET sees the MethodImpl, renders C.M, and reads the constrained call back as the ((IA)this).M() form. So the presence or absence of a MethodImpl is the whole story: absent means the default was accepted unambiguously; present means a deliberate override or a forced disambiguation, and Glass shows which.

The takeaway

Default interface methods move three things a decompiler used to take for granted. The body now lives on the interface — Glass spots it as a non-abstract virtual with IL and renders the default there. The implementing class may legitimately be empty, with no method and no MethodImpl, and Glass resists inventing one to fill the silence. And the call site must go through the interface type, so Glass keeps the cast that the language requires rather than optimising it away into code that will not compile. Read the metadata literally — body present, MethodImpl absent, receiver typed as the interface — and the feature decompiles into source that both reads correctly and builds.

Top comments (0)