閉包對於一個長期寫 Java
的開發者來說估計鮮有耳聞,我在寫 Python
和 Go
之前也是沒怎麼了解,光這名字感覺就有點”神秘莫測”,這篇文章的主要目的就是從編譯器的角度來分析閉包,徹底搞懂閉包的實現原理。
一門語言在實現閉包之前首先要具有的特性就是:First class function
函式是第一公民。
簡單來說就是函式可以像一個普通的值一樣在函式中傳遞,也能對變數賦值。
先來看看在 Go 裡是如何編寫的:
package main
import "fmt"
var varExternal int
func f1() func(int) int {
varInner := 20
innerFun := func(a int) int {
fmt.Println(a)
varExternal++
varInner++
return varInner
}
return innerFun
}
func main() {
varExternal = 10
f2 := f1()
for i := 0; i < 2; i++ {
fmt.Printf("varInner=%d, varExternal=%d \n", f2(i), varExternal)
}
fmt.Println("======")
f3 := f1()
for i := 0; i < 2; i++ {
fmt.Printf("varInner=%d, varExternal=%d \n", f3(i), varExternal)
}
}
// Output:
0
varInner=21, varExternal=11
1
varInner=22, varExternal=12
======
0
varInner=21, varExternal=13
1
varInner=22, varExternal=14
這裡體現了閉包的兩個重要特性,第一個自然就是函式可以作為值返回,同時也能賦值給變數。
第二個就是在閉包函式 f1()
對閉包變數 varInner
的訪問,每個閉包函式的引用都會在自己的函式內部儲存一份閉包變數 varInner
,這樣在呼叫過程中就不會互相影響。
從列印的結果中也能看出這個特性。
閉包之所以不太好理解的主要原因是它不太符合自覺。
本質上就是作用域的關係,當我們呼叫 f1()
函式的時候,會在棧中分配變數 varInner
,正常情況下呼叫完畢後 f1
的棧會彈出,裡面的變數 varInner
自然也會銷燬才對。
但在後續的 f2()
和 f3()
呼叫的時,卻依然能訪問到 varInner
,就這點不符合我們對函式呼叫的直覺。
但其實換個角度來看,對 innerFun
來說,他能訪問到 varExternal
和 varInner
變數,最外層的 varExternal
就不用說了,一定是可以訪問的。
但對於 varInner
來說就不一定了,這裡得分為兩種情況;重點得看該語言是靜態/動態作用域。
就靜態作用域來說,每個符號在編譯器就確定好了樹狀關係,執行時不會發生變化;也就是說 varInner
對於 innerFun
這個函式來說在編譯期已經確定可以訪問了,在執行時自然也是可以訪問的。
但對於動態作用域來說,完全是在執行時才確定訪問的變數是哪一個。
恰好 Go
就是一個靜態作用域的語言,所以返回的 innerFun
函式可以一直訪問到 varInner
變數。
但 Go 是如何做到在 f1()
函式退出之後依然能訪問到 f1()
中的變數呢?
這裡我們不妨大膽假設一下:
首先在編譯期掃描出哪些是閉包變數,也就是這裡的 varInner
,需要將他儲存到函式 innerFun()
中。
f2 := f1()
f2()
執行時需要判斷出 f2
是一個函式,而不是一個變數,同時得知道它所包含的函式體是 innerFun()
所定義的。
接著便是執行函式體的 statement
即可。
而當 f3 := f1()
重新賦值給 f3
時,在 f2
中累加的 varInner
變數將不會影響到 f3
,這就得需要在給 f3
賦值的重新賦值一份閉包變數到 f3
中,這樣便能達到互不影響的效果。
閉包掃描
GScript
本身也是支援閉包的,所以把 Go 的程式碼翻譯過來便長這樣:
int varExternal =10;
func int(int) f1(){
int varInner = 20;
int innerFun(int a){
println(a);
int c=100;
varExternal++;
varInner++;
return varInner;
}
return innerFun;
}
func int(int) f2 = f1();
for(int i=0;i<2;i++){
println("varInner=" + f2(i) + ", varExternal=" + varExternal);
}
println("=======");
func int(int) f3 = f1();
for(int i=0;i<2;i++){
println("varInner=" + f3(i) + ", varExternal=" + varExternal);
}
// Output:
0
varInner=21, varExternal=11
1
varInner=22, varExternal=12
=======
0
varInner=21, varExternal=13
1
varInner=22, varExternal=14
可以看到執行結果和 Go 的一樣,所以我們來看看 GScript
是如何實現的便也能理解 Go 的原理了。
先來看看第一步掃描閉包變數:
allVariable := c.allVariable(function)
查詢所有的變數,包括父 scope
的變數。
scopeVariable := c.currentScopeVariable(function)
查詢當前 scope
包含下級所有 scope
中的變數,這樣一減之後就能知道閉包變數了,然後將所有的閉包變數存放進閉包函式中。
閉包賦值
之後在 return innerFun
處,將閉包變數的資料賦值到變數中。
閉包函式呼叫
func int(int) f2 = f1();
func int(int) f3 = f1();
在這裡每一次賦值時,都會把 f1()
返回函式複製到變數 f2/f3
中,這樣兩者所包含的閉包變數就不會互相影響。
在呼叫函式變數時,判斷到該變數是一個函式,則直接返回函式。
之後直接呼叫該函式即可。
函數語言程式設計
接下來便可以利用 First class function
來試試函數語言程式設計:
class Test{
int value=0;
Test(int v){
value=v;
}
int map(func int(int) f){
return f(value);
}
}
int square(int v){
return v*v;
}
int add(int v){
return v++;
}
int add2(int v){
v=v+2;
return v;
}
Test t =Test(100);
func int(int) s= square;
func int(int) a= add;
func int(int) a2= add2;
println(t.map(s));
assertEqual(t.map(s),10000);
println(t.map(a));
assertEqual(t.map(a),101);
println(t.map(a2));
assertEqual(t.map(a2),102);
這個有點類似於 Java 中流的 map 函式,將函式作為值傳遞進去,後續支援匿名函式後會更像是函數語言程式設計,現在必須得先定義一個函式變數再進行傳遞。
除此之外在 GScript
中的 http
標準庫也利用了函式是一等公民的特性:
// 標準庫:Bind route
httpHandle(string method, string path, func (HttpContext) handle){
HttpContext ctx = HttpContext();
handle(ctx);
}
在繫結路由時,handle
便是一個函式,使用的時候直接傳遞業務邏輯的 handle
即可:
func (HttpContext) handle (HttpContext ctx){
Person p = Person();
p.name = "abc";
println("p.name=" + p.name);
println("ctx=" + ctx);
ctx.JSON(200, p);
}
httpHandle("get", "/p", handle);
總的來說閉包具有以下特性:
- 函式需要作為一等公民。
- 編譯期掃描出所有的閉包變數。
- 在返回閉包函式時,為閉包變數賦值。
- 每次建立新的函式變數時,需要將閉包資料複製進去,這樣閉包變數才不會互相影響。
- 呼叫函式變數時,需要判斷為函式,而不是變數。
可以在 Playground 中體驗閉包函式列印裴波那切數列的運用。
本文相關資源連結
GScript 原始碼:github.com/crossoverJie/gscript
Playground 原始碼:github.com/crossoverJie/gscript-ho...
本作品採用《CC 協議》,轉載必須註明作者和本文連結