深入理解Lua的閉包一:概念、應用和實現原理
本文首先通過具體的例子講解了Lua中閉包的概念,然後總結了閉包的應用場合,最後探討了Lua中閉包的實現原理。
閉包的概念
在Lua中,閉包(closure)是由一個函數和該函數會訪問到的非局部變量(或者是upvalue)組成的,其中非局部變量(non-local variable)是指不是在局部作用範圍內定義的一個變量,但同時又不是一個全局變量,主要應用在嵌套函數和匿名函數裏,因此若一個閉包沒有會訪問的非局部變量,那麽它就是通常說的函數。也就是說,在Lua中,函數是閉包一種特殊情況。另外在Lua的C API中,所有關於Lua中的函數的核心API都是以closure來命名的,也可視為這一觀點的延續。在Lua中,函數是一種第一類型值(First-Class Value),它們具有特定的詞法域(Lexical Scoping)。
第一類型值表示函數與其他傳統類型的值(例如數字和字符串類型)具有相同的權利。即函數可以存儲在變量或table中,可以作為實參傳遞給其他函數,還可以作為其他函數的返回值,可以在運行期間被創建。在Lua中,函數與所有其他的值是一樣都是匿名的,即他們沒有名稱。當討論一個函數時(例如print),實質上在討論一個持有某個函數的變量。比如:
function foo(x) print(x) end
實質是等價於
foo = function (x) print(x) end
因此一個函數定義實質就是一條賦值語句,這條語句創建了一種類型為“函數”的值,並賦值給一個變量。可以將表達式function (x) <body> end 視為一種函數構造式,就像table的構造式{}一樣。
值得一提的是,C語言裏面函數不能在運行期被創建,因此不是第一類值,不過有時他們被稱為第二類值,原因是他們可以通過函數指針實現某些特性,比如常常顯現的回調函數的影子。
詞法域是指一個函數可以嵌套在另一個函數中,內部的函數可以訪問外部函數的變量。比如:
- function f1(n)
- --函數參數n也是局部變量
- local function f2()
- print(n) --引用外部函數的局部變量
- end
- return f2
- end
- g1 = f1(2015)
- g1() -- 打印出2015
-
g2 = f1(2016)
- g2() -- 打印出2016
註意這裏的g1和g2的函數體相同(都是f1的內嵌函數f2的函數體),但打印值不同。這是因為創建這兩個閉包時,他們都擁有局部變量n的獨立實例。事實上,Lua編譯一個函數時,會為他生成一個原型(prototype),其中包含了函數體對應的虛擬機指令、函數用到的常量值(數,文本字符串等等)和一些調試信息。在運行時,每當Lua執行一個形如function...end 這樣的表達式時,他就會創建一個新的數據對象,其中包含了相應函數原型的引用及一個由所有upvalue引用組成的數組,而這個數據對象就稱為閉包。由此可見,函數是編譯期概念,是靜態的,而閉包是運行期概念,是動態的。g1和g2的值嚴格來說不是函數而是閉包,並且是兩個不相同的閉包,而每個閉包能保有自己的upvalue值,所以g1和g2打印出的結果當然就不相同了。
這裏的函數f2可以訪問參數n,而n是外部函數f1的局部變量。在f2中,變量n即不是全局變量也不是局部變量,將其稱為一個非局部變量(non-local variable)或upvalue。upvalue實際指的是變量而不是值,這些變量可以在內部函數之間共享,即upvalue提供一種閉包之間共享數據的方法,比如:
- function Create(n)
- local function foo1()
- print(n)
- end
- local function foo2()
- n = n + 10
- end
- return foo1,foo2
- end
- f1,f2 = Create(2015)
- f1() -- 打印2015
- f2()
- f1() -- 打印2025
- f2()
- f1() -- 打印2035
註意上面的例子中,閉包f1和f2共享同一個upvalue了,這是因為當Lua發現兩個閉包的upvalue指向的是當前堆棧上的相同變量時,會聰明地只生成一個拷貝,然後讓這兩個閉包共享該拷貝,這樣任一個閉包對該upvalue進行修改都會被另一個探知。
閉包在創建之時其upvalue就已不在堆棧上的情況也有可能發生,這是因為內嵌函數能引用更外層外包函數的局部變量:
- function Test(n)
- local function foo()
- local function inner1()
- print(n)
- end
- local function inner2()
- n = n + 10
- end
- return inner1,inner2
- end
- return foo
- end
- t = Test(2015)
- f1,f2 = t()
- f1() -- 打印2015
- f2()
- f1() -- 打印2025
- g1,g2 = t()
- g1() -- 打印2025
- g2()
- g1() -- 打印2035
- f1() -- 打印2035
註意上面的執行的結果表明了閉包f1、f2、g1和g2都共有同一個upvalue,這是因為在創建inner1,inner2這兩個閉包被創建時堆棧上根本未找到n的蹤影,而是直接使用閉包foo的upvalue。t = Test(2015)之後,t這個閉包一定已把n妥善保存好了,之後f1、f2如果在當前堆棧上未找到n就會自動到他們的外包閉包的upvalue引用數組中去找,並把找到的引用值拷貝到自己的upvalue引用數組中。所以f1、f2、g1和g2引用的upvalue實際也是同一個變量,而剛才描述的搜索機制則確保了最後他們的upvalue引用都會指向同一個地方。
閉包的應用
在許多場合中閉包都是一種很有價值的工具,主要有以下幾個方面:
I)作為高階函數的參數,比如像table.sort函數的參數。
II)創建其他的函數的函數,即函數返回一個閉包。
III)閉包對於回調函數也非常有用。典型的例子就是界面上按鈕的回調函數,這些函數代碼邏輯可能是一模一樣,只是回調函數參數不一樣而已,即upvalue的值不一樣而已。
V)創建一個安全的運行環境,即所謂的沙盒(sandbox)。當執行一些未受信任的代碼時就需要一個安全的運行環境。比如要限制一個程序訪問文件的話,只需要使用閉包來重定義函數io.open就可以了:
- do
- local oldOpen = io.open
- local accessOk = function(filename, mode)
- <權限訪問檢查>
- end
- io.open = function (filename, mode)
- if accessOk(filename, mode) then
- return oldOpen(filename, mode)
- else
- return nil, "access denied"
- end
- end
- end
經過重新定義後,原來不安全的版本保存到閉包的私有變量中,從而使得外部再也無法直接訪問到原來的版本了。
V)實現叠代器。所謂叠代器就是一種可以遍歷一種集合中所謂元素的機制。每個叠代器都需要在每次成功調用之間保持一些狀態,這樣才能知道它所在的位置及如何進到下一個位置。閉包剛好適合這種場景。比如:
- function values(t)
- local i = 0
- return function () i = i + 1 return t[i] end
- end
- t = {10, 20, 30}
- iter = values(t)
- while true do
- local element = iter()
- if element == nil then break end
- print(element)
- end
閉包的實現原理
當Lua編譯一個函數時,它會生成一個原型(prototype),原型中包括函數的虛擬機指令、函數中的常量(數值和字符串等)和一些調試信息。在任何時候只要Lua執行一個function .. end表達時,它都會創建一個新的閉包(closure)。每個閉包都有一個相應函數原型的引用以及一個數組,數組中每個元素都是一個對upvalue的引用,可以通過該數組來訪問外部的局部變量(outer local variables)。值得註意的是,在Lua 5.2之前,閉包中還包括一個對環境(environment)的引用,環境實質就是一個table,函數可以在該表中索引全局變量,從Lua 5.2開始,取消了閉包中的環境,而引入一個變量_ENV來設置閉包環境。由此可見,函數是編譯期概念,是靜態的,而閉包是運行期概念,是動態的。
作用域(生成期)規則下的嵌套函數給如何實現內存函數存儲外部函數的局部變量是一個眾所周知的難題(The combination of lexical scoping with first-class functions creates a well-known difficulty for accessing outer local variables)。比如例子:
- function add (x)
- return function (y)
- return x+y
- end
- end
- add2 = add(2)
- print(add2(5))
當add2被調用時,其函數體訪問了外部的局部變量x(在Lua中,函數參數也是局部變量)。然而,當調用add2函數時,創建add2的add函數已經返回了,如果x在棧中創建,則當add返回時,x已經不存在了(即x的存儲空間被回收了)。
為了解決上面的問題,不同語言有不同的方法,比如python通過限定作用域、Pascal限制函數嵌套以及C語言則兩者都不允許。在Lua中,使用一種稱為upvalue結構來實現閉包。任何外部的局部變量都是通過upvalue來間接訪問。upvalue初始值是指向棧中,即變量在棧中的位置。如下圖左邊。當運行時,離開變量作用域時(即超過變量生命周期),則會把變量復制到upvalue結構中(註意也只是在此刻才執行這個操作),如下圖右邊。由於對變量的訪問都是通過upvalue結構中指針間接進行的,因此復制操作對任何讀或寫變量的代碼來說都是沒有影響的。與內部函數(inner functions)不同的是,聲明該局部變量的函數都是直接在棧中操作它的。
通過為每個變量最多創建一個upvalue並按需要重復利用這個upvalue,保證了未決狀態(未超過生命周期)的局部變量(pending vars)能夠在閉包之間正確地共享。為了保證這種唯一性,Lua維護這一條鏈表,該鏈表中每個節點對應一個打開的upvalue(opend upvalue)結構,打開的upvalue是指當前正指向棧局部變量的upvalue,如上圖的未決狀態的局部變量鏈表(the pending vars list)。當Lua創建一個新的閉包時,Lua會遍歷當前函數所有的外部的局部變量,對於每一個外部的局部變量,若在上面的鏈表中能找到該變量,則重復使用該打開的upvalue,否則,Lua會創建一個新的打開的upvalue,並把它插入鏈表中。當局部變量離開作用域時(即超過變量生命周期),這個打開的upvalue就會變成關閉的upvalue(closed upvalue),並把它從鏈表中刪除,如上圖右圖所示意。一旦某個關閉的upvalue不再被任何閉包所引用,那麽它的存儲空間就會被回收。
一個函數有可能存取其更外層函數而非直接外層函數的局部變量。在這種情況下,當創建閉包時,這個局部變量可能不在棧中。Lua使用flat 閉包(flat closures)來處理這種情況。使用flat閉包,無論何時一個函數訪問一個外部的局部變量並且該變量不在直接外部函數中,該變量也會進入直接外部函數的閉包中。當一個函數被實例化時,其對應閉包的所有變量要麽在直接外部函數的棧中要麽在直接外部函數的閉包中。第一部分舉的最後一個例子就是這種情況。下一篇文章將分析Lua中閉包對應的源碼實現以及調用的過程。
參考資料
http://hi.baidu.com/wplzjtyldobrtyd/item/a293ac3c243e70ff97f88d07
http://blog.sina.com.cn/s/blog_547c04090100qfps.html
http://www.douban.com/note/183992679/
http://en.wikipedia.org/wiki/Scope_(programming)#Lexical_scoping
http://en.wikipedia.org/wiki/First-class_citizen
《Lua程序設計》
深入理解Lua的閉包一:概念、應用和實現原理