本地函数(C# 编程指南)

本地函数是一种嵌套在另一成员中的类型的方法。 仅能从其包含成员中调用它们。 可以在以下位置中声明和调用本地函数:

  • 方法(尤其是迭代器方法和异步方法)
  • 构造函数
  • 属性访问器
  • 事件访问器
  • 匿名方法
  • Lambda 表达式
  • 终结器
  • 其他本地函数

从 C# 8.0 开始,本地函数参数可以从封闭作用域中隐藏局部变量和参数。

但是,不能在表达式主体成员中声明本地函数。

注意

在某些情况下,可以使用 lambda 表达式实现本地函数也支持的功能。 有关比较,请参阅本地函数与 lambda 表达式。

本地函数可使代码意图明确。 任何读取代码的人都可以看到,此方法不可调用,包含方法除外。 对于团队项目,它们也使得其他开发人员无法直接从类或结构中的其他位置错误调用此方法。

本地函数语法

本地函数被定义为包含成员中的嵌套方法。 其定义具有以下语法:

<modifiers> <return-type> <method-name> <parameter-list>

注意

<parameter-list> 不应包含使用上下文关键字value 命名的参数。 编译器创建临时变量“value”,其中包含引用的外部变量,这在以后会导致歧义,还可能导致意外行为。

可以将以下修饰符用于本地函数:

  • async
  • unsafe
  • static 静态本地函数无法捕获局部变量或实例状态。
  • extern 外部本地函数必须为 static。

在包含成员中定义的所有本地变量(包括其方法参数)都可在非静态本地函数中访问。

与方法定义不同,本地函数定义不能包含成员访问修饰符。 由于所有本地函数都是私有的,包括访问修饰符(如 private 关键字),因此会生成编译器错误 CS0106,“修饰符”private“对此项无效。

以下示例定义了一个名为 AppendPathSeparator 的本地函数,该函数对于名为 GetText 的方法是私有的:

private static string GetText(string path, string filename)
{
     var reader = File.OpenText($"{AppendPathSeparator(path)}{filename}");
     var text = reader.ReadToEnd();
     return text;

     string AppendPathSeparator(string filepath)
     {
        return filepath.EndsWith(@"\") ? filepath : filepath + @"\";
     }
}

可以将属性应用于本地函数、其参数和类型参数,如以下示例所示:

#nullable enable
private static void Process(string?[] lines, string mark)
{
    foreach (var line in lines)
    {
        if (IsValid(line))
        {
            // Processing logic...
        }
    }

    bool IsValid([NotNullWhen(true)] string? line)
    {
        return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
    }
}

前面的示例使用特殊属性来帮助编译器在可为空的上下文中进行静态分析。

本地函数和异常

本地函数的一个实用功能是可以允许立即显示异常。 对于迭代器方法,仅在枚举返回的序列时才显示异常,而非在检索迭代器时。 对于异步方法,在等待返回的任务时,将观察到异步方法中引发的任何异常。

以下示例定义 OddSequence 方法,用于枚举指定范围中的奇数。 因为它会将一个大于 100 的数字传递到 OddSequence 迭代器方法,该方法将引发 ArgumentOutOfRangeException。 如示例中的输出所示,仅当循环访问数字时才显示异常,而非检索迭代器时。

public class IteratorWithoutLocalExample
{
   public static void Main()
   {
      IEnumerable<int> xs = OddSequence(50, 110);
      Console.WriteLine("Retrieved enumerator...");

      foreach (var x in xs)  // line 11
      {
         Console.Write($"{x} ");
      }
   }

   public static IEnumerable<int> OddSequence(int start, int end)
   {
      if (start < 0 || start > 99)
         throw new ArgumentOutOfRangeException(nameof(start), "start must be between 0 and 99.");
      if (end > 100)
         throw new ArgumentOutOfRangeException(nameof(end), "end must be less than or equal to 100.");
      if (start >= end)
         throw new ArgumentException("start must be less than end.");

      for (int i = start; i <= end; i++)
      {
         if (i % 2 == 1)
            yield return i;
      }
   }
}
// The example displays the output like this:
//
//    Retrieved enumerator...
//    Unhandled exception. System.ArgumentOutOfRangeException: end must be less than or equal to 100. (Parameter 'end')
//    at IteratorWithoutLocalExample.OddSequence(Int32 start, Int32 end)+MoveNext() in IteratorWithoutLocal.cs:line 22
//    at IteratorWithoutLocalExample.Main() in IteratorWithoutLocal.cs:line 11

如果将迭代器逻辑放入本地函数,则在检索枚举器时会引发参数验证异常,如下面的示例所示:

public class IteratorWithLocalExample
{
   public static void Main()
   {
      IEnumerable<int> xs = OddSequence(50, 110);  // line 8
      Console.WriteLine("Retrieved enumerator...");

      foreach (var x in xs)
      {
         Console.Write($"{x} ");
      }
   }

   public static IEnumerable<int> OddSequence(int start, int end)
   {
      if (start < 0 || start > 99)
         throw new ArgumentOutOfRangeException(nameof(start), "start must be between 0 and 99.");
      if (end > 100)
         throw new ArgumentOutOfRangeException(nameof(end), "end must be less than or equal to 100.");
      if (start >= end)
         throw new ArgumentException("start must be less than end.");

      return GetOddSequenceEnumerator();

      IEnumerable<int> GetOddSequenceEnumerator()
      {
         for (int i = start; i <= end; i++)
         {
            if (i % 2 == 1)
               yield return i;
         }
      }
   }
}
// The example displays the output like this:
//
//    Unhandled exception. System.ArgumentOutOfRangeException: end must be less than or equal to 100. (Parameter 'end')
//    at IteratorWithLocalExample.OddSequence(Int32 start, Int32 end) in IteratorWithLocal.cs:line 22
//    at IteratorWithLocalExample.Main() in IteratorWithLocal.cs:line 8

本地函数与 Lambda 表达式

一目了然,本地函数和 lambda 表达式 相似。 在许多情况下,选择使用 Lambda 表达式还是本地函数是风格和个人偏好的问题。 但是,应该注意,从两者中选用一种的时机和条件其实是存在差别的。

让我们检查一下阶乘算法的本地函数实现和 lambda 表达式实现之间的差异。 下面是使用本地函数的版本:

public static int LocalFunctionFactorial(int n)
{
    return nthFactorial(n);

    int nthFactorial(int number) => number < 2 
        ? 1 
        : number * nthFactorial(number - 1);
}

此版本使用 Lambda 表达式:

public static int LambdaFactorial(int n)
{
    Func<int, int> nthFactorial = default(Func<int, int>);

    nthFactorial = number => number < 2
        ? 1
        : number * nthFactorial(number - 1);

    return nthFactorial(n);
}

命名

本地函数的命名方式与方法相同。 Lambda 表达式是一种匿名方法,需要分配给 delegate 类型的变量,通常是 Action 或 Func 类型。 声明本地函数时,此过程类似于编写普通方法;声明一个返回类型和一个函数签名。

函数签名和 Lambda 表达式类型

Lambda 表达式依赖于为其分配的 Action/Func 变量的类型来确定参数和返回类型。 在本地函数中,因为语法非常类似于编写常规方法,所以参数类型和返回类型已经是函数声明的一部分。

某些 lambda 表达式具有 自然类型,这使编译器能够推断 lambda 表达式的返回类型和参数类型。

明确赋值

Lambda 表达式是在运行时声明和分配的对象。 若要使用 lambda 表达式,需要明确分配它:必须声明分配给它的 Action/Func 变量以及分配给它的 lambda 表达式。 请注意,LambdaFactorial 必须先声明和初始化 Lambda 表达式 nthFactorial,然后再对其进行定义。 如果不这样做,将导致在给nthFactorial 分配值之前引用它时出现编译时错误。

本地函数在编译时定义。 由于未将它们分配给变量,因此可以从范围内的任意代码位置引用它们;在第一个示例 LocalFunctionFactorial 中,可以在 return 语句的之前或之后声明本地函数,而不会触发任何编译器错误。

这些区别意味着使用本地函数创建递归算法会更轻松。 你可以声明和定义一个调用自身的本地函数。 必须先声明 Lambda 表达式并为其分配默认值,然后才能将其重新分配给引用相同 lambda 表达式的正文。

实现为委托

Lambda 表达式在声明时转换为委托。 本地函数更加灵活,可以像传统方法一样编写,也可以作为委托编写。 本地函数仅在作为委托使用时才会转换为委托。

如果声明了本地函数,但只是通过像调用方法一样调用该函数来引用该函数,它将不会转换成委托。

变量捕获

明确分配的规则也会影响本地函数或 Lambda 表达式捕获的任何变量。 编译器可以执行静态分析,因此本地函数能够在封闭范围内明确分配捕获的变量。 请看以下示例:

int M()
{
    int y;
    LocalFunction();
    return y;

    void LocalFunction() => y = 0;
}

编译器可以确定 LocalFunction 在调用时明确分配 y。 因为在 LocalFunction 语句之前调用了 return,所以在 y 语句中明确分配了 return。

当局部函数捕获封闭范围内的变量时,就像委托类型一样,局部函数是用闭包来实现的。

堆分配

根据它们的用途,本地函数可以避免 Lambda 表达式始终需要的堆分配。 如果本地函数永远不会转换为委托,并且本地函数捕获的变量都不会被其他转换为委托的 lambda 或本地函数捕获,则编译器可以避免堆分配。

请看以下异步示例:

public async Task<string> PerformLongRunningWorkLambda(string address, int index, string name)
{
    if (string.IsNullOrWhiteSpace(address))
        throw new ArgumentException(message: "An address is required", paramName: nameof(address));
    if (index < 0)
        throw new ArgumentOutOfRangeException(paramName: nameof(index), message: "The index must be non-negative");
    if (string.IsNullOrWhiteSpace(name))
        throw new ArgumentException(message: "You must supply a name", paramName: nameof(name));

    Func<Task<string>> longRunningWorkImplementation = async () =>
    {
        var interimResult = await FirstWork(address);
        var secondResult = await SecondStep(index, name);
        return $"The results are {interimResult} and {secondResult}. Enjoy.";
    };

    return await longRunningWorkImplementation();
}

此 lambda 表达式的关闭包含 address、index和 name 变量。 对于本地函数,实现关闭的对象可以是 struct 类型。 该结构类型将通过引用传递给本地函数。 这种实现差异将节省分配资源。

lambda 表达式的实例化需要额外的内存分配,这可能会影响时间关键代码路径的性能。 本地函数不会产生此开销。

如果你知道本地函数不会转换为委托,并且本地函数捕获的变量都不会被其他转换为委托的 lambda 或本地函数捕获,则可以通过将本地函数声明为 static 本地函数来确保避免在堆上对其进行分配。

提示

启用 .NET 代码样式规则 IDE0062 以确保始终标记本地函数static。

注意

等效于此方法的本地函数还将类用于闭包。 是否将本地函数的闭包实现为 class 或 struct 是一个实现细节。 本地函数可能使用 struct,而 lambda 将始终使用 class。

public async Task<string> PerformLongRunningWork(string address, int index, string name)
{
    if (string.IsNullOrWhiteSpace(address))
        throw new ArgumentException(message: "An address is required", paramName: nameof(address));
    if (index < 0)
        throw new ArgumentOutOfRangeException(paramName: nameof(index), message: "The index must be non-negative");
    if (string.IsNullOrWhiteSpace(name))
        throw new ArgumentException(message: "You must supply a name", paramName: nameof(name));

    return await longRunningWorkImplementation();

    async Task<string> longRunningWorkImplementation()
    {
        var interimResult = await FirstWork(address);
        var secondResult = await SecondStep(index, name);
        return $"The results are {interimResult} and {secondResult}. Enjoy.";
    }
}

yield 关键字的用法

在本示例中尚未演示的最后一个优点是,可将本地函数作为迭代器实现,使用 yield return 语法生成一系列值。

public IEnumerable<string> SequenceToLowercase(IEnumerable<string> input)
{
    if (!input.Any())
    {
        throw new ArgumentException("There are no items to convert to lowercase.");
    }
    
    return LowercaseIterator();
    
    IEnumerable<string> LowercaseIterator()
    {
        foreach (var output in input.Select(item => item.ToLower()))
        {
            yield return output;
        }
    }
}

yield return 语句不允许在 lambda 表达式中使用。 有关详细信息,请参阅编译器错误 CS1621。

尽管本地函数看起来与 lambda 表达式相比显得多余,但它们实际上具有不同的目的和用途。 如果想要编写只在另一个方法的上下文中调用的函数,则使用本地函数更高效。

C# 语言规范

有关详细信息,请参阅 C# 语言规范中的本地函数声明部分。

另请参阅