我覺(jué)得很奇怪,網(wǎng)上好像一直沒(méi)有人認(rèn)真地討論過(guò)命名函數(shù)表達(dá)式(Named Function Expression,即“有名字函數(shù)表達(dá)式”,與“匿名函數(shù)”相對(duì)。——譯者注)。而這也許正是各種各樣的誤解隨處可見(jiàn)的一個(gè)原因。在這篇文章里,我打算從理論和實(shí)踐兩個(gè)方面出發(fā),對(duì)這些令人驚嘆的JavaScript結(jié)構(gòu)的優(yōu)缺點(diǎn)給出一個(gè)結(jié)論。
簡(jiǎn)單來(lái)講,命名函數(shù)表達(dá)式只有一個(gè)用處——在調(diào)試器或性能分析程序中描述函數(shù)的名稱。沒(méi)錯(cuò),也可以使用函數(shù)名實(shí)現(xiàn)遞歸,但你很快就會(huì)知道,目前來(lái)看這通常是不切實(shí)際的。當(dāng)然,如果你不關(guān)注調(diào)試,那就沒(méi)什么可擔(dān)心的。否則,就應(yīng)該往下看一看,看看在跨瀏覽器開(kāi)發(fā)中都會(huì)出現(xiàn)哪些小毛?。╣litch),也看看應(yīng)該怎樣解決它們。
一開(kāi)始呢,我會(huì)先介紹一下什么是函數(shù)表達(dá)式,以及現(xiàn)代調(diào)試器如何處理它們之類的內(nèi)容。要是你比較心急,請(qǐng)直接跳到“最終方案”部分,該部分詳細(xì)說(shuō)明了怎樣才能安全地使用這些結(jié)構(gòu)。
在ECMAScript中,有兩個(gè)最常用的創(chuàng)建函數(shù)對(duì)象的方法,即使用函數(shù)表達(dá)式或者使用函數(shù)聲明。這兩種方法之間的區(qū)別可謂 相當(dāng)?shù)亓钊死Щ?/strong>;至少我是相當(dāng)?shù)乩Щ蟆?duì)此,ECMA規(guī)范只明確了一點(diǎn),即函數(shù)聲明 必須始終帶有一個(gè)標(biāo)識(shí)符(Identifier)——也就是函數(shù)名唄,而函數(shù)表達(dá)式 則可省略這個(gè)標(biāo)識(shí)符:
函數(shù)聲明:
function Identifier ( FormalParameterList opt ){ FunctionBody }函數(shù)表達(dá)式:
function Identifier opt ( FormalParameterList opt ){ FunctionBody }
顯然,在省略標(biāo)識(shí)符的情況下, “表達(dá)式” 也就只能是表達(dá)式了。可要是不省略標(biāo)識(shí)符呢?誰(shuí)知道它是一個(gè)函數(shù)聲明,還是一個(gè)函數(shù)表達(dá)式——畢竟,這種情況下二者是完全一樣的啊?實(shí)踐表明,ECMAScript是通過(guò)上下文來(lái)區(qū)分這兩者的:假如 function foo(){} 是一個(gè)賦值表達(dá)式的一部分,則認(rèn)為它是一個(gè)函數(shù)表達(dá)式。而如果 function foo(){} 被包含在一個(gè)函數(shù)體內(nèi),或者位于程序(的最上層)中,則將它作為一個(gè)函數(shù)聲明來(lái)解析。
function foo(){}; // 聲明,因?yàn)樗?em>程序的一部分
var bar = function foo(){}; // 表達(dá)式,因?yàn)樗?em>賦值表達(dá)式(AssignmentExpression)的一部分
new function bar(){}; // 表達(dá)式,因?yàn)樗?em>New表達(dá)式(NewExpression)的一部分
(function(){
function bar(){}; // 聲明,因?yàn)樗?em>函數(shù)體(FunctionBody)的一部分
})();
還有一種不那么顯而易見(jiàn)的函數(shù)表達(dá)式,就是被包含在一對(duì)圓括號(hào)中的函數(shù)—— (function foo(){})。將這種形式看成表達(dá)式同樣是因?yàn)樯舷挛牡年P(guān)系:(和)構(gòu)成一個(gè)分組操作符,而分組操作符只能包含表達(dá)式:
下面再多看幾個(gè)例子吧:
function foo(){}; // 函數(shù)聲明
(function foo(){}); // 函數(shù)表達(dá)式:注意它被包含在分組操作符中
try {
(var x = 5); // 分組操作符只能包含表達(dá)式,不能包含語(yǔ)句(這里的var就是語(yǔ)句)
} catch(err) {
// SyntaxError(因?yàn)椤皏ar x = 5”是一個(gè)語(yǔ)句,而不是表達(dá)式——對(duì)表達(dá)式求值必須返回值,但對(duì)語(yǔ)句求值則未必返回值。——譯者注)
}
不知道大家有沒(méi)有印象,在使用 eval 對(duì)JSON求值的時(shí)候,JSON字符串通常是被包含在一對(duì)圓括號(hào)中的—— eval('(' + json + ')')。這樣做的原因當(dāng)然也不例外——分組操作符,也就是那對(duì)圓括號(hào),會(huì)導(dǎo)致解析器強(qiáng)制將JSON的花括號(hào)當(dāng)成表達(dá)式而不代碼塊來(lái)解析:
try {
{ "x": 5 }; // {和}會(huì)被作為塊來(lái)解析
} catch(err) {
// SyntaxError(“'x':5”只是構(gòu)建對(duì)象字面量的語(yǔ)法,但該語(yǔ)法不能出現(xiàn)在外部的語(yǔ)句塊中?!g者注)
}
({ "x": 5 }); // 分組操作符會(huì)導(dǎo)致解析器強(qiáng)制將{和}作為對(duì)象字面量來(lái)解析
聲明和表達(dá)式的行為存在著十分微妙而又十分重要的差別。
首先,函數(shù)聲明會(huì)在任何表達(dá)式被解析和求值之前先行被解析和求值。即使聲明位于源代碼中的最后一行,它也會(huì)先于同一作用域中位于最前面的表達(dá)式被求值。還是看個(gè)例子更容易理解。在下面這個(gè)例子中,函數(shù) fn 是在 alert 后面聲明的。但是,在 alert 執(zhí)行的時(shí)候,fn已經(jīng)有定義了:
alert(fn());
function fn() {
return 'Hello world!';
}
函數(shù)聲明還有另外一個(gè)重要的特點(diǎn),即通過(guò)條件語(yǔ)句控制函數(shù)聲明的行為并未標(biāo)準(zhǔn)化,因此不同環(huán)境下可能會(huì)得到不同的結(jié)果。有鑒于此,奉勸大家千萬(wàn)不要在條件語(yǔ)句中使用函數(shù)聲明,而要使用函數(shù)表達(dá)式。
// 千萬(wàn)不要這樣做!
// 有的瀏覽器會(huì)把foo聲明為返回first的那個(gè)函數(shù)
// 而有的瀏覽器則會(huì)讓foo返回second
if (true) {
function foo() {
return 'first';
}
}
else {
function foo() {
return 'second';
}
}
foo();
// 記住,這種情況下要使用函數(shù)表達(dá)式:
var foo;
if (true) {
foo = function() {
return 'first';
};
}
else {
foo = function() {
return 'second';
};
}
foo();
想知道使用函數(shù)聲明的實(shí)際規(guī)則到底是什么?繼續(xù)往下看吧。嗯,有人不想知道?那請(qǐng)?zhí)^(guò)下面這段摘錄的文字。
FunctionDeclaration(函數(shù)聲明)只能出現(xiàn)在Program(程序)或FunctionBody(函數(shù)體)內(nèi)。從句法上講,它們 不能出現(xiàn)在Block(塊)({ ... })中,例如不能出現(xiàn)在 if、while 或 for 語(yǔ)句中。因?yàn)?Block(塊) 中只能包含Statement(語(yǔ)句), 而不能包含FunctionDeclaration(函數(shù)聲明)這樣的SourceElement(源元素)。另一方面,仔細(xì)看一看產(chǎn)生規(guī)則也會(huì)發(fā)現(xiàn),唯一可能讓Expression(表達(dá)式)出現(xiàn)在Block(塊)中情形,就是讓它作為ExpressionStatement(表達(dá)式語(yǔ)句)的一部分。但是,規(guī)范明確規(guī)定了ExpressionStatement(表達(dá)式語(yǔ)句)不能以關(guān)鍵字function開(kāi)頭。而這實(shí)際上就是說(shuō),FunctionExpression(函數(shù)表達(dá)式)同樣也不能出現(xiàn)在Statement(語(yǔ)句)或Block(塊)中(別忘了Block(塊)就是由Statement(語(yǔ)句)構(gòu)成的)。
由于存在上述限制,只要函數(shù)出現(xiàn)在塊中(像上面例子中那樣),實(shí)際上就應(yīng)該將其看作一個(gè)語(yǔ)法錯(cuò)誤,而不是什么函數(shù)聲明或表達(dá)式。但問(wèn)題是,我還沒(méi)見(jiàn)過(guò)哪個(gè)實(shí)現(xiàn)是按照上述規(guī)則來(lái)解析這些函數(shù)的;好像每個(gè)實(shí)現(xiàn)都有自己的一套。
有必要提醒大家一點(diǎn),根據(jù)規(guī)范的描述,實(shí)現(xiàn)可以引入語(yǔ)法擴(kuò)展(見(jiàn)第16部分),只不過(guò)任何情況下都不能違反規(guī)定。而目前的諸多客戶端也正是照此辦理的。其中有一些會(huì)把塊中的函數(shù)聲明當(dāng)作一般的函數(shù)聲明來(lái)解析——把它們提升到封閉作用域的頂部;另一些則引入了不同的語(yǔ)義并采用了稍復(fù)雜一些的規(guī)則。
在諸如此類的對(duì)ECMAScript的語(yǔ)法擴(kuò)展中,有一項(xiàng)就是函數(shù)語(yǔ)句,基于Gecko的瀏覽器(在Mac OS X平臺(tái)的Firefox 1-3.7a1pre中測(cè)試過(guò))目前都實(shí)現(xiàn)了該項(xiàng)擴(kuò)展??墒遣恢罏槭裁?,很多人好像都不知道這項(xiàng)擴(kuò)展,也就更談不上對(duì)其優(yōu)劣的評(píng)價(jià)了(MDC(Mozilla Developer Center,Mozilla開(kāi)發(fā)者中心)提到過(guò)這個(gè)問(wèn)題,但是只有那么三言兩語(yǔ))。請(qǐng)大家記住,我們是抱著學(xué)習(xí)和滿足自己好奇心的態(tài)度來(lái)討論函數(shù)語(yǔ)句的。因此,除非你只針對(duì)基于Gecko的環(huán)境編寫(xiě)腳本,否則我不建議你使用這個(gè)擴(kuò)展。
閑話少說(shuō),下面我們就來(lái)看看這些非標(biāo)準(zhǔn)的結(jié)構(gòu)有哪些特點(diǎn):
if (true) {
function f(){ }
}
else {
function f(){ }
}
if (true) {
function foo(){ return 1; }
}
else {
function foo(){ return 2; }
}
foo(); // 1
// 注意其他類型的客戶端會(huì)把這里的foo解析為函數(shù)聲明
// 因此,第二個(gè)foo會(huì)覆蓋第一個(gè),結(jié)果返回2而不返回1
// 此時(shí),foo還沒(méi)有聲明
typeof foo; // "undefined"
if (true) {
// 一進(jìn)入這個(gè)塊,foo就被聲明并在整個(gè)作用域中有效了
function foo(){ return 1; }
}
else {
// 永遠(yuǎn)不會(huì)進(jìn)入這個(gè)塊,因此這里的foo永遠(yuǎn)不會(huì)被聲明
function foo(){ return 2; }
}
typeof foo; // "function"
通常,可以通過(guò)下面這樣符合標(biāo)準(zhǔn)(但更繁瑣一點(diǎn))的代碼來(lái)模擬前例中函數(shù)語(yǔ)句的行為:
var foo;
if (true) {
foo = function foo(){ return 1; };
}
else {
foo = function foo() { return 2; };
}
if (true) {
function foo(){ return 1; }
}
String(foo); // function foo() { return 1; }
// 函數(shù)聲明
function foo(){ return 1; }
if (true) {
// 使用函數(shù)語(yǔ)句來(lái)重寫(xiě)
function foo(){ return 2; }
}
foo(); // FF及以前版本返回1,F(xiàn)F3.5及以后版本返回2
// 但是,如果前面是函數(shù)表達(dá)式,則沒(méi)有這個(gè)問(wèn)題
var foo = function(){ return 1; };
if (true) {
function foo(){ return 2; }
}
foo(); // 在所有版本中都返回2
大家請(qǐng)注意,Safari的某些早期版本(至少包括1.2.3、2.0 - 2.0.4以及3.0.4,可能也包括更早的版本)實(shí)現(xiàn)了與SpiderMonkey完全一樣的函數(shù)語(yǔ)句。本節(jié)所有的例子(不包括最后一個(gè)bug示例),在Safari的那些版本中都會(huì)得到與Firefox完全相同的結(jié)果。此外,Blackberry(至少包括8230、9000和9530)瀏覽器好像也具有類似的行為。上述這種行為的差異化再次說(shuō)明——千萬(wàn)不能盲目地依賴這些擴(kuò)展?。ㄈ缦滤?,可以根據(jù)特性測(cè)試來(lái)使用函數(shù)表達(dá)式?!g者注)!
函數(shù)表達(dá)式實(shí)際上還是很常見(jiàn)的。Web開(kāi)發(fā)中有一個(gè)常用的模式,即基于對(duì)某種特性的測(cè)試來(lái)“偽裝”函數(shù)定義,從而實(shí)現(xiàn)性能最優(yōu)化。由于這種偽裝通常都出現(xiàn)在相同的作用域中,因此基本上一定要使用函數(shù)表達(dá)式。畢竟,如前所述,不應(yīng)該根據(jù)條件來(lái)執(zhí)行函數(shù)聲明:
// 這里的contains取自APE Javascript庫(kù)的源代碼,網(wǎng)址為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;
}
})();
提到命名函數(shù)表達(dá)式,很顯然,指的就是有名字(技術(shù)上稱為標(biāo)識(shí)符)的函數(shù)表達(dá)式。在最前面的例子中,var bar = function foo(){};實(shí)際上就是一個(gè)以foo作為函數(shù)名字的函數(shù)表達(dá)式。對(duì)此,有一個(gè)細(xì)節(jié)特別重要,請(qǐng)大家一定要記住,即這個(gè)名字只在新定義的函數(shù)的作用域中有效——規(guī)范要求標(biāo)識(shí)符不能在外圍的作用域中有效:
var f = function foo(){
return typeof foo; // foo只在內(nèi)部作用域中有效
};
// foo在“外部”永遠(yuǎn)是不可見(jiàn)的
typeof foo; // "undefined"
f(); // "function"
那么,這些所謂的命名函數(shù)表達(dá)式到底有什么用呢?為什么還要給它們起個(gè)名字呢?
原因就是有名字的函數(shù)可以讓調(diào)試過(guò)程更加方便。在調(diào)試應(yīng)用程序時(shí),如果調(diào)用棧中的項(xiàng)都有各自描述性的名字,那么調(diào)試過(guò)程帶給人的就是另一種完全不同的感受。
在函數(shù)有相應(yīng)標(biāo)識(shí)符的情況下,調(diào)試器會(huì)將該標(biāo)識(shí)符作為函數(shù)的名字顯示在調(diào)用棧中。有的調(diào)試器(例如Firebug)甚至?xí)槟涿瘮?shù)起個(gè)名字并顯示出來(lái),讓它們與那些引用函數(shù)的變量具有相同的角色??蛇z憾的是,這些調(diào)試器通常只使用簡(jiǎn)單的解析規(guī)則,而依據(jù)簡(jiǎn)單的解析規(guī)則提取出來(lái)的“名字”有時(shí)候沒(méi)有多大價(jià)值,甚至?xí)玫藉e(cuò)誤結(jié)果。(Such extraction is usually quite fragile and often produces false results. )
下面我們來(lái)看一個(gè)簡(jiǎn)單的例子:
function foo(){
return bar();
}
function bar(){
return baz();
}
function baz(){
debugger;
}
foo();
// 這里使用函數(shù)聲明定義了3個(gè)函數(shù)
// 當(dāng)調(diào)試器停止在debugger語(yǔ)句時(shí),
// Firgbug的調(diào)用??雌饋?lái)非常清晰:
baz
bar
foo
expr_test.html()
這樣,我們就知道foo調(diào)用了bar,而后者接著又調(diào)用了baz(而foo本身又在expr_test.html文檔的全局作用域中被調(diào)用)。但真正值得稱道的,則是Firebug會(huì)在我們使用匿名表達(dá)式的情況下,替我們解析函數(shù)的“名字”:
function foo(){
return bar();
}
var bar = function(){
return baz();
}
function baz(){
debugger;
}
foo();
// 調(diào)用棧:
baz
bar()
foo
expr_test.html()
相反,不那么令人滿意的情況是,當(dāng)函數(shù)表達(dá)式復(fù)雜一些時(shí)(現(xiàn)實(shí)中差不多總是如此),調(diào)試器再如何盡力也不會(huì)起多大的作用。結(jié)果,我們只能在調(diào)用棧中顯示函數(shù)名字的位置上赫然看到一個(gè)問(wèn)號(hào):
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();
// 調(diào)用棧:
baz
(?)()
foo
expr_test.html()
此外,當(dāng)把一個(gè)函數(shù)賦值給多個(gè)變量時(shí),還會(huì)出現(xiàn)一個(gè)令人困惑的問(wèn)題:
function foo(){
return baz();
}
var bar = function(){
debugger;
};
var baz = bar;
bar = function() {
alert('spoofed');
}
foo();
// 調(diào)用棧:
bar()
foo
expr_test.html()
可見(jiàn),調(diào)用棧中顯示的是foo調(diào)用了bar。但實(shí)際情況顯然并非如此。之所以會(huì)造成這種困惑,完全是因?yàn)?code>baz與另一個(gè)函數(shù)——包含代碼alert('spoofed');的函數(shù)——“交換了”引用所致。實(shí)事求是地說(shuō),這種解析方式在簡(jiǎn)單的情況下固然好,但對(duì)于不那么簡(jiǎn)單的大多數(shù)情況而言就沒(méi)有什么用處了。
歸根結(jié)底,只有命名函數(shù)表達(dá)式才是產(chǎn)生可靠的棧調(diào)用信息的唯一途徑。下面我們有意使用命名函數(shù)表達(dá)式來(lái)重寫(xiě)前面的例子。請(qǐng)大家注意,從自執(zhí)行包裝塊中返回的兩個(gè)函數(shù)都被命名為了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();
// 這樣,我們就又可以看到清晰的調(diào)用棧信息了!
baz
bar
foo
expr_test.html()
在我們?yōu)榘l(fā)現(xiàn)這根救命稻草而歡呼雀躍之前,請(qǐng)大家稍安勿躁,再聽(tīng)我聊一聊大家所衷愛(ài)的JScript。
令人討厭的是,JScript(也就是IE的ECMAScript實(shí)現(xiàn))嚴(yán)重混淆了命名函數(shù)表達(dá)式。JScript搞得現(xiàn)如今很多人都站出來(lái)反對(duì)命名函數(shù)表達(dá)式。而且,直到JScript的最近一版——IE8中使用的5.8版——仍然存在下列的所有怪異問(wèn)題。
下面我們就來(lái)看看IE在它的這個(gè)“破”實(shí)現(xiàn)中到底都搞出了哪些花樣。唉,只有知已知彼,才能百戰(zhàn)不殆嘛。請(qǐng)注意,為了清晰起見(jiàn),我會(huì)通過(guò)一個(gè)個(gè)相對(duì)獨(dú)立的小例子來(lái)說(shuō)明這些問(wèn)題,雖然這些問(wèn)題很可能是一個(gè)主bug引起的一連串的后果。
var f = function g(){};
typeof g; // "function"
還有人記得嗎,我們說(shuō)過(guò):命名函數(shù)表達(dá)式的標(biāo)識(shí)符在其外部作用域中是無(wú)效的? 好啦,JScript明目張膽地違反了這一規(guī)定——上面例子中的標(biāo)識(shí)符g被解析為函數(shù)對(duì)象。這是最讓人頭疼的一個(gè)問(wèn)題了。這樣,任何標(biāo)識(shí)符都可能會(huì)在不經(jīng)意間“污染”某個(gè)外部作用域——甚至是全局作用域。而且,這種污染常常就是那些難以捕獲的bug的來(lái)源。
typeof g; // "function"
var f = function g(){};
如前所述,在特定的執(zhí)行環(huán)境中,函數(shù)聲明會(huì)先于任何表達(dá)式被解析。上面這個(gè)例子展示了JScript實(shí)際上是把命名函數(shù)表達(dá)式當(dāng)作函數(shù)聲明了;因?yàn)樗凇皩?shí)際的”聲明之前就解析了g。
這個(gè)例子進(jìn)而引出了下一個(gè)例子:
var f = function g(){};
f === g; // false
f.expando = 'foo';
g.expando; // undefined
問(wèn)題至此就比較嚴(yán)重了?;蛘呖梢哉f(shuō)修改其中一個(gè)對(duì)象對(duì)另一個(gè)絲毫沒(méi)有影響——這簡(jiǎn)直就是胡鬧!通過(guò)例子可以看出,出現(xiàn)兩個(gè)不同的對(duì)象會(huì)存在什么風(fēng)險(xiǎn)。假如你想利用緩存機(jī)制,在f的屬性中保存某個(gè)信息,然后又想當(dāng)然地認(rèn)為可以通過(guò)引用相同對(duì)象的g的同名屬性取得該信息,那么你的麻煩可就大了。
再來(lái)看一個(gè)稍微復(fù)雜點(diǎn)的情況。
var f = function g() {
return 1;
};
if (false) {
f = function g(){
return 2;
};
}
g(); // 2
要查找這個(gè)例子中的bug就要困難一些了。但導(dǎo)致bug的原因卻非常簡(jiǎn)單。首先,g被當(dāng)作函數(shù)聲明解析,而由于JScript中的函數(shù)聲明不受條件代碼塊約束(與條件代碼塊無(wú)關(guān)),所以在“該死的”if分支中,g被當(dāng)作另一個(gè)函數(shù)——function g(){ return 2 }——又被聲明了一次。然后,所有“常規(guī)的”表達(dá)式被求值,而此時(shí)f被賦予了另一個(gè)新創(chuàng)建的對(duì)象的引用。由于在對(duì)表達(dá)式求值的時(shí)候,永遠(yuǎn)不會(huì)進(jìn)入“該死的”if分支,因此f就會(huì)繼續(xù)引用第一個(gè)函數(shù)——function g(){ return 1 }。分析到這里,問(wèn)題就很清楚了:假如你不夠細(xì)心,在f中調(diào)用了g(在執(zhí)行遞歸操作的時(shí)候會(huì)這樣做?!g者注),那么實(shí)際上將會(huì)調(diào)用一個(gè)毫不相干的g函數(shù)對(duì)象(即返回2的那個(gè)函數(shù)對(duì)象?!g者注)。
聰明的讀者可能會(huì)聯(lián)想到:在將不同的函數(shù)對(duì)象與arguments.callee進(jìn)行比較時(shí),這個(gè)問(wèn)題會(huì)有所表現(xiàn)嗎?callee到底是引用f還是引用g呢?下面我們就來(lái)看一看:
var f = function g(){
return [
arguments.callee == f,
arguments.callee == g
];
};
f(); // [true, false]
g(); // [false, true]
看到了吧,arguments.callee引用的始終是被調(diào)用的函數(shù)。實(shí)際上,這應(yīng)該是件好事兒,原因你一會(huì)兒就知道了。
另一個(gè)“意外行為”的好玩的例子,當(dāng)我們在不包含聲明的賦值語(yǔ)句中使用命名函數(shù)表達(dá)式時(shí)可以看到。不過(guò),此時(shí)函數(shù)的名字必須與引用它的標(biāo)識(shí)符相同才行:
(function(){
f = function f(){};
})();
眾所周知(但愿如此?!g者注),不包含聲明的賦值語(yǔ)句(注意,我們不建議使用,這里只是出于示范需要才用的)在這里會(huì)創(chuàng)建一個(gè)全局屬性f。而這也是標(biāo)準(zhǔn)實(shí)現(xiàn)的行為??墒牵琂Script的bug在這里又會(huì)出點(diǎn)亂子。由于JScript把命名函數(shù)表達(dá)式當(dāng)作函數(shù)聲明來(lái)解析(參見(jiàn)前面的“例2”),因此在變量聲明階段,f會(huì)被聲明為局部變量。然后,在函數(shù)執(zhí)行時(shí),賦值語(yǔ)句已經(jīng)不是未聲明的了(因?yàn)閒已經(jīng)被聲明為局部變量了?!g者注),右手邊的function f(){}就會(huì)被直接賦給剛剛創(chuàng)建的局部變量f。而全局作用域中的f根本不會(huì)存在。
看完這個(gè)例子后,相信大家就會(huì)明白,如果你對(duì)JScript的“怪異”行為缺乏了解,你的代碼中出現(xiàn)“嚴(yán)重不符合預(yù)期”的行為就不難理解了。
明白了JScript的缺陷以后,要采取哪些預(yù)防措施就非常清楚了。首先,要注意防范標(biāo)識(shí)符泄漏(滲透)(不讓標(biāo)識(shí)符污染外部作用域)。其次,應(yīng)該永遠(yuǎn)不引用被用作函數(shù)名稱的標(biāo)識(shí)符;還記得前面例子中那個(gè)討人厭的標(biāo)識(shí)符g嗎?——如果我們能夠當(dāng)g不存在,可以避免多少不必要的麻煩哪。因此,關(guān)鍵就在于始終要通過(guò)f或者arguments.callee來(lái)引用函數(shù)。如果你使用了命名函數(shù)表達(dá)式,那么應(yīng)該只在調(diào)試的時(shí)候利用那個(gè)名字。最后,還要記住一點(diǎn),一定要把NFE(Named Funciont Expresssions,命名函數(shù)表達(dá)式)聲明期間錯(cuò)誤創(chuàng)建的函數(shù)清理干凈。
嗯,對(duì)于上面最后一點(diǎn),我覺(jué)得還要再啰嗦兩句:
熟悉上述JScript缺陷之后,再使用這些有毛病的結(jié)構(gòu),就會(huì)發(fā)現(xiàn)內(nèi)存占用方面的潛在問(wèn)題。下面看一個(gè)簡(jiǎn)單的例子:
var f = (function(){
if (true) {
return function g(){};
}
return function g(){};
})();
我們知道,這里匿名(函數(shù))調(diào)用返回的函數(shù)——帶有標(biāo)識(shí)符g的函數(shù)——被賦值給了外部的f。我們也知道,命名函數(shù)表達(dá)式會(huì)導(dǎo)致產(chǎn)生多余的函數(shù)對(duì)象,而該對(duì)象與返回的函數(shù)對(duì)象不是一回事。由于有一個(gè)多余的g函數(shù)被“截留”在了返回函數(shù)的閉包中,因此內(nèi)存問(wèn)題就出現(xiàn)了。這是因?yàn)椋╥f語(yǔ)句)內(nèi)部(的)函數(shù)與討厭的g是在同一個(gè)作用域中被聲明的。在這種情況下 ,除非我們顯式地?cái)嚅_(kāi)對(duì)(匿名調(diào)用返回的)g函數(shù)的引用,否則那個(gè)討厭的家伙會(huì)一直占著內(nèi)存不放。
var f = (function(){
var f, g;
if (true) {
f = function g(){};
}
else {
f = function g(){};
}
// 廢掉g,這樣它就不會(huì)再引用多余的函數(shù)了
g = null;
return f;
})();
請(qǐng)注意,這里也明確聲明了變量g,因此賦值語(yǔ)句g = null就不會(huì)在符合標(biāo)準(zhǔn)的客戶端(如非JScript實(shí)現(xiàn))中創(chuàng)建全局變量g了。通過(guò)廢掉對(duì)g的引用,垃圾收集器就可以把g引用的那個(gè)隱式創(chuàng)建的函數(shù)對(duì)象清除了。
在解決JScript NFE內(nèi)存泄漏問(wèn)題的過(guò)程中,我運(yùn)行了一系列簡(jiǎn)單的測(cè)試,以便確定廢掉g能夠釋放內(nèi)存。
這里的測(cè)試很簡(jiǎn)單。就是通過(guò)命名函數(shù)表達(dá)式創(chuàng)建10000個(gè)函數(shù),把它們保存在一個(gè)數(shù)組中。過(guò)一會(huì)兒,看看這些函數(shù)到底占用了多少內(nèi)存。然后,再?gòu)U掉這些引用并重復(fù)這一過(guò)程。下面是我使用的一個(gè)測(cè)試用例:
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();
}
通過(guò)運(yùn)行在Windows XP SP2中的Process Explorer可以看到如下結(jié)果:
IE6:
without `null`: 7.6K -> 20.3K
with `null`: 7.6K -> 18K
IE7:
without `null`: 14K -> 29.7K
with `null`: 14K -> 27K
這個(gè)結(jié)果大致驗(yàn)證了我的想法——顯式地清除多余的引用確實(shí)可以釋放內(nèi)存,但釋放的內(nèi)存空間相對(duì)不多。在創(chuàng)建10000個(gè)函數(shù)對(duì)象的情況下,大約有3MB左右。對(duì)于大型應(yīng)用程序,以及需要長(zhǎng)時(shí)間運(yùn)行或者在低內(nèi)存設(shè)備(如手持設(shè)備)上運(yùn)行的程序而言,這是絕對(duì)需要考慮的。但對(duì)小型腳本而言,這點(diǎn)差別可能也算不了什么。
有讀者可能認(rèn)為本文到此差不多就該結(jié)尾了——實(shí)際上還差得遠(yuǎn)呢 :)。我還想再多談一點(diǎn),這些內(nèi)容涉及的是Safari 2.x。
在Safari較早的版本——Safari 2.x系列中,也存在一些鮮為人知的與NFE有關(guān)的bug。我在Web上看到有人說(shuō)Safari 2.x不支持NFE 。實(shí)際上不是那么回事。Safari確實(shí)支持NFE,只不過(guò)它的實(shí)現(xiàn)中存在bug而已(很快你就會(huì)看到)。
在某些情況下,Safari 2.x遇到函數(shù)表達(dá)式時(shí)會(huì)出現(xiàn)不能完全解析程序的問(wèn)題。而且,此時(shí)的Safari不會(huì)拋出任何錯(cuò)誤(例如SyntaxError),只會(huì)“默默地知難而退”:
(function f(){})(); // <== NFE
alert(1); // 由于前面的表達(dá)式破壞了整個(gè)程序,因此這一行永遠(yuǎn)不會(huì)執(zhí)行
經(jīng)過(guò)多次測(cè)試,我得出一個(gè)結(jié)論:Safari 2.x 不能解析非賦值表達(dá)式中的命名函數(shù)表達(dá)式。下面是一些賦值表達(dá)式的例子:
// 變量聲明
var f = 1;
// 簡(jiǎn)單賦值
f = 2, g = 3;
// 返回語(yǔ)句
(function(){
return (f = 2);
})();
換句話說(shuō),把命名函數(shù)表達(dá)式放到一個(gè)賦值表達(dá)式中會(huì)讓Safari“很高興”:
(function f(){}); // 失敗
var f = function f(){}; // 沒(méi)問(wèn)題
(function(){
return function f(){}; // 失敗
})();
(function(){
return (f = function f(){}); // 沒(méi)問(wèn)題
})();
setTimeout(function f(){ }, 100); // 失敗
Person.prototype = {
say: function say() { ... } // 失敗
}
Person.prototype.say = function say(){ ... }; // 沒(méi)問(wèn)題
同時(shí)這也就意味著,在不使用賦值表達(dá)式的情況下,我們不能使用習(xí)以為常的模式返回命名函數(shù)表達(dá)式:
// 以下返回命名函數(shù)表達(dá)式的常見(jiàn)模式,對(duì)Safari 2.x來(lái)說(shuō)是不兼容的:
(function(){
if (featureTest) {
return function f(){};
}
return function f(){};
})();
// 在Safari 2.x中,應(yīng)該使用以下稍麻煩一點(diǎn)的方式:
(function(){
var f;
if (featureTest) {
f = function f(){};
}
else {
f = function f(){};
}
return f;
})();
// 或者,像下面這樣也行:
(function(){
var f;
if (featureTest) {
return (f = function f(){});
}
return (f = function f(){});
})();
/*
可是,這樣一來(lái),就額外使用了一個(gè)對(duì)函數(shù)的引用,而該引用還被封閉在了返回函數(shù)的閉包中。
為了最大限度地降低額外的內(nèi)存占用,可以考慮把所有命名函數(shù)表達(dá)式都賦值給一個(gè)變量。
*/
var __temp;
(function(){
if (featureTest) {
return (__temp = function f(){});
}
return (__temp = function f(){});
})();
...
(function(){
if (featureTest2) {
return (__temp = function g(){});
}
return (__temp = function g(){});
})();
/*
這樣,后續(xù)的賦值語(yǔ)句通過(guò)“重用”前面的引用,達(dá)到了不過(guò)多占用內(nèi)存的目的。
*/
如果兼容Safari 2.x非常重要,就應(yīng)該保證源代碼中不能出現(xiàn)任何“不兼容”的結(jié)構(gòu)。雖然這樣做不免會(huì)讓人著急上火,可只要抓住了問(wèn)題的根源,還是絕對(duì)能夠做到的。
對(duì)了,還有個(gè)小問(wèn)題必須說(shuō)明一下:在Safari 2.x中聲明命名函數(shù)時(shí),函數(shù)的字符串表示不會(huì)包含函數(shù)的標(biāo)識(shí)符:
var f = function g(){};
// 看到了嗎,函數(shù)的字符串表示中沒(méi)有標(biāo)識(shí)符g
String(f); // function () { }
這不算什么大問(wèn)題。但正如我以前說(shuō)過(guò)的,函數(shù)的反編譯結(jié)果是無(wú)論如何也不能相信的。
大家都知道,命名函數(shù)表達(dá)式的標(biāo)識(shí)符只在函數(shù)的局部作用域中有效。但包含這個(gè)標(biāo)識(shí)符的局部作用域又是什么樣子的嗎?其實(shí)非常簡(jiǎn)單。在命名函數(shù)表達(dá)式被求值時(shí),會(huì)創(chuàng)建一個(gè)特殊的對(duì)象,該對(duì)象的唯一目的就是保存一個(gè)屬性,而這個(gè)屬性的名字對(duì)應(yīng)著函數(shù)標(biāo)識(shí)符,屬性的值對(duì)應(yīng)著那個(gè)函數(shù)。這個(gè)對(duì)象會(huì)被注入到當(dāng)前作用域鏈的前端。然后,被“擴(kuò)展”的作用域鏈又被用于初始化函數(shù)。
在這里(想象一下本山大叔在小品《火炬手》中發(fā)表獲獎(jiǎng)感言的情景吧。——譯者注),有一點(diǎn)十分有意思,那就是ECMA-262定義這個(gè)(保存函數(shù)標(biāo)識(shí)符的)“特殊”對(duì)象的方式。標(biāo)準(zhǔn)說(shuō)“像調(diào)用new Object()表達(dá)式那樣”創(chuàng)建這個(gè)對(duì)象。如果從字面上來(lái)理解這句話,那么這個(gè)對(duì)象就應(yīng)該是全局Object的一個(gè)實(shí)例。然而,只有一個(gè)實(shí)現(xiàn)是按照標(biāo)準(zhǔn)字面上的要求這么做的,這個(gè)實(shí)現(xiàn)就是SpiderMonkey。因此,在SpiderMonkey中,擴(kuò)展Object.prototype有可能會(huì)干擾函數(shù)的局部作用域:
Object.prototype.x = 'outer';
(function(){
var x = 'inner';
/*
函數(shù)foo的作用域鏈中有一個(gè)特殊的對(duì)象——用于保存函數(shù)的標(biāo)識(shí)符。這個(gè)特殊的對(duì)象實(shí)際上就是{ foo: <function object> }。
當(dāng)通過(guò)作用域鏈解析x時(shí),首先解析的是foo的局部環(huán)境。如果沒(méi)有找到x,則繼續(xù)搜索作用域鏈中的下一個(gè)對(duì)象。下一個(gè)對(duì)象
就是保存函數(shù)標(biāo)識(shí)符的那個(gè)對(duì)象——{ foo: <function object> },由于該對(duì)象繼承自O(shè)bject.prototype,所以在此可以找到x。
而這個(gè)x的值也就是Object.prototype.x的值(outer)。結(jié)果,外部函數(shù)的作用域(包含x = 'inner'的作用域)就不會(huì)被解析了。
*/
(function foo(){
alert(x); // 提示框中顯示:outer
})();
})();
不過(guò),更高版本的SpiderMonkey改變了上述行為,原因可能是認(rèn)為那是一個(gè)安全漏洞。也就是說(shuō),“特殊”對(duì)象不再繼承Object.prototype了。不過(guò),如果你使用Firefox 3或者更低版本,還可以“重溫”這種行為。
另一個(gè)把內(nèi)部對(duì)象實(shí)現(xiàn)為全局Object對(duì)象的是黑莓(Blackberry)瀏覽器。目前,它的活動(dòng)對(duì)象(Activation Object)仍然繼承Object.prototype。可是,ECMA-262并沒(méi)有說(shuō)活動(dòng)對(duì)象也要“像調(diào)用new Object()表達(dá)式那樣”來(lái)創(chuàng)建(或者說(shuō)像創(chuàng)建保存NFE標(biāo)識(shí)符的對(duì)象一樣創(chuàng)建)。
人家規(guī)范只說(shuō)了活動(dòng)對(duì)象是規(guī)范中的一種機(jī)制。
好,那我們下面就來(lái)看看黑莓瀏覽器的行為吧:
Object.prototype.x = 'outer';
(function(){
var x = 'inner';
(function(){
/*
在沿著作用域鏈解析x的過(guò)程中,首先會(huì)搜索局部函數(shù)的活動(dòng)對(duì)象。當(dāng)然,在該對(duì)象中找不到x。
可是,由于活動(dòng)對(duì)象繼承自O(shè)bject.prototype,因此搜索x的下一個(gè)目標(biāo)就是Object.prototype;而
Object.prototype中又確實(shí)有x的定義。結(jié)果,x的值就被解析為——outer。跟前面的例子差不多,
包含x = 'inner'的外部函數(shù)的作用域(活動(dòng)對(duì)象)就不會(huì)被解析了。
*/
alert(x); // 提示框中顯示:outer
})();
})();
雖然這有點(diǎn)讓人不可思議,但更令人匪夷所思的則是函數(shù)中的變量甚至?xí)c已有的Object.prototype的成員發(fā)生沖突:
(function(){
var constructor = function(){ return 1; };
(function(){
constructor(); // 求值結(jié)果是{}(即相當(dāng)于調(diào)用了Object.prototype.constructor()。——譯者注)而不是1
constructor === Object.prototype.constructor; // true
toString === Object.prototype.toString; // true
// ……
})();
})();
var fn = (function(){
// 聲明要引用函數(shù)的變量
var f;
// 有條件地創(chuàng)建命名函數(shù)
// 并將其引用賦值給f
if (true) {
f = function F(){ }
}
else if (false) {
f = function F(){ }
}
else {
f = function F(){ }
}
// 聲明一個(gè)與函數(shù)名(標(biāo)識(shí)符)對(duì)應(yīng)的變量,并賦值為null
// 這實(shí)際上是給相應(yīng)標(biāo)識(shí)符引用的函數(shù)對(duì)象作了一個(gè)標(biāo)記,
// 以便垃圾回收器知道可以回收它了
var F = null;
// 返回根據(jù)條件定義的函數(shù)
return f;
})();
最后,我要給出一個(gè)應(yīng)用上述“技術(shù)”的實(shí)例。這是一個(gè)跨瀏覽器的addEvent函數(shù)的代碼:
// 1) 使用獨(dú)立的作用域包含聲明
var addEvent = (function(){
var docEl = document.documentElement;
// 2) 聲明要引用函數(shù)的變量
var fn;
if (docEl.addEventListener) {
// 3) 有意給函數(shù)一個(gè)描述性的標(biāo)識(shí)符
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創(chuàng)建的addEvent函數(shù)
// 一定要保證在賦值前使用var關(guān)鍵字
// 除非函數(shù)頂部已經(jīng)聲明了addEvent
var addEvent = null;
// 5) 最后返回由fn引用的函數(shù)
return fn;
})();
不要忘了,如果我們不想在調(diào)用棧中保留描述性的名字,實(shí)際上還有其他選擇。換句話說(shuō),就是還存在不必使用命名函數(shù)表達(dá)式的方案。首先,很多時(shí)候都可以通過(guò)聲明而非表達(dá)式定義函數(shù)。這個(gè)方案只適合不需要?jiǎng)?chuàng)建多個(gè)函數(shù)的情形:
var hasClassName = (function(){
// 定義私有變量
var cache = { };
// 使用函數(shù)聲明
function hasClassName(element, className) {
var _className = '(?:^|\\s+)' + className + '(?:\\s+|$)';
var re = cache[_className] || (cache[_className] = new RegExp(_className));
return re.test(element.className);
}
// 返回函數(shù)
return hasClassName;
})();
顯然,當(dāng)存在多個(gè)分支函數(shù)定義時(shí),這個(gè)方案就不能勝任了。不過(guò),我最早見(jiàn)過(guò)托比·蘭吉(Tobiel Langel)使用過(guò)一個(gè)很有味道的模式。他的這種模式是提前使用函數(shù)聲明來(lái)定義所有函數(shù),并分別為這些函數(shù)指定不同的標(biāo)識(shí)符:
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;
})();
雖然這個(gè)方案很優(yōu)雅,但也不是沒(méi)有缺點(diǎn)。第一,由于使用不同的標(biāo)識(shí)符,導(dǎo)致喪失了命名的一致性。且不說(shuō)這樣好還是壞,最起碼它不夠清晰。有人喜歡使用相同的名字,但也有人根本不在乎字眼上的差別??僧吘?,不同的名字會(huì)讓人聯(lián)想到所用的不同實(shí)現(xiàn)。例如,在調(diào)試器中看到attachEvent,我們就知道addEvent是基于attachEvent的實(shí)現(xiàn)(即基于IE的事件模型?!g者注)。當(dāng)然,基于實(shí)現(xiàn)來(lái)命名的方式也不一定都行得通。假如我們要提供一個(gè)API,并按照這種方式把函數(shù)命名為inner。那么API用戶的很容易就會(huì)被相應(yīng)實(shí)現(xiàn)的細(xì)節(jié)搞得暈頭轉(zhuǎn)向。(也許是因?yàn)閕nner這個(gè)名字太通用,不同實(shí)現(xiàn)中可能都會(huì)有,因此容易讓人分不清這個(gè)API到底基于哪個(gè)實(shí)現(xiàn)?!g者注)
要解決這個(gè)問(wèn)題,當(dāng)然就得想一套更合理的命名方案了。但關(guān)鍵是不要再額外制造麻煩。我現(xiàn)在能想起來(lái)的方案大概有如下幾個(gè):
'addEvent', 'altAddEvent', 'fallbackAddEvent'
// 或者
'addEvent', 'addEvent2', 'addEvent3'
// 或者
'addEvent_addEventListener', 'addEvent_attachEvent', 'addEvent_asProperty'
另外,托比使用的模式還存在一個(gè)小問(wèn)題,即增加內(nèi)存占用。提前創(chuàng)建N個(gè)不同名字的函數(shù),等于有N-1的函數(shù)是用不到的。具體來(lái)講,如果document.documentElement中包含attachEvent,那么addEventListener 和addEventAsProperty則根本就用不著了??墒?,他們都占著內(nèi)存哪;而且,這些內(nèi)存將永遠(yuǎn)都得不到釋放,原因跟JScript臭哄哄的命名表達(dá)式相同——這兩個(gè)函數(shù)都被“截留”在返回的那個(gè)函數(shù)的閉包中了。
不過(guò),增加內(nèi)存占用這個(gè)問(wèn)題確實(shí)沒(méi)什么大不了的。如果某個(gè)庫(kù)——例如Prototype.js——采用了這種模式,無(wú)非也就是多創(chuàng)建一兩百個(gè)函數(shù)而已。只要不是(在運(yùn)行時(shí))重復(fù)地創(chuàng)建這些函數(shù),而是只(在加載時(shí))創(chuàng)建一次,那么就沒(méi)有什么好擔(dān)心的。
WebKit團(tuán)隊(duì)在這個(gè)問(wèn)題采取了有點(diǎn)兒另類的策略。囿于函數(shù)(包括匿名和命名函數(shù))如此之差的表現(xiàn)力,WebKit引入了一個(gè)“特殊的”displayName屬性(本質(zhì)上是一個(gè)字符串),如果開(kāi)發(fā)人員為函數(shù)的這個(gè)屬性賦值,則該屬性的值將在調(diào)試器或性能分析器中被顯示在函數(shù)“名稱”的位置上。弗朗西斯科·托依瑪斯基(Francisco Tolmasky)詳細(xì)地解釋了這個(gè)策略的原理和實(shí)現(xiàn)。
將來(lái)的ECMAScript-262第5版(目前還是草案)會(huì)引入所謂的嚴(yán)格模式(strict mode)。開(kāi)啟嚴(yán)格模式的實(shí)現(xiàn)會(huì)禁用語(yǔ)言中的那些不穩(wěn)定、不可靠和不安全的特性。據(jù)說(shuō)出于安全方面的考慮,arguments.callee屬性將在嚴(yán)格模式下被“封殺”。因此,在處于嚴(yán)格模式時(shí),訪問(wèn)arguments.callee會(huì)導(dǎo)致TypeError(參見(jiàn)ECMA-262第5版的10.6節(jié))。而我之所以在此提到嚴(yán)格模式,是因?yàn)槿绻诨诘?版標(biāo)準(zhǔn)的實(shí)現(xiàn)中無(wú)法使用arguments.callee來(lái)執(zhí)行遞歸操作,那么使用命名函數(shù)表達(dá)式的可能性就會(huì)大大增加。從這個(gè)意義上來(lái)說(shuō),理解命名函數(shù)表達(dá)式的語(yǔ)義及其bug也就顯得更加重要了。
// 此前,你可能會(huì)使用arguments.callee
(function(x) {
if (x <= 1) return 1;
return x * arguments.callee(x - 1);
})(10);
// 但在嚴(yán)格模式下,有可能就要使用命名函數(shù)表達(dá)式
(function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x - 1);
})(10);
// 要么就退一步,使用沒(méi)有那么靈活的函數(shù)聲明
function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x - 1);
}
factorial(10);
理查德· 康福德(Richard Cornford),是他率先解釋了JScript中命名函數(shù)表達(dá)式所存在的bug。理查德解釋了我在這篇文章中提及的大多數(shù)bug,所以我強(qiáng)烈建議大家去看看他的解釋。我還要感謝Yann-Erwan Perio(這是中國(guó)人嗎?——譯者注)和道格拉斯·克勞克佛德(Douglas Crockford),他們?cè)缭?003年就在comp.lang.javascript論壇中提及并討論NFE問(wèn)題了。
約翰-戴維·道爾頓(John-David Dalton)對(duì)“最終解決方案”提出了很好的建議。
托比·蘭吉的點(diǎn)子被我用在了“替代方案”中。
蓋瑞特·史密斯(Garrett Smith)和德米特里·蘇斯尼科(Dmitry Soshnikov)對(duì)本文的多方面作出了補(bǔ)充和修正。
要提建議或者反饋錯(cuò)誤嗎?可以mailto:kangax@gmail.com給我寫(xiě)封郵件,隨便,怎么都行。
發(fā)表時(shí)間:2009年6月17日 最近修改:2009年10月9日