局部函數是C 7中的一個新功能,允許在一個函數中定義另一個函數。 何時使用局部函數? 局部函數的主要功能與匿名方法非常相似:在某些情況下,創建一個命名函數在讀者的認知負擔方面代價太大。有時,函數本身就是另一個函數的部分邏輯,因此用一個單獨的命名實體來污染“外部”範圍是毫無意義的。 您可能認為此功能是 ...
局部函數是C# 7中的一個新功能,允許在一個函數中定義另一個函數。
何時使用局部函數?
局部函數的主要功能與匿名方法非常相似:在某些情況下,創建一個命名函數在讀者的認知負擔方面代價太大。有時,函數本身就是另一個函數的部分邏輯,因此用一個單獨的命名實體來污染“外部”範圍是毫無意義的。
您可能認為此功能是多餘的,因為匿名委托或Lambda表達式可以實現相同的行為。但事實並非如此,匿名函數有一定的限制,其特征可能不適合您的場景。
用例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;
}
}
// 什麼時候發生錯誤?
string fileName = null;
// 這裡?
var query = ReadLineByLine(fileName).Select(x => $"\t{x}").Where(l => l.Length > 10);
// 還是這裡?
ProcessQuery(query);
包含yield
return
的方法很特殊。它們叫做 迭代器塊(Iterator Blocks),它們很懶。這意味著這些方法的執行是“按需”發生的,只有當方法的客戶端調用MoveNext
生成迭代器時,才會執行它們中的第一個代碼塊。在我們的例子中,這意味著錯誤只會在ProcessQuery
方法中發生,因為所有的LINQ操作符都是懶惰的。
顯然,該行為是不可取的,因為該ProcessQuery
方法拋出的異常ArgumentNullException
將不具有關於該上下文的足夠信息。所以最好儘早拋出異常 - 客戶端調用ReadLineByLine
時,而不是當客戶端處理結果時。
為瞭解決這個問題,我們需要將驗證邏輯提取到一個單獨的方法中。匿名函數是最佳候選,但匿名委托和Lambda表達式不支持迭代器塊:
VB.NET中的 Lambda表達式支持迭代器塊。
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
關鍵字的方法中拋出的任何異常,會在一個失敗的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;
// 無異常
var task = GetAllTextAsync(fileName);
// 以下行將拋出異常
var lines = await task;
從技術上說,async
是一個上下文關鍵字,但這並不改變我的觀點。
您可能認為錯誤發生時沒有太大差異,但這遠非如此。失敗的Task意味著該方法本身未能做到應該做的事情,問題出在方法本身或方法所依賴的某一個構建塊中。
在系統中傳遞結果Task時,急切的先決條件驗證尤為重要。在這種情況下,很難理解什麼時候出現什麼問題。局部函數可以解決這個問題:
public static Task<string> GetAllTextAsync(string fileName)
{
// 提前參數驗證
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:迭代器塊的局部函數
不能在Lambda表達式中使用迭代器是一個非常麻煩的問題。這是一個簡單的例子:如果要獲取類型層次結構中的所有欄位(包括私有的),則必須手動遍歷繼承層次結構。但遍歷邏輯是特定方法的,應儘可能保持局部可用:
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:遞歸匿名方法
預設情況下,匿名函數無法引用自身。要解決此限制,您應該聲明一個委托類型的局部變數,然後在Lambda表達式或匿名委托中使用該局部變數:
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:記憶體分配
如果您曾經開發過一個性能要求非常高的的應用程式,應該知道匿名方法的開銷也不小:
- 委托調用的開銷(非常小,但確實存在);
- 如果Lambda捕獲本地變數或封閉方法的參數,則需要分配2個堆記憶體(一個用於閉包實例,另一個用於委托本身);
- 如果Lambda捕獲一個封閉的實例狀態,則需要分配1個堆記憶體(只是分配委托);
- 只有當Lambda沒有捕獲任何東西或捕獲靜態時,分配0個堆記憶體。
但是局部函數的分配模式不同。
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);
}
(編譯器生成無效字元的名稱,例如<
和>
。為了提高可讀性,我更改了名稱並簡化了代碼。)
局部函數可以捕獲實例狀態、局部變數或參數,不會發生堆記憶體分配。
局部函數中使用的局部變數應該在局部函數聲明站點中明確指定。
堆記憶體分配將發生的情況很少:
- 局部函數被明確地或隱式地轉換為委托。
如果局部函數捕獲靜態/實例欄位但不捕獲局部變數/參數,則只會發生委托分配。
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); }
}
- 本地函數捕獲局部變數/參數,匿名函數從同一範圍捕獲變數/參數。
這種情況更為微妙。
C#編譯器為每個詞法範圍生成一個不同的閉包類型(方法參數和頂級局部變數駐留在同一個頂級範圍內)。在以下情況中,編譯器將生成兩個閉包類型:
public void DifferentScopes(int arg)
{
{
int local = 42;
Func<int> a = () => local;
Func<int> b = () => local;
}
Func<int> c = () => arg;
}
兩個不同的Lambda表達式如果它們從同一範圍捕獲局部變數,將使用相同的閉包類型,Lambda 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;
}
在委托調用a()
之後,變數o
似乎應該符合垃圾回收的條件,但事實並非如此,兩個Lambda表達式共用相同的閉包類型:
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
欄位的生命周期:閉包保持活動,直到應用程式到達委托func
。這可以延長VeryExpensiveObject
生命周期,這基本上會導致記憶體泄漏。
當局部函數和Lambda表達式捕獲來自同一範圍的變數時,會發生類似的問題。即使捕獲不同的變數,封閉類型將被共用,導致堆分配:
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();
}
正如您可以看到,頂層作用域中的所有局部變數現在都成為封閉類的一部分,即使當局部函數和Lambda表達式捕獲不同的變數時也會導致關閉分配。
總結
以下是C#中關於局部函數的最重要特性:
- 局部函數可以定義迭代器;
- 局部函數對非同步方法和迭代器塊的預先驗證非常有用;
- 局部函數可以遞歸;
- 如果沒有向委托進行轉換,局部函數是免分配的;
- 由於缺少委托調用開銷,局部函數的效率比匿名函數稍高;
- 局部函數可以在返回語句之後聲明,它將主邏輯與輔助函數分開;
- 局部函數可以“隱藏”在外部範圍中聲明的具有相同名稱的函數;
- 局部函數可以使用
async
和/或unsafe
修飾符,不允許使用其它修飾符; - 局部函數無法使用屬性;
- 局部函數在IDE中還不是非常友好:沒有“提取局部函數重構”(目前為止),如果一個局部函數的代碼被破壞了,您將在IDE中收到很多“波浪線”。
這是Benchmark測試結果:
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 | Allocated |
---|---|---|---|---|
DelegateInvocation | 2.3035 ns | 0.0847 ns | 0.0869 ns | 0 B |
LocalFunctionInvocation | 0.0142 ns | 0.0176 ns | 0.0137 ns | 0 B |
不要因為差異而感到困惑,它看起來很大,但我幾乎從來沒有看到委托調用開銷造成的真正問題。
原文:《Dissecting the local functions in C# 7》https://blogs.msdn.microsoft.com/seteplia/2017/10/03/dissecting-the-local-functions-in-c-7/
翻譯:Sweet Tang
本文地址:http://www.cnblogs.com/tdfblog/p/dissecting-the-local-functions-in-c-7.html
歡迎轉載,請在明顯位置給出出處及鏈接。