1. 程式人生 > >深入理解JavaScript系列(15):函式(Functions)

深入理解JavaScript系列(15):函式(Functions)

 

詳情請檢視:https://www.cnblogs.com/TomXu/archive/2012/01/30/2326372.html

 

本章節我們要著重介紹的是一個非常常見的ECMAScript物件——函式(function),我們將詳細講解一下各種型別的函式是如何影響上下文的變數物件以及每個函式的作用域鏈都包含什麼,以及回答諸如像下面這樣的問題:下面宣告的函式有什麼區別麼?(如果有,區別是什麼)。

原文:http://dmitrysoshnikov.com/ecmascript/chapter-5-functions/
var foo = function () {
  ...
};

平時的慣用方式:

function foo() {
  ...
}

或者,下面的函式為什麼要用括號括住?

(function () {
  ...
})();

關於具體的介紹,早前面的12章變數物件14章作用域鏈都有介紹,如果需要詳細瞭解這些內容,請查詢上述2個章節的詳細內容。

但我們依然要一個一個分別看看,首先從函式的型別講起:

函式型別

在ECMAScript 中有三種函式型別:函式宣告,函式表示式和函式構造器建立的函式。每一種都有自己的特點。

函式宣告

函式宣告(縮寫為FD)是這樣一種函式:
  1. 有一個特定的名稱
  2. 在原始碼中的位置:要麼處於程式級(Program level),要麼處於其它函式的主體(FunctionBody)中
  3. 在進入上下文階段建立
  4. 影響變數物件
  5. 以下面的方式宣告
function exampleFunc() {
  ...
}

這種函式型別的主要特點在於它們僅僅影響變數物件(即儲存在上下文的VO中的變數物件)。該特點也解釋了第二個重要點(它是變數物件特性的結果)——在程式碼執行階段它們已經可用(因為FD在進入上下文階段已經存在於VO中——程式碼執行之前)。

例如(函式在其宣告之前被呼叫)

foo();
 
function foo() {
  alert('foo');
}

另外一個重點知識點是上述定義中的第二點——函式宣告在原始碼中的位置:

// 函式可以在如下地方宣告:
// 1) 直接在全域性上下文中
function globalFD() {
  // 2) 或者在一個函式的函式體內
  function innerFD() {}
}

只有這2個位置可以宣告函式,也就是說:不可能在表示式位置或一個程式碼塊中定義它。

另外一種可以取代函式宣告的方式是函式表示式,解釋如下:

函式表示式

函式表示式(縮寫為FE)是這樣一種函式:
  1. 在原始碼中須出現在表示式的位置
  2. 有可選的名稱
  3. 不會影響變數物件
  4. 在程式碼執行階段建立

這種函式型別的主要特點在於它在原始碼中總是處在表示式的位置。最簡單的一個例子就是一個賦值宣告:

var foo = function () {
  ...
};

該例演示是讓一個匿名函式表示式賦值給變數foo,然後該函式可以用foo這個名稱進行訪問——foo()。

同時和定義裡描述的一樣,函式表示式也可以擁有可選的名稱:

var foo = function _foo() {
  ...
};

需要注意的是,在外部FE通過變數“foo”來訪問——foo(),而在函式內部(如遞迴呼叫),有可能使用名稱“_foo”。

如果FE有一個名稱,就很難與FD區分。但是,如果你明白定義,區分起來就簡單明瞭:FE總是處在表示式的位置。在下面的例子中我們可以看到各種ECMAScript 表示式:

// 圓括號(分組操作符)內只能是表示式
(function foo() {});
 
// 在陣列初始化器內只能是表示式
[function bar() {}];
 
// 逗號也只能操作表示式
1, function baz() {};

表示式定義裡說明:FE只能在程式碼執行階段建立而且不存在於變數物件中,讓我們來看一個示例行為:

// FE在定義階段之前不可用(因為它是在程式碼執行階段建立)
 
alert(foo); // "foo" 未定義
 
(function foo() {});
 
// 定義階段之後也不可用,因為他不在變數物件VO中
 
alert(foo);  // "foo" 未定義

相當一部分問題出現了,我們為什麼需要函式表示式?答案很明顯——在表示式中使用它們,”不會汙染”變數物件。最簡單的例子是將一個函式作為引數傳遞給其它函式。

function foo(callback) {
  callback();
}
 
foo(function bar() {
  alert('foo.bar');
});
 
foo(function baz() {
  alert('foo.baz');
});

在上述例子裡,FE賦值給了一個變數(也就是引數),函式將該表示式儲存在記憶體中,並通過變數名來訪問(因為變數影響變數物件),如下:

var foo = function () {
  alert('foo');
};
 
foo();

另外一個例子是建立封裝的閉包從外部上下文中隱藏輔助性資料(在下面的例子中我們使用FE,它在建立後立即呼叫):

var foo = {};
 
(function initialize() {
 
  var x = 10;
 
  foo.bar = function () {
    alert(x);
  };
 
})();
 
foo.bar(); // 10;
 
alert(x); // "x" 未定義

我們看到函式foo.bar(通過[[Scope]]屬性)訪問到函式initialize的內部變數“x”。同時,“x”在外部不能直接訪問。在許多庫中,這種策略常用來建立”私有”資料和隱藏輔助實體。在這種模式中,初始化的FE的名稱通常被忽略:

(function () {
   // 初始化作用域 
})();

還有一個例子是:在程式碼執行階段通過條件語句進行建立FE,不會汙染變數物件VO。

var foo = 10;
 
var bar = (foo % 2 == 0
  ? function () { alert(0); }
  : function () { alert(1); }
);
 
bar(); // 0

關於圓括號的問題

讓我們回頭並回答在文章開頭提到的問題——”為何在函式建立後的立即呼叫中必須用圓括號來包圍它?”,答案就是:表示式句子的限制就是這樣的。

按照標準,表示式語句不能以一個大括號{開始是因為他很難與程式碼塊區分,同樣,他也不能以函式關鍵字開始,因為很難與函式宣告進行區分。即,所以,如果我們定義一個立即執行的函式,在其建立後立即按以下方式呼叫:

function () {
  ...
}();
 
// 即便有名稱
 
function foo() {
  ...
}();

我們使用了函式宣告,上述2個定義,直譯器在解釋的時候都會報錯,但是可能有多種原因。

如果在全域性程式碼裡定義(也就是程式級別),直譯器會將它看做是函式宣告,因為他是以function關鍵字開頭,第一個例子,我們會得到SyntaxError錯誤,是因為函式宣告沒有名字(我們前面提到了函式宣告必須有名字)。

第二個例子,我們有一個名稱為foo的一個函式宣告正常建立,但是我們依然得到了一個語法錯誤——沒有任何表示式的分組操作符錯誤。在函式聲明後面他確實是一個分組操作符,而不是一個函式呼叫所使用的圓括號。所以如果我們宣告如下程式碼:

// "foo" 是一個函式宣告,在進入上下文的時候建立
 
alert(foo); // 函式
 
function foo(x) {
  alert(x);
}(1); // 這只是一個分組操作符,不是函式呼叫!
 
foo(10); // 這才是一個真正的函式呼叫,結果是10

上述程式碼是沒有問題的,因為宣告的時候產生了2個物件:一個函式宣告,一個帶有1的分組操作,上面的例子可以理解為如下程式碼:

// 函式宣告
function foo(x) {
  alert(x);
}
 
// 一個分組操作符,包含一個表示式1
(1);
 
// 另外一個操作符,包含一個function表示式
(function () {});
 
// 這個操作符裡,包含的也是一個表示式"foo"
("foo");
 
// 等等

如果我們定義一個如下程式碼(定義裡包含一個語句),我們可能會說,定義歧義,會得到報錯:

if (true) function foo() {alert(1)}

根據規範,上述程式碼是錯誤的(一個表示式語句不能以function關鍵字開頭),但下面的例子就沒有報錯,想想為什麼?

我們如果來告訴直譯器:我就像在函式宣告之後立即呼叫,答案是很明確的,你得宣告函式表示式function expression,而不是函式宣告function declaration,並且建立表示式最簡單的方式就是用分組操作符括號,裡邊放入的永遠是表示式,所以直譯器在解釋的時候就不會出現歧義。在程式碼執行階段這個的function就會被建立,並且立即執行,然後自動銷燬(如果沒有引用的話)。

(function foo(x) {
  alert(x);
})(1); // 這才是呼叫,不是分組操作符

上述程式碼就是我們所說的在用括號括住一個表示式,然後通過(1)去呼叫。

注意,下面一個立即執行的函式,周圍的括號不是必須的,因為函式已經處在表示式的位置,解析器知道它處理的是在函式執行階段應該被建立的FE,這樣在函式建立後立即呼叫了函式。

var foo = {
 
  bar: function (x) {
    return x % 2 != 0 ? 'yes' : 'no';
  }(1)
 
};
 
alert(foo.bar); // 'yes'

就像我們看到的,foo.bar是一個字串而不是一個函式,這裡的函式僅僅用來根據條件引數初始化這個屬性——它建立後並立即呼叫。

因此,”關於圓括號”問題完整的答案如下:當函式不在表示式的位置的時候,分組操作符圓括號是必須的——也就是手工將函式轉化成FE。
如果解析器知道它處理的是FE,就沒必要用圓括號。

除了大括號以外,如下形式也可以將函式轉化為FE型別,例如:

// 注意是1,後面的宣告
1, function () {
  alert('anonymous function is called');
}();
 
// 或者這個
!function () {
  alert('ECMAScript');
}();
 
// 其它手工轉化的形式
 
...

但是,在這個例子中,圓括號是最簡潔的方式。

順便提一句,組表示式包圍函式描述可以沒有呼叫圓括號,也可包含呼叫圓括號,即,下面的兩個表示式都是正確的FE。

實現擴充套件:函式語句

下面的程式碼,根據貴方任何一個function宣告都不應該被執行:

if (true) {
 
  function foo() {
    alert(0);
  }
 
} else {
 
  function foo() {
    alert(1);
  }
 
}
 
foo(); // 1 or 0 ?實際在上不同環境下測試得出個結果不一樣

這裡有必要說明的是,按照標準,這種句法結構通常是不正確的,因為我們還記得,一個函式宣告(FD)不能出現在程式碼塊中(這裡if和else包含程式碼塊)。我們曾經講過,FD僅出現在兩個位置:程式級(Program level)或直接位於其它函式體中。

因為程式碼塊僅包含語句,所以這是不正確的。可以出現在塊中的函式的唯一位置是這些語句中的一個——上面已經討論過的表示式語句。但是,按照定義它不能以大括號開始(既然它有別於程式碼塊)或以一個函式關鍵字開始(既然它有別於FD)。

但是,在標準的錯誤處理章節中,它允許程式語法的擴充套件執行。這樣的擴充套件之一就是我們見到的出現在程式碼塊中的函式。在這個例子中,現今的所有存在的執行都不會丟擲異常,都會處理它。但是它們都有自己的方式。

if-else分支語句的出現意味著一個動態的選擇。即,從邏輯上來說,它應該是在程式碼執行階段動態建立的函式表示式(FE)。但是,大多數執行在進入上下文階段時簡單的建立函式宣告(FD),並使用最後宣告的函式。即,函式foo將顯示”1″,事實上else分支將永遠不會執行。

但是,SpiderMonkey (和TraceMonkey)以兩種方式對待這種情況:一方面它不會將函式作為宣告處理(即,函式在程式碼執行階段根據條件建立),但另一方面,既然沒有括號包圍(再次出現解析錯誤——”與FD有別”),他們不能被呼叫,所以也不是真正的函式表示式,它儲存在變數物件中。

我個人認為這個例子中SpiderMonkey 的行為是正確的,拆分了它自身的函式中間型別——(FE+FD)。這些函式在合適的時間建立,根據條件,也不像FE,倒像一個可以從外部呼叫的FD,SpiderMonkey將這種語法擴充套件 稱之為函式語句(縮寫為FS);該語法在MDC中提及過。

命名函式表示式的特性

當函式表示式FE有一個名稱(稱為命名函式表示式,縮寫為NFE)時,將會出現一個重要的特點。從定義(正如我們從上面示例中看到的那樣)中我們知道函式表示式不會影響一個上下文的變數物件(那樣意味著既不可能通過名稱在函式宣告之前呼叫它,也不可能在宣告之後呼叫它)。但是,FE在遞迴呼叫中可以通過名稱呼叫自身。

(function foo(bar) {
 
  if (bar) {
    return;
  }
 
  foo(true); // "foo" 是可用的
 
})();
 
// 在外部,是不可用的 
foo(); // "foo" 未定義

“foo”儲存在什麼地方?在foo的活動物件中?不是,因為在foo中沒有定義任何”foo”。在上下文的父變數物件中建立foo?也不是,因為按照定義——FE不會影響VO(變數物件)——從外部呼叫foo我們可以實實在在的看到。那麼在哪裡呢?

以下是關鍵點。當直譯器在程式碼執行階段遇到命名的FE時,在FE建立之前,它建立了輔助的特定物件,並新增到當前作用域鏈的最前端。然後它建立了FE,此時(正如我們在第四章 作用域鏈知道的那樣)函式獲取了[[Scope]] 屬性——建立這個函式上下文的作用域鏈)。此後,FE的名稱新增到特定物件上作為唯一的屬性;這個屬性的值是引用到FE上。最後一步是從父作用域鏈中移除那個特定的物件。讓我們在偽碼中看看這個演算法:

specialObject = {};
 
Scope = specialObject + Scope;
 
foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
 
delete Scope[0]; // 從作用域鏈中刪除定義的特殊物件specialObject

因此,在函式外部這個名稱不可用的(因為它不在父作用域鏈中),但是,特定物件已經儲存在函式的[[scope]]中,在那裡名稱是可用的。

但是需要注意的是一些實現(如Rhino)不是在特定物件中而是在FE的啟用物件中儲存這個可選的名稱。Microsoft 中的執行完全打破了FE規則,它在父變數物件中保持了這個名稱,這樣函式在外部變得可以訪問。

NFE 與SpiderMonkey

我們來看看NFE和SpiderMonkey的區別,SpiderMonkey 的一些版本有一個與特定物件相關的屬性,它可以作為bug來對待(雖然按照標準所有的都那樣實現了,但更像一個ECMAScript標準上的bug)。它與識別符號的解析機制相關:作用域鏈的分析是二維的,在識別符號的解析中,同樣考慮到作用域鏈中每個物件的原型鏈。

如果我們在Object.prototype中定義一個屬性,並引用一個”不存在(nonexistent)”的變數。我們就能看到這種執行機制。這樣,在下面示例的”x”解析中,我們將到達全域性物件,但是沒發現”x”。但是,在SpiderMonkey 中全域性物件繼承了Object.prototype中的屬性,相應地,”x”也能被解析。

Object.prototype.x = 10;
 
(function () {
  alert(x); // 10
})();

活動物件沒有原型。按照同樣的起始條件,在上面的例子中,不可能看到內部函式的這種行為。如果定義一個區域性變數”x”,並定義內部函式(FD或匿名的FE),然後再內部函式中引用”x”。那麼這個變數將在父函式上下文(即,應該在哪裡被解析)中而不是在Object.prototype中被解析。

Object.prototype.x = 10;
 
function foo() {
 
  var x = 20;
 
  // 函式宣告
 
  function bar() {
    alert(x);
  }
 
  bar(); // 20, 從foo的變數物件AO中查詢
 
  // 匿名函式表示式也是一樣
 
  (function () {
    alert(x); // 20, 也是從foo的變數物件AO中查詢
  })();
 
}
 
foo();

儘管如此,一些執行會出現例外,它給活動物件設定了一個原型。因此,在Blackberry 的執行中,上面例子中的”x”被解析為”10″。也就是說,既然在Object.prototype中已經找到了foo的值,那麼它就不會到達foo的活動物件。

AO(bar FD or anonymous FE) -> no ->
AO(bar FD or anonymous FE).[[Prototype]] -> yes - 10

在SpiderMonkey 中,同樣的情形我們完全可以在命名FE的特定物件中看到。這個特定的物件(按照標準)是普通物件——”就像表示式new Object()“,相應地,它應該從Object.prototype 繼承屬性,這恰恰是我們在SpiderMonkey (1.7以上的版本)看到的執行。其餘的執行(包括新的TraceMonkey)不會為特定的物件設定一個原型。

function foo() {
 
  var x = 10;
 
  (function bar() {
 
    alert(x); // 20, 不上10,不是從foo的活動物件上得到的
 
    // "x"從鏈上查詢:
    // AO(bar) - no -> __specialObject(bar) -> no
    // __specialObject(bar).[[Prototype]] - yes: 20
 
  })();
}
 
Object.prototype.x = 20;
 
foo();

NFE與Jscript

當前IE瀏覽器(直到JScript 5.8 — IE8)中內建的JScript 執行有很多與函式表示式(NFE)相關的bug。所有的這些bug都完全與ECMA-262-3標準矛盾;有些可能會導致嚴重的錯誤。

首先,這個例子中JScript 破壞了FE的主要規則,它不應該通過函式名儲存在變數物件中。可選的FE名稱應該儲存在特定的物件中,並只能在函式自身(而不是別的地方)中訪問。但IE直接將它儲存在父變數物件中。此外,命名的FE在JScript 中作為函式宣告(FD)對待。即創建於進入上下文的階段,在原始碼中的定義之前可以訪問。

// FE 在變數物件裡可見
testNFE();
 
(function testNFE() {
  alert('testNFE');
});
 
// FE 在定義結束以後也可見
// 就像函式宣告一樣
testNFE();

正如我們所見,它完全違背了規則。

其次,在宣告中將命名FE賦給一個變數時,JScript 建立了兩個不同的函式物件。邏輯上(特別注意的是在NFE的外部它的名稱根本不應該被訪問)很難命名這種行為。

var foo = function bar() {
  alert('foo');
};
 
alert(typeof bar); // "function", 
 
// 有趣的是
alert(foo === bar); // false!
 
foo.x = 10;
alert(bar.x); // 未定義
 
// 但執行的時候結果一樣
 
foo(); // "foo"
bar(); // "foo"

再次看到,已經亂成一片了。

但是,需要注意的是,如果與變數賦值分開,單獨描述NFE(如通過組運算子),然後將它賦給一個變數,並檢查其相等性,結果為true,就好像是一個物件。

(function bar() {});
 
var foo = bar;
 
alert(foo === bar); // true
 
foo.x = 10;
alert(bar.x); // 10

此時是可以解釋的。實際上,再次建立兩個物件,但那樣做事實上仍保持一個。如果我們再次認為這裡的NFE被作為FD對待,然後在進入上下文階段建立FD bar。此後,在程式碼執行階段第二個物件——函式表示式(FE)bar 被建立,它不會被儲存。相應地,沒有FE bar的任何引用,它被移除了。這樣就只有一個物件——FD bar,對它的引用賦給了變數foo。

第三,就通過arguments.callee間接引用一個函式而言,它引用的是被啟用的那個物件的名稱(確切的說——再這裡有兩個函式物件。

var foo = function bar() {
 
  alert([
    arguments.callee === foo,
    arguments.callee === bar
  ]);
 
};
 
foo(); // [true, false]
bar(); // [false, true]

第四,JScript 像對待普通的FD一樣對待NFE,他不服從條件表示式規則。即,就像一個FD,NFE在進入上下文時建立,在程式碼中最後的定義被使用。

var foo = function bar() {
  alert(1);
};
 
if (false) {
 
  foo = function bar() {
    alert(2);
  };
 
}
bar(); // 2
foo(); // 1

這種行為從”邏輯上”也可以解釋。在進入上下文階段,最後遇到的FD bar被建立,即包含alert(2)的函式。此後,在程式碼執行階段,新的函式——FE bar建立,對它的引用賦給了變數foo。這樣foo啟用產生alert(1)。邏輯很清楚,但考慮到IE的bug,既然執行明顯被破壞,並依賴於JScript 的bug,我給單詞”邏輯上(logically)”加上了引號。

JScript 的第五個bug與全域性物件的屬性建立相關,全域性物件由賦值給一個未限定的識別符號(即,沒有var關鍵字)來生成。既然NFE在這被作為FD對待,相應地,它儲存在變數物件中,賦給一個未限定的識別符號(即不是賦給變數而是全域性物件的普通屬性),萬一函式的名稱與未限定的識別符號相同,這樣該屬性就不是全域性的了。

(function () {
 
  // 不用var的話,就不是當前上下文的一個變量了
  // 而是全域性物件的一個屬性
 
  foo = function foo() {};
 
})();
 
//  但,在匿名函式的外部,foo這個名字是不可用的
 
alert(typeof foo); // 未定義

“邏輯”已經很清楚了:在進入上下文階段,函式宣告foo取得了匿名函式區域性上下文的活動物件。在程式碼執行階段,名稱foo在AO中已經存在,即,它被作為區域性變數。相應地,在賦值操作中,只是簡單的更新已存在於AO中的屬性foo,而不是按照ECMA-262-3的邏輯建立全域性物件的新屬性。

通過函式構造器建立的函式

既然這種函式物件也有自己的特色,我們將它與FD和FE區分開來。其主要特點在於這種函式的[[Scope]]屬性僅包含全域性物件:

var x = 10;
 
function foo() {
 
  var x = 20;
  var y = 30;
 
  var bar = new Function('alert(x); alert(y);');
 
  bar(); // 10, "y" 未定義
 
}

我們看到,函式bar的[[Scope]]屬性不包含foo上下文的Ao——變數”y”不能訪問,變數”x”從全域性物件中取得。順便提醒一句,Function構造器既可使用new 關鍵字,也可以沒有,這樣說來,這些變體是等價的。

這些函式的其他特點與Equated Grammar Productions 和Joined Objects相關。作為優化建議(但是,實現上可以不使用優化),規範提供了這些機制。如,如果我們有一個100個元素的陣列,在函式的一個迴圈中,執行可能使用Joined Objects 機制。結果是陣列中的所有元素僅一個函式物件可以使用。

var a = [];
 
for (var k = 0; k < 100; k++) {
  a[k] = function () {}; // 可能使用了joined objects
}

但是通過函式構造器建立的函式不會被連線。

var a = [];
 
for (var k = 0; k < 100; k++) {
  a[k] = Function(''); // 一直是100個不同的函式
}

另外一個與聯合物件(joined objects)相關的例子:

function foo() {
 
  function bar(z) {
    return z * z;
  }
 
  return bar;
}
 
var x = foo();
var y = foo();

這裡的實現,也有權利連線物件x和物件y(使用同一個物件),因為函式(包括它們的內部[[Scope]] 屬性)在根本上是沒有區別的。因此,通過函式構造器建立的函式總是需要更多的記憶體資源。

建立函式的演算法

下面的偽碼描述了函式建立的演算法(與聯合物件相關的步驟除外)。這些描述有助於你理解ECMAScript中函式物件的更多細節。這種演算法適合所有的函式型別。

F = new NativeObject();
 
// 屬性[[Class]]是"Function"
F.[[Class]] = "Function"
 
// 函式物件的原型是Function的原型
F.[[Prototype]] = Function.prototype
 
// 醫用到函式自身
// 呼叫表示式F的時候啟用[[Call]]
// 並且建立新的執行上下文
F.[[Call]] = <reference to function>
 
// 在物件的普通構造器裡編譯
// [[Construct]] 通過new關鍵字啟用
// 並且給新物件分配記憶體
// 然後呼叫F.[[Call]]初始化作為this傳遞的新建立的物件
F.[[Construct]] = internalConstructor
 
// 當前執行上下文的作用域鏈
// 例如,建立F的上下文
F.[[Scope]] = activeContext.Scope
// 如果函式通過new Function(...)來建立,
// 那麼
F.[[Scope]] = globalContext.Scope
 
// 傳入引數的個數
F.length = countParameters
 
// F物件建立的原型
__objectPrototype = new Object();
__objectPrototype.constructor = F // {DontEnum}, 在迴圈裡不可列舉x
F.prototype = __objectPrototype
 
return F

注意,F.[[Prototype]]是函式(構造器)的一個原型,F.prototype是通過這個函式建立的物件的原型(因為術語常常混亂,一些文章中F.prototype被稱之為“構造器的原型”,這是不正確的)。