閉包的原理及應用

Hotlink發表於2022-04-27

閉包是在JavaScript中常見的概念,不過各類其它語言也都模擬實現了閉包的行為,包括Java,C++,Objective-C,C#,Golang等等(不過還是和傳統閉包有所區別)。之前對這個概念一直不是很清晰,希望能通過閱讀網路材料並貫通學習掌握閉包的原理和應用場景。

定義與初衷

根據維基百科的溯源,閉包(closure)概念最早是在1964年由Peter Landin定義,用於表述在他的SECD機器上求解表示式時的“環境部分”+“控制部分”,這個術語用來指代某些開放繫結(自由變數)已被其周圍的詞法環境閉合(close,或繫結)的Lambda表示式[1]。這個概念還是有些抽象,更明確一些的表述是MDN Web社群對於閉包的說明:一個函式和對其周圍狀態(詞法環境)的引用捆綁在一起,這樣的組合稱為閉包[2]。進一步的理解每個人也有不同的看法[3]。

從定義來看,閉包最大的用途其實是把一個函式,和一組它“私有”的變數看捆綁在一起。在這個函式被多次呼叫的過程中,這些變數都可以保留變化,並且又不會被其它函式改變。保留變數的值被多次使用不難,核心價值在於同時保證這個變數的私密性,對外隱藏相關資訊。這裡就涉及到變數的作用域問題,也是JavaScript中的一大知識點。絕大部分材料也都以JS來應用和解釋閉包。

閉包例項與應用

官網教程中舉了一個清晰的例子來說明對閉包的定義:

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name);
    }
    return displayName;
}
 
var myFunc = makeFunc();
myFunc();

首先,這段程式碼可以執行。直觀上var myFunc = makeFunc()已經執行完了makeFunc()函式,但隨後的myFunc()呼叫能夠正常處理的原因就在於形成了閉包。這裡形成閉包的函式是displayName例項,與其繫結的變數就是name,準確的說變數name被儲存在了displayName函式例項的詞法環境中,共同形成了閉包。因此呼叫myFunc時,變數name仍然可以被alert出來。

這是一個用於說明概念的例項,真實的應用場景中,閉包的使用方式繁多,舉一些收集到的案例:

JavaScript用閉包模擬私有方法

https://developer.mozilla.org...

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }
})();
 
console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

獨特的是能夠使用這種方法來實現類似Java封裝私有函式的場景,多個函式能夠共享相同的詞法環境(操作相同的私有變數)

Golang閉包和協程的使用

帶我重新認識閉包的例子,是學習golang時在segmentfault回答的一個問題(https://segmentfault.com/q/10...),以下程式碼中的協程用法:

for i := 0; i < 100; i++ {
    go func(i int) {
        fmt.Println(i)
    }(i)
}

以及Golang學習網站相似的例子:https://books.studygolang.com...

在Golang中,閉包體現為一個巢狀的匿名函式,它主要的用途包括[8]:

  1. 和JS一樣,函式私有變數,延長變數的生命週期,並把變數隔離起來不讓外界訪問。
  2. 回撥,和其它語言一樣。
  3. 包裝函式,並製作“中介軟體”(middleware,Golang中的概念為可重用的函式)。在Golang中,函式是一等公民,可以把函式作為引數放到另一個函式中,那麼一些通用的處理邏輯,比如列印日誌,計時器等等都可以實現為閉包。
  4. 在不少庫函式中,你可以使用閉包傳入函式來充分利用庫函式帶來的便捷性,例如在sort包中,可以通過閉包中的函式確定搜尋物件的篩選條件。

Java中的閉包與Lambda函式

Java 8之後,語言生態不再只盯著物件了,很多時候函數語言程式設計的方法顯得更加簡潔輕巧,也因此引入了Lambda表示式。而它又經常和“匿名函式”及“閉包”一起被提及。首先需要明確的是Lambda函式和匿名函式基本相等,但和閉包並不等價,從起源也能看出,閉包是在計算Lambda表示式時被引入的概念,而不等於Lambda表示式本身。針對Lambda表示式和閉包區別本身的解讀可以參考[10],有非常詳細的說明。簡單來說Lambda表示式是編寫程式的一種簡潔方式,而其中涉及到開放表示式——表示式中的變數在函式外部時,就需要使用閉包的方式把函式和外部引數所處的詞法環境繫結起來進行計算。這一點其實對任何語言也都通用。

int n = 0;
final int k = n; // With Java 8 there is no need to explicit final
Runnable r = () -> { // Using lambda
    int i = k;
    // do something
};
n++;      // Now will not generate an error
r.run();  // Will run with i = 0 because k was 0 when the lambda was created

其中變數k超出了Lambda表示式之外,需要使用閉包來引入使用。值得注意的一點是,Java本身最好的寫法就是封裝一個物件包含私有變數和私有函式,不需要像JavaScript一樣去模擬。

閉包實現

根據閉包的定義和想要達到的功能表現,我們可以看出閉包背後實現是通常的做法就是使用一個資料結構,這個結構中首先需要儲存一個指向函式程式碼的指標,其次需要儲存閉包建立時的詞法環境,最典型的就是儲存建立時所有可用的變數。

理想的可以實現閉包的語言,它在執行時的記憶體模型中,所有原子變數應該放在一個線性棧裡。而在這種語言場景下,如果建立閉包,那麼對應詞法環境中的變數就不能隨函式執行完畢被回收,此時典型的做法是把變數放在堆中(能和Java例子中閉包使用的變數需要用final修飾結合起來),直到所有的閉包引用都使用完畢再進行回收。我想這也間接說明了網上廣泛流傳的“IE中使用閉包會有記憶體洩露”問題的由來。閉包也更加適合於那些會進行“垃圾回收”的語言。

而對於只操作棧的語言來說,實現閉包就不那麼容易了,這些原子開發變數會出現野指標等問題。典型的以棧為基礎的程式語言包括C和C++。

而專門針對JavaScript來說,閉包的實現有賴於其變數作用域的相關機制,我也還需要進一步學習JavaScript語言背後的記憶體模型。

優勢和劣勢

面對閉包,我們需要考慮的是要不要用,以及不用閉包的情況下是否還有其它的替代方式。

首先閉包的最大優勢已經體現在其應用場景中,避免使用全域性變數,讓變數被函式所“私有”,並且長期留存供多次使用。

不過閉包也有其劣勢,從原理和實現的角度:

  1. 閉包主動延長了它所繫結的詞法空間中相關變數的生命週期,而這部分變數會再用完前始終放在記憶體裡,佔用資源,一旦處理不佳(比如IE)還有可能出現記憶體洩露問題。
  2. 其次,閉包的效能並不算好,一些特殊的場景需要注意寫法,例如[2]中所舉的例子:
// bad case,每次構造器被呼叫時會對方法進行賦值
function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
  this.getName = function() {
    return this.name;
  };
     
  this.getMessage = function() {
    return this.message;
  };
}     
// good case,拆分出來
function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function() {
  return this.name;
};
MyObject.prototype.getMessage = function() {
  return this.message;
};

參考資料

  1. https://en.wikipedia.org/wiki...(computer_programming)
  2. https://developer.mozilla.org...
  3. https://segmentfault.com/q/10...
  4. https://www.liaoxuefeng.com/w...
  5. https://zhuanlan.zhihu.com/p/...
  6. https://www.runoob.com/w3cnot...
  7. https://books.studygolang.com...
  8. https://www.calhoun.io/5-usef...
  9. https://riptutorial.com/java/...
  10. https://stackoverflow.com/que...

相關文章