Dissecting the local functions in C# 7

Sergey Tepliakov


The Local functions is a new feature in C# 7 that allows defining a function inside another function.

When to use a local function?

The main idea of local functions is very similar to anonymous methods: in some cases creating a named function is too expensive in terms of cognitive load on a reader. Sometimes the functionality is inherently local to another function and it makes no sense to pollute the “outer” scope with a separate named entity.

You may think that this feature is redundant because the same behavior can be achieved with anonymous delegates or lambda expressions. But this is not always the case. Anonymous functions have certain restrictions and their performance characteristics can be unsuitable for your scenarios.

Use Case 1: eager preconditions in iterator blocks

Here is a simple function that reads a file line by line. Do you know when the ArgumentNullException will be thrown?

Methods with yield return in their body are special. They called Iterator Blocks and they’re lazy. This means that the execution of those methods is happening “by demand” and the first block of code in them will be executed only when the client of the method will call MoveNext on the resulting iterator. In our case, it means that the error will happen only in the ProcessQuery method because all the LINQ-operators are lazy as well.

Obviously, the behavior is not desirable because the ProcessQuery method will not have enough information about the context of the ArgumentNullException. So it would be good to throw the exception eagerly – when a client calls ReadLineByLine but not when a client processes the result.

To solve this issue we need to extract the validation logic into a separate method. This is a good candidate for anonymous function but anonymous delegates and lambda expressions do not support iterator blocks (*):

(*) Lambda expressions in VB.NET can have an iterator block.

Use Case 2: eager preconditions in async methods

Async methods have the similar issue with exception handling: any exception thrown in a method marked with async keyword (**) manifests itself in a faulted task:

(**) Technically, async is a contextual keyword, but this doesn’t change my point.

You may think that there is not much of a difference when the error is happening. But this is far from the truth. Faulted task means that the method itself failed to do what it was supposed to do. The failed task means that the problem is in the method itself or in one of the building blocks that the method relies on.

Eager preconditions validation is especially important when the resulting task is passed around the system. In this case, it would be extremely hard to understand when and what went wrong. A local function can solve this issue:

Use Case 3: local function with iterator blocks

I found very annoying that you can’t use iterators inside a lambda expression. Here is a simple example: if you want to get all the fields in the type hierarchy (including the private once) you have to traverse the inheritance hierarchy manually. But the traversal logic is method-specific and should be kept as local as possible:

Use Case 4: recursive anonymous method

Anonymous functions can’t reference itself by default. To work around this restriction you should declare a local variable of a delegate type and then capture that local variable inside the lambda expression or anonymous delegate:

This approach is not very readable and similar solution with local function feels way more natural:

Use Case 5: when allocations matters

If you ever work on a performance critical application, then you know that anonymous methods are not cheap:

  • Overhead of a delegate invocation (very very small, but it does exist).
  • 2 heap allocations if a lambda captures local variable or argument of enclosing method (one for closure instance and another one for a delegate itself).
  • 1 heap allocation if a lambda captures an enclosing instance state (just a delegate allocation).
  • 0 heap allocations only if a lambda does not capture anything or captures a static state.

But allocation pattern for local functions is different.

If a local function captures a local variable or an argument then the C# compiler generates a special closure struct, instantiates it and passes it by reference to a generated static method:

(The compiler generate names with invalid characters like < and >. To improve readability I’ve changed the names and simplified the code a little bit.)

A local function can capture instance state, local variables (***) or arguments. No heap allocation will happen.

(***) Local variables used in a local function should be definitely assigned at the local function declaration site.

There are few cases when a heap allocation will occur:

  1. A local function is explicitly or implicitly converted to a delegate.

Only a delegate allocation will occur if a local function captures static/instance fields but does not capture locals/arguments.

Closure allocation and a delegate allocation will occur if a local function captures locals/arguments:

  1. A local function captures a local variable/argument and anonymous function captures variable/argument from the same scope.

This case is way more subtle.

The C# compiler generates a different closure type per lexical scope (method arguments and top-level locals reside in the same top-level scope). In the following case the compiler will generate two closure types:

Two different lambda expressions will use the same closure type if they capture locals from the same scope. Lambdas a and b reside in the same closure:

In some cases, this behavior can cause some very serious memory-related issues. Here is an example:

It seems that the o variable should be eligible for garbage collection right after the delegate invocation a(). But this is not the case. Two lambda expressions share the same closure type:

This means that the lifetime of the closure instance is bound to the lifetime of the func field: the closure stays alive until the delegate func is reachable from the application. This can prolong the lifetime of the VeryExpensiveObject drastically causing, basically, a memory leak.

A similar issue happens when a local function and lambda expression captures variables from the same scope. Even if they capture different variables the closure type will be shared causing a heap allocation:

Compiles to:

As you can see all the locals from the top-level scope now become part of the closure class causing the closure allocation even when a local function and a lambda expression captures different variables.

Local functions 101

Here is a list of the most important aspects about local functions in C#:

  1. Local functions can define iterators.
  2. Local functions useful for eager validation for async methods and iterator blocks.
  3. Local functions can be recursive.
  4. Local functions are allocation-free if no conversion to delegates is happening.
  5. Local functions are slightly more efficient than anonymous functions due to a lack of delegate invocation overhead (****).
  6. Local functions can be declared after return statement separating main logic from the helpers.
  7. Local functions can “hide” a function with the same name declared in the outer scope.
  8. Local functions can be async and/or unsafe no other modifiers are allowed.
  9. Local functions can’t have attributes.
  10. Local functions are not very IDE friendly: there is no “extract local function refactoring” (yet) and if a code with a local function is partially broken you’ll get a lot of “squiggles” in the IDE.

(****) Here is a benchmark and the results:

To get this numbers you have to manually “decompile” a local function to a regular function. The reason for that is simple: such a simple function like “fn” is inlined by the runtime and the benchmark won’t show you real invocation cost. To get these numbers I used a static function marked with NoInlining attribute (unfortunately, you can’t use attributes with local functions).

Sergey Tepliakov
Sergey Tepliakov

Senior Software Engineer, Tools for Software Engineers

Follow Sergey   

No Comments.