深入探索 C# 本地函数的使用

总目录


前言

在C#编程中,本地函数为我们提供了一种在方法内部定义函数的强大功能。为开发者提供了更为灵活和高效的编码方式。无论是优化代码结构,还是提升逻辑的清晰度,本地函数都有着独特的优势。接下来,让我们深入探索 C# 本地函数的使用吧!


一、C# 本地函数是什么?

1. 基本概念

本地函数(Local functions)是在 C# 7.0 版本引入的,是一种嵌套在另一成员中的方法。 仅能从其包含成员中调用它们。简单来说,本地函数是一种嵌套在其他函数中的函数

可以在以下位置中声明和调用本地函数:

  • 方法(尤其是迭代器方法和异步方法)
  • 构造函数
  • 属性访问器
  • 事件访问器
  • 匿名方法
  • Lambda 表达式
  • 终结器
  • 其他本地函数
  • 但是,不能在 expression-bodied 成员中声明本地函数。

例如,在一个计算数学表达式的方法中,可以定义一个本地函数来处理特定的计算逻辑:

public static double CalculateExpression(double x, double y)
{
	//定义一个本地函数
    double Multiply()
    {
        return x * y;
    }
    // 调用本地函数
    return Multiply() + x + y;
}

这里的Multiply函数只在CalculateExpression方法内部可见,专注于实现乘法运算,使主方法的逻辑更加清晰。

2. 语法特性详解

1) 参数传递

  • 本地函数可以接受参数,并且可以使用与外部方法相同的参数传递方式,包括值传递、引用传递等。例如:
		private static int PerformOperation(int a, int b)
		{
			// 带有参数的本地函数
	    	int Add(int x, int y)
	    	{
	        	return x + y;
	    	}
	    	return Add(a, b);
		}
  • 本地函数可以捕获并访问其所在成员内部的局部变量和方法参数。这意味着你可以在不传递参数的情况下使用这些变量:
        public static double CalculateExpression(double x, double y)
        {
            // 定义一个局部变量
            double minValue = 100;

            double Multiply()
            {
                // 本地函数不传递参数,直接使用 所在方法的参数 和 变量 
                return x * y + minValue;
            }
            // 调用本地函数
            return Multiply() + x + y;
        }

2) 返回值类型

本地函数必须有明确的返回值类型,除非它是void类型。这一点与普通函数的要求一致,确保了函数调用的可预测性。

3)修饰符

  • 本地函数不能使用访问修饰符,因为它的作用域已经被限定在其定义的方法内部,不存在外部访问的情况。
  • 可以将以下修饰符用于本地函数:
    • async
    • unsafe
    • static 静态本地函数无法捕获局部变量或实例状态。
    • extern 外部本地函数必须为 static。

二、本地函数与异常

本地函数的一个实用功能是可以允许立即显示异常。

  • 对于迭代器方法,仅在枚举(或遍历)返回的序列时才显示异常,而非在检索迭代器时。
    • 如果将迭代器逻辑放入本地函数,则在检索枚举器时会引发参数验证异常
  • 对于异步方法,在等待返回的任务时,将观察到异步方法中引发的任何异常。

1. 在迭代器方法中使用本地函数

以下示例定义 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)  
      {
         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;
      }
   }
}

在这里插入图片描述
如示例中的输出所示,仅当使用foreach 循环访问数字时才显示异常,而非检索迭代器时。
在这里插入图片描述

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

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

在这里插入图片描述
在这里插入图片描述

2. 在异步方法中使用本地函数

  • 传统的异步方法
        static async Task Main(string[] args)
        {
            Console.WriteLine("start");
            await LocalFunctionAsync(); 
            Console.WriteLine("end");
        }

        static async Task NormalAsyncMethod()
        {
            try
            {
                // 模拟可能出错的异步操作
                await Task.Delay(1000);
                throw new Exception("Normal method exception");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Caught exception in normal async method: {ex.Message}");
            }
        }
  • 在异步方法中使用本地函数
        static async Task Main(string[] args)
        {
            Console.WriteLine("start");
            await LocalFunctionAsync(); 
            Console.WriteLine("end");
        }

        static async Task LocalFunctionAsync()
        {
            try
            {
                await LocalAsyncFunction();
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Caught exception in local async function: {ex.Message}");
            }

            async Task LocalAsyncFunction()
            {
                // 模拟可能出错的异步操作
                await Task.Delay(500);
                throw new Exception("Local function exception");
            }
        }

明显感觉代码结构更为清晰

三、本地函数与 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);
}

1. 命名

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

2. 参数和返回类型

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

3. 明确赋值

  • Lambda 表达式是在运行时声明和分配的对象。

    • 若要使用 lambda 表达式,需要明确分配它:必须声明分配给它的 Action/Func 变量以及分配给它的 lambda 表达式。 请注意,LambdaFactorial 必须先声明和初始化 Lambda 表达式 nthFactorial,然后再对其进行定义。 否则,会导致分配前引用 nthFactorial 时出现编译时错误。
  • 本地函数在编译时定义。

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

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

4. 实现为委托

  • Lambda 表达式在声明时转换为委托。
  • 本地函数更加灵活,可以像传统方法一样编写,也可以作为委托编写。 只有在用作委托时,本地函数才转换为委托。
  • 如果声明了本地函数,但只是通过像调用方法一样调用该函数来引用该函数,它将不会转换成委托。

5. 变量捕获

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

        static async Task Main(string[] args)
        {
            Console.WriteLine($"M1=>{M1()}");   // 输出 M1=>8
            Console.WriteLine($"M2=>{M2()}");   // 输出 M2=>8
        }

        static int M1()
        {
            int y;
            LocalFunction();
            return y;

            void LocalFunction() => y = 8;
        }

        static int M2()
        {
            int num = 0;    //这里必须赋值默认值(比如改为:int num = 0;),下面使用 num 的行才不会报错
            Action lambdaExp = () => num = 8; // Lambda 表达式
            lambdaExp();
            return num; //错误 CS0165 使用了未赋值的局部变量“num”
        }

在这里插入图片描述

6. 堆分配

  • lambda 表达式的实例化需要额外的内存分配,这可能会影响时间关键代码路径的性能。 本地函数不会产生此开销。
  • 本地函数可以避免 Lambda 表达式始终需要的堆分配。 如果你知道本地函数不会转换为委托,并且本地函数捕获的变量都不会被其他转换为委托的 lambda 或本地函数捕获,则可以通过将本地函数声明为 static 本地函数来确保避免在堆上对其进行分配。
  • 使用本地函数相较于使用 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);
    }
}

编译后的代码:

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)方式传递到了本地函数,这也有助于节省内存分配。

7. yield 关键字的用法

  • 可将本地函数作为迭代器实现,使用 yield return 语法生成一系列值
  • yield return 语句不允许在 lambda 表达式中使用。
public IEnumerable<string> SequenceToLowercase(IEnumerable<string> input)
{
	// 确定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;
        }
    }
}

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

扩展:

四、应用场景与注意事项

1. 应用场景

编码的时候,当我们判断:

  • 当下函数仅在一个地方使用时,不需要其他地方复用,使用本地函数。
  • 不想将某段逻辑使用公共方法暴露给外部的时候,想要隐藏实现细节时,使用本地函数。
  • 函数逻辑紧密关联于其所在的方法,作为其实现的一部分时,使用本地函数。
  1. 复杂业务逻辑分解:在处理复杂业务逻辑时,将不同的逻辑模块拆分成本地函数,可以使主方法的逻辑像流水线一样清晰更加简洁易读。例如,在处理用户信息时,可能需要验证用户信息先过滤掉无效用户,然后对有效用户的信息进行格式化,最后将格式化后的信息保存到数据库。可以将验证、格式化和保存操作分别定义为本地函数:
        public void ProcessUser(User users)
        {
            bool ValidateUser()
            {
                // 验证用户信息
                return true;
            }

            void FormatUser()
            {
                // 用户信息格式化
            }
            void SaveUserToDatabase()
            {
                // 保存到数据库
            }

            // 方法 主逻辑
            if (ValidateUser())
            {
                FormatUser();
                SaveUserToDatabase();
            }
        }
  1. 递归算法实现:递归算法通常需要在一个方法内部反复调用自身。使用本地函数可以将递归逻辑更好地封装在局部范围内,避免了在类中定义多个公共方法。以计算斐波那契数列为例:
		public static int Fibonacci(int n)
		{
		    int Fib(int num)
		    {
		        if (num <= 1)
		        {
		            return num;
		        }
		        return Fib(num - 1) + Fib(num - 2);
		    }
		    return Fib(n);
		}
  1. 事件处理中的应用:在事件处理程序中,如果逻辑较为复杂,使用本地函数可以将事件处理逻辑进一步细化。比如,在一个图形界面应用中,按钮点击事件可能需要进行一系列的数据验证和界面更新操作:
		private void button_Click(object sender, EventArgs e)
		{
		    bool ValidateInput()
		    {
		        // 输入验证逻辑
		        return true;
		    }
		    void UpdateUI()
		    {
		        // 更新界面逻辑
		    }
		    if (ValidateInput())
		    {
		        UpdateUI();
		    }
		}
  1. 辅助方法:当一个方法需要一些辅助功能来完成特定任务,但这些辅助功能又不适合作为公共方法暴露给外部时,可以使用本地函数。例如,在一个计算几何图形面积的方法中,可能需要一些辅助函数来计算图形的边长或角度,这些辅助函数可以定义为本地函数。

2. 注意事项

1) 优点

  1. 增强代码可读性:当一个复杂的方法包含多个子任务时,将每个子任务提取为本地函数,可以使主方法的逻辑更加清晰。例如,在处理文件读取和数据解析的方法中,将数据解析部分写成本地函数,能让主方法专注于文件读取的流程控制。
  2. 提高代码可维护性:如果某个功能需要修改,只需要在本地函数内部进行更改,而不会影响到其他不相关的代码。这降低了代码修改时引入错误的风险。
  3. 访问局部变量:本地函数可以直接访问其所在方法的局部变量,包括参数。这使得代码编写更加方便,无需将这些变量作为参数传递给本地函数。
  4. 减少命名冲突:由于本地函数的作用域仅限于其所在的方法内部,所以不用担心与其他地方的函数名冲突。这在大型项目中尤为重要,能够避免命名空间污染。

2) 缺点

  1. 作用域限制:本地函数只能在其定义的方法内部调用,这意味着它不能在其他方法中复用。如果需要在多个方法中使用相同的功能,就需要在每个方法中重新定义本地函数,或者将其提取为独立的方法。
  2. 调试难度增加:在调试包含本地函数的代码时,由于本地函数的调用栈相对较深,可能会增加调试的难度。开发人员需要更加仔细地跟踪代码的执行流程。
  3. 性能影响:虽然现代编译器在优化本地函数方面做得很好,但在某些极端情况下,过多的本地函数调用可能会带来一定的性能开销。这是因为每次调用本地函数都需要进行额外的栈操作。

3) 注意事项

  1. 作用域限制带来的复用问题:由于本地函数的作用域局限于定义它的方法内部,当需要在多个方法中复用相同的逻辑时,需要谨慎考虑。要么在每个方法中重复定义本地函数,要么将其提取为独立的公共方法。
  2. 性能影响:虽然现代编译器在优化本地函数调用方面已经做得很好,但在某些对性能要求极高的场景下,频繁的本地函数调用可能会带来一定的性能开销。在这些情况下,需要进行性能测试和优化。
  3. 调试难度:在调试包含本地函数的代码时,由于函数调用栈的复杂性,可能会增加调试的难度。开发人员需要熟悉调试工具,以便准确跟踪代码的执行流程。

五、总结

C# 本地函数为开发者提供了一种强大的工具,能够优化代码结构,提升代码的可读性和可维护性。通过合理运用本地函数,我们可以将复杂的业务逻辑进行模块化处理,使代码更加清晰易懂。然而,在使用过程中,我们也需要注意其作用域限制、性能影响和调试难度等问题。


结语

回到目录页:C# 知识汇总
希望以上内容可以帮助到大家,如文中有不对之处,还请批评指正。


参考资料:
本地函数(C# 编程指南)
C# 中的本地函数

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

鲤籽鲲

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值