今天我們來聊一聊 C# 中的本地函數。本地函數是從 C# 7.0 開始引入,並在 C# 8.0 和 C# 9.0 中加以完善的。
引入本地函數的原因
我們來看一下微軟 C# 語言首席設計師 Mads Torgersen 的一段話:
Mads Torgersen:
我們認為這個場景是有用的 —— 您需要一個輔助函數。 您僅能在單個函數中使用它,並且它可能使用包含在該函數作用域內的變量和類型參數。 另一方面,與 lambda 不同,您不需要將其作為第一類對象,因此您不必關心為它提供一個委托類型並分配一個實際的委托對象。 另外,您可能希望它是遞歸的或泛型的,或者將其作為迭代器實現。[1]
正是 Mads Torgersen 所說的這個原因,讓 C# 語言團隊添加了對本地函數的支持。
本人在近期的項目中多次用到本地函數,發現它比使用委托加 Lambda 表達式的寫法更加方便和清晰。
本地函數是什么
用最簡單的大白話來說,本地函數就是方法中的方法,是不是一下子就理解了?不過,這樣理解本地函數難免有點片面和膚淺。
我們來看一下官方對本地函數的定義:
本地函數是一種嵌套在另一個成員中的私有方法,僅能從包含它的成員中調用它。 [2]
定義中點出了三個重點:
- 本地函數是私有方法。
- 本地函數是嵌套在另一成員中的方法。
- 只能從定義該本地函數的成員中調用它,其它位置都不可以。
其中,可以聲明和調用本地函數的成員有以下幾種:
- 方法,尤其是迭代器方法和異步方法
- 構造函數
- 屬性訪問器
- 事件訪問器
- 匿名方法
- Lambda 表達式
- 析構函數
- 其它本地函數
舉個簡單的示例,在方法 M
中定義一個本地函數 add
:
public class C
{
public void M()
{
int result = add(100, 200);
// 本地函數 add
int add(int a, int b) { return a + b; }
}
}
本地函數都是私有的,目前可用的修飾符只有 async
、unsafe
、static
(靜態本地函數無法訪問局部變量和實例成員) 和 extern
四種。在包含成員中定義的所有本地變量和其方法參數都可在非靜態的本地函數中訪問。本地函數可以聲明在其包含成員中的任意位置,但通常的習慣是聲明在其包含成員的最后位置(即結束 }
之前)。
本地函數與 Lambda 表達式的比較
本地函數和我們熟知的 Lambda 表達式 [3]非常相似,比如上面示例中的本地函數,我們可以使用 Lambda 表達式實現如下:
public void M()
{
// Lambda 表達式
Func<int, int, int> add = (int a, int b) => a + b;
int result = add(100, 200);
}
如此看來,似乎選擇使用 Lambda 表達式還是本地函數只是編碼風格和個人偏好問題。但是,應該注意到,使用它們的時機和條件其實是存在很大差異的。
我們來看一下獲取斐波那契數列第 n 項的例子,其實現包含遞歸調用。
// 使用本地函數的版本
public static uint LocFunFibonacci(uint n)
{
return Fibonacci(n);
uint Fibonacci(uint num)
{
if (num == 0) return 0;
if (num == 1) return 1;
return checked(Fibonacci(num - 2) + Fibonacci(num - 1));
}
}
// 使用 Lambda 表達式的版本
public static uint LambdaFibonacci(uint n)
{
Func<uint, uint> Fibonacci = null; //這里必須明確賦值
Fibonacci = num => {
if (num == 0) return 0;
if (num == 1) return 1;
return checked(Fibonacci(num - 2) + Fibonacci(num - 1));
};
return Fibonacci(n);
}
命名
本地函數的命名方式和類中的方法類似,聲明本地函數的過程就像是編寫普通方法。 Lambda 表達式是一種匿名方法,需要分配給委托類型的變量,通常是 Action
或 Func
類型的變量。
參數和返回值類型
本地函數因為語法類似於普通方法,所以參數類型和返回值類型已經是函數聲明的一部分。Lambda 表達式依賴於為其分配的 Action
或 Func
變量的類型來確定參數和返回值的類型。
明確賦值
本地函數是在編譯時定義的方法。由於未將本地函數分配給變量,因此可以從包含它的成員的任意代碼位置調用它們。在本例中,我們將本地函數 Fibonacci
定義在其包含方法 LocFunFibonacci
的 return
語句之后,方法體的結束 }
之前,而不會有任何編譯錯誤。
而 Lambda 表達式是在運行時聲明和分配的對象。使用 Lambda 表達式時,必須先對其進行明確賦值:聲明要分配給它的 Action
或 Func
變量,並為其分配 Lambda 表達式,然后才能在后面的代碼中調用它們。在本例中,我們首先聲明並初始化了一個委托變量 Fibonacci
, 然后將 Lambda 表達式賦值給了該委托變量。
這些區別意味着使用本地函數創建遞歸算法會更輕松。因為在創建遞歸算法時,使用本地函數和使用普通方法是一樣的; 而使用 Lambda 表達式,則必須先聲明並初始化一個委托變量,然后才能將其重新分配給引用相同 Lambda 表達式的主體。
變量捕獲
我們使用 VS 編寫或者編譯代碼時,編譯器可以對代碼執行靜態分析,提前告知我們代碼中存在的問題。
看下面一個例子:
static int M1()
{
int num; //這里不用賦值默認值
LocalFunction();
return num; //OK
void LocalFunction() => num = 8; // 本地函數
}
static int M2()
{
int num; //這里必須賦值默認值(比如改為:int num = 0;),下面使用 num 的行才不會報錯
Action lambdaExp = () => num = 8; // Lambda 表達式
lambdaExp();
return num; //錯誤 CS0165 使用了未賦值的局部變量“num”
}
在使用本地函數時,因為本地函數是在編譯時定義的,編譯器可以確定在調用本地函數 LocalFunction
時明確分配 num
。 因為在 return 語句之前調用了 LocalFunction
,也就在 return 語句前明確分配了 num
,所以不會引發編譯異常。
而在使用 Lambda 表達式時,因為 Lambda 表達式是在運行時聲明和分配的,所以在 return 語句前,編譯器不能確定是否分配了 num
,所以會引發編譯異常。
內存分配
為了更好地理解本地函數和 Lambda 表達式在分配上的區別,我們先來看下面兩個例子,並看一下它們編譯后的代碼。
Lambda 表達式:
public class C
{
public void M()
{
int c = 300;
int d = 400;
int num = c + d;
//Lambda 表達式
Func<int, int, int> add = (int a, int b) => a + b + c + d;
var num2 = add(100, 200);
}
}
使用 Lambda 表達式,編譯后的代碼如下:
public class C
{
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int c;
public int d;
internal int <M>b__0(int a, int b)
{
return a + b + c + d;
}
}
public void M()
{
<>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
<>c__DisplayClass0_.c = 300;
<>c__DisplayClass0_.d = 400;
int num = <>c__DisplayClass0_.c + <>c__DisplayClass0_.d;
Func<int, int, int> func = new Func<int, int, int>(<>c__DisplayClass0_.<M>b__0);
int num2 = func(100, 200);
}
}
可以看出,使用 Lambda 表達式時,編譯后實際上是生成了包含實現方法的一個類,然后創建該類的一個對象並將其分配給了委托。因為要創建類的對象,所以需要額外的堆(heap)分配。
我們再來看一下具有同樣功能的本地函數實現:
public class C
{
public void M()
{
int c = 300;
int d = 400;
int num = c + d;
var num2 = add(100, 200);
//本地函數
int add(int a, int b) { return a + b + c + d; }
}
}
使用本地函數,編譯后的代碼如下:
public class C
{
[StructLayout(LayoutKind.Auto)]
[CompilerGenerated]
private struct <>c__DisplayClass0_0
{
public int c;
public int d;
}
public void M()
{
<>c__DisplayClass0_0 <>c__DisplayClass0_ = default(<>c__DisplayClass0_0);
<>c__DisplayClass0_.c = 300;
<>c__DisplayClass0_.d = 400;
int num = <>c__DisplayClass0_.c + <>c__DisplayClass0_.d;
int num2 = <M>g__add|0_0(100, 200, ref <>c__DisplayClass0_);
}
[CompilerGenerated]
private static int <M>g__add|0_0(int a, int b, ref <>c__DisplayClass0_0 P_2)
{
return a + b + P_2.c + P_2.d;
}
}
可以看出,使用本地函數時,編譯后只是在包含類中生成了一個私有方法,因此調用時不需要實例化對象,不需要額外的堆(heap)分配。
當本地函數中使用到其包含成員中的變量時,編譯器生成了一個結構體,並將此結構體的實例以引用(ref
)方式傳遞到了本地函數,這也有助於節省內存分配。
綜上所述,使用本地函數相比使用 Lambda 表達式更能節省時間和空間上的開銷。
范型和迭代器
本地函數支持范型,就像普通方法那樣;而 Lambda 表達式不支持范型,因為它們必須被分配給一個有具體類型的委托變量(它們能夠使用作用域內的外部范型變量,但那並不是一回事兒)。[4]
本地函數可以作為迭代器實現;而 Lambda 表達式不可以使用 yield return
和 yield break
關鍵字實現返回 IEnumerable<T>
的功能。
本地函數與異常
本地函數還有一個比較實用的功能是,可以在迭代器方法和異步方法中立即顯示異常。
我們知道,迭代器方法的主體是延遲執行的,所以僅在枚舉其返回的序列時才顯示異常,而並非在調用迭代器方法時。
我們來看一個經典的迭代器方法的例子:
static void Main(string[] args)
{
int[] list = new[] { 1, 2, 3, 4, 5, 6 };
var result = Filter(list, null);
Console.WriteLine(string.Join(',', result));
}
public static IEnumerable<T> Filter<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (predicate == null) throw new ArgumentNullException(nameof(predicate));
foreach (var element in source)
if (predicate(element))
yield return element;
}
運行上面的代碼,由於迭代器方法的主體是延遲執行的,所以拋出異常的位置將發生在 string.Join(',', result)
所在的行,也就是在枚舉返回的序列結果 result
時顯示,如圖:
如果我們把上面的迭代器方法 Filter
中的迭代器部分放入本地函數:
static void Main(string[] args)
{
int[] list = new[] { 1, 2, 3, 4, 5, 6 };
var result = Filter(list, null);
Console.WriteLine(string.Join(',', result));
}
public static IEnumerable<T> Filter<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (predicate == null) throw new ArgumentNullException(nameof(predicate));
//本地函數
IEnumerable<T> Iterator()
{
foreach (var element in source)
if (predicate(element))
yield return element;
}
return Iterator();
}
那么這時拋出異常的位置將發生在 Filter(list, null)
所在的行,也就是在調用 Filter
方法時顯示,如圖:
可以看出,使用了本地函數包裝迭代器邏輯的寫法,相當於把顯示異常的位置提前了,這有助於我們更快的觀察到異常並進行處理。
同理,在使用了 async
的異步方法中,如果把異步執行部分放入 async
的本地函數中,也有助於立即顯示異常。由於篇幅問題這里不再舉例,可以查看官方文檔。
總結
綜上所述,本地函數是方法中的方法,但它又不僅僅是方法中的方法,它還可以出現在構造函數、屬性訪問器、事件訪問器等等成員中; 本地函數在功能上類似於 Lambda 表達式,但它比 Lambda 表達式更加方便和清晰,在分配和性能上也比 Lambda 表達式略占優勢; 本地函數支持范型和作為迭代器實現; 本地函數還有助於在迭代器方法和異步方法中立即顯示異常。
作者 : 技術譯民
出品 : 技術譯站
https://github.com/dotnet/roslyn/issues/3911 C# Design Meeting Notes ↩︎
https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/local-functions 本地函數 ↩︎
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/lambda-expressions Lambda 表達式 ↩︎
https://stackoverflow.com/questions/40943117/local-function-vs-lambda-c-sharp-7-0 Local function vs Lambda C# 7.0 ↩︎