閉包裡的自由變數

引證發表於2017-08-22

閉包(closure),是一種程式語言特性,它指的是程式碼塊和作用域環境的結合,早期由scheme語言引入這一特性,隨後幾乎所有語言都帶有這一特性,典型的閉包如下:

(define (func a)
  (lambda (b) 
     (lambda (c) (+ a b c))))
(((func 1) 2) 3)複製程式碼

閉包裡的自由變數會繫結在程式碼塊上,在離開創造它的環境下依舊生效,而這一點使用程式碼塊的人可能無法察覺。

閉包裡的自由變數的形式有很多,先舉個簡單例子。

function add(one){
    return function(two){
        return one+two;
    }
}
const a = add(1);
const b = add(2);複製程式碼

在上面的例子裡,a和b這兩個函式,程式碼塊是相同的,但若是執行a(1)和b(1)的結果卻是不同的,原因在於這兩者所繫結的自由變數是不同的,這裡的自由變數其實就是函式體裡的one。

add這個函式巢狀返回一個新的函式,而新的函式也帶來了新的作用域,在JS裡,自由變數的查詢會從本級作用域依次向外部作用域,直到查到最近的一個,而自由變數的繫結也會在函式定義的時候就已經確定,這也是詞法作用域(或稱靜態作用域)的具體表現。

自由變數的引入,可以起到和OOP裡的封裝同樣作用,我們可以在一層函式裡封裝一些不被外界知曉的自由變數,從而達到相同的效果,舉例說這麼一個簡單的java類。

class Demo{
    private int r;
    private int k = 1;
    public Demo(int r){
       this.r = r;
    }
    public int getSquare(){
        return this.r*this.r*this.k;
    }
    public void incr(){
       this.k++;
    }
}複製程式碼

這裡的變數r被封裝了,我們可以new Demo(1)或者new Demo(2)返回不同的例項,然後呼叫相同的方法來得到不同的結果,這一點如果用自由變數也可以做到。

func demo(r int) (func() int,func()){
    k := 1
    getSqueare := func() int{
        return r*r*k
    }
    incr := func (){
        k++
    }
    return getSqueare,incr
}
getSqueare,incr := demo(1)複製程式碼

在執行demo(1)或者demo(2)的時候,得到的物件都可以用來執行相同的方法,然而他們的自由變數(r和k)都是相互隔離的,這就是封裝的表現。

自由變數的確定在其他語言有著不一樣的表現,比如說php裡,函式與函式之間的作用域是完全隔離的,除非你用傳參或者global來拿到外部作用域的變數,這會導致我們做封裝的時候極為麻煩,所以php5.3里加了use語法,它允許在函式作用域裡引用上一層的自由變數。

比如上面的JS程式碼可以改成這樣的php程式碼。

function demo($r){
   $k = 1;
   return array(
      "getSquare"=>function() use ($r,&$k){
         return $r*$r*$k;
      },
      "incr"=>function() use (&$k){
         $k++;
      }
   );
}複製程式碼

這裡還有一個要注意的地方就是在use $k的時候,用了&表示按引用傳遞,因為如果不這麼做的話,內部函式裡的這個$k實際上只是一份值拷貝,無法改變其值,也無法應用改變之後的新值。

有人比較偏愛php 的use語法,因為這樣可以明確的確定需要使用的外部自由變數,而有的人偏愛js這種隱式寫法,原因是寫起來簡潔不累贅,只能說語言設計都有不同的取捨。

剛才說到的這些,其實函式都是一等公民的情況下,然而在其他形式的語言裡,其實也都有閉包,比如說在java裡,雖然無法定義一個脫離於class的函式,但是我們可以在method裡的內部定義一個class,這個class也就是local class,它實際上就是一種閉包,舉例來說。

class Demo {
  private volatile int a;
  public void test(final int b) {
    new Thread(
      new Runnable() {
        void run() {
          a++;
          System.out.print(b);
        }
      }
    ).start();
  }
}複製程式碼

上面test方法裡的local class,可以直接引用或者更改定義在類裡的private variable,也可以讀取方法裡的引數,並且它的自由變數繫結也是在定義的時候就已經確定好的。
然而由於java本身的限制,所以上面的引數b必須是final的,這一點在java8的lambda也不例外,就算在java8裡你不使用final確定,它還是隱式的認為其是final,所以無法在local class裡的方法更改這個引數。

再說說C++,C++裡最開始是使用運算子過載來達到定義函式型別,但是它有一個缺點就是無法捕獲外部的自由變數,為了達到相同的效果,你需要使用一個過載了()操作符的類物件來作為偽函式,比如這樣:

class Func{
Func(int one){ this->one = one; }  
public:
    int operator ()(int two) {  
        return this.one+two;  
    }
private: 
    int one;
};
Func f = Func(3); 
f(10);複製程式碼

C++11加入了lambda語法糖,可以很容易的捕獲自由變數:

int one = 3;
auto f = [=](int two){
    return one+two;
};
f(10);複製程式碼

在上面的[=](int two) {}語句裡,我們可以使用外部的自由變數one,C++裡可以選擇=、&來指示是否是引用還是捕獲值,或者指定任意要捕獲的變數名,這一點可以極大方便我們在C++裡使用函式。
然而在C語言裡,我們想要做同樣的事情就很困難了,C語言並不支援高階函式,我們想要讓函式能作為引數代入,或者讓函式能夠返回函式,我們需要使用函式指標,典型的函式指標是這樣子的:

int (*funcP) (int,int);
(*funcP)(1,2);複製程式碼

這裡的funcP本質上是一個指標,所以它可以在C語言裡被函式當做引數或者當做返回值,然而它無法代入自由變數,也就是說它根本沒法做到捕獲作用域變數,我們如果想要使用外層變數,必須手動加入一個引數的指標,然後再和函式指標一起代出去,這樣才能使用到外層的變數。

何其麻煩!所以很多廠商為c語言定製了閉包特性,其中比較有名的就是蘋果家的block,它的定義形式和函式指標極為相似,只不過把*換成了^,然而它卻有閉包的特性,可以捕獲自由變數,舉例來說:

int a =10;
int main(void){
  int (^op) (int);
  int b = 20;
  static c = 30;
  op = ^(int one){ return one+a+b+c;};
  op(1);
}複製程式碼

我們上面的op是一個block,在它的內部,可以捕獲到全域性變數a,以及區域性變數b,靜態變數c,對於全域性變數a以及區域性靜態變數c,它是可以直接訪問並且可以修改的,然而對於區域性變數b,它卻只能訪問,而無法做修改,並且當b的值發生變化的時候,它也無法感知,實際上是因為捕獲的時候就已經把b的值代入進棧的block object裡了。

為了能夠更好的捕獲自由變數,所以block還引入了一個特殊的修飾符,也就是__block,用於修飾區域性非靜態變數,被__block修飾的變數是可以在block裡讀取並修改的,它的值是動態生成的,實質上是每次執行的時候都會去獲取被修飾變數的記憶體區域,從而達到共享變數值的效果。

block object還有一個比較重要的地方就是它和其他變數一樣,生命週期在定義的函式執行結束之後也就結束了,這樣我們需要考慮的就是如何脫離創造它的環境下依舊有效,block引入了Block_copy這一工具函式,用於將棧上的block複製到堆上,這樣新的block就可以脫離原有的創造環境了。

總之,閉包在各種語言上有著不同的語法語義,其核心要素就是在於自由變數如何捕獲,我們在使用閉包的時候需要注意到語言的作用域方式,以及自由變數捕獲方式這些特點。

相關文章