C# 中的本地函式

技術譯民發表於2020-11-24

今天我們來聊一聊 C# 中的本地函式。本地函式是從 C# 7.0 開始引入,並在 C# 8.0 和 C# 9.0 中加以完善的。

引入本地函式的原因

我們來看一下微軟 C# 語言首席設計師 Mads Torgersen 的一段話:

Mads Torgersen:
我們認為這個場景是有用的 —— 您需要一個輔助函式。 您僅能在單個函式中使用它,並且它可能使用包含在該函式作用域內的變數和型別引數。 另一方面,與 lambda 不同,您不需要將其作為第一類物件,因此您不必關心為它提供一個委託型別並分配一個實際的委託物件。 另外,您可能希望它是遞迴的或泛型的,或者將其作為迭代器實現。[1]

正是 Mads Torgersen 所說的這個原因,讓 C# 語言團隊新增了對本地函式的支援。
本人在近期的專案中多次用到本地函式,發現它比使用委託加 Lambda 表示式的寫法更加方便和清晰。

本地函式是什麼

用最簡單的大白話來說,本地函式就是方法中的方法,是不是一下子就理解了?不過,這樣理解本地函式難免有點片面和膚淺。

我們來看一下官方對本地函式的定義:

本地函式是一種巢狀在另一個成員中的私有方法,僅能從包含它的成員中呼叫它。 [2]

定義中點出了三個重點:

  1. 本地函式是私有方法
  2. 本地函式是巢狀在另一成員中的方法。
  3. 只能從定義該本地函式的成員中呼叫它,其它位置都不可以。

其中,可以宣告和呼叫本地函式的成員有以下幾種:

  • 方法,尤其是迭代器方法和非同步方法
  • 建構函式
  • 屬性訪問器
  • 事件訪問器
  • 匿名方法
  • Lambda 表示式
  • 解構函式
  • 其它本地函式

舉個簡單的示例,在方法 M 中定義一個本地函式 add

public class C
{
    public void M()
    {
        int result = add(100, 200);
        // 本地函式 add
        int add(int a, int b) { return a + b; }
    }
}

本地函式都是私有的,目前可用的修飾符只有 asyncunsafestatic(靜態本地函式無法訪問區域性變數和例項成員) 和 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 表示式是一種匿名方法,需要分配給委託型別的變數,通常是 ActionFunc 型別的變數。

引數和返回值型別

本地函式因為語法類似於普通方法,所以引數型別和返回值型別已經是函式宣告的一部分。Lambda 表示式依賴於為其分配的 ActionFunc 變數的型別來確定引數和返回值的型別。

明確賦值

本地函式是在編譯時定義的方法。由於未將本地函式分配給變數,因此可以從包含它的成員的任意程式碼位置呼叫它們。在本例中,我們將本地函式 Fibonacci 定義在其包含方法 LocFunFibonaccireturn 語句之後,方法體的結束 } 之前,而不會有任何編譯錯誤。

Lambda 表示式是在執行時宣告和分配的物件。使用 Lambda 表示式時,必須先對其進行明確賦值:宣告要分配給它的 ActionFunc 變數,併為其分配 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 returnyield 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 時顯示,如圖:

exception_1

如果我們把上面的迭代器方法 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 方法時顯示,如圖:

exception_2

可以看出,使用了本地函式包裝迭代器邏輯的寫法,相當於把顯示異常的位置提前了,這有助於我們更快的觀察到異常並進行處理。

同理,在使用了 async 的非同步方法中,如果把非同步執行部分放入 async 的本地函式中,也有助於立即顯示異常。由於篇幅問題這裡不再舉例,可以檢視官方文件

總結

綜上所述,本地函式是方法中的方法,但它又不僅僅是方法中的方法,它還可以出現在建構函式、屬性訪問器、事件訪問器等等成員中; 本地函式在功能上類似於 Lambda 表示式,但它比 Lambda 表示式更加方便和清晰,在分配和效能上也比 Lambda 表示式略佔優勢; 本地函式支援範型和作為迭代器實現; 本地函式還有助於在迭代器方法和非同步方法中立即顯示異常。


作者 : 技術譯民
出品 : 技術譯站


  1. https://github.com/dotnet/roslyn/issues/3911 C# Design Meeting Notes ↩︎

  2. https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/local-functions 本地函式 ↩︎

  3. https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/lambda-expressions Lambda 表示式 ↩︎

  4. https://stackoverflow.com/questions/40943117/local-function-vs-lambda-c-sharp-7-0 Local function vs Lambda C# 7.0 ↩︎

相關文章