从来没有深入了解ECMA,网上找了一下,发现早在2010年就有大佬 Dmitry Soshnikov 总结了ECMA中的核心内容,我这里只是翻译记录,加深自己的印象。文章原文来自ECMA-262-3 in detail. Chapter 5. Functions.
介绍
在这篇文章中,我们将讨论一个通用的ECMAScript对象 — 函数。特别是,我们将介绍各种类型的函数,将定义每种类型是如何影响上下文变量对象以及每个函数的作用域链中都包含了什么。我们将会回答那些被频繁问到的问题,比如:”下面创建函数的方式有什么不同(如果有,是什么不同呢)?”
变量的形式定义函数:
1 | var foo = function() {...}; |
“习惯性”的方式定义的函数:
1 | function foo() {...}; |
或者,“为什么在下面的函数中,函数必须要用括号括起来?”
1 | (function foo(){...})(); |
由于这些文章是在更早的章节中进行的,因此,为了全面的理解此部分,有必要阅读第二章:变量对象以及第四章:作用域链,因为我们将频繁的时候这些章节的术语。
让我们一个一个来(了解)。首先来了解一下函数类型。
函数类型
在ECMAScript中,有三种函数类型并且每一种都有自己的特点。
函数声明
一个函数声明(简写为FD)特点有:
- 一个不可或缺的名字;
- 在源码中它位于:无论是进程级别的代码或者是直接在另一个函数的函数体中(FunctionBody);
- 在进入上下文阶段被创建;
- 影响变量对象;
- 使用下面的方式声明
1 | function exampleFunc() { |
这种类型的函数的主要特点就是只有他们影响变量对象(他们存储在上下文的VO中)。这个特点决定了第二个重点(这是变量对象性质的结果) — 在代码执行阶段,他们已经可以获得(从在进入上下文阶段FD存储在VO中的时候 — 在执行代码之前)。
例子(在源代码中的位置里,函数是在声明之前被调用的):
1 | foo(); |
同样重要的是函数在源码中定义的位置(请看上面函数声明定义特点的第二点):
1 | // 函数声明可以在 |
这是代码中函数声明的仅有的两个位置(即,在一个表达式或者是一个代码块中声明是不可以的)。
这里有一种函数声明的替换形式,叫做函数表达式,我们接下来要讲到的。
函数表达式
一个函数表达式(缩写形式为FF),有:
- 在源码中只能在表达式位置(译者注: =)定义;
- 可以拥有一个可选的名字;
- 声明对变量对象没有影响;
- 在代码执行阶段被创建。
这种类型的函数的主要特点是在源码中,他们总是在表达式位置出现。这是一个简单的例子,一个赋值表达式:
1 | var foo = function () { |
这个例子展示了一个匿名函数是如何分配给了 foo
变量。此后,这个函数可以通过 foo
名字访问 — foo()
。
定义指出这种类型的函数可以拥有一个可选的名字:
1 | var foo = function _foo() { |
这里要注意的重点是通过变量 foo
从外侧访问FE是可以访问的 — foo()
,当从函数内部(例如,函数递归调用),使用 _foo
名字也是可以的。
当一个FE被分配一个名字,那和FD就很难区分了。然而,如果你知道(他们的)定义,要说出它们的不同也很容易:FE总是在表达式位置。下面的例子中,我们可以看到各种类型的ECMAScript表达式,这里所有的函数都是FE:
1 | // 括号中(分组运算符),只能是一个表达式 |
定义也指出了FE是在函数执行阶段创建,并且没有保存在变量对象中。我们来看看这种表现的例子:
1 | // FE是不可访问的,无论是在定义前,因为代码执行阶段才创建 |
那现在的逻辑问题是,为什么我们需要这种类型的函数?答案显而易见 — 在表达式中使用他们并“不污染”全局变量。这可以通过将一个函数作为参数传递给另一个函数来证明:
1 | function foo(callback) { |
例子中,FE被分配给一个变量,这个函数保存在内存中,并且可以通过变量名在以后访问(因为我们知道,不变量影响VO):
1 | var foo = function () { |
另一个例子是创建密封的作用域,以从额外上下文隐藏辅助帮助数据(下面的例子中,我们使用FE,在创建后直接调用):
1 | var foo = {}; |
我们看到函数 [foo.bar](http://foo.bar)
(通过它的 [[Scope]]
属性)能够访问函数 initialize
的内部属性。同时, x
不能直接在外部访问。这种策略被用于创建“私有”库,并且隐藏辅助实体。这种模式下,通常会省略初始化FE的名称:
1 | (function (){ |
另一个例子,运行时候创建的FE不会污染VO:
1 | var foo = 10; |
注意:ES5标准化了绑定函数。这种类型的函数正确绑定了 this 值,使其在任何地方调用都被锁定。
1 | var boundFn = function () { |
“关于括号包裹”的问题
我们回到文章开始,来回答这个问题 — “为什么如果我们想要直接从函数定义中调用函数就必须要用括号括起来”。问题的答案是:表达式语句的限制。
根据标准,表达式语句不能以花括号开头 — {
因为他可能和块(Block)无法区分,同时表达式语句也不能以 function
开头,因为这可能和函数声明无法区分。即,如果我们用下面的方式定义立即执行函数(以 function
关键字开头):
1 | function () { |
我们处理的是函数声明,在这两种情况下,解析器都会产生错误解析。但是,这些解析错误的原因各不相同。
如果我们将这样一个定义放在全局代码(进程级别),解释器会把这个函数当作声明来对待,因为它是以 function
关键字开头。在第一个例子中我们得到 SyntacError
因为缺少函数名称(我们说一个函数声明必须有一个名字)。
在第二个例子中,我们确实有一个名字了,并且函数声明应该会被正常的创建。但是没有,因为我们会遇到另一个错误 — 分组运算(这里就是指()
)里面没有表达式。注意,在这个例子中,它确实是遵循函数声明(规则)的分组运算,但是不是函数调用的括号。所以如果我们有以下代码:
1 | // foo 是一个函数,并且在进入上下文阶段被创建 |
一切正常,因为我们这里有两种句法形式 — 一个函数声明和一个包含表达式( 1
)的分组运算。上面的例子等同于:
1 | // 函数声明 |
如果在语句中有这样的定义,那正如我们所说,因为有歧义,所以我们会得到一个语法错误。
1 | if (true) function foo() {console.log(1)}; |
规范上上面的构建语法是错误的(表达式语句是不能以 function
关键字开头的),但是正如我们接下来看到的,所有语法都没有提供语法错误,但是可以以自己的方式处理错误。
有了这一切,我们怎么告诉解释器我们真正需要的是在创建一个函数后立即调用?答案显而易见。他应该是一个函数表达式而不是一个函数声明。而创建一个表达式最简单的方法就是上面提到的分组运算。这里面的总是一个表达式。因此,解释器区分出代码是函数表达式(FE),并且没有歧义。这样的函数将会在执行阶段被创建,然后执行,然后移除(如果没有对他的引用了)。
1 | (function foo(x) { |
上面的例子中,最后的括号(自变量产生)已经是函数调用,而不是FD情况下的分组运算。
注意。在下面例子中的立即执行函数,包裹的括号是不需要的,因为函数已经在表达式位置并且解释器知道他处的是一个在代码执行阶段创建的FE:
1 | var foo = { |
我们看到, foo.bar
是一个字符串而不是乍一看之下的一个函数。这里的函数只是被用于初始化属性 — 取决于条件参数 — 此后创建并立即调用它。
因此,问题“关于括号”的完整答案如下:
- 当函数没有在表达式位置并且我们想要在创建以后立即调用它就需要一个括号组(grouping parentheses) — 这种情况我们只是手动的把函数转成FE。
- 但解释器知道他是处理一个FE的情况下,比如函数已经在表达式位置 — 括号是不需要的。
除了括号以外,使用任何其他方法将函数转换成FE也是可以的。例如:
1 | 1, function () { |
但是,括号组才是做这种操作的最普遍和高雅的方法。
顺便提一下,分组运算不仅可以包裹没有调用括号的函数,也可以包裹有调用括号的形式。下面两种表达式都是正确的FE:
1 | (function () {})(); |
实现扩展:函数声明
下面例子展示了一段代码,其中没有一个实现是可以按照规范进行处理的:
1 | if (true) { |
有必要吐槽一下,根据标准这个语法是不正确的,因为正如我们记得的,函数声明(FD)不能在一个代码块中(这里 if
和 else
包含代码块)。就像之前说的,FD只能出现在两个地方:在进程级别或者是直接在另一个函数的函数体中。
上面的例子是错误的,因为代码块只能包含语句。函数能出现在块中的唯一一个地方是这一种语句 — 表达式语句。但是通过定义,他是不能以花括号(因为他与代码块没有区别)或者是 function
关键字(因为他与FD没有区别)开头。
然而在错误处理部分标准是允许对程序语法进行扩展。在函数出现在块中的情况下可以看到这样的扩展之一。今天所有的实现在这种情况下都不会抛出异常并且会处理它。但是每一个都是他们自己的方式。
if-else
分支的存在假定了正在做选择,将定义这两根函数中的哪一个。由于此决定是在运行时候做出的,这就意味着应该使用函数表达式(FE)。但是大多数实现将会在进入上下阶段简单的创建这两个函数声明(FD),但是因为两个函数有相同的名字,所以只有最后一个函数声明会被调用。在这个例子中函数 foo
显示 1
,尽管 else
语句从没有执行。
但是,SpiderMonkey 实现了两种方法对待这种情况:一种处理方法是他不认为这是一个函数声明(即:函数是在代码执行阶段创建的),但是另一方面,他们不是真实的函数表达式因为不能在没有括号的情况下调用他们(再次是解析错误,“与FD毫无区别”),并他们会存储在变量对象中。
命名函数表达式(NFE)的特点
出现FE有一个名字的情况(命名的函数表达式,简写为NFE),一个重要的特点出现了。正如我们从定义中知道的(上面的🌰)函数表达式不会影响上下文中的变量对象(这就意味着在定义之前或者之后通过名字调用他们是不可能了)。但是,FE可以在递归中调用他自己。
1 | (function foo(bar) { |
‘foo’是存放在哪里的呢? foo
是在活动对象里面?不,因为没有任何 “foo” 名字(这里的名字可以理解为变量)定义在 foo
函数中。父级上下文的变量对象创建的 foo
?也不是,始终牢记这个定义 — FE不影响VO — 正如我们在外面调用 foo
看到的那样。那么是哪里呢?
这里是它工作的原理:当解释器在代码执行阶段遇到命名FE的时候,在创建FE之前,会创建辅助的特殊对象并且添加在当前作用域的前端。然后在函数获得 [[Scope]]
属性阶段创建FE自身(从第四章.作用域链中可以知道)— 创建函数上下文的作用域链。此后,命名FE作用一个独特的属性添加都这个特殊的对象上面;这个属性的值就是对这个FE的引用。最后的操作是从父级作用域链移除译者特殊的对象。让我们在伪代码中看一下这个运算:
1 | specialObject = {}; |
因此,从外面这个函数名是不可获得的(因为现在没有在父级作用域中了),但是特殊对象已经被存放在了函数的 [[Scope]]
中了,并且这里的这个名字是可以访问的。
但是,有必要注意的是,在一些实现中,比如Rhino,把可选名字保存在FE的活动对象中而不是保存在特殊对象中。Microsoft — JScript的实现,完全是打破了FE的规则,把这个名字保存在父级变量对象上面,导致函数在外面也是可以访问的。(译者注:现在开发虽然基本上不会考虑IE浏览器了,但是,通过这里,知道了为什么IE有这么些问题,以至于IE会被逐渐淘汰,不遵守游戏规则,只有出局)。
NFE与SpiderMonkey
我们来看看不同的实现是怎样处理这个问题的。一些版本的SpiderMonkey有与特殊对象相似的特点,那就是会被当成一个bug对待(尽管所有的都是根据标准实现的,所有这更多的是规范的编码缺陷)。这与标识符解析机制有些相似:作用域链是二维的,当处理一个标识符时,它还会考虑作用域链中每一个对象的原型链。
我们可以用实际来看看这个机制。如果我们在 Object.prototype
中定义一个属性并且使用代码中“不存在”的变量。在下面的例子中,当解析名称 x
时,对全局对象也未找到 x
。然而因为在SpiderMonkey中全局对象继承自 Object.prototype
,名称 x
在这里被解析了:
1 | Object.prototype.x = 10; |
活动对象没有属性。在相同的条件下,在具有内部函数的例子中可能看到相同的表现。如果我们定义一个局部变量 x
并且声明内部函数(FD或者匿名的FE),然后在内部函数中引用这个 x
,这个变量将会在父级函数上下文中正常解析,从而替代 Object.prototype
上的 x
:
1 | Object.prototype.x = 10; |
一些实现中给活动对象(AO)添加了一个属性,相对于大多数的其他实现都比较例外。在Blackberry的实现中,上面例子中 x
的值被解析为 10
。因为在 Object.prototype
中找到了值,所有没有到达 foo
的AO中 。
1 | AO(bar FD or anonymous FE) -> no -> |
而且,在命名为FE的特殊对象的情况下,我们可以在比较旧的SpiderMonkey版本(ES5之前)中看到完全相同的情况。这个特殊对视(根据标准)是一个平常的对象 — ”就像是通过表达式 new Object()
创建的“,那么相应的他应该继承于 Object.prototype
,这就是在SpiderMonkey实现中(但是也只是到1.7版本)我们可以确切看到的。其他的实现(包括新版本的SpiderMonkey)都没有给这个特殊对象添加属性了:
1 | function foo() { |
注意,在SE5以及以后的版本中,这种表现都改变了
NFE 与 JScript
来自Microsoft的ECMAScript的实现 — JScript,内嵌在IE浏览器中(IE8更新到了5.8版本),对NFE有用大量的bug。每一个bug都完完全全的违背了ECMA-262-3的标准;他们当中的一些还可能会造成严重的错误。
附上维基百科中的版本:
JScript
首先,这种情况下,JScript打破FE最主要的规则,即他们不应该按照名称被存储在变量对象中。一个可选FE名称应该被存放在特殊对象并且只能被内部函数自己访问(不管是内部哪里),但是这里是直接保存在了父级的变量对象上。而且,命名FE在JScript中被按照FD对待,即,是在进入上下文阶段被创建并且在源码中可以在定义之前访问:
1 | // FE is available in the variable object |
我们看到,完全违背了规则。(译者注:如果你还是用win7,可以尝试一下IE8以下版本的浏览器)。
其次,如果在声明时将命名的FE分配给变量,JScript会创建两个不同的函数对象。很难将这种行为按逻辑来命名(特别eui考虑到NFE以外完全不可能使用其名称):
1 | var foo = function bar() { |
再一次,完全混乱了。
但是,有必要注意,如果要与分配变量分开描述NFE(例如通过分组运算),然后再将其分配给变量,则检查相等性将返回 true
,就像是一个对象:
1 | (function bar() {}); |
这时候就可以解释了。实际上,再次创建了两个对象,但此后实际上只剩下了一个。如果再次把这里的NFE当成FD对待,那么在进入上下文阶段 FD bar 就该被创建。此后,在代码执行阶段,第二个对象 — FE bar 被创建,且没有保存在任何地方 。确切的说,没有任何对 bar
的引用,他已经被移除了。因此,只有一个对象 — FD bar
,被分配 foo
变量的引用。
第三,关于通过 arguments.callee
间接引用函数的情况,它引用了已激活了函数名称的对象(确切的说,是函数,因为这里有两个对象):
1 | var foo = function bar() { |
第四,由于JScript将NFE视为常规的FD,因此不会提交给条件运算规则(检测),就像FD一样,NFE在进入上下文被创建并且使用代码中的最后一个定义:
1 | var foo = function bar() { |
这种行为也可以被”逻辑的“解释。在进入上下文阶段,最后一次遇到的FD bar 被创建,即函数体是 console.log(2)
的函数。此后,代码执行阶段,新的函数 FE bar
被创建,其引用被分配给 foo
变量。因此(在代码中,条件值为 false
的 if 块不可访问), foo
激活提供 console.log(1)
。逻辑很明确,但是考虑到IE的bug,我使用了”逻辑上“一词,因为这样的实现显示被破坏并且取决于JScript的bugs。
在JScript中的第五个NFE的bug与通过将值分配给不合规的标识符来创建的对象的属性有关(例如没有使用 var
关键字)。因为NFE在这里被当作FD对待,同时相应的,存储在了全局对象上面,分配个不合规的标识符(例如不是变量而是全局对象的普通属性),例子中,当函数名与不合规的标识符名字相同的时候,这个属性就不会成为全局属性。
1 | (function () { |
再一次,“逻辑”很明确:函数声明foo在进入上下文阶段时到达匿名函数的局部上下文的活动对象。并且在代码执行阶段的那一刻,名称foo已经存在于AO中,即被视为局部名称。因此,在分配操作中,仅存在AO属性foo中已经存在的更新,而不是根据ECMA-262-3的逻辑创建全局对象的新属性。
通过Function构造器创建的函数
这种类型的函数对象与FD和FE分开讨论,因为它具有自己的特点。主要的特点就是这类函数的 [[Scope]]
属性只包含全局对象:
1 | var x = 10; |
我们看到bar函数的[[Scope]]
没有包含foo上下文的AO — 变量“y“不可访问而且变量”x“是从全局上下文拿到的。顺便一提,注意了,Function构造器可以带有new关键字使用也可以没有它,在这个例子的情况下,两种是等效的。
这类函数的另一个特点与”等价语法生产(Equated Grammar Productions)“和”链接对象(Joined Objects)“有关。为了进行优化,这种机制被规范作为简体提了出来(但是,具体的实现商有权不使用这种优化)。例如,我们有一个有100个元素的数组,并且在循环的时候用函数填充,那么实现就可以使用链接对象的机制。结果,数组中的所有元素只有一个函数对象可以使用:
1 | var a = []; |
但是通过Function构造器创建的函数永远不会Joined:
1 | var a = []; |
另一个与 joined object 有关的例子:
1 | function foo() { |
这里实现也有权连接对象x和y(并使用一个对象),因为函数物理特点(包括它们的内部[[Scope]]属性)是不可区分的。因此,通过Function构造函数创建的函数始终需要更多的内存资源。
函数创建算法
函数创建算法的伪代码(joined object的步骤除外)像下面这样描述。这个描述帮助我们更详细的了解ECMAScript中存在哪些函数对象。这种算法对所有的函数类型都是相同的。
1 | F = new NativeObject(); |
注意,*F.[[Prototype]]**是一个函数的原型而F.prototype***是此函数创建的对象的原型。