徹底理解閉包實現原理

2022-10-26 09:08:29

前言

閉包對於一個長期寫 Java 的開發者來說估計鮮有耳聞,我在寫 PythonGo 之前也是沒怎麼了解,光這名字感覺就有點"神祕莫測",這篇文章的主要目的就是從編譯器的角度來分析閉包,徹底搞懂閉包的實現原理。

函數一等公民

一門語言在實現閉包之前首先要具有的特性就是: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 來說,他能存取到 varExternalvarInner 變數,最外層的 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 中體驗閉包函數列印裴波那切數列的運用。

本文相關資源連結