理解“閉包”

_哲思發表於2022-03-12

理解“閉包”

作者:哲思

時間:2021.9.5

郵箱:1464445232@qq.com

GitHub:zhe-si (哲思) (github.com)

前言

說起“閉包”,我的大腦裡的第一反應不是在程式設計中常講的“閉包”,反而是大學離散數學課本中的“閉包”。為了明確二者的區別與聯絡,並加強對“閉包的”本身的理解,我對“閉包”進行了一些研究,並撰寫此文,希望能給大家幫助。

離散數學——“閉包”

首先,簡單說明一下基本概念:

  • 集合:有限、無序、互異的元素組成的整體
  • 運算:n個集合對映到某一集合的對映過程
  • 關係:多個集合笛卡兒積的子集,如三元關係類似 <a, b, c>,可以想象為一個n維矩陣

接下來,給出“閉包”比較官方的定義:

【維基百科】

數學中,若對某個集合的成員進行一種運算,生成的仍然是這個集合的成員,則該集合被稱為在這個運算下閉合

  • 例如,實數在減法下閉合,但自然數不行(自然數 3 和 7 的減法 3 − 7 的結果不是自然數)。

當一個集合 S 在某個運算下不閉合的時候,我們通常可以找到包含 S 的最小的閉合集合。這個最小閉合集合被稱為 S 的(關於這個運算的)閉包

  • 例如,實數是減法運算下的閉合集合,即實數是減法運算下的閉包。而自然數則不是減法下的閉包。

簡單解釋,就是在給定的關係中,新增最少的元素,使其具有某種性質,則稱新增後的集合為該性質上關係的閉包。如:具有自反性,則為自反閉包。

再抽象一些:定義某個特定的、封閉的範圍,範圍內的元素滿足某些性質,就是該性質下的“閉包”

通過“閉包”,我們可以讓當前研究的關係利用構造閉包得到的性質進行簡化。反過來,也可以讓滿足某些性質的集合限定在某個確定且封閉的範圍內

函數語言程式設計——“閉包”

概念理解

在js、python、kotlin等語言中常談的“閉包”,其實都是指的函數語言程式設計中的“閉包”,也稱“詞法閉包”或“函式閉包”。

這個概念是在λ演算中被提出,並由Peter J. Landin在1964年引入術語——“閉包”(closure)。定義中,“閉包”包括兩部分:環境部分、控制部分。

函數語言程式設計的基礎就是λ演算,一種通過函式去描述世界的正規化,重點是描述事物與事物間的關係。

介紹一種函式式語言:scheme(Lisp的一個方言)來描述lambda演算。

\[( define (f\ X)(Y)) \]

式中,定義了一個名為\(f\)的函式,引數是X,返回值是Y

  • 引數:X可為任意個,寫作:\(x1\ x2\ ……\)

  • 返回值:Y可為表示式(如:\((+\ x\ y)\),表示\(x + y\))或函式(如:\((lambda (x\ y)(+\ x\ y))\),表示一個接受引數\(x\)\(y\)並返回\(x + y\)的函式)

現在,通過scheme定義一個\(x + y\)的函式(假設“+”運算已經定義):

\[(define\ (f\ x\ y)\ (+\ x\ y)) \]

可以通過該函式計算\(5 + 1\)

\[(f\ 5\ 1);\ Value:\ 6 \]

接下來,我們定義一個通用的函式,該函式可以返回一個可以給某值\(x\)加固定值\(y\)的lambda函式:

\[(define\ (f\ y)\ (lambda\ (x)\ (+\ x\ y))) \]

該函式的引數是\(y\),返回值是\((lambda\ (x)\ (+\ x\ y))\)。我們將目光聚焦返回值,可以看到,該函式接受一個引數\(x\),卻沒有\(y\)引數,\(y\)來自定義該返回值函式的上下文環境!也就是說,\(y\)由定義該函式時的上下文環境決定,相對於定義的返回值函式,\(y\)是自由的,來自環境,在不同環境下有不同表現,不被函式定義本身所限制\(x\)則被返回值函式的定義宣告為該函式的引數,是繫結在函式內的

我們繼續上面的例子,通過該函式得到一個可以給\(x\)加1的函式:

\[(f\ 1);\ Value:\ (lambda\ (x)\ (+\ x\ 1)) \]

那麼,我們進一步實現給5加1的運算:

\[((f\ 1)\ 5);\ Value:\ 6 \]

這裡總共發生了兩個過程:在\(f\)函式執行的區域性環境下定義了一個函式、使用返回的新定義的函式在其執行的區域性環境下得到最終結果。

正常情況下,函式內的區域性變數會在函式執行結束後釋放。但在該場景下,若\(y\)被釋放,則新定義的函式中的\(y\)就無法從環境中獲得了。所以,提出了一種機制,當函式在執行中,其內的某變數被其內定義的函式引用後,不會立刻釋放該變數,允許新定義的函式持有該變數的引用這就是在lambda演算中引入“閉包”的原因,這種機制產生的持有上層函式環境、新定義的函式,就叫“閉包”

現在,我們已經基本理解了“閉包”,最後給出它的一些經典定義:

【MDN】

閉包是將函式與其引用的周邊狀態繫結在一起形成(封裝)的組合。

【犀牛書】

將函式物件和作用域相互關聯起來(一對變數的繫結),函式體內部的變數都可以儲存在函式作用域內,這種特性在電腦科學文獻中稱為閉包。

【維基百科】

在一些語言中,在函式中可以(巢狀)定義另一個函式時,如果內部的函式引用了外部的函式的變數,則可能產生閉包。閉包可以用來在一個函式與一組“私有”變數之間建立關聯關係。在給定函式被多次呼叫的過程中,這些私有變數能夠保持其永續性。

三個定義含義基本一致。簡單來說,就是\(閉包 = 函式 + 環境\),環境就是上面說到的作用域。

三個滿足閉包的條件:1. 訪問所在作用域;2. 函式巢狀;3. 在所在作用域外被呼叫。條件3是為了說明呼叫返回新函式的過程中在記憶體裡實際形成了閉包;而條件2不是絕對條件,從某種意義上,全域性作用域也是一種環境;所以,滿足條件1即可稱為閉包。

畫龍點睛

到這裡,偏向概念上的東西已經講完了,但還有一些重要的細節,在這裡通過論斷的方式提出。

  1. 函數語言程式設計,允許執行時定義函式

    在上面,我們一直沒有關注一個事情——我們不是靜態的在編譯前定義的函式,而是在執行時動態定義的。這是函數語言程式設計的關鍵之一,也是閉包形成的基礎。

    在上面lambda演算的例子中,我們每次在某個環境下動態定義新的函式,並將環境儲存,進而形成了閉包。

  2. 函式的每次呼叫都會產生一個新的環境

    環境產生的時機是每次呼叫,其區域性環境在呼叫後產生並作為執行時新動態定義的函式的環境。

    這裡強調一個點——“每次呼叫”。也就是說,如果只有一次函式呼叫,在其內定義多個新函式,每個新函式共享環境。

    這裡是一個很容易出錯的地方,來個例題:利用閉包,修改下面的程式碼,讓迴圈輸出的結果依次為1, 2, 3, 4, 5。

    該例中,我們期望每一個timer引用的i的取值作為一個獨立的環境,這樣timer閉包就可以輸出不同的數字。

    而在下面給定的原始碼中,定義的多個新函式繫結的是同一個環境(最外層函式的 i 區域性變數),導致每個輸出都為i的最終值6。

    for (var i = 1; i <= 5; i++) {
      setTimeout(function timer() {
        console.log(i);
      }, i * 1000);
    }
    

    我們為每一個 i 的取值新增了一個函式的呼叫,對應產生了一個獨立的環境,每一個新定義的timer都使用獨立環境的 ii 變數,實現了我們的目標效果。

    for (var i = 1; i <= 5; i++) {
      (function () {
        var ii = i;
        setTimeout(function timer() {
          console.log(ii);
        }, i * 1000);
      })()
    }
    
  3. 呼叫定義新函式的函式才會產生新的閉包

    閉包的產生對應一個新的函式被定義出來,與閉包函式本身被呼叫無關。

    這裡也有一個例子:

    var fn = null;
    function foo() {
      var a = 2;
      function innnerFoo() {
        console.log(c); // 在這裡,試圖訪問函式bar中的c變數,會丟擲錯誤
        console.log(a);
      }
      fn = innnerFoo; // 將 innnerFoo的引用,賦值給全域性變數中的fn
    }
    
    function bar() {
      var c = 100;
      fn(); // 此處的保留的innerFoo的引用
    }
    
    foo();
    bar();
    

    本例中,試圖在bar函式中呼叫某閉包函式訪問bar內的區域性變數,期望讓閉包函式本身的呼叫影響閉包的產生,顯然是錯誤的。

    還是那句話,只有呼叫定義新函式的函式(或者像本例將新定義的函式通過儲存在一個變數而不是作為返回值返回)的時候產生的環境才可以被閉包函式引用併產生新的閉包。

  4. 一般,閉包繫結的環境只包含用到的部分

    也就是說,一般不會不考慮用不用都把環境完整儲存(這樣沒有引用所在外部作用域的非閉包函式豈不也要像閉包一樣將環境儲存?),而是用了哪個變數就儲存那個變數。

    舉個例子:

    我們定義一個兩層巢狀的閉包函式c,來探究部分繫結的問題。

    python版本

    def a():
        a1 = 4
        a2 = 5
    
        def b():
            b1 = 7
            b2 = 8
    
            def c():
                c1 = 12
                c2 = 13
                return c1 + b1
                # return c1 + a1
            return c
        return b
    

    js版本

    function a() {
        var a1 = 1;
        var a2 = 2;
        return function b() {
            var b1 = 11;
            var b2 = 12;
            return function c() {
                var c1 = 101;
                var c2 = 102;
                return c1 + b1; 
                // return c1 + a1;
            }
        }
    }
    

    我們只看最內層的閉包函式c。如果引用b1,則返回的函式只包含環境中的b1;如果引用a1,則返回的函式只包含環境中的a1。引用b2、a2同理,大家可以親手嘗試一下。

    結果用js進行展示,如下圖:

    閉包巢狀js結果

    閉包巢狀js結果2

實現剖析

以js為例,介紹一下閉包基本的實現方式。

在js中,變數分為兩種,區域性和全域性,並通過一個作用域鏈儲存在堆中。

當查詢變數時,會從作用域鏈末端(一般,末端即是當前作用域下的儲存節點)向上遊進行查詢,直到找到目標變數。這也是函式可以引用外部作用域的變數的原因。

當函式執行結束釋放時,一般會將其對應的區域性儲存空間釋放,也就是由gc垃圾回收機制對無有效引用或不可達的物件進行釋放。但由於引入了閉包,允許作用域內定義的新函式持有當前作用域的變數的引用(注意,不是拷貝),再加上新函式被返回後儲存,導致變數被閉包函式持有有效引用而無法被垃圾回收機制釋放。這就是閉包實現的基本原理。

給一個簡單的例子,如下圖。在全域性呼叫foo函式,foo函式定義並返回一個新的innerFoo閉包函式儲存在全域性,返回的閉包函式持有foo函式呼叫中的區域性變數arguments。在foo執行結束後,會釋放除新定義的閉包函式innerFoo以及對應的環境arguments變數之外的其他區域性變數(a變數)。

閉包原理圖

同時,由於保留下來的環境只有對應的閉包函式持有引用,所以也只能通過閉包函式進行訪問,也就產生了一個訪問的作用域。該特性可以避免使用全域性變數,防止全域性變數對名稱空間等的汙染。

這樣的實現方式,讓區域性變數可以不跟隨定義它的函式的結束而釋放,讓外部訪問函式內的區域性變數成為可能。但同時,更復雜的引用持有機制也容易造成記憶體洩漏。

類似技術對照

閉包技術是函數語言程式設計解決定義函式依靠的資料與函式的繫結問題,同時提供變數私有化、區域性化的效果。這與物件導向封裝資料與對應方法的思路十分相似。(如果不熟悉物件導向,可以參考文章:從物件導向解讀設計思想,包含了對物件導向從淺入深的講解)

常見的兩個相似技術是函式物件和內部類:

  • 函式物件:

    c++通過自定義類過載"()"運算子可以實現一種類似函式的呼叫方式,同時可以自由定義和配置不同物件(修改其內屬性的值)來實現"()"運算子呼叫的不同效果,類似閉包繫結不同環境。

    python也可以通過定義“_call_”魔術方法實現類似效果。

  • 內部類:

    java支援在函式中定義一個內部類,在內部類中可以引用外部函式的區域性變數以及外部類的屬性。

    這個特性,也被java用來支援函數語言程式設計的實現,如java中的lambda表示式。

思想擴充

沒有證據可以證明該術語與數學中的“閉包”的關係,但我認為這裡的“閉包”是數學中“閉包”概念上的一種引申。

數學中某性質的閉包指某個特定的、封閉的範圍,範圍內的元素都滿足該性質。

在函數語言程式設計的閉包中,閉包函式繫結的環境本身即可看成一種特定、封閉的範圍,而函式就是該環境下滿足的性質。

雖然沒有將二者統一的必要,但確實可以從抽象形式上看出二者的一些相似性。同時,這些相似性也有益於我們去更好的理解與運用“閉包”。

【擴充】資料庫——“閉包”

在關係型資料庫中,定義了屬性集和FD集(函式依賴,某個屬性決定另一個屬性時,稱另一屬性依賴於該屬性),則某屬性子集的“閉包”指該子集所有屬性與基於該子集通過函式依賴可以推匯出的所有屬性的集合。

\[子集A的閉包 = 子集A \ ∪ \ 子集A通過FD集推匯出的所有屬性集合 \]

舉個例子:

屬性集:\(A\ B\ C\ D\)

FD集:\(\{ A \rightarrow B, B \rightarrow C, D \rightarrow B \}\)

記子集T的閉包為\(T^+\),則:

  • \(A^+ = A B C\)

    解釋:A可以推匯出B,B推匯出C,加起來為ABC

  • \((AD)^+ = ABCD\)

    解釋:A可以推匯出B,B推匯出C,D推匯出B,加起來為ABCD

  • \((BD)^+ = BCD\)

    解釋:B可以推匯出C,D可以推匯出B,加起來為BCD

從上面的概念和例子中可以明顯看出,在資料庫中“閉包”的概念是離散數學中“閉包”概念的推廣。這裡的運算就是FD集,滿足FD集這樣的性質的最小集合就是某子集在該性質下的閉包。

後記

關於“閉包”這個單詞的含義還有很多,本文只是介紹了數學和計算機領域比較重要的概念與理解。但在瞭解了諸多領域對於該詞的描述後,發現萬變不離其宗,都是在研究一個滿足某些性質的封閉環境(範圍)。可能,這本身就是認識論和方法論的一個重要內容——讓我們在一個確定的範圍內,去探究事物的本質。

相關文章