理解“閉包”
作者:哲思
時間:2021.9.5
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即可稱為閉包,
畫龍點睛
到這里,偏向概念上的東西已經講完了,但還有一些重要的細節,在這里通過論斷的方式提出,
-
函式式編程,允許運行時定義函式,
在上面,我們一直沒有關注一個事情——我們不是靜態的在編譯前定義的函式,而是在運行時動態定義的,這是函式式編程的關鍵之一,也是閉包形成的基礎,
在上面lambda演算的例子中,我們每次在某個環境下動態定義新的函式,并將環境保存,進而形成了閉包,
-
函式的每次呼叫都會產生一個新的環境,
環境產生的時機是每次呼叫,其區域環境在呼叫后產生并作為運行時新動態定義的函式的環境,
這里強調一個點——“每次呼叫”,也就是說,如果只有一次函式呼叫,在其內定義多個新函式,每個新函式共享環境,
這里是一個很容易出錯的地方,來個例題:利用閉包,修改下面的代碼,讓回圈輸出的結果依次為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); })() } -
呼叫定義新函式的函式才會產生新的閉包,
閉包的產生對應一個新的函式被定義出來,與閉包函式本身被呼叫無關,
這里也有一個例子:
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內的區域變數,期望讓閉包函式本身的呼叫影響閉包的產生,顯然是錯誤的,
還是那句話,只有呼叫定義新函式的函式(或者像本例將新定義的函式通過保存在一個變數而不是作為回傳值回傳)的時候產生的環境才可以被閉包函式參考并產生新的閉包,
-
一般,閉包系結的環境只包含用到的部分,
也就是說,一般不會不考慮用不用都把環境完整保存(這樣沒有參考所在外部作用域的非閉包函式豈不也要像閉包一樣將環境保存?),而是用了哪個變數就保存那個變數,
舉個例子:
我們定義一個兩層嵌套的閉包函式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 bjs版本
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中,變數分為兩種,區域和全域,并通過一個作用域鏈存盤在堆中,
當查找變數時,會從作用域鏈末端(一般,末端即是當前作用域下的存盤節點)向上游進行查找,直到找到目標變數,這也是函式可以參考外部作用域的變數的原因,
當函式執行結束釋放時,一般會將其對應的區域存盤空間釋放,也就是由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集這樣的性質的最小集合就是某子集在該性質下的閉包,
后記
關于“閉包”這個單詞的含義還有很多,本文只是介紹了數學和計算機領域比較重要的概念與理解,但在了解了諸多領域對于該詞的描述后,發現萬變不離其宗,都是在研究一個滿足某些性質的封倍訓境(范圍),可能,這本身就是認識論和方法論的一個重要內容——讓我們在一個確定的范圍內,去探究事物的本質,
本文來自博客園,作者:_哲思,轉載請注明原文鏈接:https://www.cnblogs.com/zhe-si/p/15999113.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/441943.html
標籤:其他
