Adding local functions in C # was originally unnecessary for me. After reading the article in the blog
SergeyT , I realized that this feature is really needed. So, who doubts the need for local functions and who still does not know what it is, go for knowledge!
Local Functions is a new feature in C # 7 that allows you to define a function within another function.
When to use local functions?
The basic idea of ​​local functions is very similar to anonymous methods: in some cases the creation of a named function is too expensive in terms of cognitive load on the reader. Sometimes functionality, in its essence, is local to another function, and there is no reason to contaminate the “external” scope with a separate named entity.
You might think that this possibility is redundant, because the same behavior can be achieved with anonymous delegates or lambda expressions. But it's not always the case. Anonymous functions have certain limitations, and their performance characteristics may not be suitable for your scripts.
')
Usage example 1: prerequisites in iterator blocks
Here is a simple function that reads a file in rows. Do you know when ArgumentNullException will be thrown?
public static IEnumerable<string> ReadLineByLine(string fileName) { if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName)); foreach (var line in File.ReadAllLines(fileName)) { yield return line; } }
Methods with a
yield return in the body are special. They are called
iterator blocks , and they are lazy. This means that the execution of these methods occurs “on demand”, and the first block of code in them will be executed only when the client of the method calls
MoveNext on the resulting iterator. In our case, this means that the error will occur only in the
ProcessQuery method, because all LINQ operators are also lazy.
Obviously, this behavior is undesirable because the
ProcessQuery method
will not have sufficient information about the context
ArgumentNullException . Therefore, it would be nice to throw an exception immediately - when the client calls
ReadLineByLine , but not when the client processes the result.
To solve this problem, we need to extract the validation logic into a separate method. This is a good candidate for an anonymous function, but anonymous delegates and lambda expressions do not support iterator blocks (*):
(*) Lambda expressions in VB.NET can have an iterator block.
public static IEnumerable<string> ReadLineByLine(string fileName) { if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName)); return ReadLineByLineImpl(); IEnumerable<string> ReadLineByLineImpl() { foreach (var line in File.ReadAllLines(fileName)) { yield return line; } } }
Usage example 2: prerequisites in asynchronous methods
Asynchronous methods have a similar problem with exception handling: any exception created by a method marked with the
async keyword appears in the returned task:
public static async Task<string> GetAllTextAsync(string fileName) { if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName)); var result = await File.ReadAllTextAsync(fileName); Log($"Read {result.Length} lines from '{fileName}'"); return result; } string fileName = null;
You might think that when an error occurs there is not much difference. But this is far from the truth. A faulted task means that the method itself could not accomplish what it was supposed to do. A faulty task means that the problem lies in the method itself or in one of the blocks on which the method depends.
Verifying reliable preconditions is especially important when the resulting task is passed through the system. In this case, it would be very difficult to understand when and what went wrong. Local function can solve this problem:
public static Task<string> GetAllTextAsync(string fileName) {
Usage example 3: local function with iterator blocks
I was very annoyed that you cannot use iterators inside lambda expressions. Here is a simple example: if you want to get all the fields in a type hierarchy (including private), you need to go through the inheritance hierarchy manually. But the traversal logic is specific to a particular method and should be maximally “localized”:
public static FieldInfo[] GetAllDeclaredFields(Type type) { var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; return TraverseBaseTypeAndSelf(type) .SelectMany(t => t.GetFields(flags)) .ToArray(); IEnumerable<Type> TraverseBaseTypeAndSelf(Type t) { while (t != null) { yield return t; t = t.BaseType; } } }
Usage example 4: recursive anonymous method
Anonymous functions by default cannot refer to themselves. To circumvent this restriction, you must declare a local variable with a delegate type, and then capture this local variable inside a lambda expression or an anonymous delegate:
public static List<Type> BaseTypesAndSelf(Type type) { Action<List<Type>, Type> addBaseType = null; addBaseType = (lst, t) => { lst.Add(t); if (t.BaseType != null) { addBaseType(lst, t.BaseType); } }; var result = new List<Type>(); addBaseType(result, type); return result; }
This approach is not very readable, and the following solution with a local function seems more natural:
public static List<Type> BaseTypesAndSelf(Type type) { return AddBaseType(new List<Type>(), type); List<Type> AddBaseType(List<Type> lst, Type t) { lst.Add(t); if (t.BaseType != null) { AddBaseType(lst, t.BaseType); } return lst; } }
Usage example 5: when allocation issues matter
If you have ever worked on a performance-critical application, then you know that anonymous methods are not cheap:
- Overhead for calling a delegate (very small, but they exist).
- Allocation of 2 objects in the managed heap, if the lambda expression captures a local variable or method argument (one for the closure instance and the other for the delegate itself).
- Allocation of 1 object in a managed heap, if the lambda expression captures the instance fields of the object.
- The absence of allocations will be only if the lambda expression does not capture anything or operates only with static members.
But the allocation model for local functions is significantly different.
public void Foo(int arg) { PrintTheArg(); return; void PrintTheArg() { Console.WriteLine(arg); } }
If a local function captures a local variable or argument, then the C # compiler generates a special closure structure, creates its instance and passes it by reference to the generated static method:
internal struct c__DisplayClass0_0 { public int arg; } public void Foo(int arg) {
(The compiler generates names with invalid characters, such as <and>. To improve readability, I changed the names and simplified the code a bit.)
A local function can capture an instance state, local variables (***), or arguments. No allocation will take place in the managed heap.
(***) Local variables used in a local function must be defined (definitely assigned) at the place where the local function is declared.
There are several cases when an object will be created on a managed heap:
1. The local function is explicitly or implicitly converted to a delegate.
Delegate allocation will occur if a local function captures the fields of an instance or static field, but does not capture local variables or arguments.
public void Bar() {
The closure and the delegate will be allocated if the local function captures local / arguments
public void Baz(int arg) {
2. A local function captures a local variable / argument, and an anonymous function captures a variable / argument from the same scope.
This case is more subtle.
The C # compiler generates a separate closure type for each lexical scope (method arguments and top-level local variables are in the same top-level scope). In the following case, the compiler will generate two types of closures:
public void DifferentScopes(int arg) { { int local = 42; Func<int> a = () => local; Func<int> b = () => local; } Func<int> c = () => arg; }
Two different lambda expressions use the same type of closure if they capture variables from the same scope. The generated methods for
a and
b lambda expressions are in the same type of closure:
private sealed class c__DisplayClass0_0 { public int local; internal int DifferentScopes_b__0() {
In some cases, this behavior can cause some very serious memory problems. Here is an example:
private Func<int> func; public void ImplicitCapture(int arg) { var o = new VeryExpensiveObject(); Func<int> a = () => o.GetHashCode(); Console.WriteLine(a()); Func<int> b = () => arg; func = b; }
It seems that the variable
o should be available for garbage collection immediately after calling delegate
a () . But this is not the case, since two lambda expressions use the same type of closure:
private sealed class c__DisplayClass1_0 { public VeryExpensiveObject o; public int arg; internal int ImplicitCapture_b__0() => this.o.GetHashCode(); internal int ImplicitCapture_b__1() => this.arg; } private Func<int> func; public void ImplicitCapture(int arg) { var c__DisplayClass1_ = new c__DisplayClass1_0() { arg = arg, o = new VeryExpensiveObject() }; var a = new Func<int>(c__DisplayClass1_.ImplicitCapture_b__0); Console.WriteLine(func()); var b = new Func<int>(c__DisplayClass1_.ImplicitCapture_b__1); this.func = b; }
This means that
the lifetime of the closure instance is tied to the lifetime of the func field : the closure remains alive as long as the delegate is accessible from the application code. This can extend the lifetime of
VeryExpensiveObject , which is essentially a kind of memory leak.
A similar problem occurs when a local function and a lambda expression capture variables from the same scope. Even if they capture different variables, the type of closure will be common, causing the object to be allocated on the managed heap:
public int ImplicitAllocation(int arg) { if (arg == int.MaxValue) {
It will be converted by the compiler to:
private sealed class c__DisplayClass0_0 { public int arg; public int local; internal int ImplicitAllocation_b__0() => this.arg; internal int ImplicitAllocation_g__Local1() => this.local; } public int ImplicitAllocation(int arg) { var c__DisplayClass0_ = new c__DisplayClass0_0 { arg = arg }; if (c__DisplayClass0_.arg == int.MaxValue) { var func = new Func<int>(c__DisplayClass0_.ImplicitAllocation_b__0); } c__DisplayClass0_.local = 42; return c__DisplayClass0_.ImplicitAllocation_g__Local1(); }
As you can see, all local variables from the upper scope are now becoming part of the closure class, which creates a closure object, even when the local function and lambda expression captures different variables.
Local Functions 101
The following is a list of the most important aspects of local functions in C #:
- Local functions can define iterator blocks.
- Local functions are useful for immediate (eager) checking of preconditions in asynchronous methods and iterator blocks.
- Local functions can be recursive.
- Local functions do not allocate on the heap, unless they are converted to delegates.
- Local functions are slightly more efficient than anonymous functions due to the lack of overhead for delegate calls (****).
- Local functions can be declared after the return statement, which allows us to separate the main logic of the method from the auxiliary one.
- Local functions can "hide" a function with the same name declared in the outer scope.
- Local functions can be asynchronous and / or unsafe (unsafe); other modifiers are not allowed.
- Local functions cannot have attributes.
- Local functions are not very friendly to the IDE: there is no “refactoring for allocating local methods” (R # 2017.3 already supports this feature. - note), and if the code with the local function does not compile, you will get a lot of underscores. in IDE.
(****) Here are the results of the microbenchmark:
private static int n = 42; [Benchmark] public bool DelegateInvocation() { Func<bool> fn = () => n == 42; return fn(); } [Benchmark] public bool LocalFunctionInvocation() { return fn(); bool fn() => n == 42; }
Method
| Mean
| Error
| Stddev
|
DelegateInvocation
| 1.5041 ns
| 0.0060 ns
| 0.0053 ns
|
LocalFunctionInvocation
| 0.9298 ns
| 0.0063 ns
| 0.0052 ns
|
To get these numbers, you need to manually "decompile" a local function into a regular function. The reason for this is simple: a function as simple as “fn” will be inline (runtime) and the test will not show the actual cost of the call. To get these numbers, I used a static function marked with the
NoInlining attribute (unfortunately, you cannot use attributes with local functions).