新来的总监,把C#闭包讲得那叫一个透彻

闭包作为前端面试的必考题目,常让1-3年工作经验的Javascripter感到困惑,我的主力语言C#/GO均有闭包。

1. 闭包:关键点在于函数是否捕获了其外部作用域的变量

闭包的形成: 定义函数时, 函数引用了其外部作用域的变量, 之后就形成了闭包。

闭包的结果: 引用的变量和定义的函数都会一同存在(即使已经脱离了函数定义/引用的变量的作用域),一直到闭包被消灭。

    public  static Action Closure()
    {
      var x = 1;
      Action action= () =>
         {
             var y = 1;
             var result = x + y;
             Console.WriteLine(result);
             x++;
         };
         return action;
    }

public static void  Main() {
       var  a=Closure();
        a();
        a();
}
 //  调用函数输出
  2
  3

委托action是一个函数,它使用了“x”这个外部作用域的变量(x变量不是函数内局部变量),变量引用将被捕获形成闭包。

即使action被返回了(即使“x”已经脱离了它被引用时的作用域环境(Closure)),但是两次执行能输出2,3 说明它脱离原引用环境仍然能用。


当你在代码调试器(debugger)里观察“action”时,可以看到一个Target属性,里面封装了捕获的x变量:

实际上,委托,匿名函数和lambda都是继承自Delegate类
Delegate不允许开发者直接使用,只有编译器才能使用, 也就是说delegate Action都是语法糖。

  • Method:MethodInfo反射类型- 方法执行体
  • Target:当前委托执行的对象,这些语法糖由编译器生成了继承自Delegate类型的对象,包含了捕获的自由变量。

再给一个反例:

public class Program
{
    private static int x = 1; // 静态字段
    public static void Main()
    {
        var  action = NoClosure();
        action();
        action(); 
    }
    
   public  static Action NoClosure(){
        Action action=()=>{
            var  y =1;
            var sum = x+y;
            Console.WriteLine($"sum = { sum }");
            x++;
        };
        return action;
    }
}

x 是静态字段,在程序中有独立的存储区域, 不在线程的函数堆栈区,不属于某个特定的作用域。

匿名函数使用了 x,但没有捕获外部作用域的变量,因此不构成闭包, Target属性对象无捕获的字段。

从编程设计的角度:闭包开创了除全局变量传值, 函数参数传值之外的第三种变量使用方式。

2. 闭包的形成时机和效果

闭包是词法闭包的简称,维基百科上是这样定义的:
在计算机科学中,闭包是在词法环境中绑定自由变量的一等函数”。

闭包的形成时机:

  • 一等函数
  • 外部作用域变量

闭包的形态:
会捕获闭包函数内引用的外部作用域变量, 一直持有,直到闭包函数不再使用被销毁。

内部实现是形成了一个对象(包含执行函数和捕获的变量,参考Target对象), 只有形成堆内存,才有后续闭包销毁的行为,当闭包这个对象不再被引用时,闭包被GC清理。

闭包的生命周期:
离不开作用域这个概念,函数理所当然管控了函数内的局部变量作用域,但当它引用了外部有作用域的变量时, 就形成了闭包函数。

当闭包(例如一个委托或 lambda 表达式)不再被任何变量、对象或事件持有引用时,它就变成了“不可达”对象, 闭包被gc清理,其实就是堆内存被清理。

2.1 一等函数

一等函数很容易理解,就是在各语言, 函数被认为是某类数据类型, 定义函数就成了定义变量, 函数也可以像变量一样被传递。

很明显,在C#中我们常使用的匿名函数、lambda表达式都是一等函数。

Func<string,string> myFunc = delegate(string var1)
                                {
                                    return "some value";   
                                };
Func<string,string> myFunc = var1 => "some value";  

string myVar = myFunc("something");

2.2 自由变量

在函数中被引用的外部作用域变量, 注意, 这个变量是外部有作用域的变量,也就说排除全局变量(这些变量在程序的独立区域, 不属于任何作用域)。

public void Test() 
{
       var myVar = "this is good";
       Func<string,string> myFunc = delegate(string var1)
                                {
                                    return var1 + myVar;   
                                };
}

上面这个示例,myFunc形成了闭包,捕获了myVar这个外部作用域的变量;
即使Test函数返回了委托myFunc(脱离了定义myVar变量的作用域),闭包依然持有myVar的变量引用,
注意,引用变量,并不是使用当时变量的副本值

我们再回过头来看结合了线程调度的闭包面试题。

3. 闭包函数关联线程调度: 依次打印连续的数字

 static void Closure1()
{
    for (int i = 0; i < 10; i++)
    {                 
         Task.Run(()=> Console.WriteLine(i));
    }
 }

每次输出数字不固定

并不是预期的 0.1.2.3.4.5.6.7.8.9

首先形成了闭包函数()=> Console.WriteLine(i), 捕获了外部有作用域变量i的引用, 此处捕获的变量i相对于函数是全局变量。
但是Task调度闭包函数的时机不确定, 所以打印的是被调度时引用的变量i值。

数字符合但乱序:为每个闭包函数绑定独立变量

循环内增加局部变量, 解绑全局变量 (或者可以换成foreach,foreach相当于内部给你整了一个局部变量)。

能输出乱序的0,1,2,3,4,5,6,7,8,9

因为每次循环内产生的闭包函数捕获了对应的局部变量j,这样每个任务执行环境均独立维护了一个变量j, 这个j不是全局变量, 但是由于Task启动时机依然不确定,故是乱序。

数字符合且有序

核心是解决Task调度问题, 但是调度顺序的不确定性导致这个坑其实不好填, 故应该推翻之前打印任务id的思路。

新的思路是:一个共享变量,每个任务打印该变量自增的一个阶段,但是该自增不允许被打断。

 public static void Main(string[] args)
    {
        var s =0;
        var  lo = new Program();
        for (int i = 0; i < 10; i++)
        {              
           Task.Run(()=> 
            { 
                lock(lo)
                {
                    Console.WriteLine(s);   // 依然形成了闭包函数, 之后闭包函数被线程调度
                    s++;
                }                
            });
        }
        Thread.Sleep(2000);
    }  // 上面是一个明显的锁争用

3.Golang闭包的应用

gin 框架中中间件的默认形态是:

package middleware
func AuthenticationMiddleware(c *gin.Context) {
   ......
}

 //  Use方法的参数签名是这样:  type HandlerFunc func(*Context), 不支持入参
router.Use(middleware.AuthenticationMiddleware)   

实际实践上我们又需要给中间件传参, 闭包提供了这一能力。

func Authentication2Middleware(log *zap.Logger) gin.HandlerFunc  {
     return func(c *gin.Context) { 
         ...    这里面可以利用log 参数。
     }
}

var logger  *zap.Logger
api.Use(middleware.Authentication2Middleware(logger))

总结

本文屏蔽语言差异,理清了[闭包]的概念核心: 函数引用了其外部作用域的变量,

核心特征:一等函数、自由变量,核心结果: 即使脱离了原捕获变量的原作用域,闭包函数依然持有该变量引用。

不仅能帮助我们应对多语种有关闭包的面试题, 也帮助我们了解[闭包]在通用语言中的设计初衷。

另外我们通过C# 调试器巩固了Delegate 抽象类,这是lambda表达式,委托,匿名函数的底层抽象数据结构类,包含两个重要属性 Method Target,分别表征了方法执行体、当前委托作用的对象,

可想而知,其他语言也是通过这个机制捕获闭包当中的自由变量。

posted @ 2021-04-06 08:55  码甲哥不卷  阅读(2489)  评论(5)    收藏  举报