C#7でのローカル関数の分析

C#でローカル関数を追加することは、当初私にとって冗長でした。 SergeyTブログの記事を読んだ後、この機能が本当に必要であることに気付きました。 だから、ローカル機能の必要性を疑い、それが何であるかをまだ知らない人は、知識のために先に進んでください!



ローカル関数は、別の関数内で関数を定義できるC#7の新しい機能です。



ローカル機能を使用する場合



ローカル関数の基本的な考え方は、匿名メソッドに非常によく似ています。場合によっては、名前付き関数の作成は、読者の認知的負荷の観点から非常に高価です。 本質的に、機能は別の機能に対してローカルであり、「外部」スコープを別の名前付きエンティティで汚染しても意味がありません。



匿名のデリゲートまたはラムダ式でも同じ動作を実現できるため、この機能は冗長であると考えるかもしれません。 しかし、これは常にそうではありません。 匿名関数には一定の制限があり、そのパフォーマンス特性はシナリオに適さない場合があります。



ケーススタディ1:イテレータブロックの前提条件



以下は、ファイルを1行ずつ読み取る単純な関数です。 ArgumentNullExceptionがいつスローされるかを知っていますか?

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);
      
      





bodyにyield returnを持つメソッドは特別です。 それらはイテレータブロックと呼ばれ、怠zyです。 つまり、これらのメソッドの実行は「オンデマンド」で行われ、メソッドのクライアントが結果のイテレーターでMoveNextを呼び出すときにのみ、それらのコードの最初のブロックが実行されます。 私たちの場合、これは、すべてのLINQステートメントも遅延しているため、 ProcessQueryメソッドでのみエラーが発生することを意味します。



ProcessQueryメソッドに ArgumentNullExceptionのコンテキストに関する十分な情報がないため、この動作は明らかに望ましくありません。 そのため、クライアントがReadLineByLineを呼び出したときに例外をすぐにスローするとよいでしょうが、クライアントが結果を処理するときはそうではありません。



この問題を解決するには、検証ロジックを別のメソッドに抽出する必要があります。 これは匿名関数の良い候補ですが、匿名デリゲートとラムダ式は反復ブロック(*)をサポートしていません。



(*)VB.NETのラムダ式には、イテレータブロックを含めることができます。

 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; } } }
      
      







ケーススタディ2:非同期メソッドの前提条件



非同期メソッドにも例外処理に関する同様の問題があります。asyncキーワードでマークされたメソッドによってスローされた例外は、返されたタスクに表示されます。

 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;
      
      





エラーが発生したときに大きな違いはないと考えるかもしれません。 しかし、これは真実とはほど遠い。 障害のあるタスクとは、メソッド自体が実行すべきことを実行できないことを意味します。 障害のあるタスクとは、問題がメソッド自体またはメソッドが依存するブロックの1つにあることを意味します。



信頼できる前提条件を確認することは、結果のタスクがシステムを通過するときに特に重要です。 この場合、いつ、何が問題になったかを理解することは非常に困難です。 ローカル関数はこの問題を解決できます:

 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; } }
      
      







ケーススタディ3:イテレータブロックを使用したローカル関数



ラムダ式内でイテレータを使用できないことに非常に悩まされました。 以下に簡単な例を示します。型階層(プライベートなものを含む)のすべてのフィールドを取得する場合は、継承階層を手動で確認する必要があります。 ただし、バイパスロジックは特定のメソッドに固有であり、最大限に「ローカライズ」する必要があります。

 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; } } }
      
      







ケーススタディ4:再帰的な匿名メソッド



匿名関数はデフォルトでは自分自身を参照できません。 この制限を回避するには、デリゲート型でローカル変数を宣言し、ラムダ式または匿名デリゲート内でこのローカル変数をキャプチャする必要があります。

 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; }
      
      





このアプローチは非常に読みやすいものではなく、ローカル関数を使用した次のソリューションはより自然に見えます。

 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; } }
      
      







ユースケース5:割り当ての問題が重要な場合



パフォーマンスが重要なアプリケーションで作業したことがある場合、匿名メソッドは安価ではないことがわかります。



ただし、ローカル関数の割り当てモデルは大きく異なります。

 public void Foo(int arg) { PrintTheArg(); return; void PrintTheArg() { Console.WriteLine(arg); } }
      
      





ローカル関数がローカル変数または引数をキャプチャする場合、C#コンパイラーは特別なクロージャー構造を生成し、そのインスタンスを作成し、生成された静的メソッドへの参照によって渡します。

 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); }
      
      





(コンパイラは、<and>などの無効な文字を含む名前を生成します。読みやすくするために、名前を変更し、コードを少し簡略化しました。)



ローカル関数は、インスタンスの状態、ローカル変数(***)、または引数をキャプチャできます。 管理ヒープでの割り当ては発生しません。

(***)ローカル関数で使用されるローカル変数は、ローカル関数の宣言の場所で確実に割り当てられなければなりません。



オブジェクトがマネージヒープ上に作成される場合がいくつかあります。



1.ローカル関数は、明示的または暗黙的にデリゲートに変換されます。

ローカル関数がインスタンスまたは静的フィールドをキャプチャするが、ローカル変数または引数をキャプチャしない場合、 デリゲート割り当てが発生します。

 public void Bar() { // Just a delegate allocation Action a = EmptyFunction; return; void EmptyFunction() { } }
      
      





ローカル関数がローカル/引数をキャプチャすると、クロージャとデリゲートが発生します

 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.ローカル関数はローカル変数/引数をキャプチャし、匿名関数は同じスコープから変数/引数をキャプチャします。

これはより微妙なケースです。



C#コンパイラは、各レキシカルスコープに対して個別のクロージャータイプを生成します(メソッド引数とローカルトップレベル変数は同じトップレベルスコープにあります)。 次の場合、コンパイラーは2種類のクロージャーを生成します。

 public void DifferentScopes(int arg) { { int local = 42; Func<int> a = () => local; Func<int> b = () => local; } Func<int> c = () => arg; }
      
      





2つの異なるラムダ式は、同じスコープから変数をキャプチャする場合、同じタイプのクロージャーを使用します。 ラムダ式aおよびbに対して生成されたメソッドは、同じクロージャータイプにあります。

 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); }
      
      





場合によっては、この動作が非常に深刻なメモリの問題を引き起こす可能性があります。 以下に例を示します。

 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; }
      
      





変数oデリゲートa()を呼び出した直後にガベージコレクションに使用できるようになっているようです。 しかし、2つのラムダ式は同じタイプのクロージャーを使用するため、これはそうではありません。

 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; }
      
      





これは、クロージャーインスタンスのライフタイムがfuncフィールドのライフタイムに関連付けられていることを意味します。アプリケーションコードからデリゲートにアクセスできる限り、クロージャーは存続します。 これにより、 VeryExpensiveObjectの寿命を延ばすことができます。これは、本質的にメモリリークの一種です。



ローカル関数とラムダ式が同じスコープから変数をキャプチャするときに、同様の問題が発生します。 異なる変数をキャプチャしても、クロージャのタイプは共通であり、オブジェクトが管理ヒープに割り当てられます。

 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; }
      
      





コンパイラによって次のように変換されます。

 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(); }
      
      





ご覧のとおり、上位スコープのすべてのローカル変数はクロージャークラスの一部になり、ローカル関数とラムダ式が異なる変数をキャプチャする場合でも、クロージャーオブジェクトの作成につながります。



ローカル機能101



以下は、C#のローカル関数の最も重要な側面のリストです。



(****)マイクロベンチマークの結果は次のとおりです。

 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; }
      
      





方法

平均

エラー

Stddev

DelegateInvocation

1.5041 ns

0.0060 ns

0.0053 ns

LocalFunctionInvocation

0.9298 ns

0.0063 ns

0.0052 ns



これらの数値を取得するには、ローカル関数を手動で通常の関数に「逆コンパイル」する必要があります。 この理由は単純です。「fn」などの単純な関数は実行時にインラインになり、テストでは呼び出しの実際のコストは表示されません。 これらの数値を取得するには、 NoInlining属性でマークされた静的関数を使用しました(残念ながら、ローカル関数では属性を使用できません)。



All Articles