命名函数表达式探秘
函數表達式與函數聲明
在ECMAScript中,有兩個最常用的創建函數對象的方法,即使用函數表達式或者使用函數聲明。這兩種方法之間的區別可謂 相當地令人困惑;至少我是相當地困惑。對此,ECMA規范只明確了一點,即函數聲明 必須始終帶有一個標識符(Identifier)——也就是函數名唄,而函數表達式 則可省略這個標識符:
函數聲明: function Identifier ( FormalParameterList opt ){ FunctionBody }函數表達式: function Identifier opt ( FormalParameterList opt ){ FunctionBody }
顯然,在省略標識符的情況下, “表達式” 也就只能是表達式了。可要是不省略標識符呢?誰知道它是一個函數聲明,還是一個函數表達式——畢竟,這種情況下二者是完全一樣的啊?實踐表明,ECMAScript是通過上下文來區分這兩者的:假如 function foo(){} 是一個賦值表達式的一部分,則認為它是一個函數表達式。而如果 function foo(){} 被包含在一個函數體內,或者位于程序(的最上層)中,則將它作為一個函數聲明來解析。
function foo(){}; // 聲明,因為它是程序的一部分var bar = function foo(){}; // 表達式,因為它是賦值表達式(AssignmentExpression)的一部分new function bar(){}; // 表達式,因為它是New表達式(NewExpression)的一部分(function(){function bar(){}; // 聲明,因為它是函數體(FunctionBody)的一部分})();還有一種不那么顯而易見的函數表達式,就是被包含在一對圓括號中的函數—— (function foo(){})。將這種形式看成表達式同樣是因為上下文的關系:(和)構成一個分組操作符,而分組操作符只能包含表達式:
下面再多看幾個例子吧:
function foo(){}; // 函數聲明(function foo(){}); // 函數表達式:注意它被包含在分組操作符中try {(var x = 5); // 分組操作符只能包含表達式,不能包含語句(這里的var就是語句)} catch(err) {// SyntaxError(因為“var x = 5”是一個語句,而不是表達式——對表達式求值必須返回值,但對語句求值則未必返回值。——譯者注)}不知道大家有沒有印象,在使用 eval 對JSON求值的時候,JSON字符串通常是被包含在一對圓括號中的—— eval('(' + json + ')')。這樣做的原因當然也不例外——分組操作符,也就是那對圓括號,會導致解析器強制將JSON的花括號當成表達式而不代碼塊來解析:
try {{ "x": 5 }; // {和}會被作為塊來解析} catch(err) {// SyntaxError(“'x':5”只是構建對象字面量的語法,但該語法不能出現在外部的語句塊中。——譯者注)}({ "x": 5 }); // 分組操作符會導致解析器強制將{和}作為對象字面量來解析聲明和表達式的行為存在著十分微妙而又十分重要的差別。
首先,函數聲明會在任何表達式被解析和求值之前先行被解析和求值。即使聲明位于源代碼中的最后一行,它也會先于同一作用域中位于最前面的表達式被求值。還是看個例子更容易理解。在下面這個例子中,函數 fn 是在 alert 后面聲明的。但是,在 alert 執行的時候,fn已經有定義了:
alert(fn());function fn() {return 'Hello world!';}
函數聲明還有另外一個重要的特點,即通過條件語句控制函數聲明的行為并未標準化,因此不同環境下可能會得到不同的結果。有鑒于此,奉勸大家千萬不要在條件語句中使用函數聲明,而要使用函數表達式。
// 千萬不要這樣做!// 有的瀏覽器會把foo聲明為返回first的那個函數// 而有的瀏覽器則會讓foo返回secondif (true) {function foo() {return 'first';}}else {function foo() {return 'second';}}foo();// 記住,這種情況下要使用函數表達式:var foo;if (true) {foo = function() {return 'first';};}else {foo = function() {return 'second';};}foo();想知道使用函數聲明的實際規則到底是什么?繼續往下看吧。嗯,有人不想知道?那請跳過下面這段摘錄的文字。
FunctionDeclaration(函數聲明)只能出現在Program(程序)或FunctionBody(函數體)內。從句法上講,它們 不能出現在Block(塊)({ ... })中,例如不能出現在 if、while 或 for 語句中。因為 Block(塊) 中只能包含Statement(語句), 而不能包含FunctionDeclaration(函數聲明)這樣的SourceElement(源元素)。另一方面,仔細看一看產生規則也會發現,唯一可能讓Expression(表達式)出現在Block(塊)中情形,就是讓它作為ExpressionStatement(表達式語句)的一部分。但是,規范明確規定了ExpressionStatement(表達式語句)不能以關鍵字function開頭。而這實際上就是說,FunctionExpression(函數表達式)同樣也不能出現在Statement(語句)或Block(塊)中(別忘了Block(塊)就是由Statement(語句)構成的)。
由于存在上述限制,只要函數出現在塊中(像上面例子中那樣),實際上就應該將其看作一個語法錯誤,而不是什么函數聲明或表達式。但問題是,我還沒見過哪個實現是按照上述規則來解析這些函數的;好像每個實現都有自己的一套。
命名函數表達式
函數表達式實際上還是很常見的。Web開發中有一個常用的模式,即基于對某種特性的測試來“偽裝”函數定義,從而實現性能最優化。由于這種偽裝通常都出現在相同的作用域中,因此基本上一定要使用函數表達式。畢竟,如前所述,不應該根據條件來執行函數聲明:
// 這里的contains取自APE Javascript庫的源代碼,網址為http://dhtmlkitchen.com/ape/,作者蓋瑞特·斯密特(Garrett Smit)var contains = (function() {var docEl = document.documentElement;if (typeof docEl.compareDocumentPosition != 'undefined') {return function(el, b) {return (el.compareDocumentPosition(b) & 16) !== 0;}}else if (typeof docEl.contains != 'undefined') {return function(el, b) {return el !== b && el.contains(b);}}return function(el, b) {if (el === b) return false;while (el != b && (b = b.parentNode) != null);return el === b;}})();提到命名函數表達式,很顯然,指的就是有名字(技術上稱為標識符)的函數表達式。在最前面的例子中,var bar = function foo(){};實際上就是一個以foo作為函數名字的函數表達式。對此,有一個細節特別重要,請大家一定要記住,即這個名字只在新定義的函數的作用域中有效——規范要求標識符不能在外圍的作用域中有效:
var f = function foo(){return typeof foo; // foo只在內部作用域中有效};// foo在“外部”永遠是不可見的typeof foo; // "undefined"f(); // "function"那么,這些所謂的命名函數表達式到底有什么用呢?為什么還要給它們起個名字呢?
原因就是有名字的函數可以讓調試過程更加方便。在調試應用程序時,如果調用棧中的項都有各自描述性的名字,那么調試過程帶給人的就是另一種完全不同的感受。
調試器中的函數名
在函數有相應標識符的情況下,調試器會將該標識符作為函數的名字顯示在調用棧中。有的調試器(例如Firebug)甚至會為匿名函數起個名字并顯示出來,讓它們與那些引用函數的變量具有相同的角色。可遺憾的是,這些調試器通常只使用簡單的解析規則,而依據簡單的解析規則提取出來的“名字”有時候沒有多大價值,甚至會得到錯誤結果。(Such extraction is usually quite fragile and often produces false results. )
下面我們來看一個簡單的例子:
function foo(){return bar();}function bar(){return baz();}function baz(){debugger;}foo();// 這里使用函數聲明定義了3個函數// 當調試器停止在debugger語句時,// Firgbug的調用棧看起來非常清晰:bazbarfooexpr_test.html()這樣,我們就知道foo調用了bar,而后者接著又調用了baz(而foo本身又在expr_test.html文檔的全局作用域中被調用)。但真正值得稱道的,則是Firebug會在我們使用匿名表達式的情況下,替我們解析函數的“名字”:
function foo(){return bar();}var bar = function(){return baz();}function baz(){debugger;}foo();// 調用棧:bazbar()fooexpr_test.html()相反,不那么令人滿意的情況是,當函數表達式復雜一些時(現實中差不多總是如此),調試器再如何盡力也不會起多大的作用。結果,我們只能在調用棧中顯示函數名字的位置上赫然看到一個問號:
function foo(){return bar();}var bar = (function(){if (window.addEventListener) {return function(){return baz();}}else if (window.attachEvent) {return function() {return baz();}}})();function baz(){debugger;}foo();// 調用棧:baz(?)()fooexpr_test.html()此外,當把一個函數賦值給多個變量時,還會出現一個令人困惑的問題:
function foo(){return baz();}var bar = function(){debugger;};var baz = bar;bar = function() { alert('spoofed');}foo();// 調用棧:bar()fooexpr_test.html()可見,調用棧中顯示的是foo調用了bar。但實際情況顯然并非如此。之所以會造成這種困惑,完全是因為baz與另一個函數——包含代碼alert('spoofed');的函數——“交換了”引用所致。實事求是地說,這種解析方式在簡單的情況下固然好,但對于不那么簡單的大多數情況而言就沒有什么用處了。
歸根結底,只有命名函數表達式才是產生可靠的棧調用信息的唯一途徑。下面我們有意使用命名函數表達式來重寫前面的例子。請大家注意,從自執行包裝塊中返回的兩個函數都被命名為了bar:
function foo(){return bar();}var bar = (function(){if (window.addEventListener) {return function bar(){return baz();}}else if (window.attachEvent) {return function bar() {return baz();}}})();function baz(){debugger;}foo();// 這樣,我們就又可以看到清晰的調用棧信息了!bazbarfooexpr_test.html()JScript的bug
令人討厭的是,JScript(也就是IE的ECMAScript實現)嚴重混淆了命名函數表達式。JScript搞得現如今很多人都站出來反對命名函數表達式。而且,直到JScript的最近一版——IE8中使用的5.8版——仍然存在下列的所有怪異問題。
下面我們就來看看IE在它的這個“破”實現中到底都搞出了哪些花樣。唉,只有知已知彼,才能百戰不殆嘛。請注意,為了清晰起見,我會通過一個個相對獨立的小例子來說明這些問題,雖然這些問題很可能是一個主bug引起的一連串的后果。
例1:函數表達式的標識符滲透到外部(enclosing)作用域中
var f = function g(){};typeof g; // "function"還有人記得嗎,我們說過:命名函數表達式的標識符在其外部作用域中是無效的? 好啦,JScript明目張膽地違反了這一規定——上面例子中的標識符g被解析為函數對象。這是最讓人頭疼的一個問題了。這樣,任何標識符都可能會在不經意間“污染”某個外部作用域——甚至是全局作用域。而且,這種污染常常就是那些難以捕獲的bug的來源。
例2:將命名函數表達式同時當作函數聲明和函數表達式
typeof g; // "function"var f = function g(){};如前所述,在特定的執行環境中,函數聲明會先于任何表達式被解析。上面這個例子展示了JScript實際上是把命名函數表達式當作函數聲明了;因為它在“實際的”聲明之前就解析了g。
這個例子進而引出了下一個例子:
例3:命名函數表達式會創建兩個截然不同的函數對象!
var f = function g(){};f === g; // falsef.expando = 'foo';g.expando; // undefined問題至此就比較嚴重了。或者可以說修改其中一個對象對另一個絲毫沒有影響——這簡直就是胡鬧!通過例子可以看出,出現兩個不同的對象會存在什么風險。假如你想利用緩存機制,在f的屬性中保存某個信息,然后又想當然地認為可以通過引用相同對象的g的同名屬性取得該信息,那么你的麻煩可就大了。
再來看一個稍微復雜點的情況。
例4:只管順序地解析函數聲明而忽略條件語句塊
var f = function g() {return 1;};if (false) {f = function g(){return 2;};}g(); // 2要查找這個例子中的bug就要困難一些了。但導致bug的原因卻非常簡單。首先,g被當作函數聲明解析,而由于JScript中的函數聲明不受條件代碼塊約束(與條件代碼塊無關),所以在“該死的”if分支中,g被當作另一個函數——function g(){ return 2 }——又被聲明了一次。然后,所有“常規的”表達式被求值,而此時f被賦予了另一個新創建的對象的引用。由于在對表達式求值的時候,永遠不會進入“該死的”if分支,因此f就會繼續引用第一個函數——function g(){ return 1 }。分析到這里,問題就很清楚了:假如你不夠細心,在f中調用了g(在執行遞歸操作的時候會這樣做。——譯者注),那么實際上將會調用一個毫不相干的g函數對象(即返回2的那個函數對象。——譯者注)。
聰明的讀者可能會聯想到:在將不同的函數對象與arguments.callee進行比較時,這個問題會有所表現嗎?callee到底是引用f還是引用g呢?下面我們就來看一看:
var f = function g(){return [arguments.callee == f,arguments.callee == g];};f(); // [true, false]g(); // [false, true]看到了吧,arguments.callee引用的始終是被調用的函數。實際上,這應該是件好事兒,原因你一會兒就知道了。
另一個“意外行為”的好玩的例子,當我們在不包含聲明的賦值語句中使用命名函數表達式時可以看到。不過,此時函數的名字必須與引用它的標識符相同才行:
(function(){f = function f(){};})();不包含聲明的賦值語句(注意,我們不建議使用,這里只是出于示范需要才用的)在這里會創建一個全局屬性f。而這也是標準實現的行為。可是,JScript的bug在這里又會出點亂子。由于JScript把命名函數表達式當作函數聲明來解析(參見前面的“例2”),因此在變量聲明階段,f會被聲明為局部變量。然后,在函數執行時,賦值語句已經不是未聲明的了,右手邊的function f(){}就會被直接賦給剛剛創建的局部變量f。而全局作用域中的f根本不會存在。
看完這個例子后,相信大家就會明白,如果你對JScript的“怪異”行為缺乏了解,你的代碼中出現“嚴重不符合預期”的行為就不難理解了。
JScript的內存管理
明白了JScript的缺陷以后,要采取哪些預防措施就非常清楚了。首先,要注意防范標識符泄漏(滲透)(不讓標識符污染外部作用域)。其次,應該永遠不引用被用作函數名稱的標識符;還記得前面例子中那個討人厭的標識符g嗎?——如果我們能夠當g不存在,可以避免多少不必要的麻煩哪。因此,關鍵就在于始終要通過f或者arguments.callee來引用函數。如果你使用了命名函數表達式,那么應該只在調試的時候利用那個名字。最后,還要記住一點,一定要把NFE(Named Funciont Expresssions,命名函數表達式)聲明期間錯誤創建的函數清理干凈。
下面看一個簡單的例子:
var f = (function(){if (true) {return function g(){};}return function g(){};})();我們知道,這里匿名(函數)調用返回的函數——帶有標識符g的函數——被賦值給了外部的f。我們也知道,命名函數表達式會導致產生多余的函數對象,而該對象與返回的函數對象不是一回事。由于有一個多余的g函數被“截留”在了返回函數的閉包中,因此內存問題就出現了。這是因為(if語句)內部(的)函數與討厭的g是在同一個作用域中被聲明的。在這種情況下 ,除非我們顯式地斷開對(匿名調用返回的)g函數的引用,否則那個討厭的家伙會一直占著內存不放。
var f = (function(){var f, g;if (true) {f = function g(){};}else {f = function g(){};}// 廢掉g,這樣它就不會再引用多余的函數了g = null;return f;})();請注意,這里也明確聲明了變量g,因此賦值語句g = null就不會在符合標準的客戶端(如非JScript實現)中創建全局變量g了。通過廢掉對g的引用,垃圾收集器就可以把g引用的那個隱式創建的函數對象清除了。
測試
這里的測試很簡單。就是通過命名函數表達式創建10000個函數,把它們保存在一個數組中。過一會兒,看看這些函數到底占用了多少內存。然后,再廢掉這些引用并重復這一過程。下面是我使用的一個測試用例:
function createFn(){return (function(){var f;if (true) {f = function F(){return 'standard';}}else if (false) {f = function F(){return 'alternative';}}else {f = function F(){return 'fallback';}}// var F = null;return f;})();}var arr = [ ];for (var i=0; i<10000; i++) {arr[i] = createFn();}通過運行在Windows XP SP2中的Process Explorer可以看到如下結果:
IE6:without `null`: 7.6K -> 20.3Kwith `null`: 7.6K -> 18KIE7:without `null`: 14K -> 29.7Kwith `null`: 14K -> 27K這個結果大致驗證了我的想法——顯式地清除多余的引用確實可以釋放內存,但釋放的內存空間相對不多。在創建10000個函數對象的情況下,大約有3MB左右。對于大型應用程序,以及需要長時間運行或者在低內存設備(如手持設備)上運行的程序而言,這是絕對需要考慮的。但對小型腳本而言,這點差別可能也算不了什么。
解決方案
var fn = (function(){// 聲明要引用函數的變量var f;// 有條件地創建命名函數// 并將其引用賦值給fif (true) {f = function F(){ }}else if (false) {f = function F(){ }}else {f = function F(){ }}// 聲明一個與函數名(標識符)對應的變量,并賦值為null// 這實際上是給相應標識符引用的函數對象作了一個標記,// 以便垃圾回收器知道可以回收它了var F = null;// 返回根據條件定義的函數return f;})();最后,我要給出一個應用上述“技術”的實例。這是一個跨瀏覽器的addEvent函數的代碼:
// 1) 使用獨立的作用域包含聲明var addEvent = (function(){var docEl = document.documentElement;// 2) 聲明要引用函數的變量var fn;if (docEl.addEventListener) {// 3) 有意給函數一個描述性的標識符fn = function addEvent(element, eventName, callback) {element.addEventListener(eventName, callback, false);}}else if (docEl.attachEvent) {fn = function addEvent(element, eventName, callback) {element.attachEvent('on' + eventName, callback);}}else {fn = function addEvent(element, eventName, callback) {element['on' + eventName] = callback;}}// 4) 清除由JScript創建的addEvent函數// 一定要保證在賦值前使用var關鍵字// 除非函數頂部已經聲明了addEventvar addEvent = null;// 5) 最后返回由fn引用的函數return fn;})();替代方案
不要忘了,如果我們不想在調用棧中保留描述性的名字,實際上還有其他選擇。換句話說,就是還存在不必使用命名函數表達式的方案。首先,很多時候都可以通過聲明而非表達式定義函數。這個方案只適合不需要創建多個函數的情形:
var hasClassName = (function(){// 定義私有變量var cache = { };// 使用函數聲明function hasClassName(element, className) {var _className = '(?:^|\\s+)' + className + '(?:\\s+|$)';var re = cache[_className] || (cache[_className] = new RegExp(_className));return re.test(element.className);}// 返回函數return hasClassName;})();顯然,當存在多個分支函數定義時,這個方案就不能勝任了。不過,我最早見過托比·蘭吉(Tobiel Langel)使用過一個很有味道的模式。他的這種模式是提前使用函數聲明來定義所有函數,并分別為這些函數指定不同的標識符:
var addEvent = (function(){var docEl = document.documentElement;function addEventListener(){/* ... */}function attachEvent(){/* ... */}function addEventAsProperty(){/* ... */}if (typeof docEl.addEventListener != 'undefined') {return addEventListener;}elseif (typeof docEl.attachEvent != 'undefined') {return attachEvent;}return addEventAsProperty;})();雖然這個方案很優雅,但也不是沒有缺點。第一,由于使用不同的標識符,導致喪失了命名的一致性。且不說這樣好還是壞,最起碼它不夠清晰。有人喜歡使用相同的名字,但也有人根本不在乎字眼上的差別。可畢竟,不同的名字會讓人聯想到所用的不同實現。例如,在調試器中看到attachEvent,我們就知道addEvent是基于attachEvent的實現。當然,基于實現來命名的方式也不一定都行得通。假如我們要提供一個API,并按照這種方式把函數命名為inner。那么API用戶的很容易就會被相應實現的細節搞得暈頭轉向。
要解決這個問題,當然就得想一套更合理的命名方案了。但關鍵是不要再額外制造麻煩。我現在能想起來的方案大概有如下幾個:
'addEvent', 'altAddEvent', 'fallbackAddEvent'// 或者'addEvent', 'addEvent2', 'addEvent3'// 或者'addEvent_addEventListener', 'addEvent_attachEvent', 'addEvent_asProperty'另外,托比使用的模式還存在一個小問題,即增加內存占用。提前創建N個不同名字的函數,等于有N-1的函數是用不到的。具體來講,如果document.documentElement中包含attachEvent,那么addEventListener 和addEventAsProperty則根本就用不著了。可是,他們都占著內存哪;而且,這些內存將永遠都得不到釋放,原因跟JScript臭哄哄的命名表達式相同——這兩個函數都被“截留”在返回的那個函數的閉包中了。
不過,增加內存占用這個問題確實沒什么大不了的。如果某個庫——例如Prototype.js——采用了這種模式,無非也就是多創建一兩百個函數而已。只要不是(在運行時)重復地創建這些函數,而是只(在加載時)創建一次,那么就沒有什么好擔心的。
對未來的思考
將來的ECMAScript-262第5版(目前還是草案)會引入所謂的嚴格模式(strict mode)。開啟嚴格模式的實現會禁用語言中的那些不穩定、不可靠和不安全的特性。據說出于安全方面的考慮,arguments.callee屬性將在嚴格模式下被“封殺”。因此,在處于嚴格模式時,訪問arguments.callee會導致TypeError(參見ECMA-262第5版的10.6節)。而我之所以在此提到嚴格模式,是因為如果在基于第5版標準的實現中無法使用arguments.callee來執行遞歸操作,那么使用命名函數表達式的可能性就會大大增加。從這個意義上來說,理解命名函數表達式的語義及其bug也就顯得更加重要了。
// 此前,你可能會使用arguments.callee(function(x) {if (x <= 1) return 1;return x * arguments.callee(x - 1);})(10);// 但在嚴格模式下,有可能就要使用命名函數表達式(function factorial(x) {if (x <= 1) return 1;return x * factorial(x - 1);})(10);// 要么就退一步,使用沒有那么靈活的函數聲明function factorial(x) {if (x <= 1) return 1;return x * factorial(x - 1);}factorial(10);轉載于:https://www.cnblogs.com/MasterYao/p/7782419.html
總結
- 上一篇: jmeter-00 JMeter 运行过
- 下一篇: F5与Ctrl+F5及地址栏输入地址回车