徹底理解閉包實現原理

前言

閉包對於一個長期寫 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 中體驗閉包函數列印裴波那切數列的運用。

本文相關資源鏈接