原文: http://dmitrysoshnikov.com/ecmascript/chapter-7-2-oop-ecmascript-implementation/
这一章的第二部分是关于EMCAScript中的面向对象编程。在第一部分中我们讨论了OOP的基本理论并勾画出和ECMAScript的相似之处。在阅读第二部分之前,如果有必要,我还是建议首先阅读这一章的第一部分.基本理论,因为后面将会用到其中的一些术语。
在回顾了一般理论中的一些重点之后,我们终于回到了ECMAScript本身。现在,我们已经了解了它的OOP方式,让我们再来准确地给出一个定义:
ECMAScript是一种面向对象的编程语言,它支持基于原型的委托式继承(delegating inheritance based on prototypes)。
让我们从数据类型开始分析。首先需要注意的是,ECMAScript中将实体(数据)分为原始值(primitive values)和对象。因此,一些文章中所说的“everything in JavaScript is an object”是不正确的(不完整的)。原始值涉及到数据的几种具体类型,让我们来讨论一下相关的细节。
虽然ECMAScript是一种动态的,使用“鸭子类型”的弱类型的,并且是类型自动转换的;然而它还是有一些数据类型。而对象就是其中的一种具体类型。
标准中定义了九种类型,而在ECMAScript程序中只有六种是可以直接访问的:
另外三种类型只能在实现器级别中被访问(没有一个ECMAScript对象能够访问这些类型),它们在规范中被用来解释一些运算的行为,或用来储存中间值,等等。这几种类型如下:
简单地说,引用(Reference)类型用来解释诸如delete, typeof, this等运算,它由一个基本对象(base object)和属性名组成(译者按:具体例子可以参见第三章中的活化对象的this关键字部分)。列表(List)类型用来解释参数列表的行为(在new 表达式和函数调用中)。最后,完成(Completion)类型用来解释break, continue, return 和 throw 语句的行为。
原始值类型
回到ES程序中使用的六种类型上,前面五种:Undefined, Null, Boolean, Number, String 都是原始值类型(typeof Primitive value)。
例如:
var a = undefined;var b = null;var c = true;var d = 'test';var e = 10;
在实现器中这些值是在一个低级别上直接呈现的。它们不是对象,没有原型(prototypes),也没有构造式(constructors)。
如果没有正确地理解而只是用typeof运算来返回类型,那么得到的结果将可能是错误的。其中一个例子是关于null值。当对null进行typeof 运算时,返回值是“object”,而null实际的类型应该是Null。
alert(typeof null); // “object”
其原因是null的返回值是根据标准中表格中的简单描述——“对于(typeof运算中)null的值应该返回‘object’字符串”来实现的。
规范中并没有澄清这一点,但是Brendan Eich(Javascript发明者)注意到,和undefined不同,null主要用在对象的场合中,换句话说,本质上更接近于对象(它意味着一个对象的“空引用”,可能是为将来的操作预留位置)。但是,在一些草案中,将这个现象描述为一个普通的bug。最后结果是,让它保持原样(返回“object”),虽然ECMA-262-3中定义null的类型为Null。
Object类型是唯一用来表示ECMAScript对象的类型。
Object是一种无序的(unordered)键值对的集合。
对象中的键(keys)称为属性。属性(的值)可以是原始值也可以是其他对象。当属性的值是函数时,它们被称为方法(methods)。
例如:
var x = { // object "x" 有三个属性: a, b, ca: 10, // 原始值b: {z: 100}, // 另一个对象c: function () { // 函数(方法)alert('method x.c');}};alert(x.a); // 10alert(x.b); // [object Object]alert(x.b.z); // 100x.c(); // 'method x.c'
正如我们在本章的第一部分中所注意到的,ES中的对象是完全动态的。这意味着我们可以在程序执行的任何时候添加、修改和删除对象的属性。
例如:
var foo = {x: 10};// add new propertyfoo.y = 20;console.log(foo); // {x: 10, y: 20}// change property value to functionfoo.x = function () {console.log('foo.x');};foo.x(); // 'foo.x'// delete propertydelete foo.x;console.log(foo); // {y: 20}
某些属性不能被修改——只读的(read-only)属性或者已经删除的——未配置的(non-configurable)属性。我们将在后面的属性的内在属性一节中简短地讨论这些情况。
注意,ES5中标准化的静态对象(static object)不能扩展新属性,也不能修改或者删除现有属性。这些被称为冻结的对象(frozen objects)。可以通过使用Object.freeze(o)方法来获得这些对象。
var foo = {x: 10};// freeze the objectObject.freeze(foo);console.log(Object.isFrozen(foo)); // true// can't modifyfoo.x = 100;// can't extendfoo.y = 200;// can't deletedelete foo.x;console.log(foo); // {x: 10}
同样,可以通过Object.preventExtensions(o) 方法来防止扩展,或者通过Object.defineProperty(o)方法来具体控制属性的内部参数:
var foo = {x : 10};Object.defineProperty(foo, "y", {value: 20,writable: false, // read-onlyconfigurable: false // non-configurable});// can't modifyfoo.y = 200;// can't deletedelete foo.y; // false// prevent extensionsObject.preventExtensions(foo);console.log(Object.isExtensible(foo)); // false// can't add new propertiesfoo.z = 30;console.log(foo); {x: 10, y: 20}
同样需要注意的是,规范中区分的本地对象(native objects),内建对象(built-in objects)和宿主对象(host objects)。
内建和本地的对象是由ECMAScript规范和实现器来定义的,它们之间的区别并不大。本地对象(native objects)是指由ECMAScript实现器提供的全部对象(其中一些是内建对象,另一些可以是在程序扩展中创建的,比如用户定义的对象)。
内建对象(built-in objects)是本地对象的子类型,它们会在程序开始前预先建立到ECMAScript中(比如parseInt, Math 等等)。
宿主对象(host objects)是由宿主环境(通常是一个浏览器)提供的对象,比如window, alert等。
注意,宿主对象可能由ES本身来实现并完全符合其规范中的语义。从这个角度上说,它们可以被(非正式的)称为“本地宿主(native-host)”对象。当然,这只是一个虚拟的概念,ES规范中并没有这样的定义。
对于一些原始值,规范中也定义了特殊的包装对象(wrapper object)。这些对象如下:
这些对象通过相应的内建构造式创建,并将原始值作为一个内部属性。这些对象可以转换为原始值,反之亦然。
示例——和原始类型对应的对象的值:
var c = new Boolean(true);var d = new String('test');var e = new Number(10);// converting to primitive// conversion: ToPrimitive// applying as a function, without "new" keywordс = Boolean(c);d = String(d);e = Number(e);// back to Object// conversion: ToObjectс = Object(c);d = Object(d);e = Object(e);
此外,还有一些通过特殊的内建构造式创建的对象:Function, Array, RegExp, Math, Date, 等等。这些对象同样是对象类型的可能值,而它们之间的区别是通过内部属性来管理的,下面我们也会讨论这一点。
对于以下三种对象的值:对象(object), 数组(array), 正则表达式(regexp expression),有一个简短的(和完整的内建构造式创建方式相比,)表示法,分别成为:对象初始化器(object initialiser),数组初始化器(array initialiser),正则表达式字面量(regexp expression literal):
// equivalent to new Array(1, 2, 3);// or array = new Array();// array[0] = 1;// array[1] = 2;// array[2] = 3;var array = [1, 2, 3];// equivalent to// var object = new Object();// object.a = 1;// object.b = 2;// object.c = 3;var object = {a: 1, b: 2, c: 3};// equivalent to new RegExp("^\\d+$", "g")var re = /^\d+$/g;
注意,如果将名称绑定——Object, Array, RegExp 重新复制到新的对象上,之后使用字面量表示法的语法在不同实现器中可能会有所不同。例如在目前的Rhino实现器或者旧的1.7版的SpiderMonkey中,使用字面量表示法将会创建和构造式名称相对应的新的值类型的对象。在另一些实现器中(包括目前的Spider和TraceMonkey)字面量表示法的语义不会随着构造式名称绑定到新的对象上而改变。
var getClass = Object.prototype.toString;Object = Number;var foo = new Object;alert([foo, getClass.call(foo)]); // 0, "[object Number]"var bar = {};// in Rhino, SpiderMonkey 1.7 - 0, "[object Number]"// in other: still "[object Object]", "[object Object]"alert([bar, getClass.call(bar)]);// the same with Array nameArray = Number;foo = new Array;alert([foo, getClass.call(foo)]); // 0, "[object Number]"bar = [];// in Rhino, SpiderMonkey 1.7 - 0, "[object Number]"// in other: still "", "[object Object]"alert([bar, getClass.call(bar)]);// but for RegExp, semantics of the literal// isn't being changed in all tested implementationsRegExp = Number;foo = new RegExp;alert([foo, getClass.call(foo)]); // 0, "[object Number]"bar = /(?!)/g;alert([bar, getClass.call(bar)]); // /(?!)/g, "[object RegExp]"
注意,虽然在ES3中,两种正则表达式的情况(字面量形式和对象形式)在语义上是相等的,但是还是有所不同。正则字面量只是一个实例,它在解析阶段创建;而RegExp构造式创建的总是一个新的对象。这将产生一些问题,比如当test失败时正则对象的lastIndex属性:
for (var k = 0; k < 4; k++) {var re = /ecma/g;alert(re.lastIndex); // 0, 4, 0, 4alert(re.test("ecmascript")); // true, false, true, false}// in contrast withfor (var k = 0; k < 4; k++) {var re = new RegExp("ecma", "g");alert(re.lastIndex); // 0, 0, 0, 0alert(re.test("ecmascript")); // true, true, true, true}
注意,ES5中问题已经被解决,正则字面量也总是创建新的对象。
在各种文章和讨论中,常常把Javascript对象(这里通常是特指通过声明的形式——通过对象初始化器“{}”创建的对象)称为哈希表(hash-tables)或者简称——hash(从Ruby和Perl中来的术语)、关联数组(associative arrays, 从PHP中来的术语)、字典(dictionaries,从Python中来的术语),等等。
使用这些术语主要是从具体的技术中带来的习惯。事实上,它们确实足够相似,在“键值对”储存的方式上完全符合关联数组或哈希表的数据结构。并且哈希表的抽象数据类型可能并且常常也在实现器级别上使用到。
然而,虽然术语本身是对思想的一种概念式的描述,但是涉及到ECMAScript,(用关联数组来描述对象)在技术上是不正确的。因为ECMAScript中只有一种object类型,而在它的以“键值对”方式储存的“子类型”方面,其他的object也可以使用这种方式。因此,没有针对这种储存方式而定义的特殊的独立的术语(hash或者其他)。任何对象,无论它的内部属性是什么,都可以以这种方式储存:
var a = {x: 10};a['y'] = 20;a.z = 30;var b = new Number(1);b.x = 10;b.y = 20;b['z'] = 30;var c = new Function('');c.x = 10;c.y = 20;c['z'] = 30;// etc. – with any object "subtype"
而且,由于ES中对象的委托的特性它可以是非空的(nonempty),因此术语hash也是不合适的。
Object.prototype.x = 10;var a = {}; // create "empty" "hash"alert(a["x"]); // 10, but it's not emptyalert(a.toString); // functiona["y"] = 20; // add new pair to "hash"alert(a["y"]); // 20Object.prototype.y = 20; // and property into the prototypedelete a["y"]; // removealert(a["y"]); // but key and value are still here – 20
注意,ES5中提供了标准化的创建没有属性的对象的能力——它们的原型会被设为null。这是通过Object.create(null)方法来实现的。从这个角度上说,这些对象是纯粹的哈希表。
var aHashTable = Object.create(null);console.log(aHashTable.toString); // undefined
同样,一些属性可能有特殊的读写器(getters/setters),所以可能产生混乱:
var a = new String("foo");a['length'] = 10;alert(a['length']); // 3
然而,即使考虑“hash”可以有原型的情况(例如在Ruby或Python中,有hash-objects委托的类),在ES中这个属于还是不合适的,因为各种属性访问器(即.和[])之间没有语义上的区别。
同样,在ES中一个“属性(property)”的概念在语义上并不细分为“键(key)”,“数组索引(array index)”,“方法(method)”或“属性(property)”。它们都是属性,在原型链的测试中都符合读写算法的一般规则。
在下面的Ruby的例子中我们能看到语义上的区别,因此在术语上可以做出区分。
a = {}a.class # Hasha.length # 0# new "key-value" paira['length'] = 10;# but semantics for the dot notation# remains other and means access# to the "property/method", but not to the "key"a.length # 1# and the bracket notation# provides access to "keys" of a hasha['length'] # 10# we can augment dynamically Hash class# with new properties/methods and they via# delegation will be available for already created objectsclass Hashdef z100endend# a new "property" is availablea.z # 100# but not a "key"a['z'] # nil
总之,ECMA-262-3标准中没有关于hash(或者其他类似概念)的定义。然而如果只是从理论的数据结构的角度上说,也可以这样称呼对象。
将对象转换为原始值可以通过valueOf方法。正如我们注意到的,将构造式(或具体类型)作为函数调用,换句话说不带new运算符,结果也是将一个对象类型转换为一个原始值。这种转换事实上隐含了valueOf方法的调用:
var a = new Number(1);var primitiveA = Number(a); // implicit "valueOf" callvar alsoPrimitiveA = a.valueOf(); // explicitalert([typeof a, // "object"typeof primitiveA, // "number"typeof alsoPrimitiveA // "number"]);
这种方法允许对象参与不同的运算,比如相加:
var a = new Number(1);var b = new Number(2);alert(a + b); // 3// or even sovar c = {x: 10,y: 20,valueOf: function () {return this.x + this.y;}};var d = {x: 30,y: 40,// the same .valueOf// functionality as "с" object has,// borrow it:valueOf: c.valueOf};alert(c + d); // 100
valueOf方法的默认值根据对象的类型而定。对于一些对象,它返回的是this值——例如Object.prototype.valueOf;对于另一些则是可计算的值,例如,Date.prototype.valueOf(),会返回date的time值:
var a = {};alert(a.valueOf() === a); // true, "valueOf" returned this valuevar d = new Date();alert(d.valueOf()); // timealert(d.valueOf() === d.getTime()); // true
同样,有另一个原始值可以表示对象——字符串表示。这是和toString方法相关的,这种方法在一些运算中同样会自动调用:
var a = {valueOf: function () {return 100;},toString: function () {return '__test';}};// in this operation// toString method is// called automaticallyalert(a); // "__test"// but here - the .valueOf() methodalert(a + 10); // 110// but if there is no// valueOf method, it// will be replaced with the//toString methoddelete a.valueOf;alert(a + 10); // "_test10"
定义在Object.prototype上的toString方法有特殊的含义。它返回的是内部属性[[Class]]的值,我们将在下面讨论到。
除了将对象转换为原始值(ToPrimitive)之外,相反也可以把原始值转换为对象(ToObject)。
其中一种显示的调用ToObject的方式就是将内建的Object构造式作为函数使用(虽然对于一些类型而言加上new运算符也可以):
var n = Object(1); // [object Number]var s = Object('test'); // [object String]o// also for some types it is// possible to call Object with new operatorvar b = new Object(true); // [object Boolean]o// but applied with arguments,// new Object creates a simple objectvar o = new Object(); // [object Object]o// in case if argument for Object function// is already object value,// it simply returnsvar a = [];alert(a === new Object(a)); // truealert(a === Object(a)); // true
关于调用内建构造器时是否加上new运算符,并没有一般规则,而是根据具体的构造器而定。例如Array和Function构造器无论在作为构造式调用(使用new),还是作为函数调用(不使用new),结果都是一样的:
var a = Array(1, 2, 3); // [object Array]var b = new Array(1, 2, 3); // [object Array]var c = [1, 2, 3]; // [object Array]var d = Function(''); // [object Function]var e = new Function(''); // [object Function]
在使用一些运算符时,也可能会发生显式和隐式的类型转换:
var a = 1;var b = 2;// implicitvar c = a + b; // 3, numbervar d = a + b + '5' // "35", string// explicitvar e = '10'; // "10", stringvar f = +e; // 10, numbervar g = parseInt(e, 10); // 10, number// etc.
所有的属性都可以有一些内部参数:
{ReadOnly} ——(有这个内部属性时)对属性写入值的尝试会被忽略;ReadOnly的属性可以通过宿主环境的行为而改变,因此ReadOnly并不等于“常量(constant value)”
{DontEnum} —— 属性不能通过for…in循环枚举
{DontDelete} —— 对这个属性的delete运算将会被忽略
{Internal} —— 属性是内部的,它没有名称并且只在实现器级别上使用。这类属性不能通过ECMAScript程序访问。
注意,在ES5中,{ReadOnly}, {DontEnum}, {DontDelete}分别被重命名为[[Writable]], [[Enumerable]]和[[Configurable]],并且可以通过Object.defineProperty以及类似方法来手动管理。
var foo = {};Object.defineProperty(foo, "x", {value: 10,writable: true, // aka {ReadOnly} = falseenumerable: false, // aka {DontEnum} = trueconfigurable: true // {DontDelete} = false});console.log(foo.x); // 10// attributes set is called a descriptorvar desc = Object.getOwnPropertyDescriptor(foo, "x");console.log(desc.enumerable); // falseconsole.log(desc.writable); // true// etc.
对象也可以有一些内部属性,这些属性是实现器级别的,不能在ECMAScript程序中直接访问(然而正如我们下面将看到的,一些实现器也允许访问其中的一些属性)。这些属性在管理上是有两个外加的中括号“[[]]”。
我们将会接触它们中的一部分(所有对象中必需的那些);其他属性的描述可以在规范中找到。
每一个对象都需要实现如下的属性和方法:
[[Prototype]]——对象的原型(在下面会详细谈到)
[[Class]]——一个用于表示对象类型的字符串(例如Object, Array, Function,等);它用于区分对象
[[Get]]——一种获取属性值的方法
[[Put]]——一种设定属性值的方法
[[CanPut]]——检查是否可以写入相关属性
[[HasProperty]]——检查对象是否已经有了相关属性
[[Delete]]——将属性从对象中删除
[[DefaultValue]]——返回和对象相关的原始值(为了获得这个值调用了valueOf方法,对于一些对象,可能抛出TypeError异常)
在ES程序中,可以通过Object.prototype.toString()方法来间接获得对象的[[Class]]属性。(译者按:注意和对象的一些分支类型的toString方法相区别,比如Array.prototype.toString)。这个方法将返回如下字符串“[object ”+[[Class]]+”]”,例如:
var getClass = Object.prototype.toString;getClass.call({}); // [object Object]getClass.call([]); // [object Array]getClass.call(new Number(1)); // [object Number]// etc.
这个特性常常被用来检查对象的类型,然而需要注意的是,在规范中,宿主对象(host objects)的内部属性[[Class]]可以是任何值,包括内建对象的[[Class]]属性的值,这样理论上就不能100%保证检测正确性。例如,document.childNodes.item(…)的属性[[Class]]在IE中返回为“String”(而在其他实现器中则是“Function”)。
// in IE - "String", in other - "Function"alert(getClass.call(document.childNodes.item));
所以,如我们上面所说,ECMAScript中对象的创建是通过构造器(constructor)来完成的。
构造器(Constructor)是一个函数,它用来创建(create)对象和初始化(initialize)新创建的对象。
创建(内存分配)的过程是由构造器函数的内部方法[[Construct]]负责的。这个内部方法的行为是指定好的,所有的构造器函数都使用这个方法来为新对象分配内存。
初始化的过程是通过在新创建的对象的上下文中调用函数来管理的,它由构造器函数的内部方法[[Call]]负责。
注意,在用户代码中,只有初始化阶段是可以访问的。虽然,即使在初始化阶段中我们也能够返回不同的对象而无视在创建阶段中生成的this对象:
function A() {// update newly created objectthis.x = 10;// but return different objectreturn [1, 2, 3];}var a = new A();console.log(a.x, a); undefined, [1, 2, 3]
根据第五章.函数中讨论过的函数对象创建的算法我们看到,函数是一种本地对象,它有若干内部属性其中包括[[Call]]和[[Construct]],它还有显式的属性prototype——未来对象的原型的引用(注意,下面这个例子中以及后面的例子中用到的NativeObject是我的伪代码,以用来表示ECMA-262-3中的“本地对象(native object)”的概念,它不是一个内建的构造器):
F = new NativeObject();F.[[Class]] = "Function"... // 其他内部属性设定F.[[Call]] = <reference to the function>F.[[Construct]] = internalConstructor... // 其他内部属性设定__objectPrototype = {};__objectPrototype.constructor = F // {DontEnum}F.prototype = __objectPrototype
因此,除了[[Class]]属性(值为“Function”),[[Call]]属性在对象区分方面起到主要作用。因此,有内部属性[[Call]]的对象被当做函数调用。这些对象的typeof运算的返回值是“function”。但是,在“宿主可调用对象(host callable object)”的情况中(主要是本地对象),一些实现器中的typeof运算(以及[[Call]]属性)可以返回其他值:例如,IE中的window.alert(…):
// in IE - "Object", "object", in other - "Function", "function"alert(Object.prototype.toString.call(window.alert));alert(typeof window.alert); // "Object"
内部方法[[Construct]]由构造器函数的new运算符激活。如我们所说,这个方法负责内存的分配以及对象的创建。如果没有参数,函数构造器的调用括号可以省略:
function A(x) { // constructor Аthis.x = x || 10;}// without arguments, call// brackets can be omittedvar a = new A; // or new A();alert(a.x); // 10// explicit passing of// x argument valuevar b = new A(20);alert(b.x); // 20
我们同样知道,构造器内部的this的值(在初始化阶段)是新创建的对象(newly created object)。
让我们来考虑对象创建的算法。
内部方法[[Construct]]的行为可以描述为:
F.[[Construct]](initialParameters):O = new NativeObject();O.[[Class]] = "Object"var __objectPrototype = F.prototype;// if __objectPrototype is an object, then:O.[[Prototype]] = __objectPrototype// else:O.[[Prototype]] = Object.prototype;// initialization of the newly created object// applying the F.[[Call]]; pass:// as this value – newly created object - O,// arguments are the same as initialParameters for FR = F.[[Call]](initialParameters); this === O;// where R is the returned value of the [[Call]]// in JS view it looks like:// R = F.apply(O, initialParameters);// if R is an objectreturn R// elsereturn O
注意两个主要特点:
首先,所创建的对象的原型是从“当前”时刻下构造器函数的prototype属性而来的(意味着由一个构造器创建的两个对象的原型可以是不同的,因为函数的prototype属性同样是可变的)。
其次,正如上面提到的,如果对象初始化时[[Call]]返回的是对象,那么它将作为整个new表达式的返回值:
function A() {}A.prototype.x = 10;var a = new A();alert(a.x); // 10 – by delegation, from the prototype// set .prototype property of the// function to new object; why explicitly// to define the .constructor property,// will be described belowA.prototype = {constructor: A,y: 100};var b = new A();// object "b" has new prototypealert(b.x); // undefinedalert(b.y); // 100 – by delegation, from the prototype// however, prototype of the "a" object// is still old (why - we will see below)alert(a.x); // 10 - by delegation, from the prototypefunction B() {this.x = 10;return new Array();}// if "B" constructor had not return// (or was return this), then this-object// would be used, but in this case – an arrayvar b = new B();alert(b.x); // undefinedalert(Object.prototype.toString.call(b)); // [object Array]
让我们来考虑原型的细节。
每一个对象都有一个原型(prototype)(除了一些系统对象之外。译者按:这里指的是对象的内部属性,注意和函数对象的prototype属性相区别)。原型是和内部的、隐含的、不能直接访问的[[Prototype]]属性相联系的。一个原型可以是一个对象(object, 特指纯键值对的类型而非其他类型的对象),也可以是null。
上面的例子中有两个重点。其中之一是关于函数原型属性的constructor属性的。
正如我们在函数对象创建的算法中看到的,在函数创建时设定了函数原型属性的constructor属性。这个属性的值是循环引用函数本身:
function A() {}var a = new A();alert(a.constructor); // function A() {}, by delegationalert(a.constructor === A); // true
对于这个情况常常有一种误解——constructor属性被错误地当做了所创建对象的自有属性。然而正如我们看到的,这个属性属于原型而对象通过继承访问。
通过继承的constructor属性,实例对象(instance)可以间接引用原型对象:
function A() {}A.prototype.x = new Number(10);var a = new A();alert(a.constructor.prototype); // [object Object]alert(a.x); // 10, via delegation// the same as a.[[Prototype]].xalert(a.constructor.prototype.x); // 10alert(a.constructor.prototype.x === a.x); // true
注意,虽然函数的prototype属性和原型的constructor属性都可以在对象创建后重定义,但是这种情况下对象可能失去上面的引用机制。
如果我们在初始的原型中新增或者修改已有属性(通过函数的prototype属性),实例将可以看到这些新增或修改后的结果。
但是,如果我们完全改变函数的prototype属性(通过赋值为一个新对象),对于初始构造器(函数)的引用将会丢失(初始的原型也是一样,不能通过.contructor.prototype访问)。这是因为新创建的对象没有了constructor属性(译者按:这里丢失的constructor属性,而对象的[[Property]]属性仍然引用到对象创建时的构造器函数的prototype属性上,对比上面对象创建的算法):
function A() {}A.prototype = {x: 10};var a = new A();alert(a.x); // 10alert(a.constructor === A); // false!
因此必须手动添加对构造器的引用(译者按:由于算法中[[Protortpe]]属性引用函数的prototype,因此可以委托到新的constructor)
function A() {}A.prototype = {constructor: A,x: 10};var a = new A();alert(a.x); // 10alert(a.constructor === A); // true
注意,虽然手动储存constructor属性可以避免丢失对构造器的引用,但是它没有{DontEnum}内部参数,因此会在prototype的for…in循环中枚举到(而函数创建过程中自动设定的这个属性有{DontEnum})。
在ES5中引入了控制属性的[[Enumerable]]内部参数的方法:defineProperty
var foo = {x: 10};Object.defineProperty(foo, "y", {value: 20,enumerable: false // aka {DontEnum} = true});console.log(foo.x, foo.y); // 10, 20for (var k in foo) {console.log(k); // only "x"}var xDesc = Object.getOwnPropertyDescriptor(foo, "x");var yDesc = Object.getOwnPropertyDescriptor(foo, "y");console.log(xDesc.enumerable, // trueyDesc.enumerable // false);
一个对象的原型([[Prototype]]内部属性)常常容易不正确地和函数prototype属性的显式引用混淆起来。是的,它们确实引用的是同一个对象:
a.[[Prototype]] ——> Prototype <—— A.prototype
而且,实例的[[Prototype]]正是从构造器的prototype属性上获得值——在对象创建时。
但是,对于构造器的prototype属性的重置(译者按:不是修改或增减prototype属性中的属性)不会影响到已创建的对象的原型。改变的只是构造器的prototype属性!这意味着之后创建的新对象将会有新的原型。但是已创建的对象(在构造器的prototype改变之前创建的),引用的还是旧的原型并且这个引用将不能再被改变(译者按:因为只存在于内部属性,且即使通过constructor.prototype间接方式访问到的也是构造器的新的原型属性):
// was before changing of A.prototype
a.[[Prototype]] ——> Prototype <—— A.prototype
// became after
A.prototype ——> New prototype // new objects will have this prototype
a.[[Prototype]] ——> Prototype // reference to old prototype
例如:
function A() {}A.prototype.x = 10;var a = new A();alert(a.x); // 10A.prototype = {constructor: A,x: 20y: 30};// object "а" delegates to// the old prototype via// implicit [[Prototype]] referencealert(a.x); // 10alert(a.y) // undefinedvar b = new A();// but new objects at creation// get reference to new prototypealert(b.x); // 20alert(b.y) // 30
因此,有时在一些JS文章中出现的声称的“动态改变原型将会影响到所有对象,它们将拥有新的原型”的说法是不正确的。只有在原型改变后创建的对象才受这些新的原型的影响。
这里的主要规则是:对象的原型是在对象创建时设定的,在这之后不能改变为新的对象。只有当它和构造器的prototype的显示引用指向的是同一个对象时,才能通过构造器的prototype新增或者修改对象原型的属性。
然而,一些实现器,比如SpiderMonkey(译者按:目前除IE外的几个主流浏览器都支持proto(FF, Chrome, Opera,Safari),而IE到10为止仍不支持),提供了对于对象原型的显示引用,通过一个非标准的proto属性:
function A() {}A.prototype.x = 10;var a = new A();alert(a.x); // 10var __newPrototype = {constructor: A,x: 20,y: 30};// reference to new objectA.prototype = __newPrototype;var b = new A();alert(b.x); // 20alert(b.y); // 30// "a" object still delegates// to the old prototypealert(a.x); // 10alert(a.y); // undefined// change prototype explicitlya.__proto__ = __newPrototype;// now "а" object references// to new object alsoalert(a.x); // 20alert(a.y); // 30
注意,在ES5中引入了Object.getPropertyOf(o)方法(译者按:同样,IE目前不支持),可以直接返回一个对象的[[Prototype]]属性——实例的初始原型。然而和proto不同,这个方法只是一个getter,它不允许设定原型。
var foo = {};Object.getPrototypeOf(foo) == Object.prototype; // true
由于一个实例对象的原型是独立于它的构造器和构造器的prototype属性的,构造器在完成了它的主要目的——创建对象——之后可以被删除。原型对象将仍然存在,并通过[[Prototype]]属性引用:
function A() {}A.prototype.x = 10;var a = new A();alert(a.x); // 10// set "А" to null - explicit// reference on constructorA = null;// but, still possible to create// objects via indirect reference// from other object if// .constructor property has not been changedvar b = new a.constructor();alert(b.x); // 10// remove both implicit referencesdelete a.constructor.prototype.constructor;delete b.constructor.prototype.constructor;// it is not possible to create objects// of "А" constructor anymore, but still// there are two such objects which// still have reference to their prototypealert(a.x); // 10alert(b.x); // 10
对一个原型的显示引用——通过构造器的prototype属性,是和instanceof运算的工作相关的。
这个运算直接工作于原型链上而不是通过构造器。关于这一点常常有一种误解,那就是,当进行下面这种检查方式时:
if (foo instanceof Foo) {...}
它不是表示检查对象foo是否由构造器Foo创建!
instanceof运算所做的只是获取一个对象的原型——foo.[[Prototype]],并且检查它在原型链中的存在情况(译者按:比如foo.[[Prototype]],它在原型链中有两个层级,一个的值等于Foo.prototype,一个的值等于Object.prototype,这两个值都是值相应foo和Foo创建时获得的[[Prototype]]),(对于运算符右边则分析它的原型属性)对比分析Foo.prototype。instanceof运算由构造器的内部方法[[HasInstance]]激活。
让我们来一个示例:
function A() {}A.prototype.x = 10;var a = new A();alert(a.x); // 10alert(a instanceof A); // true// if set A.prototype// to null...A.prototype = null;// ...then "a" object still// has access to its// prototype - via a.[[Prototype]]alert(a.x); // 10// however, instanceof operator// can't work anymore, because// starts its examination from the//prototype property of the constructoralert(a instanceof A); // error, A.prototype is not an object
而另一方面,可能通过一个构造器创建的对象,但在instanceof检查另一个构造器时返回true。而这只需要将对象的[[Property]]和新的构造器的prototype属性设为同一个对象即可:
function B() {}var b = new B();alert(b instanceof B); // truefunction C() {}var __proto = {constructor: C};C.prototype = __proto;b.__proto__ = __proto;alert(b instanceof C); // truealert(b instanceof B); // false
ECMAScript中原型最有用的就是作为对象方法、默认状态和共享属性的储存器。
的确,对象可以有自身的状态,但方法通常都是相同的。因此,为了内存占用的性能优化,方法通常定义在原型中。这意味着,通过一个构造器创建的所有实例,总是共用相同的方法。
function A(x) {this.x = x || 100;}A.prototype = (function () {// initializing context,// use additional objectvar _someSharedVar = 500;function _someHelper() {alert('internal helper: ' + _someSharedVar);}function method1() {alert('method1: ' + this.x);}function method2() {alert('method2: ' + this.x);_someHelper();}// the prototype itselfreturn {constructor: A,method1: method1,method2: method2};})();var a = new A(10);var b = new A(20);a.method1(); // method1: 10a.method2(); // method2: 10, internal helper: 500b.method1(); // method1: 20b.method2(); // method2: 20, internal helper: 500// both objects are use// the same methods from// the same prototypealert(a.method1 === b.method1); // truealert(a.method2 === b.method2); // true
正如我们提到过的,对于属性的读写是通过内部方法[[Get]]和[[Put]]来管理的。这两个方法是通过属性访问器(property accessors)激活的——点符号或中括号:
// writefoo.bar = 10; // [[Put]] is calledconsole.log(foo.bar); // 10, [[Get]] is calledconsole.log(foo['bar']); // the same
让我们通过伪代码来展示这些方法的工作原理。
[[Get]] method
(除了对象的自有属性外,)[[Get]]方法也考虑到了对象原型链中的属性。因此原型中的属性也像对象的自有属性一样可以被访问到。
O.[Get]:
// if there is own// property, return itif (O.hasOwnProperty(P)) {return O.P;}// else, analyzing prototypevar __proto = O.[[Prototype]];// if there is no prototype (it is,// possible e.g. in the last link of the// chain - Object.prototype.[[Prototype]],// which is equal to null),// then return undefined;if (__proto === null) {return undefined;}// else, call [[Get]] method recursively -// now for prototype; i.e. go through prototype// chain: try to find property in the// prototype, after that – in a prototype of// the prototype and so on, until// [[Prorotype]] will be equal to nullreturn __proto.[[Get]](P)
注意,由于[[Get]]方法可以返回undefined,因此可以像下面这样检查变量的存在性:
if (window.someObject) {...}
这里首先检查属性someObject在window中的存在性,(如果不存在,)然后在window的原型中检查,然后在原型的原型,等等。而在这里,根据算法返回的是undefined值。
注意,对于实际的存在性是由in运算负责的。它同样考虑原型链中的属性。
if ('someObject' in window) {...}
它帮助避免了一些情况中上面的检测失效,比如属性值为false的情况将不会通过,即使属性是确实存在的。
[[Put]] method
相反,对象的[[Put]]方法创建或者更新一个自有属性(own property),同时遮蔽掉原型链中的同名属性。
O.[Put]:
// if we can't write to// this property then exitif (!O.[[CanPut]](P)) {return;}// if object doesn't have such own,// property, then create it; all attributes// are empty (set to false)if (!O.hasOwnProperty(P)) {createNewProperty(O, P, attributes: {ReadOnly: false,DontEnum: false,DontDelete: false,Internal: false});}// set the value;// if property existed, its// attributes are not changedO.P = Vreturn;
例如:
Object.prototype.x = 100;var foo = {};console.log(foo.x); // 100, inheritedfoo.x = 10; // [[Put]]console.log(foo.x); // 10, owndelete foo.x;console.log(foo.x); // again 100, inherited注意,不能遮蔽那些继承的只读的属性,对于这些属性的赋值将会被忽略。这是由内部方法[[CanPut]]控制的。// For example, property "length" of// string objects is read-only; let's make a// string as a prototype of our object and try// to shadow the "length" propertyfunction SuperString() {/* nothing */}SuperString.prototype = new String("abc");var foo = new SuperString();console.log(foo.length); // 3, the length of "abc"// try to shadowfoo.length = 5;console.log(foo.length); // still 3
在ES5的严格模式下,试图遮蔽一个不可写的属性将会导致一个TypeError。
我们说过,内部方法[[Put]]和[[Get]]由属性访问器(property accessors)激活,在ES中属性访问器是通过点符号“.”和中括号“[]”获得的。点符号是使用在当属性名是一个有效的标示符名称且提前已知(译者按:对应于中括号中可以使用形式名的情况),中括号允许动态地使用属性的形式名称(译者按,即实际属性名或属性名的一部分赋值到某个变量上,然后将变量名作为形式名称或形式名称的部分访问)。
var a = {testProperty: 10};alert(a.testProperty); // 10, dot notationalert(a['testProperty']); // 10, bracket notationvar propertyName = 'Property';alert(a['test' + propertyName]); // 10, 中括号中的动态属性名
这里有一个重要的特性——属性访问器总是对它左边的部分进行ToObject的转换。并且由于这个隐式的转换,可以粗略地说“everything in JavaScript is an object”(但是正如我们已经知道的——当然不是所有的,除了对象还有原始值)。
如果我们对一个原始值使用属性访问器,我们只是创建了中间包装对象然后赋值给它。当工作结束是,包装对象被移除。
var a = 10; // primitive value// but, it has access to methods,// just like it would be an objectalert(a.toString()); // "10"// moreover, we can even// (try) to create a new// property in the "а" primitive calling [[Put]]a.test = 100; // seems, it even works// but, [[Get]] doesn't return// value for this property, it returns// by algorithm - undefinedalert(a.test); // undefined
那么,为什么这个例子中的原始值a可以访问toString方法,但是不能访问新创建的test属性?
答案很简单:
首先,如我们所说,在属性访问器应用后,左边已经不是一个原始值,而是一个中间对象。在这个例子中是一个new Number(a),它可以通过委托访问到原型链中的toString方法。
// Algorithm of evaluating a.toString():wrapper = new Number(a);wrapper.toString(); // "10"delete wrapper;
然后,当执行[[Put]]方法也创建了一个新的包装对象用于属性的设置:
// Algorithm of evaluating a.test = 100:wrapper = new Number(a);wrapper.test = 100;delete wrapper;
我们看到,第三步中wrapper已经移除,因此它的属性test也随之移除了。
然后,[[Get]]调用,属性访问器又创建了一个新的包装对象,然而这个新对象上当然没有任何test的属性:
// Algorithm of evaluating a.test:wrapper = new Number(a);wrapper.test; // undefined
因此对于原始值来说只能读属性而不能写。同时,如果一个原始值需要常常使用属性访问器,那么为了节约时间,不如直接把它换成对象形式。反过来,如果值只参与小型的计算而不需要访问属性,那么使用原始值效率更高。
如我们所知,ECMAScript使用基于原型的委托式继承。
(对象的)原型(式继承)是链式的,称为原型链(prototype chain)(译者按:object -> object.[[Prototype]] === object.constructor.prototype(when object create) -> object.[[Prototype]].[[Prototype]] …)
事实上,所有委托的实现和原型链分析的工作都简化为了上面提到的[[Get]]的工作。
如果你完全理解了上面[[Get]]方法的简单算法,关于Javascript中继承的问题就不证自明了。
论坛中常常谈论Javascript中的继承,我通过一个一行代码的例子来具体描述这门语言中的对象结构以及基于委托的继承。事实上我们可以不创建任何构造器或者对象,因为这门语言中已经到处是继承了。这行代码很简单:
alert(1..toString()); // “1”
现在,我们已经知道了[[Get]]的算法和属性访问器,我们能看到这里面发生了什么:
首先,从原始值1创建了包装对象new Number(1);
然后这个包装对象调用了继承的方法toString
为什么发生了继承?因为ES中的对象可以有自有属性,而这种情况下创建的包装对象没有自有属性toString,因此,它从原型中,即Number.prototype中继承了toString方法。
注意语法中的细节。上面例子中的两个点不是一个错误。第一个点是用作一个数值的分数部分,第二个点是属性访问器
1.toString(); // SyntaxError!(1).toString(); // OK1..toString(); // OK1['toString'](); // OK
让我们来看下如何为用户定义的对象创建原型链。
function A() {alert('A.[[Call]] activated');this.x = 10;}A.prototype.y = 20;var a = new A();alert([a.x, a.y]); // 10 (own), 20 (inherited)function B() {}// the easiest variant of prototypes// chaining is setting child// prototype to new object created,// by the parent constructorB.prototype = new A();// fix .constructor property, else it would be АB.prototype.constructor = B;var b = new B();alert([b.x, b.y]); // 10, 20, both are inherited// [[Get]] b.x:// b.x (no) -->// b.[[Prototype]].x (yes) - 10// [[Get]] b.y// b.y (no) -->// b.[[Prototype]].y (no) -->// b.[[Prototype]].[[Prototype]].y (yes) - 20// where b.[[Prototype]] === B.prototype,// and b.[[Prototype]].[[Prototype]] === A.prototype
这种方式有两个特点:
首先,B.prototype将包含x属性。虽然第一眼看去似乎这是不正确的。因为x属性是定义在A中的自有属性,所以预期也它是B构造器产生的对象的自有属性(就像A产生的对象a那样)。
在一般的原型式继承中,当一个对象没有所需的自有属性时将会委托一个原型。这个机制背后的逻辑是可能构造器B创建的对象不需要属性x。相反,在基于类的继承模式中,(类)所有的属性都复制给了类的后裔。
然而,如果还是有必要让属性x成为构造器B创建的对象的自有属性的话(模拟基于类的方式),也有几种技术可以实现,其中一种我们将在下面看到。
第二点,其实已经不算是特点而是一个缺点——当后裔原型创建时(e.g. B.prototype = new A())会执行父构造器的代码。我们看到”A.[[Call]] activated”的消息出现了两次,构造器A创建对象a时以及A创建的新对象被用作B.prototype时。
一个更严重的例子是当父构造器抛出异常时:可能,对于构造器创建的真实对象而言这种检查是需要的,但很明显,将这些父对象作为原型时再检查是完全不能接受的:
function A(param) {if (!param) {throw 'Param required';}this.param = param;}A.prototype.x = 10;var a = new A(20);alert([a.x, a.param]); // 10, 20function B() {}B.prototype = new A(); // Error
同样,父构造器中的大量运算也将会是这种方式的一个缺点。
为了解决这些“特性”和问题,现在的程序员使用的是我们下面展示的这种原型链的标准模式。这个技巧的主要目的是创建一个用来链接原型的中间包装构造器(intermediate wrapper constructor):
function A() {alert('A.[[Call]] activated');this.x = 10;}A.prototype.y = 20;var a = new A();alert([a.x, a.y]); // 10 (own), 20 (inherited)function B() {// 或者简单的A.apply(this, arguments);B.superproto.constructor.apply(this, arguments);}// 通过创建空的构造器来链接原型var F = function() {};F.prototype = A.prototype;B.prototype = new F();B.superproto = A.prototype; // 显式引用父原型// fix constructorB.prototype.constructor = B;var b = new B(); // 'A.[[Call]] activated'alert([b.x, b.y]); // 10 (own), 20 (inherited)
注意,我们如何在b实例上创建了自有属性x:在新创建的对象的上下文中通过B.superproto.constructor的引用来调用父构造式。
我们同样解决了创建子代原型时会不必要地调用父构造器的问题。
而为了避免每次重复链接原型部分(创建中间构造器,设置”superproto”,储存初始构造器等等),可以把这个部分封装在函数里:
function inherit(child, parent) {var F = function() {};F.prototype = parent.prototype;child.prototype = new F();child.prototype.constructor = child;child.superproto = parent.prototype;return child;}
相应的继承:
function A() {}A.prototype.x = 10;function B() {}inherit(B, A); // chaining prototypesvar b = new B();alert(b.x); // 10, found in the A.prototype
这种包装有许多变体(在语法上),然而它们都是为了简化上面所说的过程。
例如,我们可以把中间构造器的声明提到外面来以便只执行一次:
var inherit = (function(){function F() {}return function (child, parent) {F.prototype = parent.prototype;child.prototype = new F;child.prototype.constructor = child;child.superproto = parent.prototype;return child;};})();
由于一个对象的原型是[[Prototype]]属性,这就意味着上面简化后F.prototype的重新使用不会影响到之前通过通过F继承的child:
function A() {}A.prototype.x = 10;function B() {}inherit(B, A);B.prototype.y = 20;B.prototype.foo = function () {alert("B#foo");};var b = new B();alert(b.x); // 10, is found in A.prototypefunction C() {}inherit(C, B);// and using our "superproto" sugar// we can call parent method with the same nameC.ptototype.foo = function () {C.superproto.foo.call(this);alert("C#foo");};var c = new C();alert([c.x, c.y]); // 10, 20c.foo(); // B#foo, C#foo
注意,ES5标准化了这种优化原型链的函数,它是Object.create方法。
ES3中这个方法的简化版可以通过如下方式实现:
Object.create || Object.create = function(parent, properties) {function F() {}F.prototype = parent.prototype;var child = new F;for (var k in properties) {child[k] = properties[k];}return child;};//Usage:var foo = {x: 10};var bar = Object.create(foo, {y: 20});console.log(bar.x, bar.y); // 10, 20For details see this chapter.
同样,所有现存的模拟“JS中类式继承”的各种变体也都是基于这个原则。当然现在我们看到,事实上它甚至不是一个“基于类的继承的模拟”,而是一种简单的原型链的重用。
这一章很长,有不少细节。我希望这些材料能有助于理解相关的主题。任何问题欢迎在评论中讨论。
这一章的第二部分是关于EMCAScript中的面向对象编程。在第一部分中我们讨论了OOP的基本理论并勾画出和ECMAScript的相似之处。在阅读第二部分之前,如果有必要,我还是建议首先阅读这一章的第一部分.基本理论,因为后面将会用到其中的一些术语。
这一章我们讨论ECMAScript中面向对象编程(object-oriented programming)的几个主要方面。由于这一主题已经在许多文章中谈论过,本章并不打算“老调重弹”,而是试图更多地着眼于这些过程内在的理论方面。尤其是,我们将研究对象创建的算法,看看对象间的关系(包括最基本的关系——继承)是如何实现的,并且给出一些讨论中将用到的准确定义(我希望这样能够打消一些术语和思路上的疑惑以及一些关于Javascript文章中OOP部分的常见的混淆)。
在这一章中我们来谈谈Javascript中被讨论最多的话题之一——关于闭包(closures)。事实上这个主题并不是新鲜的。然而我们在这里将试着更多从理论的角度去分析和理解它,然后我们还会看一下ECMAScript内关于闭包的内容。
在这章里我们讨论ECMAScript中的一个基本对象——函数。我们将会看到不同类型的函数如何影响一个上下文中的变量对象,以及这些函数的作用域链中都包含什么。我们将会回答像下面这样经常被问到的问题:“下面这两种创建函数的方式有什么区别吗(如果有的话,区别是什么呢)?”
正如我们从第二章.变量对象中了解到的,执行上下文的数据(变量,函数声明,函数形参)以变量对象的属性的方式储存。
许多程序员习惯于认为在编程语言中,this关键字是与面向对象编程紧密相关的,而且引用的是由构造式最新创建的对象。在ECMAScript中,这个概念也被实现了,然而我们将看到,在这里它不仅仅指向已创建的对象。
在程序中我们总是声明变量和函数然后用它们来搭建我们的系统。但是解释器(interpreter)是在哪里和以什么方式来找到我们的数据(函数,变量)的呢?
第1章:在这一章里,我们将会讨论ECMAScript中的执行上下文(execution context)以及与它们相关的可执行代码(executable code)的类型。