SoFunction
Updated on 2025-03-01

Detailed introduction to C# local functions and Lambda expressions

1. C# local functions and Lambda expressions

C# local functions are usually consideredlambda Further enhancement of expressions. Although functions are relevant, there are significant differences.

Local FunctionsyesNested functions] function C# implementation. It is a bit unusual for a language to get support for nested functions after lambdas. Usually the opposite is true.

Lambda or general first-class functions require implementation of local variables that are not allocated on the stack and whose life cycle is associated with the functional objects that require them. It is almost impossible to implement them correctly and effectively if you don't rely on garbage collection or reduce the burden of ownership of variables to the user through solutions such as capture lists. This is a serious blocking problem for some early languages. Simple implementations of nested functions do not encounter this complexity, so it is more common for a language to support only nested functions and not lambdas.

Anyway, since C# has long used lambda, it does make sense to look at the differences and similarities.

2. Lambda expressions

The Lambda expression x => x + x is an expression that abstractly represents a piece of code and how it binds to parameters and variables in its lexical environment. As an abstract representation of the code, lambda expressions cannot be used alone. In order to use the value generated by the lambda expression, it needs to be converted to more content, such as a delegate or an expression tree.

using System;
using ;

class Program
{
    static void Main(string[] args)
    {
        // can't do much with the lambda expression directly
        // (x => x + x).ToString();  // error

        // can assign to a variable of delegate type and invoke
        Func<int, int> f = (x => x + x);
        (f(21)); // prints "42"

        // can assign to a variable of expression type and introspect
        Expression<Func<int, int>> e = (x => x + x);
        (e);     // prints "x => (x + x)"
    }
}

There are a few points worth noting:

  • lambdas is an expression that produces the value of the function.
  • lambda The lifetime of a value is infinite - starting from the execution of a lambda expression, as long as there is any reference to the value. This means that any local variables used or "captured" from the enclosed method must be allocated on the heap. Since the life cycle of a lambda value is not limited by the life cycle of the stack frame that produces it, variables cannot be allocated on that stack frame.
  • lambda Expressions require that all external variables used in the body be explicitly allocated when executing a lambda expression. The moments of the first and last use of lambdas are rarely deterministic, so the language assumes that lambda values ​​can be used immediately after creation, as long as they are accessible. Therefore, a lambda value must be fully available at creation and all external variables it uses must be explicitly allocated.
        int x;

        // ERROR: 'x' is not definitely assigned
        Func<int> f = () => x;
  • lambdas have no name and cannot be cited symbolically. In particular, lambda expressions cannot be declared recursively.

Notice:Recursive lambdas can be created by calling variables assigned to lambdas or by passing them to higher-order methods that apply their parameters, but this does not express a true self-reference.

3. Local functions

Local functions are basically just methods declared in another method as a way to reduce the visibility of the method within its declared scope.

Naturally, the code in a local function can access everything that is accessible within its scope - local variables, parameters of enclosed methods, type parameters, local functions. One notable exception is the visibility of external method labels. The tags of the enclosed method are not visible in the local function. This is just a normal lexical scope, and it works the same as lambdas.

public class C
{
    object o;

    public void M1(int p)
    {
        int l = 123;

        // lambda has access to o, p, l,
        Action a = ()=> o = (p + l);
    }

    public void M2(int p)
    {
        int l = 123;

        // Local Function has access to o, p, l,
        void a()
        {
          o = (p + l);
        }
    }
}

The obvious difference from lambda is that local functions have names and can be used without any indirect way. Local functions can be recursive.

static int Fac(int arg)
{
    int FacRecursive(int a)
    {
        return a <= 1 ?
                    1 :
                    a * FacRecursive(a - 1);
    }

    return FacRecursive(arg);
}

The main semantic difference with lambda expressions is that local functions are not expressions, they are declaration statements. When it comes to code execution, declarations are very passive entities. In fact, the statement is not really "executed". Similar to other declarations such as tags, local function declarations simply introduce functions into inclusion scope without running any code.

More importantly, neither the declaration itself nor the regular call of nested functions will lead to uncertain capture of the environment. In simple and common cases, such as ordinary call/return scenarios, the captured local variables do not need to be heap-allocated.

example:

public class C
{    
    public void M()
    {
        int num = 123;

        // has access to num
        void  Nested()
        {
           num++;
        }

        Nested();

        (num);
    }
}

The above code is roughly equivalent to (decompiled):

public class C
{
  // A struct to hold "num" variable.
  // We are not storing it on the heap,
  // so it does not need to be a class
  private struct <>c__DisplayClass0_0
  {
      public int num;
  }

  public void M()
  {
      // reserve storage for "num" in a display struct on the _stack_
      C.<>c__DisplayClass0_0 env = default(C.<>c__DisplayClass0_0);

      // num = 123
       = 123;

      // Nested()
      // note - passes env as an extra parameter
      C.<M>g__a0_0(ref env);

      // (num)
      ();
  }

    // implementation of the the "Nested()".
    // note - takes env as an extra parameter
    // env is passed by reference so it's instance is shared
    // with the caller "M()"
    internal static void <M>g__a0_0(ref C.<>c__DisplayClass0_0 env)
    {
         += 1;
    }
}

Please note thatThe above code directly calls the implementation of "Nested()" (not indirectly through delegate) and does not introduce an allocation of display storage on the heap (as lambda does). Local variables are stored in structures rather than in classes. Life cyclenumIt has not changed because of its useNested(), so it can still be allocated on the stack.M()Can only passnumReference passes, but the compiler uses structs for packaging, so it can pass all local variables, just like num uses only one env The parameters are the same.

Another interesting point is that local functions can be used as long as they are visible within a given range. This is an important fact that makes recursive and mutually recursive scenarios possible. This also makes the exact location of local function declarations in the source code largely unimportant.

For example, all variables of a closed method must be explicitly allocated when the local function that reads them is called, not when it is declared. In fact, if the call can happen earlier, there will be no benefit in making that request at the time of declaration.

public void M()
{
    // error here -
    // Use of unassigned local variable 'num'
    Nested();

    int num;

    // whether 'num' is assigned here or not is irrelevant
    void  Nested()
    {
       num++;
    }

    num = 123;

    // no error here - 'num' is assigned
    Nested();

    (num);
}

Also - If you have never used a local function, it will not be better than an inaccessible piece of code and any variables, otherwise it will be used without allocation.

public void M()
{        
    int num;

    // warning - Nested() is never used.
    void  Nested()
    {
       // no errors on unassigned 'num'.
       // this code never runs.
       num++;
    }
}

4. So, what is the purpose of local functions?

Compared with lambdas, the main value proposition of local functions is that local functions are simpler in conceptually and runtime overhead.

Lambda works well as a role for a class of functions, but sometimes you only need a simple assistant. Lambdas assigned to local variables can do this, but there are indirect overhead, delegate allocation, and possible closure overhead. Private methods are also effective, with lower call costs, but have encapsulation problems or lack of encapsulation. Such assistants are visible to everyone in the inclusion type. Too many such helpers can lead to serious confusion.

Local functions are very suitable for this situation. The overhead of calling a local function is comparable to that of calling a private method, but there is no problem contaminating the inclusion type using other methods that should not be called.

This is the end of this article about the detailed introduction of C# local functions and Lambda expressions. For more related contents of C# local functions and Lambda expressions, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!