代码改变世界

解读ECMAScript[2]——函数、构造器及原型

2011-01-12 21:37 by T2噬菌体, ... 阅读, ... 评论, 收藏, 编辑

摘要

上一篇文章简要解读了ECMAScript中关于执行环境、作用域和闭包的基本概念。这一篇文章将在上一篇文章的基础上,重点讨论ECMAScript中的函数(function),以及与其相关的构造器(Constructor)和原型(Prototype)。如不做特殊说明,本文小写开头的“function”指“函数”,而大写开头的“Function”特指ECMAScript中的内置“Function”对象,请注意辨析。

很不一样的“function”

我想每一位朋友对编程语言中的“函数”一词都不会陌生,在典型的面向对象语言中(如Java、C#等),函数也被叫做方法(Method),它往往是作为类(Class)或者说对象(Object)的一部分而存在,用于完成一个独立的任务。可以这么说,在C#中“function is a part of Object”,也就是说C#在语义上将function与Object定义为从属关系,对于C#或Java程序员来说,这种想法已经深入骨髓了。但是我想告诉诸位,如果你是在学习或编写JavaScript,请彻底摒弃这种思想观念,否则将很难真正理解JavaScript中的“函数”,也会觉得JavaScript中很多代码很费解。例如下面一段代码:

function FuncA(){
    return new function(){
        alert('Is it strange?');
    }
}

var f1 = FuncA;
var f2 = f1();
f2(); //Is it strange?

如果用C#的语义概念去看,这段代码简直太匪夷所思了,但是JavaScript中这段代码完全合法。

下面文章会具体解读ECMAScript中关于function的奇特定义。

function IS A KIND OF Object

在继续之前,读者朋友可以试试如下代码:

function Func(){
    return 0;
}
alert(Func instanceof Object);

如果您的浏览器没有抽风的话,上面的代码应该显示“true”。这也就是说,JavaScript中的函数实际上是一种特殊类型的对象。

在ECMAScript中声明或创建一个函数实际等同于创建了一个Object,而函数名只是指向这个函数对象的一个指针变量。

这也就能理解为什么在JavaScript可以直接返回一个函数或者将函数名赋给变量了,因为函数本身是一个对象,而函数名本身就是一个变量。在ECMAScript中,对函数变量的正式名称是“Function Object”。

之所以很难意识到JavaScript中的函数实际是对象,是因为语法层面的封装。例如,我们已经习惯了这样创建函数:

function add(a, b){
    return a + b;
}

这种语法叫做函数声明(Function Declaration),它模拟了面向对象语言中的函数定义方式,让我们很自然将其想成一个“常规”的函数。但实际上,JavaScript中有与上面代码等效的代码:

var add = new Function('a', 'b', 'return a + b');

这段代码与上一段代码基本等效,但是很少有人这样用。其实可以将这种定义看成是ECMAScript的“原生”定义函数的方式,而我们常用的写法更像一种语法糖,可以让我们用熟悉的方式定义函数而不是在编码的时候照顾ECMAScript的“特有个性”。从这种定义方式可以很明显看出,函数在ECMAScript中是一个Function对象,并且函数名只不过是这个对象的一个指针变量,理解这点对理解ECMAScript中函数的运作方式尤为重要。Function是一个内置(Build-in)对象,用于构造函数对象,下面本文将解读这个Function内置对象。

另外要明确一点,Function Object是一种特殊的Object,是Object的一个真子集,也就是说函数一定是对象,而对象不一定是函数。

函数的创建

ECMA-262中定义了三种创建函数的方式,分别是原生Function对象构造、函数声明和函数表达式,但三者存在等价对应关系。

函数声明的基本框架为

function 函数名称(参数列表){
    //执行代码体
}

其对应的函数表达式框架为

var 函数名称 = function(参数列表){
    //执行代码体
};

而这两者在ECMAScript内部的原生创建方式对应代码如下:

var 函数名称 = new Function('参数列表', '执行代码体');

在EMCA-262的正式定义方式为:

new Function(p1, p2, ..., pn, body);

所以在ECMAScript标准中,只定义了原生创建方式的构造流程。ECMA-262中对Function建立函数对象的过程描述比较繁琐,在本文中我将其简要总结如下:

1、如果没有传递给Function参数,则构造一个参数列表和执行代码体均为空的函数对象。

2、如果只传递了一个参数,则构造一个参数列表为空的函数对象,其执行代码体为其唯一传递过来的参数。

3、如果传递了一个以上的参数,最后一个解析为执行代码体,剩下的参数解析为参数列表。或者说“p1 – pn”解析成参数列表,而“body”解析成代码体。如果pi中存在字符“,”则将其解析为由“,”分割的多个参数。

4、以上过程中如果解析参数列表或代码体失败,抛出SyntaxError异常,否则以解析出的参数列表和代码体建立一个函数对象。

5、将执行创建此函数对象的执行环境的作用域链传入,作为此函数对象的Scope属性。

6、返回此函数对象的指针。

函数的两种调用方式

传统的面向对象语言中,function只有一种调用方式,就是作为函数调用(Invoked as a function),这是因为面向对象语言中有类(Class)的概念和语言元素。用过JavaScript的都知道,JavaScript中是没有class关键字的(ECMA-262第五版中增加了class,但本系列文章讨论的是第三版标准),为了能模拟“类”,ECMAScript引入了“作为构造器调用(Invoked as a constructor)”的函数调用方式,同时仍保留了“作为函数调用”的方式。这样ECMAScript中就有两种函数调用方式,而通过两种方式调用同一个函数,目的和效果都存在本质的区别。看下面一段代码:

function MyFunction(){
    return 0;
}

var invokedAsFunction = MyFunction();
var invokedAsConstructor = new MyFunction();

alert(typeof invokedAsFunction); //number
alert(invokedAsFunction); //0
alert(typeof invokedAsConstructor); //object

这段代码显示了同一个函数当做函数和构造器两种不同方式调用的区别。

当直接使用函数名调用函数时,是将函数作为函数调用,它的效果和常规意义上的函数一样,执行一段代码并返回结果。

当在函数名前加上“new”关键字调用时,是作为构造器调用,它的效果类似面向对象语言中类的构造函数,返回一个对象。

作为函数调用没有太多好说的,下面着重介绍作为构造器调用函数,因为这和JavaScript中的面向对象编程有很大联系。

ECMAScript中的构造器(Constructor)

我们知道JavaScript中是没有类的,如果想实现类似于C#中通过类构造对象的效果,一种方法就是上文提到的“Invoked as a constructor”。看下面一段代码:

function University(name, loca){
    this._name = name;
    this._loca = loca;
    this.showInfo = function(){
        alert(this._name + '是一所' + this._loca + '的大学');
    }
}

var u1 = new University('烟台大学', '山东');
var u2 = new University('北京航空航天大学', '北京');

u1.showInfo(); //烟台大学是一所山东的大学
u2.showInfo(); //北京航空航天大学是一所北京的大学

这里我们使用new关键字后,University所做的工作就像一个构造函数一样,生成了两个University对象。其实在上一节我们创建新的函数时就是以构造器方式调用内置对象Function。

但是要记得,构造函数和函数本身没有任何区别,决定一个函数是作为构造函数运行还是普通函数运行的是调用方式上。所以,下面的代码也完全合法:

var u = University('清华大学', '北京');

但是此时University被当做一个普通函数调用,那么会产生什么效果呢?因为University本身没有返回值,所以u的值会是“undefined”,而此时是在全局作用域中执行University,“this”指向的是全局变量window,所以其结果是此语句为全局变量window添加了两个变量成员“_name”和“_loca”,值分别为“清华大学”和“北京”。

一般来说我们很少将一个构造器函数同时用于普通函数,所以一种良好的编程理念就是应该保证如果某个函数的目的是作为构造器,那么即使被误当做函数调用,即忘了加new关键字时,它的工作方式仍然应该是构造器。例如,内置的Function即使你没有使用new,其工作方式仍然是构造器而不是普通函数。这通常需要使用一点小技巧,一种简单的方式是使用寄生构造模式,例如可以将University做如下修改:

function University(name, loca){
    var o = new Object();
    o._name = name;
    o._loca = loca;
    o.showInfo = function(){
        alert(this._name + '是一所' + this._loca + '的大学');
    }
    return o;
}

var u = University('烟台大学', '山东');
u.showInfo(); //烟台大学是一所山东的大学 

像这样显式创建一个对象并返回,不论在调用University时是否使用new,都会按照构造器方式进行。但是这种方式只适用于构造器模式创建对象,而不适用于下面要说的原型模式。

原型(Prototype)

函数与prototype

使用上文提到的构造器方式创建对象会存在一些问题,其中最为突出的一点就是所有成员(包括属性和方法)在每个对象中都存在一份拷贝,例如上面的代码创建了u1和u2两个University对象,其中每个对象都有一份_name、_loca和showInfo,可以这样验证:

alert(u1.showInfo === u2.showInfo); //false

如果说每个对象有自己的属性这点还说得过去,那么每个对象都保持一份方法的对象拷贝就实在不合理了,因为一般来说方法是共用的,每个对象保持一份拷贝浪费存储资源,也无法实现继承。所以ECMAScript中给出了原型对象(prototype)的定义。ECMA-262中对prototype的定义是:

A prototype property is automatically created for every function, to provide for the possibility that the function will be used as a constructor.

这句话只能看出prototype是每个函数都会有的一个成员,并且会在此函数被当做构造器调用时发挥作用。下面是我对原型的总结:

当一个函数对象F被创建时,会自动为其创建一个叫“prototype”的对象成员,叫做F的原型。当类似于“var o = new F(参数列表)”的调用发生时,F会按照构造器模式创建o,并为o自动创建一个属性“__proto__”,此属性指向F的prototype。

例如执行下列代码:

function F(){
}

var o1 = new F();
var o2 = new F();

F、o1和o2的关系如下图所示。

image

原型链与ECMAScript的成员查找算法

说到这里,可能还看不出来原型的作用是什么。实际上,原型的作用之一就是为程序员提供一种定义公共成员的途径。上文说过,我们遇到的问题之一是University的不同对象保有不同的方法,使用原型,University可重新编写如下:

function University(name, loca){
    this._name = name;
    this._loca = loca;
}

University.prototype.showInfo = function(){
        alert(this._name + '是一所' + this._loca + '的大学');
}

var u1 = new University('烟台大学', '山东');
var u2 = new University('北京航空航天大学', '北京');

u1.showInfo(); //烟台大学是一所山东的大学
u2.showInfo(); //北京航空航天大学是一所北京的大学
alert(u1.showInfo === u2.showInfo); //true

这里将方法成员showInfo放到了University的prototype上,实现了相同的效果并避免了上述问题。但是这里还有一个疑问:showInfo定义在University.prototype上,o1和o2是如何找到的?这就涉及到ECMAScript的成员查找算法(注意这里是成员查找,一定要和上一篇文章中变量查找区分开来,变量查找基于作用域链,而成员查找基于原型链)。

在ECMAScript中,当查找一个对象的某个成员时,首先在对象上查找,如果找不到则到其“__proto__”指向的原型对象上找,再找不到则到原型对象的“__proto__”指向的对象也就是原型的原型上找,直到找到;如果某个原型的“__proto__”为null并且此时还没有找到合适成员,则返回“undefined”。

例如上面的代码中,各个对象的关系如下图所示:

image

上图中红色部分组成了一个叫原型链(prototype chain)的结构,其中对象u1和u2的原型为University.prototype,但原型对象本身也是普通对象,没上面特别的,默认原型对象的原型就是内置对象Object了,Object的__proto__为null,并定义了一系列公用方法,如大家熟悉的toString。明白了原型链的概念,再结合上述成员查找算法,应该就很容易明白为什么JavaScript中几乎每个对象都有toString方法,因为Object处于原型链顶端,所以按照算法最后一定可以访问到toString。再如上面的showInfo,因为定义在了University.prototype上,所以按照算法u1和u2向上查一层就可找到了。

虽然大多数时候原型对象都是在创建函数时自动生成的,但是我们也可以用自定义对象将其替换掉。要知道,在JavaScript中没有什么是神圣的!你可以做任何想做的事。实际上,替换默认原型对象是很有用的。从上面的描述可以看出,如果使用自动原型对象,原型链最多只能有两级,而通过手工构造原型链,就可以实现任意长度的原型链,从而模拟面向对象中的继承机制。例如:

function Computer(){
}

Computer.prototype.powerOn = function(){
    alert('Power on!');
}

Computer.prototype.powerOff = function(){
    alert('Power off!');
}

function Notebook(){
}

Notebook.prototype = new Computer(); //继承了Computer

Notebook.prototype.powerOn = function(useBattery){
    if(useBattery){
        alert('Notebook power on with battery!');
    }else{
        alert('Notebook power on!');
    }
}

var o = new Notebook();
o.powerOn(true); //Notebook power on with battery!
o.powerOff(); //Power off!

这段代码通过替换prototype,使得Notebook继承了Computer,并重写了其powerOn方法。至于这段代码的实现继承原理,请读者结合上面的原型链及成员查找原理自行分析。

虽然ECMAScript中没有类的概念,但可以说在ECMAScript中,prototype决定了对象的类型。其实ECMAScript这种设计方式正是设计模式中原型模式(Prototype)的具体应用实践。

顺便一提,通过原型链查找成员是很费资源的操作,尤其是在目的成员在较深处时,因此请避免频繁调用较深的原型链成员,如需多次使用,可先将其保存成临时变量,避免频繁的原型链深度搜索。

关于ECMAScript函数的一些话题

在本文的最后,我想聊聊ECMAScript中关于函数的几个话题,着重在于ECMAScript中函数的特别之处。

参数不敏感

与很多语言不通,ECMAScript中函数声明处的参数列表对于函数的运行根本无关紧要,实际调用时多几个少几个完全没有问题,参数声明唯一的作用就是为参数具名。例如下列代码:

function add(){
    return arguments[0] + arguments[1];
}

alert(add(1, 1)); //2

即使在声明时没有指定任何参数,调用时仍然可以传入参数,函数内部通过内置对象arguments访问。实际上在函数被调用时,解释器会自动为这个函数创建一个内部数组变量arguments,其中顺序包含着传入的参数。如果在函数声名时指定了n的参数的参数列表,则为arguments前n个成员具名,如果arguments长度不足n,那么后面的具名参数值为undefined。这是一种很有用的特性,可以用于创建动态长度参数函数,例如:

function add(){
    var r = 0;
    for(var v in arguments){
        r += Number(v);
    }
    return r;
}

alert(add(1, 2, 3)); //3
alert(add(1, 2, 3, 4, 5, 6)); //15

调用函数时指定作用域

我们知道,在全局作用域下调用某函数,默认情况下函数内部的this指向全局变量window,有时我们希望控制函数的作用域,即其this指向。内置Function的prototype对象上定义了call和apply两个方法,可以实现这个需求。因为这两个方法定义在Function的prototype上,所以任何函数都可以调用。

这两个函数的使用方法基本一致,只是call用于分别传递参数,而apply要求将参数作为数组传递。两个函数的定义为:

Function.prototype.apply(thisArg, argArray)

Function.prototype.call(thisArg, arg1, …, argn)

下面的代码展示了两者的用法:

function University(name, loca){
    this._name = name;
    this._loca = loca;
    this.showInfo = function(){
        alert(this._name + '是一所' + this._loca + '的大学');
    }
}

var u1 = new Object();
var u2 = new Object();
var argArray = ['烟台大学', '山东'];
University.apply(u1, argArray);
University.call(u2, '北京航空航天大学', '北京');

u1.showInfo(); //烟台大学是一所山东的大学
u2.showInfo(); //北京航空航天大学是一所北京的大学

使用apply或call显式指定作用域后,Unversity的this不会指向默认的window,而是指向apply或call第一个参数指定的对象。

主要参考文献:

[1] ECMA International. ECMA-262 3rd edition. http://www.ecma-international.org/publications/standards/Ecma-262-arch.htm, 1999.

[2] Nicholas C. Zakas 著. 李松峰,曹力 译. JavaScript高级程序设计. 人民邮电出版社,2010.

[3] Nicholas C. Zakas 著. 丁琛 译. 高性能JavaScript. 电子工业出版社,2010.

[4] David Flanagan 著. 张铭泽 译. JavaScript权威指南 第三版. 中国电力出版社,2001.