WillFuqua

The Implementation of C# Local Functions

C# 7 local functions provide a more intuitive syntax over creating verbose System.Func delegates, as well as being more capable (they support ref and out parameters, async, generics, etc). In addition, some articles have mentioned that local functions compile down to normal methods, thus reducing GC allocations when compared to System.Func.

I was curious about that last part. How does it work? Let’s open up the dotPeek decompiler and find out!

First, here’s a simple test program using a local function:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine(AddFive(7));
    }

    static int AddFive(int a)
    {
        // the local function declaration
        int InnerAdd(int b) => a + b;

        return InnerAdd(5);
    }
}

Admittedly, the above local function is not really needed in this case, but it’s simple enough code that the decompilation won’t be scary!

Decompiling the Program

After decompiling the above program, we get the following for the AddFive method:

public static int AddFive(int a)
{
    // object of a compiler-generated type is created
    // reference to a compiler-generated method
    return Program.<AddFive>g__InnerAdd1_0(5, ref new Program.<>c__DisplayClass1_0()
    {
        a = a
    });
}

The above comments are helpfully added by the decompiler. As we can see, the C# compiler created the following for us:

Inspecting the Intermediate Language (IL)

In order to look into the generated class and function, we need to look at the IL code. Here is the IL code for the generated class that captures the a parameter:

.class nested private sealed auto ansi beforefieldinit
  '<>c__DisplayClass1_0'
    extends [mscorlib]System.ValueType
{
  .custom instance void
    [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
    = (01 00 00 00 )

  .field public int32 a
}

Two interesting things about this are that it only has one field, int32 a, that is used to pass our a parameter to the function, and that the class extends from System.ValueType. System.ValueType is the base class for all value types, so the generated value type will not cause heap allocations. The C# compiler prevents user code from extending System.ValueType.

Next, let’s look at the generated method:

.method assembly hidebysig static int32
    '<AddFive>g__InnerAdd1_0'(
      int32 b,
      [in] valuetype Demo.Program/'<>c__DisplayClass1_0'& obj1
    ) cil managed
  {
    .custom instance void
      [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
      = (01 00 00 00 )
    .maxstack 8

    IL_0000: ldarg.1      // obj1
    IL_0001: ldfld        int32 Demo.Program/'<>c__DisplayClass1_0'::a
    IL_0006: ldarg.0      // b
    IL_0007: add
    IL_0008: ret

  } // end of method Program::'<AddFive>g__InnerAdd1_0'
} // end of class Demo.Program

Despite being a bit long, this is pretty straight-forward. It’s a static function that takes two parameters, int b and our generated obj1. It loads our argument obj1 onto the stack, then loads field obj1.a, then loads our argument b. Next, it calls add, which pops the top two values off the stack and adds them, then pushes the result back on the stack. Finally, it calls ret to return that result.

Adding More Complexity

Let’s make things a bit more interesting. What if our nested function mutates (gasp)?

public static int AddFive(int a)
{
    void InnerAdd(int b) => a += b;

    InnerAdd(5);

    return a;
}

The InnerAdd function is now a void function, that mutates a in the outer scope. In this case, our decompiled AddFive function looks like this:

public static int AddFive(int a)
{
   // object of a compiler-generated type is created
   Program.<>c__DisplayClass1_0 cDisplayClass10 = new Program.<>c__DisplayClass1_0();
   // reference to a compiler-generated field
   cDisplayClass10.a = a;
   // reference to a compiler-generated method
   Program.<AddFive>g__InnerAdd1_0(5, ref cDisplayClass10);
   // reference to a compiler-generated field
   return cDisplayClass10.a;
}

This is more interesting than the first case. We can see that our generated class is set up ahead of time, then passed into the generated static function, and then all subsequent references to the parameter a are rewritten into references to the generated field! Fascinating.

tagged as and