📜 ⬆️ ⬇️

Analyzing local functions in C # 7

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; } } // When the error will happen? string fileName = null; // Here? var query = ReadLineByLine(fileName).Select(x => $"\t{x}").Where(l => l.Length > 10); // Or here? ProcessQuery(query); 

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; // No exceptions var task = GetAllTextAsync(fileName); // The following line will throw var lines = await task; 

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) { // Eager argument validation if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName)); return GetAllTextAsync(); async Task<string> GetAllTextAsync() { var result = await File.ReadAllTextAsync(fileName); Log($"Read {result.Length} lines from '{fileName}'"); return result; } } 


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:

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) { // Closure instantiation var c__DisplayClass0_ = new c__DisplayClass0_0() { arg = arg }; // Method invocation with a closure passed by ref Foo_g__PrintTheArg0_0(ref c__DisplayClass0_); } internal static void Foo_g__PrintTheArg0_0(ref c__DisplayClass0_0 ptr) { Console.WriteLine(ptr.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() { // Just a delegate allocation Action a = EmptyFunction; return; void EmptyFunction() { } } 

The closure and the delegate will be allocated if the local function captures local / arguments
 public void Baz(int arg) { // Local function captures an enclosing variable. // The compiler will instantiate a closure and a delegate Action a = EmptyFunction; return; void EmptyFunction() { Console.WriteLine(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() { // Body of the lambda 'a' return this.local; } internal int DifferentScopes_b__1() { // Body of the lambda 'a' return this.local; } } private sealed class c__DisplayClass0_1 { public int arg; internal int DifferentScopes_b__2() { // Body of the lambda 'c' return this.arg; } } public void DifferentScopes(int arg) { var closure1 = new c__DisplayClass0_0 { local = 42 }; var closure2 = new c__DisplayClass0_1() { arg = arg }; var a = new Func<int>(closure1.DifferentScopes_b__0); var b = new Func<int>(closure1.DifferentScopes_b__1); var c = new Func<int>(closure2.DifferentScopes_b__2); } 

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) { // This code is effectively unreachable Func<int> a = () => arg; } int local = 42; return Local(); int Local() => local; } 

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 #:

(****) 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).

Source: https://habr.com/ru/post/346174/


All Articles