全網最通透的「閉包」認知 -超越語言

閉包作為前端面試的必考題目,常讓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閉包也就不難了。

總結

本文屏蔽語言差異,理清了[閉包]的概念核心: 頭等函數、自由變量,不僅能幫助我們應對多語種有關閉包的面試題, 也幫助我們了解[閉包]在通用語言中的設計初衷。