動態

詳情 返回 返回

徹底理解閉包實現原理 - 動態 詳情

前言

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

本文相關資源鏈接

  • GScript 源碼:https://github.com/crossoverJie/gscript
  • Playground 源碼:https://github.com/crossoverJie/gscript-homepage

Add a new 評論

Some HTML is okay.