深入理解JavaScript系列(16):閉包(Closures)
介紹
本章我們將介紹在JavaScript裏大家常常來討論的話題 —— 閉包(closure)。閉包事實上大家都已經談爛了。雖然如此,這裏還是要試著從理論角度來討論下閉包,看看ECMAScript中的閉包內部到底是怎樣工作的。
正如在前面的文章中提到的。這些文章都是系列文章,相互之間都是有關聯的。因此。為了更好的理解本文要介紹的內容,建議先去閱讀第14章作用域鏈和第12章變量對象。
英文原文:http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/
概論
在直接討論ECMAScript閉包之前,還是有必要來看一下函數式編程中一些基本定義。
眾所周知,在函數式語言中(ECMAScript也支持這樣的風格),函數即是數據。就例如說,函數能夠賦值給變量,能夠當參數傳遞給其它函數。還能夠從函數裏返回等等。這類函數有特殊的名字和結構。
定義
A functional argument (“Funarg”) — is an argument which value is a function. 函數式參數(“Funarg”) —— 是指值為函數的參數。
樣例:
function exampleFunc(funArg) { funArg(); } exampleFunc(function() { alert(‘funArg‘); });
上述樣例中funarg的實際參數事實上是傳遞給exampleFunc的匿名函數。
反過來,接受函數式參數的函數稱為高階函數(high-order function 簡稱:HOF)。還能夠稱作:函數式函數或者偏數理或操作符。上述樣例中。exampleFunc 就是這種函數。
此前提到的,函數不僅能夠作為參數。還能夠作為返回值。這類以函數為返回值的函數稱為帶函數值的函數(functions with functional value or function valued functions)。
(function functionValued() { return function () { alert(‘returned function is called‘); }; })()();
能夠以正常數據形式存在的函數(例如說:當參數傳遞。接受函數式參數或者以函數值返回)都稱作 第一類函數(一般說第一類對象)。在ECMAScript中。全部的函數都是第一類對象。
函數能夠作為正常數據存在(比如:當參數傳遞,接受函數式參數或者以函數值返回)都稱作第一類函數(一般說第一類對象)。
在ECMAScript中。全部的函數都是第一類對象。
接受自己作為參數的函數,稱為自應用函數(auto-applicative function 或者 self-applicative function):
(function selfApplicative(funArg) { if (funArg && funArg === selfApplicative) { alert(‘self-applicative‘); return; } selfApplicative(selfApplicative); })();
以自己為返回值的函數稱為自復制函數(auto-replicative function 或者 self-replicative function)。
通常,“自復制”這個詞用在文學作品中:
(function selfReplicative() { return selfReplicative; })();
自復制函數的當中一個比較有意思的模式是讓僅接受集合的一個項作為參數來接受從而取代接受集合本身。
// 接受集合的函數 function registerModes(modes) { modes.forEach(registerMode, modes); } // 使用方法 registerModes([‘roster‘, ‘accounts‘, ‘groups‘]); // 自復制函數的聲明 function modes(mode) { registerMode(mode); // 註冊一個mode return modes; // 返回函數自身 } // 使用方法,modes鏈式調用 modes(‘roster‘)(‘accounts‘)(‘groups‘) //有點類似:jQueryObject.addClass("a").toggle().removClass("b")
但直接傳集合用起來相對來說,比較有效而且直觀。
在函數式參數中定義的變量,在“funarg”激活時就行訪問了(由於存儲上下文數據的變量對象每次在進入上下文的時候就創建出來了):
function testFn(funArg) { // funarg激活時, 局部變量localVar能夠訪問了 funArg(10); // 20 funArg(20); // 30 } testFn(function (arg) { var localVar = 10; alert(arg + localVar); });
然而。我們從第14章知道,在ECMAScript中,函數是能夠封裝在父函數中的。並能夠使用父函數上下文的變量。這個特性會引發funarg問題。
Funarg問題
在面向堆棧的編程語言中,函數的局部變量都是保存在棧上的,每當函數激活的時候,這些變量和函數參數都會壓入到該堆棧上。
當函數返回的時候,這些參數又會從棧中移除。這樣的模型對將函數作為函數式值使用的時候有非常大的限制(例如說,作為返回值從父函數中返回)。絕大部分情況下,問題會出如今當函數有自由變量的時候。
自由變量是指在函數中使用的。但既不是函數參數也不是函數的局部變量的變量
樣例:
function testFn() { var localVar = 10; function innerFn(innerParam) { alert(innerParam + localVar); } return innerFn; } var someFn = testFn(); someFn(20); // 30
上述樣例中,對於innerFn函數來說,localVar就屬於自由變量。
對於採用面向棧模型來存儲局部變量的系統而言。就意味著當testFn函數調用結束後,其局部變量都會從堆棧中移除。
這樣一來。當從外部對innerFn進行函數調用的時候,就會錯誤發生(由於localVar變量已經不存在了)。
並且。上述樣例在面向棧實現模型中,要想將innerFn以返回值返回根本是不可能的。由於它也是testFn函數的局部變量,也會隨著testFn的返回而移除。
另一個問題是當系統採用動態作用域。函數作為函數參數使用的時候有關。
看例如以下樣例(偽代碼):
var z = 10; function foo() { alert(z); } foo(); // 10 – 使用靜態和動態作用域的時候 (function () { var z = 20; foo(); // 10 – 使用靜態作用域, 20 – 使用動態作用域 })(); // 將foo作為參數的時候是一樣的 (function (funArg) { var z = 30; funArg(); // 10 – 靜態作用域, 30 – 動態作用域 })(foo);
我們看到,採用動態作用域。變量(標識符)的系統是通過變量動態棧來管理的。
因此,自由變量是在當前活躍的動態鏈中查詢的,而不是在函數創建的時候保存起來的靜態作用域鏈中查詢的。
這樣就會產生沖突。例如說。即使Z仍然存在(與之前從棧中移除變量的樣例相反),還是會有這樣一個問題: 在不同的函數調用中,Z的值究竟取哪個呢(從哪個上下文,哪個作用域中查詢)?
上述描寫敘述的就是兩類funarg問題 —— 取決於是否將函數以返回值返回(第一類問題)以及是否將函數當函數參數使用(第二類問題)。
為了解決上述問題。就引入了 閉包的概念。
閉包
閉包是代碼塊和創建該代碼塊的上下文中數據的結合。
讓我們來看以下這個樣例(偽代碼):
var x = 20; function foo() { alert(x); // 自由變量"x" == 20 } // 為foo閉包 fooClosure = { call: foo // 引用到function lexicalEnvironment: {x: 20} // 搜索上下文的上下文 };
上述樣例中。“fooClosure”部分是偽代碼。相應的,在ECMAScript中,“foo”函數已經有了一個內部屬性——創建該函數上下文的作用域鏈。
“lexical”一般是省略的。上述樣例中是為了強調在閉包創建的同一時候,上下文的數據就會保存起來。當下次調用該函數的時候,自由變量就能夠在保存的(閉包)上下文中找到了,正如上述代碼所看到的,變量“z”的值總是10。
定義中我們使用的比較廣義的詞 —— “代碼塊”,然而。通常(在ECMAScript中)會使用我們經經常使用到的函數。當然了,並非全部對閉包的實現都會將閉包和函數綁在一起。例如說,在Ruby語言中,閉包就有可能是: 一個過程對象(procedure object), 一個lambda表達式或者是代碼塊。
對於要實現將局部變量在上下文銷毀後仍然保存下來,基於棧的實現顯然是不適用的(由於與基於棧的結構相矛盾)。因此在這樣的情況下,上層作用域的閉包數據是通過 動態分配內存的方式來實現的(基於“堆”的實現)。配合使用垃圾回收器(garbage collector簡稱GC)和 引用計數(reference counting)。
這樣的實現方式比基於棧的實現性能要低,然而,不論什麽一種實現總是能夠優化的: 能夠分析函數是否使用了自由變量,函數式參數或者函數式值。然後依據情況來決定 —— 是將數據存放在堆棧中還是堆中。
ECMAScript閉包的實現
討論完理論部分。接下來讓我們來介紹下ECMAScript中閉包到底是怎樣實現的。這裏還是有必要再次強調下:ECMAScript僅僅使用靜態(詞法)作用域(而諸如Perl這種語言。既能夠使用靜態作用域也能夠使用動態作用域進行變量聲明)。
var x = 10; function foo() { alert(x); } (function (funArg) { var x = 20; // 變量"x"在(lexical)上下文中靜態保存的,在該函數創建的時候就保存了 funArg(); // 10, 而不是20 })(foo);
技術上說,創建該函數的父級上下文的數據是保存在函數的內部屬性 [[Scope]]中的。
假設你還不了解什麽是[[Scope]],建議你先閱讀第14章, 該章節對[[Scope]]作了很具體的介紹。
假設你對[[Scope]]和作用域鏈的知識全然理解了的話,那對閉包也就全然理解了。
依據函數創建的算法,我們看到 在ECMAScript中,全部的函數都是閉包,由於它們都是在創建的時候就保存了上層上下文的作用域鏈(除開異常的情況) (無論這個函數興許是否會激活 —— [[Scope]]在函數創建的時候就有了):
var x = 10; function foo() { alert(x); } // foo是閉包 foo: <FunctionObject> = { [[Call]]: <code block of foo>, [[Scope]]: [ global: { x: 10 } ], ... // 其他屬性 };
如我們所說,為了優化目的,當一個函數沒有使用自由變量的話,實現可能不保存在副作用域鏈裏。只是,在ECMA-262-3規範裏不論什麽都沒說。因此,正常來說,全部的參數都是在創建階段保存在[[Scope]]屬性裏的。
有些實現中。同意對閉包作用域直接進行訪問。比方Rhino,針對函數的[[Scope]]屬性,相應有一個非標準的 __parent__屬性,在第12章中作過介紹:
var global = this; var x = 10; var foo = (function () { var y = 20; return function () { alert(y); }; })(); foo(); // 20 alert(foo.__parent__.y); // 20 foo.__parent__.y = 30; foo(); // 30 // 能夠通過作用域鏈移動到頂部 alert(foo.__parent__.__parent__ === global); // true alert(foo.__parent__.__parent__.x); // 10
全部對象都引用一個[[Scope]]
這裏還要註意的是:在ECMAScript中。同一個父上下文中創建的閉包是共用一個[[Scope]]屬性的。也就是說,某個閉包對當中[[Scope]]的變量做改動會影響到其它閉包對其變量的讀取:
這就是說:全部的內部函數都共享同一個父作用域
var firstClosure; var secondClosure; function foo() { var x = 1; firstClosure = function () { return ++x; }; secondClosure = function () { return --x; }; x = 2; // 影響 AO["x"], 在2個閉包公有的[[Scope]]中 alert(firstClosure()); // 3, 通過第一個閉包的[[Scope]] } foo(); alert(firstClosure()); // 4 alert(secondClosure()); // 3
關於這個功能有一個很普遍的錯誤認識。開發者在循環語句裏創建函數(內部進行計數)的時候常常得不到預期的結果,而期望是每一個函數都有自己的值。
var data = []; for (var k = 0; k < 3; k++) { data[k] = function () { alert(k); }; } data[0](); // 3, 而不是0 data[1](); // 3, 而不是1 data[2](); // 3, 而不是2
上述樣例就證明了 —— 同一個上下文中創建的閉包是共用一個[[Scope]]屬性的。
因此上層上下文中的變量“k”是能夠非常easy就被改變的。
activeContext.Scope = [ ... // 其他變量對象 {data: [...], k: 3} // 活動對象 ]; data[0].[[Scope]] === Scope; data[1].[[Scope]] === Scope; data[2].[[Scope]] === Scope;
這樣一來,在函數激活的時候,終於使用到的k就已經變成了3了。
例如以下所看到的。創建一個閉包就能夠解決問題了:
var data = []; for (var k = 0; k < 3; k++) { data[k] = (function _helper(x) { return function () { alert(x); }; })(k); // 傳入"k"值 } // 如今結果是正確的了 data[0](); // 0 data[1](); // 1 data[2](); // 2
讓我們來看看上述代碼都發生了什麽?函數“_helper”創建出來之後。通過傳入參數“k”激活。
其返回值也是個函數。該函數保存在相應的數組元素中。這樣的技術產生了例如以下效果: 在函數激活時,每次“_helper”都會創建一個新的變量對象。當中含有參數“x”,“x”的值就是傳遞進來的“k”的值。這樣一來。返回的函數的[[Scope]]就成了例如以下所看到的:
data[0].[[Scope]] === [ ... // 其他變量對象 父級上下文中的活動對象AO: {data: [...], k: 3}, _helper上下文中的活動對象AO: {x: 0} ]; data[1].[[Scope]] === [ ... // 其他變量對象 父級上下文中的活動對象AO: {data: [...], k: 3}, _helper上下文中的活動對象AO: {x: 1} ]; data[2].[[Scope]] === [ ... // 其他變量對象 父級上下文中的活動對象AO: {data: [...], k: 3}, _helper上下文中的活動對象AO: {x: 2} ];
我們看到。這時函數的[[Scope]]屬性就有了真正想要的值了。為了達到這種目的。我們不得不在[[Scope]]中創建額外的變量對象。要註意的是,在返回的函數中。假設要獲取“k”的值,那麽該值還是會是3。
順便提下,大量介紹JavaScript的文章都覺得僅僅有額外創建的函數才是閉包,這樣的說法是錯誤的。實踐得出,這樣的方式是最有效的。然而。從理論角度來說,在ECMAScript中全部的函數都是閉包。
然而,上述提到的方法並非唯一的方法。
通過其它方式也能夠獲得正確的“k”的值,例如以下所看到的:
var data = []; for (var k = 0; k < 3; k++) { (data[k] = function () { alert(arguments.callee.x); }).x = k; // 將k作為函數的一個屬性 } // 結果也是對的 data[0](); // 0 data[1](); // 1 data[2](); // 2
Funarg和return
另外一個特性是從閉包中返回。在ECMAScript中,閉包中的返回語句會將控制流返回給調用上下文(調用者)。而在其它語言中。比方,Ruby,有非常多中形式的閉包。對應的處理閉包返回也都不同,以下幾種方式都是可能的:可能直接返回給調用者,或者在某些情況下——直接從上下文退出。
ECMAScript標準的退出行為例如以下:
function getElement() { [1, 2, 3].forEach(function (element) { if (element % 2 == 0) { // 返回給函數"forEach"函數 // 而不是返回給getElement函數 alert(‘found: ‘ + element); // found: 2 return element; } }); return null; }
然而,在ECMAScript中通過try catch能夠實現例如以下效果:
var $break = {}; function getElement() { try { [1, 2, 3].forEach(function (element) { if (element % 2 == 0) { // // 從getElement中"返回" alert(‘found: ‘ + element); // found: 2 $break.data = element; throw $break; } }); } catch (e) { if (e == $break) { return $break.data; } } return null; } alert(getElement()); // 2
理論版本號
這裏說明一下,開發者常常錯誤將閉包簡化理解成從父上下文中返回內部函數,甚至理解成僅僅有匿名函數才幹是閉包。
再說一下,由於作用域鏈,使得全部的函數都是閉包(與函數類型無關: 匿名函數,FE,NFE,FD都是閉包)。
這裏僅僅有一類函數除外,那就是通過Function構造器創建的函數,由於其[[Scope]]僅僅包括全局對象。
為了更好的澄清該問題。我們對ECMAScript中的閉包給出2個正確的版本號定義:
ECMAScript中,閉包指的是:
- 從理論角度:全部的函數。由於它們都在創建的時候就將上層上下文的數據保存起來了。哪怕是簡單的全局變量也是如此,由於函數中訪問全局變量就相當於是在訪問自由變量。這個時候使用最外層的作用域。
- 從實踐角度:下面函數才算是閉包:
- 即使創建它的上下文已經銷毀,它仍然存在(比方,內部函數從父函數中返回)
- 在代碼中引用了自由變量
閉包使用方法實戰
實際使用的時候,閉包能夠創建出很優雅的設計。同意對funarg上定義的多種計算方式進行定制。例如以下就是數組排序的樣例。它接受一個排序條件函數作為參數:
[1, 2, 3].sort(function (a, b) { ... // 排序條件 });
相同的樣例還有。數組的map方法是依據函數中定義的條件將原數組映射到一個新的數組中:
[1, 2, 3].map(function (element) { return element * 2; }); // [2, 4, 6]
使用函數式參數,能夠非常方便的實現一個搜索方法,而且能夠支持無限制的搜索條件:
someCollection.find(function (element) { return element.someProperty == ‘searchCondition‘; });
還有應用函數。比方常見的forEach方法,將函數應用到每一個數組元素:
[1, 2, 3].forEach(function (element) { if (element % 2 != 0) { alert(element); } }); // 1, 3
順便提下,函數對象的 apply 和 call方法,在函數式編程中也能夠用作應用函數。 apply和call已經在討論“this”的時候介紹過了;這裏,我們將它們看作是應用函數 —— 應用到參數中的函數(在apply中是參數列表,在call中是獨立的參數):
(function () { alert([].join.call(arguments, ‘;‘)); // 1;2;3 }).apply(this, [1, 2, 3]);
閉包還有另外一個很重要的應用 —— 延遲調用:
var a = 10; setTimeout(function () { alert(a); // 10, after one second }, 1000);
還有回調函數
//... var x = 10; // only for example xmlHttpRequestObject.onreadystatechange = function () { // 當數據就緒的時候,才會調用; // 這裏。不論是在哪個上下文中創建 // 此時變量“x”的值已經存在了 alert(x); // 10 }; //...
還能夠創建封裝的作用域來隱藏輔助對象:
var foo = {}; // 初始化 (function (object) { var x = 10; object.getX = function _getX() { return x; }; })(foo); alert(foo.getX()); // 獲得閉包 "x" – 10
總結
本文介紹了很多其它關於ECMAScript-262-3的理論知識,而我覺得,這些基礎的理論有助於理解ECMAScript中閉包的概念。假設有不論什麽問題。我回在評論裏回復大家。
深入理解JavaScript系列(16):閉包(Closures)