腳本語言如何實現閉包?

類似JavaScript, Python 之類的腳本語言中,從解釋器的角度來看,閉包是什麼實現的


很久以前寫的文章:

如何實現語言中的閉包(Closure)


這個問題其實不需要加上「腳本語言」的限定,因為在「非腳本語言」里的閉包的實現思路其實也是類似的。

先放個傳送門:JVM的規範中允許編程語言語義中創建閉包(closure)嗎? - RednaxelaFX 的回答

讀完上面的鏈接之後,這裡順帶補充點豆知識:

當大家在編程語言的上下文里說「閉包」,最主要的關注點是如何訪問非局部(non-local)的變數,特別是結合詞法作用域與嵌套函數這兩種功能時,被嵌套函數(nested function)如何訪問外圍函數(enclosing function)的局部變數的問題。

非局部變數,對於一個函數來說,就是不在該函數里聲明的變數。有兩種常見的情況:

  • 全局變數:在全局作用域聲明的變數,全局可見,生命期與應用生命周期一致。這種情況通常會專門特殊討論,而不當作「非局部變數」來討論。實現閉包時通常不考慮全局變數。
    • 靜態局部變數:C/C++支持所謂「靜態局部變數」(static local variable)。它的生命期可以看作與全局變數類似,而它的作用域則是限定在函數內的。這種情況可以看作全局變數的特殊形式,同樣通常不當作「非局部變數」來討論。
  • 外圍函數的局部變數:假如被嵌套函數要去訪問外圍函數的局部變數,這就是一種「非局部」訪問,此時對與被嵌套函數而言,外圍函數的局部變數就是「非局部變數」。

接下來就主要討論如何訪問外圍函數的變數。這跟編程語言里的「函數」或「過程」的具體設計很有關係——它到底有多靈活、有什麼限制。

例如說,如果一門語言里 ,常式不可重入(non-reentrant)——既不支持在一個線程上遞歸調用,也不支持多線程並發調用,那麼該常式在同一時間最多只可能有一份活躍的調用,於是它的「局部變數」就只需要純靜態分配好存儲空間即可,就像全局變數一樣。

在這種前提下,即便這門語言支持嵌套的常式聲明並且支持詞法作用域,被嵌套常式要訪問外圍常式的局部變數還是可以直接訪問,就像訪問全局變數一樣直接。

進一步,如果一門語言的函數支持重入,至少支持遞歸調用,那麼一個函數就可能同時有多份活躍的調用,於是需要分別存儲它們的局部變數。此時就會引入「調用棧」的概念。為什麼函數調用要用棧實現? - RednaxelaFX 的回答

在這種前提下,如果這門語言支持嵌套的函數聲明並且支持詞法作用域,但對嵌套函數的生命期加上特殊限制:它只能在外圍函數尚未返回的時候才可以被調用,那麼就有特殊的方式來實現非局部變數的訪問——棧幀里的「靜態鏈」(static link)。請跳傳送門:求講解下列鏈接以及pascal嵌套子程序是如何實現的? - RednaxelaFX 的回答

Pascal的嵌套函數,以及GCC所支持的C語言擴展功能「嵌套函數」,都是這樣的設計。

再進一步,如果去掉上面對嵌套函數生命期的限制,允許它在外圍函數已經返回後還可以被調用,那麼被被嵌套函數所捕獲的外圍函數的局部變數的生命期就必須比外圍函數自身被調用的生命期要長,於是就不能總是被分配在棧幀上。這就回到了本回答開頭我放的傳送門所討論的。

《Programming Language Pragmatics CD-ROM, Third Edition》的3.6 The Binding of Referencing Environments小節對這個話題有非常好的綜述,請各位同學參考~

(這本書有新版《Programming Language Pragmatics, Fourth Edition》 了,內容更加精鍊)

龍書,以及源自龍書的一些衍生書,主要講解了上述Pascal的那種嵌套函數的實現方式。例如說陳意雲/張昱的《編譯原理》里的講解:

&<- 可以跟龍書第1版第7章的Access to Nonlocal Names小節對比閱讀看看 ^_^


所謂閉包,就是一個特殊的函數,它能引用定義這個函數時的外部變數;函數內部本身也是一個 scope。實現時,無非是控制好外部不能訪問 closure 內部的變數,修改 closure 引用到的所有外部變數的生命周期,讓其保持到 closure 引用結束。如果所有變數都是在堆上統一使用引用計數管理的,事情會很簡單,其它玩法也只是略麻煩一點。

不過,closure 對外部變數的引用,也可以有不同語義,比如取 value snapshot,這樣在 closure 內部做一個副本就行了,不用修改原變數的生命周期了。


建議看一遍這篇文章: You-Dont-Know-JS/ch5.md at master · getify/You-Dont-Know-JS · GitHub


前面R大和輪子哥從貼近編譯原理的角度講了閉包的實現,我猜有很多同學只想找一個語義層面的解釋……我補充一個,像C#的microsoft編譯器,會生成一個包含外部變數的額外類,還生成了一個包含內部變數和對少一個類引用的類。


閉包主要的功能是實現函數的嵌套,內部函數可以直接訪問外部函數的臨時變數或參數,而無需顯式地通過參數傳遞.

如:

var a = [1,2,3,4];

var cmp = 3;

a.forEach(function(v) {

if (v &< cmp) {

...

}

});

回調函數中直接訪問了外層變數cmp.

我們可以將閉包用如下的C語言形式表示:

struct closure {

void (*func) (void); //執行函數指針

Value vars[VAR_NUM]; //複製所有要訪問的外層臨時變數和參數值

};

如果需要在閉包內修改外層變數,則可以用如下形式表示閉包:

struct closure {

void (*func) (void); //執行函數指針

Value *vars[VAR_NUM]; //所有要訪問的外層臨時變數和參數值指針

};

C++中閉包實現就是採用類似的方式.由於閉包中包含外層臨時變數的指針,閉包的生命期必定要小於外層臨時變數的生命期.因此在C++中閉包只能作為回調參數在函數中調用,不能像函數一樣在任意時刻調用.

對於大部分腳本語言來說,由於使用堆上分配的棧幀代替堆棧,以及使用GC管理對象和棧幀的生命期,閉包的實現更加容易.在一些腳本中,閉包的定義類似:

struct closure {

void (*func) (void); //執行函數指針

StackFrame *frames[FRAME_NUM]; //外層函數使用的棧幀

};

因為有GC管理所有對象的生命期,腳本中閉包指針可以被保存並被隨意調用,和函數使用方法沒有區別.因此在很多腳本中,閉包完全定價於函數.


推薦閱讀:

iOS
閉包中的[weak self]在什麼情況下需要使用,什麼情況下可以不加?

Python中後期綁定(late binding)是什麼意思?
為何前端面試官都喜歡問閉包?
如何通俗地解釋閉包的概念?

TAG:編譯原理 | 閉包 |