ECMA-262 Javascript 函式(Function)
YehYeh\'s Notepad yehyeh@gmail.com 

ECMA-262-3 第五章 函式(Functions)

本文翻譯自Dmitry Soshnikov 的部落格
採重點式翻譯,並非依順序逐字翻譯,另有刪除、補充一些內容,若有疑惑請參考原文。

1. 介紹(Introduction)

  • 本文將介紹ECMAScript中的函式
  • 本文將提及
    • 各種型態的函式
    • 各種型態的函式如何影響情境的變數物件(Variables Object, VO)
    • 函式的那些東西會被包含進作用域鏈(Scope Chain)
    • 一些常見相關問題說明,例如:
      • 下列2種建立函式的方法是否有差異?有什麼差異?
        var foo = function () {
            ...
        };
        

        function foo() {
            ...
        }
        
      • 下面的寫法,函式為何要用小括號括住?
        (function () {
          ...
        })();
        
  • 本文會直接使用Chapter 2. Variable object和Chapter 4. Scope chain裡的術語,為了全面瞭解本文的內容,請先閱讀Chapter 2和Chapter 4
Δ 回到最上方

2. 函式的類型(Types of functions)

  • ECMAScript中有3種類型的函式,每種各有其色

2-1 函式宣告式(Function Declaration)

  • 函式宣告式(Function Declaration, FD)的定義
    • 具函式名稱
    • 程式中的位置,在程式級別(Program Level)中或直接在另一個函式的主體中(FunctionBody)
    • 在進入情境時建立
    • 會影響變數物件(Variable Object, VO)
    • 以下面的形式宣告
      function exampleFunc() {
          ...
      }
      
  • 函式宣告式的主要特色在於會影響VO(函式儲存在情境的VO中)
    • 這點會導致 - 在執行時期(Execution Stage),函式就已存在
      • 因為在開始執行前,進入情境時,FD就已被存到VO
      • 程式碼中,程式呼叫(第1行)的位置比程式宣告(第3行)更前面
        foo();
          
        function foo() {
            alert('foo');
        }
        
    • 程式碼中,函式定義的位置也很重要(參考函式宣告式的定義中第2點)
      • 程式碼中,程式呼叫(第1行)的位置比程式宣告(第3行)更前面
        // 函式宣告的位
        // 1) 直接在全域情境(Clobal Context)中
        function globalFD() {
        									  
            // 2) 另一個函式的主體內
            function innerFD() {}
        }
        
      • 函式宣告式宣告的位置只能是上述的2種之一,例如
        • 不能宣告在表示式位置
        • 不能宣告在程式區塊中
Δ 回到最上方

2-2 函式表示式(Function Expression)

  • 函式表示式(Function Expression, FE)的定義
    • 在程式碼中,只能定義在表示式的位置(Expression Position)
    • 可以具名或匿名
    • 不會影響變數物件(VO)
    • 在程式執行時期(Code Execution Stage)被建立
  • 函式表示式(FE)最重要的特色是,只能定義在程式碼中表示式的位置
    • 指派表示式(Assignment Expression)
      var foo = function () {
          ...
      };
      
      • 匿名的FE被指派給變數foo
      • 可以用foo來執行函式 - foo()
  • 函式表示式(FE)也可以具名
    • var foo = function _foo() {
          ...
      };
      
      • FE外部可以用foo()或_foo()來呼叫函式,但內部只能用_foo()來呼叫(EX:遞迴)
  • 具名的FE和FD很難區分,主要依FE只能出現在表示式位置區分
    • //小括號(Grouping Operator)中只能是表示式
      (function foo() {});
      
      //在陣列初始化的位置 - 只能是表示式
      [function bar() {}];
      
      //逗號也是表示式的運算子 
      1, function baz() {};
      
  • FE的定義中規定FE在執行時期才建立,並且不會被存在變數物件(VO)
    • // 因為FE在執行時會被建立
      // 所以不能在定義前執行							  
      alert(foo); // "foo" 未定義
        
      (function foo() {});
        							
      // 因為不在VO,所以定義後也不能執行							  
      alert(foo);  // "foo" 未定義
      
      • 補充:FE通常會指派給一個變數,透過變數來執行
  • 為什麼需要函式表示式(FE)
    • FE可以在表示式的位置使用,且不會被放到VO中
    • 在函式引數位置使用FE
      function foo(callback) {
          callback();
      }
      							  
      foo(function bar() {     // bar = FE
          alert('foo.bar');    // foo.bar
      });
      							  
      foo(function baz() {    // baz = FE
          alert('foo.baz');   // foo.baz
      });
      							
      bar();                  // bar 未定義
      baz();                  // baz 未定義
      
    • FE指派給變數
      • 函式存留在記憶體中
      • 稍後可用變數名稱存取FE
      var foo = function () {
          alert('foo');
      };
      									  
      foo();
      
    • 建立作用域,使外部的情境(External Context)無法存取作用域內的資料
      var foo = {};                 // foo = FE
      
      (function initialize() {      // FE
        var x = 10;							  
        foo.bar = function () {
          alert(x);
        };							  
      })();
        
      foo.bar();       // 10;							  
      alert(x);        // "x" 未定義
      
      • foo.bar函式利用它的[[Scope]]屬性存取initialize函式的內部變數x
      • 外部無法直接存取x
      • 很多函式庫應用這種寫法來建立私有(private)資料和隱藏輔助的程式
      • 這種用途的FE,通常會匿名
        (function () {  
            // initializing scope											  
        })();
        
    • 可在執行時期依條件建立FE,且不影響VO
      var foo = 10;
      
      var bar = (foo % 2 == 0
          ? function () { alert(0); }
          : function () { alert(1); }
      );
        
      bar();    // 0
      
Δ 回到最上方

2-2-1 關於括號問題(Question “about surrounding parentheses”)

  • 為什麼「如果要在定義函式後,立即執行函式,一定要加小括號?」
    • 這是因為表示式敘述(Expression Statement)的限制,根據標準
      • 表示式敘述不能以左大括號{開始,會無法和區塊(Block)區分
      • 表示式敘述不能以關鍵字function開始,會無法和函式宣告(function declation)區分
    • 錯誤的範例 - 會導致剖析錯誤(Parse Error)
      function () {
          ...
      }();
      
      function foo() {	
          ...
      }();
      
      • 上面的程式碼是在全域的程式(Global Code),以關鍵字function開頭,剖析器(Parser)會誤判為FD
        • 第1個例子還會出現缺少函式名稱的語法錯誤(SyntaxError) FD一定具函式名
        • 第2個例子會被判定為FD接群組運算子
          • 會出現語法錯誤 - 群組運算子內沒有表示式(a grouping operator without an expression indside it)
    • 正常的FD範例
      // "foo" 是FD,在進入情境是被建立
      							
      alert(foo);     // function
      						 
      function foo(x) {
          alert(x);
      }(1);           // (1)是群組運算子,不是函式呼叫
      						 
      foo(10);        // 函式呼叫, 10
      
    • 上例的另一種寫法
      function foo(x) {	// FD
        alert(x);
      }
       							
      // 含有表示式(Expression)的群組運算子
      (1);
       							
      // 群組運算子裡面含FE
      (function () {});
       							
      // 含有表示式(Expression)的群組運算子
      ("foo");
       
      // etc
      
    • 在敘述(Statement)裡定義函數,會模稜兩可,造成語法錯誤
      if (true) function foo() {alert(1)}
      
      • 依規格而言,這種寫法是錯誤的語句結構(syntactically incorrect)
        • 一個表示式敘述,不能以function關鍵字開頭
      • 實際上沒有Javascript引擎會判定為語法錯誤,而是各用各的方法處理
  • 如何讓剖析器(Parser)認定是要在函式宣告後立即執行?
    • 使用函式表示式(FE),而不是使用函式宣告式(FD)
    • 最簡單建立表示式(Expression)的方法就是使用群組運算子(),其小括號內一定是表示式
      • 剖析器可以明確判定為FE,不會誤判
      • FE在執行時期(execution stage)被建立,若沒有其它變數參考到該FE,則執行完後就會移除FE
      • (function foo(x) {
            alert(x);
        })(1); // OK, 會執行函式呼叫,不是群組運算子, 1
        
    • 不需要小括號就可直接執行的FE
      • var foo = {									  
            bar: function (x) {         // FE
                return x % 2 != 0 ? 'yes' : 'no';
            }(1)									  
        };
          
        alert(foo.bar);                 // 'yes'
        
        • 因為FE在表示式的位置,所以剖析器(Parser)不用小括號也可判定為FE
        • FE在執行時期(execution stage)被建立
        • 不仔細看可能會把第7行的foo.bar當作字串(string)而不是函式
        • FE在這個範例中,依傳入的參數,初始化bar屬性,FE在建立後立即被執行
  • 為什麼「如果要在定義函式後,立即執行函式,一定要加小括號?」的完整答案
    • 當函式不是在表示式的位置,且需要在建立函式後立即執行該函式,此時需要加上手動加上小括號將函式轉換為FE
    • 當函式本來就在表式示的位置,剖析器(Parser)本來就會判定函式為FE,此時不需要加小括號
  • 小括號以外,還有其它方法可以將函式轉成FE
    • // 方法1							
      1, function () {
        alert('anonymous function is called');
      }();
       
      // 方法2
      !function () {
        alert('ECMAScript');
      }();
       							
      // 其它轉換的方法							 
      ...
      
  • 順帶一提,群組運算子(Grouping Operator)有沒有括住呼叫用的小括號都是合法的FE
    • (function () {}) ();
      (function () {} () );
      
Δ 回到最上方

2-2-2 實作的擴充:函式敘述(Implementations extension: Function Statement)

  • 如果瀏覽器完全根據規格實作,則無法處理下面的程式碼:
    if (true) {							  
        function foo() {
            alert(0);
        }							  
    } else {							  
        function foo() {
            alert(1);
        }							  
    }
    		  
    foo(); // 1 or 0 ? 在不同的瀏覽器測試
    
    • 根據標準,這種語法結構是錯誤
    • 依據前面提及的函式宣告式(FD)定義的第2點,FD不應該出現在程式區塊中(if和else的程式區塊)
      • FD只能出現在程式級別(Program Level)中或直接在另一個函式的主體中(FunctionBody)
    • 因為程式區塊只能包含敘述,上例語法結構是錯誤的
  • 函式若要出現在程式區塊中,只能用表示式敘述(Expression Statement)
    • 不能出現在左大括號旁,會無法區分是程式區塊還是函式
    • 不能出現在function關鍵字旁,會無法區分是FD還是FE
  • 在標準的錯誤處理章節中允許Javascript引擎實作時對程式語法進行擴充
    • Javscript引擎都會對這個案例擴充,允許將函式放在程式區塊中
    • 目前所有Javascript引擎,對將函式放在程式區塊中的案例都不會丟出Exception,而是用各自的方法處理
    • 在if-else中,應該會有一個函式在執行時被建立,所以應該使用函式表示式(FE)
    • 大部份的Javascript引擎在實作時會在進入情境時將兩個函式宣告式(FD)都建立,但因為兩個函式同名,所以只有最後宣告的函式可以被呼叫
      • 在上面的例子中,雖然else分支不可能會被執行,但foo函式會顯示1
  • SpiderMonkey實作處理這種案例的方式:
    • 不把函式視為FD(在程式執行時依條件建立函式)
    • 因為這類函式不是真正的函式表示式(FE),如果沒有用小括號括住時不能執行(剖析錯誤)
    • 這些函式會被存在變數物件(VO)
  • 我認為SpiderMonkey處理這種案例的方法是正確的
    • 將這類型的函式,獨立成一種介於FE和FD之間的函式(FE+FD)
    • 在執行時依條件建立
    • 型式像FD
    • 可以在外部份呼叫
  • 這種語法擴充,SpiderMonkey將之命名為函式敘述(Function Statement, FS),在MDC中有提到
    • Javascript的發明者Brendan Eich也提到過SpiderMonkey實作的這種函式型態
Δ 回到最上方

2-2-3 具名函式表示式的特色(Feature of Named Function Expression, NFE)

  • FE具有一個名稱時,稱為具名函式表示式(Named Function Expression),縮寫為NFE
  • 根據之前提及的FE定義,FE不會影響情境變數物件(VO)
    • 這代表不能在定義之前或之後用函式名呼叫該函式
  • 在遞迴呼叫時FE可以用函式名呼叫自己
    • (function foo(bar) {							  
          if (bar) {
              return;
          }							  
          foo(true); // "foo" 名稱有效							  
      })();
      
      // 在外部,正常情況foo是無效的  							
      foo(); // "foo" 未定義
      
    • foo被存在那?
      • 在foo的執行中物件(VO)嗎? 錯,因為foo函式內沒有定義foo
      • 在建立foo的情境的變數物件(VO)中嗎? 錯,根據FE的定,FE不會影響VO
    • 工作原理
      • 當直譯器(interpreter)在執行時遇到NFE
      • 在建立FE之前,會建立一個輔助用的特殊物件,並將這物件加到作用域鏈的前端
      • 接著在函式取得[[Scope]]屬性時建立FE,此時可以在[[Scope]]找到該特殊物件
      • 然後FE的名稱被加到特殊物件中,成為一個屬性,屬性的值即參考到FE
      • 最後由父作用域鏈中將該特殊物件移除
      • 演算法的虛擬碼
        specialObject = {};
          
        Scope = specialObject + Scope;
          
        foo = new FunctionExpression;
        foo.[[Scope]] = Scope;
        specialObject.foo = foo; // {DontDelete}, {ReadOnly}
          
        delete Scope[0]; // 由作用域鏈前端移除該特殊物件
        
    • 因為函式名稱從未加入到父作用域,所以在函式外部是無效的
    • 因為特殊物件存儲在函式的[[Scope]],所以在函式內部是有效的
    • 在一些Javascript引擎的實作中(例如Rhino),將函式名存在FE的執行中物件,而不是存在特殊物件
      • 微軟的JScript則完全破壞FE的規則,將函式名保留在父變數物件,所以函式在外部仍可被呼叫
Δ 回到最上方

2-2-4 NFE和SpiderMonkey(NFE and SpiderMonkey)

  • 本節探討不同的Javascirpt引擎如何處理上節提出的問題
  • SpiderMonkey的一些版本有一個和特殊物件有關的特性,
    • 這個特性可以視為Bug(但所有的實作都符合標準,所以應該視為規格上的錯誤)
    • 這特性和識別字的處理機制有關
    • 作用域鏈是用二維分析,當處理一個識別字時,會考慮作用域鏈中每個物件的原型鏈
  • 要讓這個特性出現,要在Object.prototype定義一個屬性,指向一個不存在的變數
    • 在下例中,當處理名稱x時,透過作用域鏈一直找到全域物件也無法找到x
      Object.prototype.x = 10;
      											  
      (function () {
          alert(x); // 10
      })();
      
    • 因為SpiderMonkey的全域物件繼承Object.prototype,所以會找到x
  • 執行中物件沒有原型(Prototype)
  • 在相同的初始條件下,可以在內部函式(Inner Function)看到相同的行為
    • 如果在函式內定義一個區域變數x,並宣告一個內部函式(FD或匿名FE)
    • 然後由內部函式中參考x,x可以在父函式的情境中被正常處理
    Object.prototype.x = 10;					  
    function foo() {					  
        var x = 20;
        function bar() { // 函式宣告式
            alert(x);
        }					  
        bar();           // 20, from AO(foo)					  					  					 
        (function () {   // 匿名FE
            alert(x);    // 20, also from AO(foo)
        })();					  
    } 
    foo();
    
  • 有些Javascript引擎的實作給執行中物件設定原型,就會出現Exception
    • 在Blackberry的Javascript引擎,上例的x值會被處理成10
    • 因為在Object.prototype就可以找到x的值
      AO(bar FD or anonymous FE) -> no ->
      AO(bar FD or anonymous FE).[[Prototype]] -> yes - 10
      
  • 在SpiderMonkey中,有物殊物件的具名FE也有同樣的狀況
    • 特殊物件是一個正常的物件,就像用表示式new Object()建立的物件一樣
    • 在SpiderMonkey 1.7及之前的版本,特殊物件應該繼承自Object.prototype
    • 較新的版本則不會給特殊物件設定prototype屬性
    function foo() {				  
        var x = 10;				  
        (function bar() {				  
            alert(x);  // 20, but not 10, as don't reach AO(foo) 
    				  
            // "x" is resolved by the chain:
            // AO(bar) - no -> __specialObject(bar) -> no
            // __specialObject(bar).[[Prototype]] - yes: 20				  
        })();
    }				  
    Object.prototype.x = 20;				  
    foo();	
    
Δ 回到最上方

2-2-5 NFE和JScript(NFE and JScript)

  • JScript是微軟對ECMAScript的實作,內建在Internet Explorer中,有不少具名函式表示式(NFE)相關的Bug
  • JScript的每個Bug都完全違背ECMA-262-3標準,其中一些會引起嚴重錯誤
  • JScript在上面的案例中,完全破壞FE的主要規則
    • JScript將函式名存存在父變數物件(Parent Variable Object)
    • JScript將具名函式表示式(NFE)當作函式宣告式(FD)
      • 在進入情境時被建立
      • 在函式定義前就可執行
    • JScript不應該將函式名稱存在變數物件(VO)
    • FE的名稱應該被存在特殊物件,且只有函式內部能存取
    // FE被放在VO,且被當成FD,所以宣告前就可以執行
    testNFE();	
    				  
    (function testNFE() {
        alert('testNFE');
    });
    				
    // FE被放在VO,且被當成FD,所以宣告後也可以執行
    testNFE();		
    
  • 在NFE宣告時指派給變數的時候,JScript建立2個不同的函式物件,這完全不合邏輯
    (特別是在NFE外部,NFE的名稱應該是無效的才對)
    var foo = function bar() {
        alert('foo');
    };
      
    alert(typeof bar); // "function", NFE又在VO – 總是錯誤					  
    // 但更有趣的是
    alert(foo === bar); // false!					  
    foo.x = 10;
    alert(bar.x); // undefined
      					
    // 但foo()和bar()的執行結果卻是相同的  
    foo(); // "foo"
    bar(); // "foo"		
    
  • 要注意的是,如果將NFE宣告和指派給變數分開的話(例如用群組運算子),2個物件會變相同
    (function bar() {});					  
    var foo = bar;
    										  
    alert(foo === bar); // true
    					  
    foo.x = 10;
    alert(bar.x); // 10		
    
    • 事實上,一開始還是建立2個物件,但只留一個
    • 如果又把NFE當成FD,在進入情境時,FD bar就已建立
    • 接著,在程式執行階段,第2個物件FE bar被建立,並且沒有被存在任何地方
      • 沒有任何東西參考到FE bar,所以它會被移除
    • 所以只剩一個物件FD bar,被foo變數參考到
  • arguments.claaee間接參考函式時,參考到的執行函式的名稱
    var foo = function bar() {					  
        alert([
            arguments.callee === foo,
            arguments.callee === bar
        ]);					  
    };
      
    foo(); // [true, false]
    bar(); // [false, true]	
    
  • JScript將NFE當作FD,但在條件運算子時卻不用這規則
    • 就像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變數參考到
    • 程式中,if(false)裡的程式碼是不會被執行到的
    • 所以foo執行時會做alert(1)
  • JScript的第5個NFE Bug是關於在建立全域物件屬性時,用未宣告的識別字(沒有使用var宣告)指派其值
    • NFE被當作FD,並存在變數物件(VO)中,指派給未宣告的識別字(例如全域物件的屬性)
    • 當函式名稱和未宣告的識別字相同時,這屬性不是全域的
      (function () {							  							  
          // 沒有用var宣告,foo應該是全域物件的屬性
          foo = function foo() {};							  
      })();
      							  							
      // 但在匿名函式的外部,沒辨法存取foo 							  
      alert(typeof foo); // undefined	
      
    • 函式宣告式foo在進入匿名函式的區域情境時取得執行物件
    • 並且在執行時,名稱foo已存在AO中,所以被認為是區域的名稱
    • 指派運算子只是在AO的foo屬性上更新而已,沒有依據ECMA-262-3的邏輯在全域物件建立新屬性
Δ 回到最上方

2-3 用函式的建構函式建立函式(Functions created via Function constructor)

  • 這類型的函式物件和FDFE分開來探討,因為它有一些獨有的特色
  • 最主要的特色是這種函式的[[Scope]]屬性只含有全域物件
    var x = 10;
      
    function foo() {	  
        var x = 20;
        var y = 30;
        var bar = new Function('alert(x); alert(y);');
        bar(); // 10, "y" is not defined
    }
    
    • 可以看到bar函式的[[Scope]]屬性沒有包含foo情境的AO
    • 無法存取變數y,可以全域情境存取到x
    • 函式的建構函式可以用new關鍵字也可不用
  • 這類型函式的另一個特色是同類語法產生(Equated Grammar Productions)物件合併(Joined Objects)
    • 這種機制是規格書中對最佳化所提出的建議(但Javascript引擎實作時有權利決定是否要引入這種最佳化)
    • 例如,有一個有100個元素的陣列,用迴圈指派函式給每個元素,實作時可以用物件結合的機制
      • 所以對陣列全部的元素而言,只能使用同一個函式物件
      • var a = [];
          
        for (var k = 0; k < 100; k++) {
            a[k] = function () {}; // 這裡可能使用 - 物件合併								  
        }
        
    • 是用函式的建構函式建立函式不會被合併
      var a = [];
      							  
      for (var k = 0; k < 100; k++) {
           a[k] = Function(''); // 一定是100個不同的函式
      }
      
    • 另一個和物件合併相關的範例
      function foo() {							  
          function bar(z) {
              return z * z;
          }							  
          return bar;
      }
        
      var x = foo();
      var y = foo();
      
      • Javascript引擎實作時有權利決定是否要合併物件x和y,但實際上函式都相同
    • 因此,使用函式的建構函式建立的函式需要更多的記憶體資源
Δ 回到最上方

3. 函式建立的演算法(Algorithm of function creation)

  • 下面是函式建立時使用的演算法的虛擬碼描述(除了物件合併)
  • 這些描述有助於更詳細瞭解ECMAScript中的函式物件
  • 所有類型的函式演算法都相同
    F = new NativeObject(); 
    
    F.[[Class]] = "Function"                // 屬性 [[Class]] 等於 "Function"					  				
    F.[[Prototype]] = Function.prototype    // 函式物件的prototype
      					
    // 屬性[[Call]]參考到函式本身
    // [[Call]]在遇到F()時執行,並建立新的執行情境
    F.[[Call]] = <reference to function> 
    
    // 建立物件的建構函式
    // [[Construct]]被"new"關鍵字執行,並配置記憶體
    // 且會呼叫F.[[Call]]來初始化已建立的物件,將this值設為新建的物件 
    F.[[Construct]] = internalConstructor
      					
    // 目前情境的作用域鏈,例如建立函式F的情境
    F.[[Scope]] = activeContext.Scope
    // 如果函式是用new Function(...)建立的,就作
    F.[[Scope]] = globalContext.Scope
      					
    // 型式參數(Formal Parameters)的個數
    F.length = countParameters
      
    // a prototype of created by F objects
    // F物件建立的prototype
    __objectPrototype = new Object();
    __objectPrototype.constructor = F // {DontEnum}, 在迴圈中不能列舉(enumerable)
    F.prototype = __objectPrototype
      
    return F
    
  • F.[[Prototype]]是一個函式(建構函式)的原型(Prototype)且F.prototype是該函式建立的物件的原型
    • 因為術語上很容易搞混,且F.prototype在一些文章中被錯誤的叫做"建構函式的原型(Prototype of the Constructor)"
Δ 回到最上方

4. 結論(Conclusion)

  • 在後面物件和原型的章節還會再提到函式用來當建構函式時是如何運作
Δ 回到最上方

5. 額外的文獻(Additional literature)

Δ 回到最上方