local functions vs lambda expressions

10/30/2022
7 minute read

.NET knows local functions and lambda expressions. You can almost take them interchangeably, but there are also some differences between them.

This article will show the differences between them.

What are local functions?

A local function is a method, which is nested in another member. A typical example would be like that:

int Fib(int n) 
{
    if (n < 0)
        throw new ArgumentException("n must be >= 0", nameof(n));

    return FibLocal(n);

    int FibLocal(int number) 
    {
        if (number == 0)
            return 0;
        if (number == 1)
            return 1;
        
        return FibLocal(number - 1) + FibLocal(number - 2);
    }
}

Use-cases are like in the example above to have a sanitation check. Also only Fib can call FibLocal. So there is no chance that another outside function can mistakenly call FibLocal. Local functions can make the intend of your function clearer by separating some concerns. There are also circumstances, where it makes your code easier.

💡 Local functions are like "regular" private methods on your class. The compiler generates them for you. You can check sharplab.io with the given example from above to see the "magic". So everything written below applies to "local" functions as well as "regular" functions.


On first glance they look exactly the same as lambda functions but there are some noticeable differences between them, so let's go through to give you a better overview.

Recursion

The example above (Fibonacci) is done recursively via a local function. You can't do this easily with lambda expression, so this following code, would not compile:

Func<int, int> FibLambda = n =>
{
    if (n == 0 || n == 1)
        return n;

    return FibLambda(n - 2) + FibLambda(n - 1);
};

You get an error: "Local variable 'FibLambda' might not be initialized before accessing". You have to write something like this to make it work (basically initializing the lambda with null):

Func<int, int> FibLambda = null;
FibLambda = n =>
{
    if (n == 0 || n == 1)
        return n;

    return FibLambda(n - 2) + FibLambda(n - 1);
};

And that code brings you some compiler warning, especially when you enable nullable reference types. So not optimal in my opinion.

yield aka generator functions

Local functions in contrast to delegates in general (also including lambdas) can use the yield keyword.

IEnumerable<int> GetNumbers(int i)
{
    return GetNumbersLocal(i);

    static IEnumerable<int> GetNumbersLocal(int end)
    {
        for (var i = 0; i < end; i++)
            yield return i;
    }
}

If you try the same with a lambda expression, you are greeted with the following error: "Only methods, operators and accessors could contain 'yield' statement".

Performance

In short: A local function is just that, a function. A lambda on the contrary is a delegate, which has to be created. Delegates do create new allocations and can have other implications. I will put some links at the end of the post. Anyway in general local functions outperform lambdas. There are certain scenarios where a local function gets "converted" to a delegate thus having the same performance.

Also, local functions are more efficient with capturing local variables: lambdas usually capture variables into a class, while local functions can use a struct (passed using ref), which again avoids an allocation.

Here a small benchmark, where we compare local functions against lambdas expression. One time a static version, which doesn't capture any state from the outside world (aka closure) and one time with.

[MemoryDiagnoser()]
public class Benchmark
{
    private const int Iterations = 100;

    [Params(4)]
    public int Number { get; set; }

    [Benchmark]
    public int LocalFunctionNonStatic()
    {
        var sum = 0;
        for (var i = 0; i < Iterations; i++)
            sum += Add(i);

        return sum;

        int Add(int number) => number + Number;
    }

    [Benchmark]
    public int LocalFunctionStatic()
    {
        var sum = 0;
        for (var i = 0; i < Iterations; i++)
            sum += Add(i);

        return sum;

        static int Add(int number) => number + 4;
    }

    [Benchmark]
    public int LambdaCreatedNonStatic()
    {
        Func<int, int> add = n => n + Number;
        var sum = 0;
        for (var i = 0; i < Iterations; i++)
            sum += add(i);

        return sum;
    }

    [Benchmark]
    public int LambdaCreatedStatic()
    {
        Func<int, int> add = static n => n + 4;
        var sum = 0;
        for (var i = 0; i < Iterations; i++)
            sum += add(i);

        return sum;
    }
}

Results:

// * Summary *

BenchmarkDotNet=v0.13.1.1833-nightly, OS=macOS Monterey 12.6 (21G115) [Darwin 21.6.0]
Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
.NET SDK=7.0.100-rc.2.22477.23
  [Host]     : .NET 7.0.0 (7.0.22.47203), Arm64 RyuJIT AdvSIMD
  DefaultJob : .NET 7.0.0 (7.0.22.47203), Arm64 RyuJIT AdvSIMD


|                 Method | Number |      Mean |    Error |   StdDev |  Gen 0 | Allocated |
|----------------------- |------- |----------:|---------:|---------:|-------:|----------:|
| LocalFunctionNonStatic |      4 |  48.75 ns | 0.076 ns | 0.064 ns |      - |         - |
|    LocalFunctionStatic |      4 |  47.22 ns | 0.115 ns | 0.108 ns |      - |         - |
| LambdaCreatedNonStatic |      4 | 136.90 ns | 0.288 ns | 0.240 ns | 0.0100 |      64 B |
|    LambdaCreatedStatic |      4 | 130.43 ns | 0.396 ns | 0.351 ns |      - |         - |

We can see all the effects as described above. The lambda can cause allocations (with closures) whereas the local function doesn't. Also the runtime is faster.

💡 A small tip: You can decorate a local function (as a regular function) with the static keyword to prohibit this closure behavior. The same applies to lambda expressions:

void MyMethod()
{
    int someState = 2;

    // Accessing someState inside the lambda would lead to a compiler error
    Func<int> myFunc = static () => { return 2; }

    static void MyMethodLocal()
    {
        // Accessing someState would lead to a compiler error
    }
}

Other things

Here are also other things, which could make a difference for you:

  • Local functions can be generic. And I don't mean that they "inherit" a generic type from the parent, but they can define a new generic constraint if they want to. A lambda expression only can use the generic constraints from the outer scope instead of creating one on its own).
  • Local functions can be defined after the return statement. Obviously, that doesn't work for lambdas, as they are "regular" variables, which have to be declared before use.
  • Reflection: A local function is really a "regular" function, which can be looked up via reflection. Warning: Don't do this! As said earlier, these are auto-generated functions whereby the name can change. So you are looking for trouble if you do this. Anyway if you would list all private functions of a class, local functions would appear.
  • Lambdas can not have the unsafe keyword, local functions can have that.
  • LINQ: When it comes to LINQ queries, there is virtually no difference as LINQ always captures either an Expression or a Func, which again are delegates. So also your local function will be treated as a delegate in this scenario. You don't gain the performance improvements as described above.

Closing

Lambdas are a convenient and easy way of creating a small function. They have some "short-comings" which might or might not be relevant in your use case. In any way it is interested to see and know those differences. A lot of people do like (including me) to refactor certain small aspects out of a function into local function. At the end it boils also down to personal preference.

Resources

Source Generators and Regular Expressions

Source Generators are more and more an integral part of the .NET ecosystem. But how does that play together with everyone's favorite: Regular Expressions?

In this blog post we will dive in how we can leverage source generators in combination with regular expression to have a debuggable, but also very performant way of executing regular expressions!

ReadOnlySpan<char> and strings - How not to compare them

Many know that you can take ReadOnlySpan<char> objects when dealing with strings. They give you a direct way of operating on the underlying memory. Often times you can use them interchangeably, but there are scenarios where you really have to watch out what is going on.

This blog post will have a look at a major problem with ReadOnlySpan when used like a "regular" string.

Delegate, Action, Func, Lambda expression - What the heck!?

C# offers a lot of utility especially around the delegate topic. So let's see what exactly a delegate is and how the distinct types like delegate, Action, Func, Predicate, anonymous function, lambda expressions and MulticastDelegate behave. A lot to digest and discover so let's go.

An error has occurred. This application may no longer respond until reloaded. Reload x