全网最通透的“闭包”认知 -超越语言

闭包作为前端面试的必考题目,常让1-3年工作经验的Javascripter感到困惑,其实C#语言也有闭包。

今天我们深入聊一聊[闭包], 查缺补漏!

  1. 以面试题 · 投石问路
  2. 以C#闭包 · 庖丁解牛
  3. 跨语言 · 追本溯源
    • 一等函数
    • 自由变量
    • 词法作用域
  4. 答面试题 · 返璞归真

1. 投石问路

调用下面函数,输出结果是什么样呢?

 static void Closure1()
{
    for (int i = 0; i < 5; i++)
    {                 
         Task.Run(()=> Console.WriteLine(i));
    }
 }
//  输出:
5
5
5
5
5

是不是很意外? 如何输出原本预期的 0,1,2,3,4。

bingo, 加一个临时变量就可以解决。

static void Closure2()
{
       for (int i = 0; i < 5; i++)
        {
            int j = i;
           Task.Run(() => Console.WriteLine(j));
       }
}
// 输出:
3
0
1
4
2
//  多次执行的结果不一样,但是总是会保持输出 0,1,2,3,4 的乱序组合

以上闭包概念涉及到 Task任务,理解起来更加复杂,我们来看一个基础的C#闭包。

2. 庖丁解牛

一个闭包就是一个“捕获”或“携带”了其生成的环境中、所引用的自由变量的函数。
这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。

 static void Closure()
 {
      var x = 1;
      Action action= () =>
         {
             var y = 1;
             var result = x + y;
             Console.WriteLine(result);
             x++;
         };
      action();
      action();
}
 //  调用函数输出
  2
  3

我们首先定义了一个委托action,它引用了“x”变量(x变量既不是入参,也不是委托函数内的局部变量), 这个变量将被action”捕获”,被自动添加到action 的运行环境中了。

当我们执行action时,原始的“x”已经脱离了它被引用时的作用域环境,但是两次执行能输出2,3 说明它脱离原引用环境仍然能用。

当你在代码调试器(debugger)里观察“action”时,会发现很有趣的事情。我们可以看到,C# 编译器为我们创建了一个 Target 类,里面封装了 x 变量:

源码追溯,匿名函数和lambda 都是继承自Delegate抽象类,Delegate类有个Target
属性(获取当前委托调用实例方法的实例类也就是说这些概念在底层都是对象) 。
至此可以想见我们每次执行委托,实际是是执行某个匿名类上的实例方法。

都说了闭包是跨越语言的设计, 至少我知道 Javascript C# 都有闭包。

3. 追本溯源

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

头等函数

头等函数( First Class)意味着语言将其视为第一类数据类型的函数, 意味着你可以将函数分配给一个变量(或作为参数传递),然后像正常函数一样调用。

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

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

string myVar = myFunc("something");

自由变量

自由变量只是一个在函数中被引用的变量,它不是函数的参数也不是函数的局部变量。

var myVar = "this is good";

Func<string,string> myFunc = delegate(string var1)
                                {
                                    return var1 + myVar;   
                                };

词法作用域引用的自由变量,注意,是引用自由变量,并不是使用当时自由变量的值

☺️通俗点, 就是告知这个变量环境,我这个匿名函数等会执行时要用到这个变量;如果我没被销毁,你不能销毁我引用的自由变量。

我们再回过头来看[投石问路]的面试题。

4. 返璞归真

首先你要知道: 循环内开启的每个Task任务,并不能保证执行顺序。

Demo1: 输出5,5,5,5,5

这是因为在 for循环内,开启了5个Task任务,每个任务均引用了自由变量i (相对于每个任务执行环境,i 属于全局变量);
for循环先执行完,i=5, 5个任务输出时自然使用值5.

为什么加上临时变量就能达到预期?

Demo2: 输出乱序的0,1,2,3,4

这是因为 在for循环内, 但是每个任务均引用了自由变量 j (每个任务执行环境均维护了一个变量j);
任务乱序执行时依旧能获取本任务绑定的自由变量值j。


有这样的认知,理解JavaScript闭包也就不难了。

总结

本文屏蔽语言差异,理清了[闭包]的概念核心: 头等函数、自由变量,不仅能帮助我们应对多语种有关闭包的面试题, 也帮助我们了解[闭包]在通用语言中的设计初衷。