“面向对象”有三个基本特性,即”封装,继承,多态“。一般来说,三个特性都完全满足的话,我们称为“面向对象语言”,而称满足其中部分特性的语言为“基于对象语言”。“对象系统 ”的继承特性,有三种实现方案,包括基于类(class-based)、基于原型(prototype-based)和基于元类(metaclass-based )。这三种对象模型各具特色,也各有应用。
在这其中,JavaScript使用了原型继承来实现对象系统,并基于原型继承实现了具备类继承特征的对象系统。
空对象和空的对象
null
null 不是“空的对象”,而是代表这样一个对象:
- 属于对象类型;
- 对象是空值的。
因为它是对象类型,所以你甚至可以去列举(for..in)它;又因为它是空值,所以没有任何方法和属性,因而列举不到内容。 由于它并不是自 Object( )构造器 (或其子类)实例而来,因此instanceof 运算会返回 false。
“空的对象”
“空的对象”是整个原型继承体系的根基。
let obj0 = new Obejct() // 一个标准的、通过 Object( )构造的对象实例。
let obj1 = Object.prototype; // 来自Object( )构造器的原型实例。
let obj2 = {} // 对象直接量也会隐式地调用 Object( )来构造实例。
空的对象具有 “对象 ”的一切特性。 因此可以存取预定义属性和方法(toStirng、valueOf 等),而 instanceof 运算也会返回 true。
在缺省情况下,空的对象只有预定义属性和方法,而 for .. in 语句并不列举这些属性和方法。因此,所谓空的对象在 for..in 中并不产生任何效果。
空的对象是所有对象的基础
// 取原型对象
obj = Object.prototype;
// 列举对象成员并计数
var num = 0;
for (var n in obj) {
num++;
}
// 显示计数: 0
alert(num);
Object构造器的原型就是一个对象,那么由于原型继承的基本特性,以下两行代码创建对象的过程, 都是自 Object.prototype 上复制出一个了“对象”的映像来——它们也是“空的对象”。
obj1 = new Object();
obj2 = { };
因此对象的“构建过程”可以被简单地映射为“复制”。如下图所示:
原型的含义是指:如果构造器(Object)有一个原型对象(Object.prototype),则由该构造器创建的实例(obj)都必然复制自该原型对象。换言之,所谓“原型(Prototype)”,就是构造器用于生成实例的模板。而这样的“复制”就存在多种可能性,由此引申出动态绑定和静态绑定等问题。假如先不考虑“复制”如何被实现,至少我们可以关注到:由于实例(obj)复制自Object.prototype,所以它必然有了(或称为“继承了”)后者—原型对象—的所有属性、方法和其他性质。这也就是所谓继承性的实现。
构造复制?
上面的这个图例假设每构造一个实例,都从原型中复制出一个实例来,新的实例与原型占用了相同的内存空间。这虽然使得 obj1、obj2 与它们的原型“完全一致”,但也非常地不经济——内存空间的消耗会急速增加。
写时复制?
另一个策略来自于一种欺骗系统的技术:“写时复制”。这种机制的情况大致如下:
- 在系统中指明 obj1 和 obj2 等同于它们的原型
- 在读取的时候,只需要顺着指示去读原型
- 需要写对象(例如 obj2)的属性时,我们就复制一个原型的映象出来,并使以后的操作指向该映象
优缺点: 只在第一次写的时候会用一些代码来分配内存,并带来一些代码和内存上的开销。但此后这种开销就不再有了,因为访问映象与访问原型的效率是一致的。不过对于经常写操作的系统来说,这种法子并不比上一种法子经济。
读遍历
JavaScript 采用了第三种法子:把写复制的粒度从原型变成了成员。
仅当写某个实例的成员时,将成员的信息复制到实例的映象中。
这样一来,在初始构该对象时的局面仍与“图二”一致。但需要写对象属性(例如 obj2.value=10 )时,会产生一个名为 value 的属性值,放在 obj2 对象的成员列表中
我们发现:
- obj2是一个 指向原型的引用
在操作过程中并没有一个与原型相同大小的对象实例创建出来。这样一来,写操作并不导致大量的内存分配,因此内存的使用上就显得经济了。
- obj2(以及所有的对象实例)需要维护一张 成员列表 ,这个成员列表指向在 obj2 中发生了修改的成员名、值与类型。
对象的成员的存取规则:
- 规则1:保证在读取时被首先访问到即可。
- 规则2:如果在对象中没有指定属性,则尝试遍历对象的整个原型链,直到原型为空(null) 或找到该属性。
构造过程:从函数到构造器
上文阐述完“怎么得到对象”,但这并不是构造的全过程,接下来将继续阐述“函数作为一个构造器,都做了些什么”。
其实函数首先只是函数,尽管它有一个prototype成员。在默认情况下,所有函数的这个成员总是一个指向空白对象(标准的Object()构造器的实例),不过该实例创建后,constructor属性总是会先被赋值为当前函数。可以理解为如下代码:
// 简单版本的 asConstructor()
function asConstructor() {
return Object.assign(f, {
prototype: {constructor: f}
})
}
aClass = asContructor(new Function)
关于这一点很容易证明,因为delete运算符总是可以删去当前属性,而让成员存取到原型的属性值。所以:
function MyObject(){} // 一个函数构造器
console.log(MyObject.prototype.constructor===MyObject) // true
delete MyObject.prototype.constructor
console.log(MyObject.prototype.constructor===Object) // true
当函数有了prototype这个属性之后,它就变成了一个“构造器”。于是,当用户试图用new运算创建它的一个实例时,JavaScript引擎就再构造一个对象,并使该对象的原型链指向这个MyObject.prototype属性就可以了。
test = new MyObject()
test.constructor == MyObject.prototype.constructor
因此,函数与构造器并没有明显的界限,唯一的区别只在于原型prototype属性是不是一个有意义的值。例如,一个普通的JavaScript函数,如果不将它视作构造器,那么这个prototype属性就显得很多余了。
预定义属性与方法
上面讨论过:JavaScript 中的对象实例本质上只是“一个指向其原型的,并持有一个成员列表的结构”。这个结构最简单的表示法如下:
它所有的对象性质,来源于系统对原型的,以及对成员列表的理解。
“空的对象是所有对象的基础”。也就是 Object.prototype是所有对象的最顶层的原型。我们所谓的“空的对象 ”,以及“干净的对象”,只不过是满足以下条件的一个结构:
- proto 指向 Object.prototype;
- props 指向一个空表。
var empty = {}
var proto = Object.getPrototypeOf(empty)
var props = Object.getOwnPropertyNames(empty)
console.log(proto === Object.prototype) // true
console.log(props.length)
而这样的空白对象看起来“干净”,是因为原型链上没有显式成员:
// 显示非零值,表面原型链上的Object.prototype属性不是空的
var propsInchain = Object.getOwnPropertyDescriptors(Object.prototype)
Object.keys(propsInchain).length
// 显示 0,表面原型链上的Object.prototype没有可见属性
var enumerableMembers = Object.keys(propsInchain).filter(des=> des.enumerble)
enumerableMembers.length
更进一步的推论是:我们所有的“实例”,之所以具有对象的某些性质,是因为它们的共同原型 Object.prototype 具有某些性质。
对这些对象实例的这些性质做一个分类。 需要强调的是,某些性质并不是“原型继承 ”所必须的,而是 JavaScript 作为“动态语言 ”所必须的。
| 成员名 | 类型 | 分类 | |
|---|---|---|---|
| toString | function | 动态语言 | |
| toLocaleString | function | ||
| valueOf | function | ||
| constructor | function | 对象系统:构造 | |
| propertyIsEnumerable | function | 对象系统:属性 | |
| hasOwnProperty | function | ||
| isPrototypeOf | function | 对象系统:原型 |
对于一个具体的构造器来说,它除了具有上述普通对象的成员之外,还具有几个特别的、属于函数类型对象的成员
| 成员名 | 类型 | 分类 | |
|---|---|---|---|
| call | function | 动态语言 | |
| apply | function | ||
| bind | function | ||
| name | string | ||
| arguments | object | 函数式语言 | |
| length | number | ||
| caller | function | ||
| prototype | object | 对象系统:原型 |
原型链维护
原型链:对象所有的父类和祖先类的原型所形成的、可上溯访问的链表。
外部原型链与constructor属性
在ES6以前的JavaScript中,需要用户代码来维护一个外部原型链,也称之为“构造器原型链”。这个显式维护的链表是通过修改构造器的prototype属性来形成的,例如:
function MyObject() { }
function MyObjectEx() { }
MyObjectEx.prototype = new MyObject();
/**
* 创建对象实例
*/
obj1 = new MyObjectEx();
obj2 = new MyObjectEx();
这是一种基于传统原型继承风格来实现的对象系统。用户代码可以通过访问子类(例如MyObjectEx)的属性来找到父类(即MyObject),从而得到这样一个外部的、显式的原型链。例如:
// 子类的constructor属性指向了父类
MyObjectEx.prototype.constructor === MyObject
然而这也会导致子类实例拥有一个错误的constructor属性。测试如下:
// 接上
obj = new MyObjectEx(); // MyObjectEx 构建的实例
console.log(obj.constructor === MyObject) // true
上例(错误地)暗示了MyObjectEx()的对象的构造器是MyObject。为了解决这一问题,用户代码还需要显式维护原型的constructor属性:
function MyObject() { }
function MyObjectEx() { }
MyObjectEx.prototype = new MyObject();
MyObjectEx.prototype.constructor = MyObjectEx
然而由于覆盖了原型的constructor属性,原型与父类之间的关系(即原型链)就被切断了。因此在早期JavaScript中,外部原型链与有效的constructor属性只能二取其一。但是还是有一些框架提出了一种编写构造器函数的模式,可以兼得二者之利。例如:
// 构造器声明
function MyObject() { }
// 子类的一种实现模式
function MyObjectEx() {
this.constructor = MyObjectEx
}
// 构建外部原型链
MyObjectEx.prototype = new MyObject();
这样一来,MyObjectEx()构造的实例的constructor属性都正确地指向MyObjectEx(),而原型的constructor则指向MyObject()。但是因为每次构造实例时都要重写constructor属性,所以它效率较低。
MyObjectEx.prototype.constructor === MyObject //true
obj.constructor === MyObjectEx // true
内部原型链
面向对象的继承性约定:子类与父类具有相似性。
因此为了达成这种一致性且保证它不被修改,ECMAScript约定对象实例必须在内部持有该对象的原型。并且,ECMAScript还进一步规范了存取这个内部原型的标准方法,这就是Object.getPrototypeOf()和Object.setPrototypeOf()。
Object.getPrototypeOf()方法需要传入一个对象实例作为参数,用于获取该对象的原型。
而Object.setPrototypeOf()就用于重写内部原型,以切断对象与它的构造器或类之间的关系,或者使对象实例“变成”基于其他原型,从而得到新的内部原型链。
原型继承的实质
我们前面讲过“原型继承系统”的特性,包括:
- 原型是一个对象;
- 在属性访问时,如果子类对象没有,则访问其原型的成员列表。
我们如果修改一个构造器(或称为类)的原型,则所有由该类创建的实例都将受到影响——如果有其它子类继承自该类,则所有子类也将受到影响——因为在存取成员列表时必将回溯到该类。 正是 JavaScript 为每个构造器初始化了一个“空的对象”,才使我们可以用“原型修改”的方法重写构造器(类)的成员列表,而子类才可以继承这些属性。
修改原型是 JavaScript 中最常用的构建类系统的方法,它的好处在于可以在实例构造之后“动态地”影响到这些实例。也就是说,实例(对象)的特性不但可以在 new 运算中通过“构造”来产生,也可以在此后通过原型 “修改”来产生。
动态修改原型的特性,是我们原型继承的基础。
原型继承与原型修改——原型继承的实质:从无到有
- 原型继承:关注于继承对象(在类属关系上)的层次,
- 原型修改:关注具体对象实例的行为特性。
在 JavaScript 中,原型的这两方面的特性是相互独立的,这也构成了“基于原型继承的对象系统”最独特的设计观念:
将对象(类)的继承关系,与对象(类)的行为描述分离。
这与“基于类继承的对象系统”存在本质的不同。因为基于类继承设计时 ,我们必须预先考虑好某个类“是或者否”具有某种属性、方法与特质(Attribute),如果某个类的成员设计得不正确,则它的子类、接口以及实例等等在使用中都将遇到问题。因而“重构”是必然、经常和更易出错的。
基于原型的类继承有一下两个特性
- “类属关系的继承性”总是一开始就能被设计正确的;
- 成员的修改是正常的、标准的构造对象系统的方法。
在这里,所谓“从无到有 ”是指:在理论上我们可以先构建一个“没有任何成员”的类属关系的继承系统,然后通过“不断地修改原型”,从而获得一个完整的对象系统。
/**
* 公共函数: 子类派生 extend()
*/
extend = function(subClass, baseClass) {
// 暂存父类构造器
subClass.baseConstructor = baseClass;
subClass.base = {};
// 复制父类特性(属性与方法)
baseClass.call(subClass.base);
}
/**
* 构建对象系统
*/
function Mouse() { /* 测试用 */ }
function Animal(name) {
this.name = name;
this.say = function(message) {
alert(this.name + ": " + message);
}
this.eat = function() {
this.say("Yum!");
}
}
function Cat() {
Cat.baseConstructor.call(this, 'cat');
this.eat = function(food) {
if (food instanceof Mouse)
Cat.base.eat.call(this);
else
this.say("Yuk! I only eat mice - not " + food.name);
}
}
extend(Cat, Animal);
function Lion() {
Lion.baseConstructor.call(this, 'lion');
}
extend(Lion, Cat);
/**
* 测试
*/
var cat = new Cat();
var lion = new Lion();
var mouse = new Mouse();
var unknowObj = {};
cat.eat(mouse); // Yum!
cat.eat(unknowObj); // Yuk! I only eat mice - not undefined
lion.eat(mouse); // Yum!