Closures in .NET: Know the internals

Published by

on

Closures are a powerful feature in many programming languages, including javascript, C#, etc., They allow a function to access variables from its enclosing scope, even after that scope has exited. This can be particularly useful in asynchronous programming, event handling, and lambda expressions. In my last blog, we have seen the closure in Javascript, which uses lexical scoping for closures. In this article we will explore how closures work in .NET, with a focus on C# examples and their Intermediate Language (IL) equivalents.

What is a Closure in .NET?

In .NET, a closure occurs when a lambda expression or anonymous method captures variables from its surrounding scope. When you define a lambda expression that references local variables, the compiler creates an anonymous class behind the scenes. This class stores the captured variables, allowing the lambda to access them even after the original context has ended.

Example in C#

Let’s consider a simple example in C#:

public class MainClass
{
    public class Counter
    {   
        public Func<int> GetIncrementor(int count = 0)
        {
            Func<int> incrementCount = () => count++;
            return incrementCount;
        }
    }

    public static void Main()
    {
        Counter counter = new Counter();
        var increment = counter.GetIncrementor(1);
        Console.WriteLine("First Call: {0}", increment());
        
        Console.WriteLine("Second Call: {0}", increment());
        
        Console.WriteLine("Third Call: {0}", increment());
    }
}

Output

First Call: 1
Second Call: 2
Thirud Call: 3

In the above example – when GetIncrementor is called, it creates a closure around the count variable. This closure is formed by the lambda expression () => count++, which captures count from its surrounding scope. Here’s the crucial part: the lambda expression retains a reference to count even after GetIncrementor has finished execution.

Each time the increment function is called in Main, it’s operating on the same count variable, which was captured during the creation of the closure. This is why count retains its value between calls, and you see it increment with each successive call.

Behind the Scenes: IL Equivalent

When the C# compiler encounters a closure, it generates an anonymous class to hold the captured variables. Here’s a simplified view of what happens at the IL level:

  1. Anonymous Class Creation: The compiler creates a class with fields corresponding to the captured variables.
  2. Rewriting the Lambda: The lambda expression is converted into a method of this anonymous class.
  3. Instantiating the Class: When the method creating the closure is called, an instance of the anonymous class is created, and the captured variables are initialized.

Lets see how the IL for the above example, which will give clear insight to the internals of closure in .NET. The IL equivalent of the above will be quite big – so lets take only the Counter class for the IL understanding

Note: The below IL is generated using https://sharplab.io/

.class nested public auto ansi beforefieldinit Counter
        extends [System.Runtime]System.Object
    {
        // Nested Types
        .class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass0_0'
            extends [System.Runtime]System.Object
        {
            .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
                01 00 00 00
            )
            // Fields
            .field public int32 count

            // Methods
            .method public hidebysig specialname rtspecialname 
                instance void .ctor () cil managed 
            {
                // Method begins at RVA 0x20ae
                // Code size 8 (0x8)
                .maxstack 8

                IL_0000: ldarg.0
                IL_0001: call instance void [System.Runtime]System.Object::.ctor()
                IL_0006: nop
                IL_0007: ret
            } // end of method '<>c__DisplayClass0_0'::.ctor

            .method assembly hidebysig 
                instance int32 '<GetIncrementor>b__0' () cil managed 
            {
                // Method begins at RVA 0x20e8
                // Code size 18 (0x12)
                .maxstack 3
                .locals init (
                    [0] int32
                )

                IL_0000: ldarg.0
                IL_0001: ldfld int32 MainClass/Counter/'<>c__DisplayClass0_0'::count
                IL_0006: stloc.0
                IL_0007: ldarg.0
                IL_0008: ldloc.0
                IL_0009: ldc.i4.1
                IL_000a: add
                IL_000b: stfld int32 MainClass/Counter/'<>c__DisplayClass0_0'::count
                IL_0010: ldloc.0
                IL_0011: ret
            } // end of method '<>c__DisplayClass0_0'::'<GetIncrementor>b__0'

        } // end of class <>c__DisplayClass0_0


        // Methods
        .method public hidebysig 
            instance class [System.Runtime]System.Func`1<int32> GetIncrementor (
                [opt] int32 count
            ) cil managed 
        {
            .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
                01 00 01 00 00
            )
            .param [1] = int32(0)
            // Method begins at RVA 0x20b8
            // Code size 33 (0x21)
            .maxstack 2
            .locals init (
                [0] class MainClass/Counter/'<>c__DisplayClass0_0' 'CS$<>8__locals0',
                [1] class [System.Runtime]System.Func`1<int32> incrementCount,
                [2] class [System.Runtime]System.Func`1<int32>
            )

            // sequence point: hidden
            IL_0000: newobj instance void MainClass/Counter/'<>c__DisplayClass0_0'::.ctor()
            IL_0005: stloc.0
            IL_0006: ldloc.0
            IL_0007: ldarg.1
            IL_0008: stfld int32 MainClass/Counter/'<>c__DisplayClass0_0'::count
            IL_000d: nop
            IL_000e: ldloc.0
            IL_000f: ldftn instance int32 MainClass/Counter/'<>c__DisplayClass0_0'::'<GetIncrementor>b__0'()
            IL_0015: newobj instance void class [System.Runtime]System.Func`1<int32>::.ctor(object, native int)
            IL_001a: stloc.1
            IL_001b: ldloc.1
            IL_001c: stloc.2
            IL_001d: br.s IL_001f

            IL_001f: ldloc.2
            IL_0020: ret
        } // end of method Counter::GetIncrementor

        .method public hidebysig specialname rtspecialname 
            instance void .ctor () cil managed 
        {
            // Method begins at RVA 0x20ae
            // Code size 8 (0x8)
            .maxstack 8

            IL_0000: ldarg.0
            IL_0001: call instance void [System.Runtime]System.Object::.ctor()
            IL_0006: nop
            IL_0007: ret
        } // end of method Counter::.ctor

    }

In the above IL – notice the nested type (class) namely <>c__DisplayClass0_0 although it is not in our C# class Counter. This nested class is compiler generated anonymous class for handling closure internally.

Lets see the equivalent C# for the above nested class in IL
Note: The below is manual rewrite of the above IL for easy understanding.

private sealed class ClosureAnonymousClass
{
     public int count;

     internal int increment()
     {
         return count++;
     }
}

When we call GetIncrementor of Class Counter, the above private class instance is created and the count variable is initialized (capturing the scope) with parameter passed to GetIncrementor method. The increment method of private class reference is returned as Func<int>.

The equivalent of GetIncrementor method in C# will be as below with this anonymous class

public Func&lt;int> GetIncrementor(int count = 0)
{
    var closureClass = new ClosureAnonymousClass();
    closureClass.count = count;
    Func&lt;int> incrementCount = closureClass.increment;
    return incrementCount;
}

As this method reference is passed on to the caller method, which will be referenced via a variable. Till that variable is dereferenced or gone out of the scope, the private class object instance will be retained in memory.

Why Use Closures?

Closures are particularly useful for:

  1. Event Handling: Capturing state for use when an event is triggered.
  2. Asynchronous Programming: Maintaining state across asynchronous calls.
  3. LINQ Queries: Capturing variables in LINQ lambda expressions.

Considerations

While closures are powerful, they come with caveats:

  1. Memory Leaks: If not managed carefully, closures can lead to unintended memory retention.
  2. Mutability: Captured variables are mutable, which can lead to side effects if not handled cautiously.

Conclusion

Closures in .NET offer a flexible way to write concise and powerful code. By understanding how they are implemented under the hood, you can harness their full potential while being aware of their pitfalls. The harmony of C# and IL in closures is a fine example of .NET’s robustness and flexibility.

Articles to read

Understanding C# Features (6) Closure
Closures In C# Demystified


Disclaimer: This blog contains the individual opinions and perspectives of Vijay Pandurangan, which are not necessarily indicative of the views of his employer. The author assumes no responsibility for any actions taken or decisions made based on the information presented in this blog. Should any content from this blog be referenced or used in articles, white papers, wikis, blogs, or similar formats, it should be attributed solely to Vijay Pandurangan, independent of his professional affiliations. Please note that the use of his employer’s name in such contexts is not permitted.


Discover more from Vijay Pandurangan

Subscribe now to keep reading and get access to the full archive.

Continue reading