ECMA-262-3 深入解析:第五章 函数

导言

在这篇文章中,我们将更详细的讨论一个常见的ECMAScript对象——函数。我们将详细地研究函数的各种类型,解释这种或那种类型的函数如何影响一个上下文的变量对象以及函数某一个类型的作用域链中包含什么。我们将回答论坛中像这样经常被问及的问题:“下面创建的函数与以一种“习惯的方式”定义的函数之间有什么区别(如果有,是什么?)”(注:原文的顺序不是这样,本人作了调整)

1. var foo = function () {
2. ...
3. };

以一种“习惯的方式”定义的函数:

1. function foo() {
2. ...
3. }

或者,“为什么下面的函数调用应该用圆括号包围?”

1. (function  () {
2. ...
3. })();

因为这些文章独立于早期的章节,由于我们会积极地使用来自这些章节的语法,为了全面理解这一部分,如有必要,值得去读第二章:变量对象第四章:作用域链

但是让我们一个个接着来,首先从函数的类型研究起。

函数类型

在ECMAScript 中有三种函数类型,每一种都有自己的特点。

函数声明

函数声明(缩写为FD)是这样一种函数:

  1. 有一个特定的名称
  2. 在源码中的位置:要么处于程序级(Program level),要么处于其它函数的主体(FunctionBody)中
  3. 在进入上下文阶段创建
  4. 影响变量对象
  5. 以下面的方式声明
1. function exampleFunc() {
2. ...
3. }

这种函数类型的主要特点在于它们仅仅影响变量对象(即函数存储在上下文的VO中)。该特点也解释了第二个重要点(它是变量对象特性的结果)——在代码执行阶段它们已经可用(因为FD在进入上下文阶段已被VO收集)。

例如(源码中函数在其声明之前被调用)

1. foo();
2.  
3. function foo() {
4. alert('foo');
5. }

同样一个重要点是定义中的第二点——源码中函数声明的位置:

01. // function declaration
02. // is directly in:
03. // either the global context
04. // at Program level
05. function globalFD() {
06. // or directly inside the body
07. // of another function
08. function innerFD() {}
09. }

在代码中的任何其他位置,函数声明不能出现——即,不可能在表达式位置或一个代码块中定义它。

取代函数声明(更有可能认为是与之相对)的是函数表达式

函数表达式

函数表达式(缩写为FE)是这样一种函数:

  • 在源码中须出现在表达式的位置
  • 可选的名称
  • 不会影响变量对象
  • 在代码执行阶段创建

这种函数类型的主要特点在于它在源码中总是处在表达式的位置。表达式最简单的一个例子就是一个赋值声明:

1. var foo = function () {
2. ...
3. };

在这个例子中,一个匿名的FE呈现出来,它分配给变量“foo”。这样,函数通过名称“foo”来使用——foo()。

也正如定义中提到的那样,FE可以拥有一个可选的名称。

1. var foo = function _foo() {
2. ...
3. };

需要注意的是,在外部FE通过变量“foo”来访问——foo(),而在函数内部(如递归调用),有可能使用名称“_foo”。

如果FE有一个名称,就很难与FD区分。但是,如果你明白定义,区分起来就简单明了:FE总是处在表达式的位置。在下面的例子中我们可以看到各种ECMAScript 表达式:

1. (function foo() {});// in parentheses (grouping operator) can be only an expression;
2. [function bar() {}];// in the array initialiser – also only expressions;
3. 1, function baz() {};// "comma" also operates with expressions;

在定义中也谈到FE在代码执行期创建,不会存储到变量对象中。让我们看看示例中的这些行为:

01. // FE is not available neither before the definition
02. // (because it is created at code execution phase),
03.  
04. alert(foo);// "foo" is not defined
05.  
06. (function foo() {});
07.  
08. // nor after, because it is not in the VO
09.  
10. alert(foo); // "foo" is not defined

相当一部分问题出现了,它们究竟需要什么?答案很明显——在表达式中使用它们,”不会污染”变量对象。最简单的例子是一个函数作为参数传递给其它函数。

01. function foo(callback) {
02. callback();
03. }
04.  
05. foo(function bar() {
06. alert('foo.bar');
07. });
08.  
09. foo(function baz() {
10. alert('foo.baz');
11. });

如果是那样的法,当一些变量存储一个FE的引用时,函数保持在内存中,并可以通过变量名访问到(因为我们知道变量影响变量对象)。

1. var foo = function () {
2. alert('foo');
3. };
4.  
5. foo();

另外一个例子是创建封装的闭包从外部上下文中隐藏辅助性数据(在下面的例子中我们使用FE,它在创建后立即调用):

01. var foo = {};
02.  
03. (function initialize() {
04.  
05. var x = 10;
06.  
07. object.bar = function () {
08. alert(x);
09. };
10.  
11. })();
12.  
13. foo.bar();// 10;
14.  
15. alert(x);// "x" is not defined

我们看到函数foo.bar(通过[[Scope]]属性)访问到函数initialize的内部变量“x”。同时,“x”在外部不能直接访问。在许多库中,这种策略常用来创建”私有”数据和隐藏辅助实体。在这种模式中,初始化的FE的名称通常被忽略:

1. (function () {
2.  
3. // initializing scope
4.  
5. })();

其它例子——在代码执行阶段根据条件创建的FE,不会污染VO。

1. var foo = 10;
2.  
3. var bar = (foo % 2 == 0
4. function () { alert(0); }
5. function () { alert(1); }
6. );
7.  
8. bar();// 0

“关于包围圆括号”的问题

让我们回头并回答在文章开头提到的问题——”为何在函数创建后的立即调用中必须用圆括号来包围它?”从下面对表达式语句的限制中可以得到这个问题的答案。

按照标准,既然表达式声明(ExpressionStatement)很难与块区分,它就不能以一个大括号{开始,同样,既然它很难与函数声明区分,他也不能以函数关键字开始。即,如果我们定义一个函数,在其创建后立即按以下方式调用:

1. function foo() {
2. ...
3. }();

由于不知道如何处理,解析器将产生一个解析错误——在代码执行阶段应该创建哪一个(在进入上下文时应该创建哪一个)?函数声明函数表达式?相应地,解析器相当”失落(falls)”,显示一条错误信息。

纠正这种情况最简单的方法就是将函数转换成FE类型,例如,使用组运算符,运算符里面总是一个表达式。这样,解析器将其作为函数表达式(FE)与代码区分,歧义就此消失。

注意,下面的例子中,由于函数已经处在表达式的位置,解析器知道它处理的是在函数执行阶段应该被创建的FE,这样在函数创建后立即调用函数,就没必要用圆括号包围。

01. var foo = {
02.  
03. bar: function (x) {
04. return x % 2 != 0 ? 'yes' 'no';
05. }(1)
06.  
07. };
08.  
09. alert(foo.bar);// 'yes'

正如我们看到的,foo.bar是一个字符串而不是一个函数(原文是“As we see foo.bar is a string but not a function as can seem at inattentive viewing of a code”,本人觉得有些啰嗦)。这里的函数仅仅用来根据条件参数初始化这个属性——它创建后并立即调用。

因此,”关于圆括号”问题完整的答案如下:当函数不在表达式的位置,圆括号是必须的。如果我们想在创建时调用它——在那种情况下我们需手动的将函数转换成FE。如果解析器知道它处理的是FE,就没必要用圆括号。

除了包绕圆括号,有可能使用函数转换的任何其它方法转换FE,例如:

1. 1, function () {
2. alert('anonymous function is called');
3. }();

但是,在这个例子中,圆括号是最简洁的方式。

顺便提一句,组表达式包围函数描述可以没有调用圆括号,也可包含调用圆括号,即,下面的两个表达式都是正确的FE。

1. (function () {})();
2. (function () {}());

执行扩展:函数语句

在下面的例子中,按照规范,我们看到代码中任何一种执行都不会进行:

01. if (true) {
02.  
03. function foo() {
04. alert(0);
05. }
06.  
07. else {
08.  
09. function foo() {
10. alert(1);
11. }
12.  
13. }
14.  
15. foo();// 1 or 0 ? test in different implementations

这里有必要说明的是,按照标准,这种句法结构通常是不正确的,因为我们还记得,一个函数声明(FD)不能出现在代码块中(这里if和else包含代码块)。我们曾经讲过,FD仅出现在两个位置:程序级(Program level)或直接位于其它函数体中。

因为代码块仅包含语句,所以这是不正确的。可以出现在块中的函数的唯一位置是这些语句中的一个——上面已经讨论过的表达式语句。但是,按照定义它不能以大括号开始(既然它有别于代码块)或以一个函数关键字开始(既然它有别于FD)。

但是,在标准的错误处理章节中,它允许程序语法的扩展执行。这样的扩展之一就是我们见到的出现在代码块中的函数。在这个例子中,现今的所有存在的执行都不会抛出异常,都会处理它。但是它们都有自己的方式。

if-else分支语句的出现意味着一个动态的选择。即,从逻辑上来说,它应该是在代码执行阶段动态创建的函数表达式(FE)。但是,大多数执行在进入上下文阶段时简单的创建函数声明(FD),并使用最后声明的函数。即,函数foo将显示”1″,事实上else分支将永远不会执行

但是,SpiderMonkey (和TraceMonkey)以两种方式对待这种情况:一方面它不会将函数作为声明处理(即,函数在代码执行阶段根据条件创建),但另一方面,既然没有括号包围(再次出现解析错误——”与FD有别”),他们不能被调用,所以也不是真正的函数表达式,它储存在变量对象中。

我个人认为这个例子中SpiderMonkey 的行为是正确的,拆分了它自身的函数中间类型——(FE+FD)。这些函数在合适的时间创建,根据条件,也不像FE,倒像一个可以从外部调用的FD(这句话翻译的有些晦涩,请参看原文Such functions are correctly created due the time and according to conditions, but also unlike FE, but like FD are available to a call from the outside)。这种语法扩展SpiderMonkey 称之为函数语句(缩写为FS);该语法在MDC中提及过

命名函数表达式(NFE)的特点

在那个例子中,当FE有一个名称(命名的函数表达式,缩写为NFE)时,将会出现一个重要的特点。从定义(正如我们从上面示例中看到的那样)中我们知道函数表达式不会影响一个上下文的变量对象(那样意味着既不可能通过名称在函数声明之前调用它,也不可能在声明之后调用它)。但是,FE在递归调用中可以通过名称调用自身。

01. (function foo(bar) {
02.  
03. if (bar) {
04. return;
05. }
06.  
07. foo(true);// "foo" name is available
08.  
09. })();
10.  
11. // but from the outside, correctly, is not
12.  
13. foo();// "foo" is not defined

“foo”储存在什么地方?在foo的激活对象中?不是,因为在foo中没有定义任何”foo”。在创建foo上下文的父变量对象中?也不是,因为按照定义——FE不会影响VO(变量对象)——从外部调用foo我们可以实实在在的看到。那么在哪里呢?

以下是关键点。当解释器在代码执行阶段遇到命名的FE时,在FE创建之前,它创建了辅助的特定对象,并添加到当前作用域链的最前端。然后它创建了FE自身,此时(正如我们在第四章 作用域链知道的那样)函数获取了[[Scope]] 属性——创建这个函数上下文的作用域链(i.e. in [[Scope]] there is that special object(这里理解起来有些困惑))。此后,特定对象唯一的属性(FE的名称)被添加;this值是对FE的引用。最后一步是从父作用域链中移除那个特定的对象。让我们在伪码中看看这个算法:

01. specialObject = {};
02.  
03. Scope = specialObject + Scope;
04.  
05. foo = FunctionExpression;
06. foo.[[Scope]] = Scope;
07. specialObject.foo = foo;// {DontDelete}, {ReadOnly}
08.  
09. delete Scope[0];// remove specialObject from the front of scope chain

这样,在函数外部这个名称不可用(因为它不在父作用域链中),但是,特定对象已经存储在函数的[[scope]]中,在那里名称是可用的。

但是需要注意的是一些执行(如Rhino)不是在特定对象中而是在FE的激活对象中存储这个可选的名称。Microsoft 中的执行完全打破了FE规则,它在父变量对象中保持了这个名称,这样函数在外部变得可以访问。

NFE 与SpiderMonkey

说到执行,SpiderMonkey 的一些版本有一个与特定对象相关的属性,它可以作为bug来对待(虽然按照标准所有的都那样执行,但有更多的缺陷(这里翻译的比较晦涩,参看原文:hough all implemented according to the standard so it is more editorial defect of the specification))。它与标识符的解析机制相关:作用域链的分析是二维的,在标识符的解析中,同样考虑到作用域链中每个对象的原型链。

如果我们在Object.prototype中定义一个属性,并引用一个”不存在(nonexistent)”的变量。我们就能看到这种执行机制。这样,在下面示例的”x”解析中,我们将到达全局对象,但是没发现”x”。但是,在SpiderMonkey 中全局对象继承了Object.prototype中的属性,相应地,”x”在哪里被解析。

1. Object.prototype.x = 10;
2.  
3. (function () {
4. alert(x);// 10
5. })();

激活对象没有原型。按照同样的起始条件,在上面的例子中,不可能看到内部函数的这种行为。如果定义一个局部变量”x”,并定义内部函数(FD或匿名的FE),然后再内部函数中引用”x”。那么这个变量将在父函数上下文(即,应该在哪里被解析)中而不是在Object.prototype中被解析。

01. Object.prototype.x = 10;
02.  
03. function foo() {
04.  
05. var x = 20;
06.  
07. // function declaration
08.  
09. function bar() {
10. alert(x);
11. }
12.  
13. bar();// 20, from AO(foo)
14.  
15. // the same with anonymous FE
16.  
17. (function () {
18. alert(x);// 20, also from AO(foo)
19. })();
20.  
21. }
22.  
23. foo();

尽管如此,一些执行会出现例外,它给激活对象设置了一个原型。因此,在Blackberry 的执行中,上面例子中的”x”被解析为”10″。即,既然在Object.prototype中已经找到了foo的值,那么它就不会到达foo的激活对象。

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

在SpiderMonkey 中,同样的情形我们完全可以在命名FE的特定对象中看到。这个特定的对象(按照标准)是普通对象——”就像表达式new Object()“,相应地,它应该从Object.prototype 继承属性,这恰恰是我们在SpiderMonkey (1.7以上的版本)看到的执行。其余的执行(包括新的TraceMonkey)不会为特定的对象设置一个原型。

01. function foo() {
02.  
03. var x = 10;
04.  
05. (function bar() {
06.  
07. alert(x);// 20, but not 10, as don't reach AO(foo)
08.  
09. // "x" is resolved by the chain:
10. // AO(bar) - no -> __specialObject(bar) -> no
11. // __specialObject(bar).[[Prototype]] - yes: 20
12.  
13. })();
14. }
15.  
16. Object.prototype.x = 20;
17.  
18. foo();

NFE与Jscript

当前IE浏览器(直到JScript 5.8 — IE8)中内置的JScript 执行有很多与函数表达式(NFE)相关的bug。这些bug中的每一个完全与ECMA-262-3标准矛盾;有些可能会导致严重的错误。

首先,这个例子中JScript 破坏了FE的主要规则,它不应该通过函数名存储在变量对象中。可选的FE名称应该存储在特定的对象中,并只能在函数自身(没有别的地方)中访问。这里它直接存储在父变量对象中。此外,命名的FE在JScript 中作为函数声明(FD)对待。即创建于进入上下文的阶段,在源代码中的定义之前可以访问。

01. // FE is available in the variable object
02. // via optional name before the
03. // definition like a FD
04. testNFE();
05.  
06. (function testNFE() {
07. alert('testNFE');
08. });
09.  
10. // and also after the definition
11. // like FD; optional name is
12. // in the variable object
13. testNFE();

正如我们所见,它完全违背了规则。

其次,在声明中将命名FE赋给一个变量时,JScript 创建了两个不同的函数对象。逻辑上(特别注意的是在NFE的外部它的名称根本不应该被访问)很难命名这种行为。

01. var foo = function bar() {
02. alert('foo');
03. };
04.  
05. alert(typeof bar);// "function", NFE again in the VO – already mistake
06.  
07. // but, further is more interesting
08. alert(foo === bar);// false!
09.  
10. foo.x = 10;
11. alert(bar.x);// undefined
12.  
13. // but both function make
14. // the same action
15.  
16. foo();// "foo"
17. bar();// "foo"

我们再次看到乱成一片。

但是,需要注意的是,如果与变量赋值分开,单独描述NFE(如通过组运算符),然后将它赋给一个变量,并检查其相等性,结果为true,就好像是一个对象。

1. (function bar() {});
2.  
3. var foo = bar;
4.  
5. alert(foo === bar);// true
6.  
7. foo.x = 10;
8. alert(bar.x);// 10

此时是可以解释的。实际上,再次创建两个对象,但那样做事实上仍保持一个。如果我们再次认为这里的NFE被作为FD对待,然后在进入上下文阶段创建FD bar。此后,在代码执行阶段第二个对象——函数表达式(FE)bar 被创建,它不会被存储。相应地,没有FE bar的任何引用,它被移除了。这样就只有一个对象——FD bar,对它的引用赋给了变量foo。

第三,就通过arguments.callee间接引用一个函数而言,它引用的是被激活的那个对象的名称(确切的说——有两个对象(翻译的很晦涩,参看原文:to be exact — functions since there are two objects))。

01. var foo = function bar() {
02.  
03. alert([
04. arguments.callee === foo,
05. arguments.callee === bar
06. ]);
07.  
08. };
09.  
10. foo();// [true, false]
11. bar();// [false, true]

第四,JScript 像普通的FD对待NFE,他不服从条件表达式规则。即,就像一个FD,NFE在进入上下文时创建,在代码中最后的定义被使用。

01. var foo = function bar() {
02. alert(1);
03. };
04.  
05. if (false) {
06.  
07. foo = function bar() {
08. alert(2);
09. };
10.  
11. }
12. bar();// 2
13. foo();// 1

这种行为从”逻辑上”也可以解释。在进入上下文阶段,最后遇到的FD bar被创建,即包含alert(2)的函数。此后,在代码执行阶段,新的函数——FE bar创建,对它的引用赋给了变量foo。这样foo激活产生alert(1)。逻辑很清楚,但考虑到IE的bug,既然执行明显被破坏,并依赖于JScript 的bug,我给单词”逻辑上(logically)”加上了引号。

JScript 的第五个bug与全局对象的属性创建相关,全局对象由赋值给一个未限定的标识符(即,没有var关键字)来生成。既然NFE在这被作为FD对待,相应地,它存储在变量对象中,赋给一个未限定的标识符(即不是赋给变量而是全局对象的普通属性),万一函数的名称与未限定的标识符相同,这样该属性就不是全局的了。

01. (function () {
02.  
03. // without var not a variable in the local
04. // context, but a property of global object
05.  
06. foo = function foo() {};
07.  
08. })();
09.  
10. // however from the outside of
11. // anonymous function, name foo
12. // is not available
13.  
14. alert(typeof foo);// undefined

同样,”逻辑”很清楚:在进入上下文阶段,函数声明foo取得了匿名函数局部上下文的激活对象。在代码执行阶段,名称foo在AO中已经存在,即,它被作为局部变量。相应地,在赋值操作中,只是简单的更新已存在于AO中的属性foo,而不是按照ECMA-262-3的逻辑创建全局对象的新属性

通过函数构造器创建的函数

既然这种函数对象也有自己的特色,我们将它与FD和FE区分开来。其主要特点在于这种函数的[[Scope]]属性仅包含全局对象:

01. var x = 10;
02.  
03. function foo() {
04.  
05. var x = 20;
06. var y = 30;
07.  
08. var bar = new Function('alert(x); alert(y);');
09.  
10. bar();// 10, "y" is not defined
11.  
12. }

我们看到,函数bar的[[Scope]]属性不包含foo上下文的Ao——变量”y”不能访问,变量”x”从全局对象中取得。顺便提醒一句,Function构造器既可使用new 关键字,也可以没有,这样说来,这些变体是等价的。

这些函数的其他特点与Equated Grammar Productions 和Joined Objects相关。作为优化建议(但是,执行有权不使用优化),规范提供了这些机制。如,如果我们有一个100个元素的数组,在函数的一个循环中,执行可能使用Joined Objects 机制。结果是数组中的所有元素仅一个函数对象可以使用

1. var a = [];
2.  
3. for (var k = 0; k < 100; k++) {
4. a[k] = function () {};// possibly, joined objects are used
5. }

但是通过函数构造器创建的函数不会被连接。

1. var a = [];
2.  
3. for (var k = 0; k < 100; k++) {
4. a[k] = Function('');// always 100 different funcitons
5. }

另外一个与联合对象(joined objects)相关的例子:

01. function foo() {
02.  
03. function bar(z) {
04. return z * z;
05. }
06.  
07. return bar;
08. }
09.  
10. var x = foo();
11. var y = foo();

这里执行也有权连接对象x和对象y(使用同一个对象),因为函数(包括它们的内部[[Scope]] 属性)在根本上是没有区别的。因此,通过函数构造器创建的函数总是需要更多的内存资源。

创建函数的算法

下面的伪码描述了函数创建的算法(联合对象的步骤除外(except steps with joined objects),这里有待进一步理解??)。这些描述有助于你理解ECMAScript中函数对象的更多细节。这种算法适合所有的函数类型。

01. F = new NativeObject();
02.  
03. // property [[Class]] is "Function"
04. F.[[Class]] = "Function"
05.  
06. // a prototype of a function object
07. F.[[Prototype]] = Function.prototype
08.  
09. // reference to function itself
10. // [[Call]] is activated by call expression F()
11. // and creates a new execution context
12. F.[[Call]] =
13.  
14. // built in general constructor of objects
15. // [[Construct]] is activated via "new" keyword
16. // and exactly it allocates memory for new
17. // objects; then it calls F.[[Call]]
18. // to initialize created objects passing as
19. // this value newly created object
20. F.[[Construct]] = internalConstructor
21.  
22. // scope chain of the current context
23. // i.e. context which creates function F
24. F.[[Scope]] = activeContext.Scope
25. // if this functions is created
26. // via new Function(...), then
27. F.[[Scope]] = globalContext.Scope
28.  
29. // quantity of formal parameters
30. F.length = countParameters
31.  
32. // a prototype of created by F objects
33. __objectPrototype = new Object();
34. __objectPrototype.constructor = F// {DontEnum}, is not enumerable in loops
35. F.prototype = __objectPrototype
36.  
37. return F

注意,F.[[Prototype]]是函数(构造器)的一个原型,F.prototype是通过这个函数创建的对象的原型(因为术语常常混乱,一些文章中F.prototype被称之为“构造器的原型”,这是不正确的)。

结论

这篇文章有些长。但是,当我们在接下来关于对象和原型章节中作为构造器它们的工作原理时,我们将再次提及函数。同样,我很乐意在评论中回答您的任何问题。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值