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

介绍

本章节我们要着重介绍的是一个非常常见的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 ProductionsJoined 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被称之为“构造器的原型”,这是不正确的)。

结论

这篇文章有些长。但是,当我们在接下来关于对象和原型章节中将继续讨论函数,同样,我很乐意在评论中回答您的任何问题。

其它参考

同步与推荐

本文已同步至目录索引:深入理解JavaScript系列

深入理解JavaScript系列文章,包括了原创,翻译,转载等各类型的文章,如果对你有用,请推荐支持一把,给大叔写作的动力。

点赞