第五章: 原型 - “类”
“类”
现在你可能会想知道:“为什么 一个对象需要链到另一个对象?” 真正的好处是什么?这是一个很恰当的问题,但在我们能够完全理解和体味它是什么和如何有用之前,我们必须首先理解 [[Prototype]]
不是 什么。
正如我们在第四章讲解的,在 JavaScript 中,对于对象来说没有抽象模式/蓝图,即没有面向类的语言中那样的称为类的东西。JavaScript 只有 对象。
实际上,在所有语言中,JavaScript 几乎是独一无二的,也许是唯一的可以被称为“面向对象”的语言,因为可以根本没有类而直接创建对象的语言很少,而 JavaScript 就是其中之一。
在 JavaScript 中,类不能(因为根本不存在)描述对象可以做什么。对象直接定义它自己的行为。这里 仅有 对象。
“类”函数
在 JavaScript 中有一种奇异的行为被无耻地滥用了许多年来 山寨 成某些 看起来 像“类”的东西。我们来仔细看看这种方式。
“某种程度的类” 这种奇特的行为取决于函数的一个奇怪的性质:所有的函数默认都会得到一个公有的,不可枚举的属性,称为 prototype
,它可以指向任意的对象。
function Foo() {
// ...
}
Foo.prototype; // { }
这个对象经常被称为 “Foo 的原型”,因为我们通过一个不幸地被命名为 Foo.prototype
的属性引用来访问它。然而,我们马上会看到,这个术语命中注定地将我们搞糊涂。为了取代它,我将它称为 “以前被认为是 Foo 的原型的对象”。只是开个玩笑。“一个被随意标记为‘Foo 点儿原型’的对象”,怎么样?
不管我们怎么称呼它,这个对象到底是什么?
解释它的最直接的方法是,每个由调用 new Foo()
(见第二章)而创建的对象将最终(有些随意地)被 [[Prototype]]
链接到这个 “Foo 点儿原型” 对象。
让我们描绘一下:
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true
当通过调用 new Foo()
创建 a
时,会发生的事情之一(见第二章了解所有 四个 步骤)是,a
得到一个内部 [[Prototype]]
链接,此链接链到 Foo.prototype
所指向的对象。
停一会来思考一下这句话的含义。
在面向类的语言中,可以制造一个类的多个 拷贝(即“实例”),就像从模具中冲压出某些东西一样。我们在第四章中看到,这是因为初始化(或者继承)类的处理意味着,“将行为计划从这个类拷贝到物理对象中”,对于每个新实例这都会发生。
但是在 JavaScript 中,没有这样的拷贝处理发生。你不会创建类的多个实例。你可以创建多个对象,它们的 [[Prototype]]
连接至一个共通对象。但默认地,没有拷贝发生,如此这些对象彼此间最终不会完全分离和切断关系,而是 链接在一起。
new Foo()
得到一个新对象(我们叫他 a
),这个新对象 a
内部地被 [[Prototype]]
链接至 Foo.prototype
对象。
结果我们得到两个对象,彼此链接。 如是而已。我们没有初始化一个对象。当然我们也没有做任何从一个“类”到一个实体对象的拷贝。我们只是让两个对象互相链接在一起。
事实上,这个使大多数 JS 开发者无法理解的秘密,是因为 new Foo()
函数调用实际上几乎和建立链接的处理没有任何 直接 关系。它是某种偶然的副作用。 new Foo()
是一个间接的,迂回的方法来得到我们想要的:一个被链接到另一个对象的对象。
我们能用更直接的方法得到我们想要的吗?可以! 这位英雄就是 Object.create(..)
。我们过会儿就谈到它。
名称的意义何在?
在 JavaScript 中,我们不从一个对象(“类”)向另一个对象(“实例”) 拷贝。我们在对象之间制造 链接。对于 [[Prototype]]
机制,视觉上,箭头的移动方向是从右至左,由下至上。
这种机制常被称为“原型继承(prototypal inheritance)”(我们很快就用代码说明),它经常被说成是动态语言版的“类继承”。这种说法试图建立在面向类世界中对“继承”含义的共识上。但是 弄拧(意思是:抹平) 了被理解的语义,来适应动态脚本。
先入为主,“继承”这个词有很强烈的含义(见第四章)。仅仅在它前面加入“原型”来区别于 JavaScript 中 实际上几乎相反 的行为,使真相在泥泞般的困惑中沉睡了近二十年。
我想说,将“原型”贴在“继承”之前很大程度上搞反了它的实际意义,就像一只手拿着一个桔子,另一手拿着一个苹果,而坚持说苹果是一个“红色的桔子”。无论我在它前面放什么令人困惑的标签,那都不会改变一个水果是苹果而另一个是桔子的 事实。
更好的方法是直白地将苹果称为苹果——使用最准确和最直接的术语。这样能更容易地理解它们的相似之处和 许多不同之处,因为我们都对“苹果”的意义有一个简单的,共享的理解。
由于用语的模糊和歧义,我相信,对于解释 JavaScript 机制真正如何工作来说,“原型继承”这个标签(以及试图错误地应用所有面向类的术语,比如“类”,“构造器”,“实例”,“多态”等)本身带来的 危害比好处多。
“继承”意味着 拷贝 操作,而 JavaScript 不拷贝对象属性(原生上,默认地)。相反,JS 在两个对象间建立链接,一个对象实质上可以将对属性/函数的访问 委托 到另一个对象上。对于描述 JavaScript 对象链接机制来说,“委托”是一个准确得多的术语。
另一个有时被扔到 JavaScript 旁边的术语是“差分继承”。它的想法是,我们可以用一个对象与一个更泛化的对象的 不同 来描述一个它的行为。比如,你要解释汽车是一种载具,与其重新描述组成一个一般载具的所有特点,不如只说它有四个轮子。
如果你试着想象,在 JS 中任何给定的对象都是通过委托可用的所有行为的总和,而且 在你思维中你扁平化 所有的行为到一个有形的 东西 中,那么你就可以(八九不离十地)看到“差分继承”是如何自圆其说的。
但正如“原型继承”,“差分继承”假意使你的思维模型比在语言中物理发生的事情更重要。它忽视了这样一个事实:对象 B
实际上不是一个差异结构,而是由一些定义好的特定性质,与一些没有任何定义的“漏洞”组成的。正是通过这些“漏洞”(缺少定义),委托可以接管并且动态地用委托行为“填补”它们。
对象不是像“差分继承”的思维模型所暗示的那样,原生默认地,通过拷贝 扁平化到一个单独的差异对象中。因此,对于描述 JavaScript 的 [[Prototype]]
机制如何工作来说,“差分继承”就不是自然合理。
你 可以选择 偏向“差分继承”这个术语和思维模型,这是个人口味的问题,但是不能否认这个事实:它 仅仅 符合你思维中的主观过程,不是引擎的物理行为。
“构造器”(Constructors)
让我们回到早先的代码:
function Foo() {
// ...
}
var a = new Foo();
到底是什么导致我们认为 Foo
是一个“类”?
其一,我们看到了 new
关键字的使用,就像面向类语言中人们构建类的对象那样。另外,它看起来我们事实上执行了一个类的 构造器 方法,因为 Foo()
实际上是个被调用的方法,就像当你初始化一个真实的类时这个类的构造器被调用的那样。
为了使“构造器”的语义更令人糊涂,被随意贴上标签的 Foo.prototype
对象还有另外一招。考虑这段代码:
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
Foo.prototype
对象默认地(就在代码段中第一行中声明的地方!)得到一个公有的,称为 .constructor
的不可枚举(见第三章)属性,而且这个属性回头指向这个对象关联的函数(这里是 Foo
)。另外,我们看到被“构造器”调用 new Foo()
创建的对象 a
看起来 也拥有一个称为 .constructor
的属性,也相似地指向“创建它的函数”。
注意: 这实际上不是真的。a
上没有 .constructor
属性,而 a.constructor
确实解析成了 Foo
函数,“constructor”并不像它看起来的那样实际意味着“被XX创建”。我们很快就会解释这个奇怪的地方。
哦,是的,另外…… 根据 JavaScript 世界中的惯例,“类”都以大写字母开头的单词命名,所以使用 Foo
而不是 foo
强烈地意味着我们打算让它成为一个“类”。这对你来说太明显了,对吧!?
注意: 这个惯例是如此强大,以至于如果你在一个小写字母名称的方法上使用 new
调用,或并没有在一个大写字母开头的函数上使用 new
,许多 JS 语法检查器将会报告错误。这是因为我们如此努力地想要在 JavaScript 中将(假的)“面向类” 搞对,所以我们建立了这些语法规则来确保我们使用了大写字母,即便对 JS 引擎来讲,大写字母根本没有 任何意义。
构造器还是调用?
上面的代码的段中,我们试图认为 Foo
是一个“构造器”,是因为我们用 new
调用它,而且我们观察到它“构建”了一个对象。
在现实中,Foo
不会比你的程序中的其他任何函数“更像构造器”。函数自身 不是 构造器。但是,当你在普通函数调用前面放一个 new
关键字时,这就将函数调用变成了“构造器调用”。事实上,new
在某种意义上劫持了普通函数并将它以另一种方式调用:构建一个对象,外加这个函数要做的其他任何事。
举个例子:
function NothingSpecial() {
console.log( "Don't mind me!" );
}
var a = new NothingSpecial();
// "Don't mind me!"
a; // {}
NothingSpecial
仅仅是一个普通的函数,但当用 new
调用时,几乎是一种副作用,它会 构建 一个对象,并被我们赋值到 a
。这个 调用 是一个 构造器调用,但是 NothingSpecial
本身并不是一个 构造器。
换句话说,在 JavaScrip t中,更合适的说法是,“构造器”是在前面 用 new
关键字调用的任何函数。
函数不是构造器,但是当且仅当 new
被使用时,函数调用是一个“构造器调用”。
机制
仅仅是这些原因使得 JavaScript 中关于“类”的讨论变得命运多舛吗?
不全是。 JS 开发者们努力尽可能地模拟面向类:
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
var a = new Foo( "a" );
var b = new Foo( "b" );
a.myName(); // "a"
b.myName(); // "b"
这段代码展示了另外两种“面向类”的花招:
this.name = name
:在每个对象(分别在a
和b
上;参照第二章关于this
绑定的内容)上添加了.name
属性,和类的实例包装数据值很相似。Foo.prototype.myName = ...
:这也许是更有趣的技术,它在Foo.prototype
对象上添加了一个属性(函数)。现在,也许让人惊奇,a.myName()
可以工作。但是是如何工作的?
在上面的代码段中,有很强的倾向认为当 a
和 b
被创建时,Foo.prototype
上的属性/函数被 拷贝 到了 a
与 b
俩个对象上。但是,这没有发生。
在本章开头,我们解释了 [[Prototype]]
链,以及它如何作为默认的 [[Get]]
算法的一部分,在不能直接在对象上找到属性引用时提供后备的查询步骤。
于是,得益于他们被创建的方式,a
和 b
都最终拥有一个内部的 [[Prototype]]
链接链到 Foo.prototype
。当无法分别在 a
和 b
中找到 myName
时,就会在 Foo.prototype
上找到(通过委托,见第六章)。
复活“构造器”
回想我们刚才对 .constructor
属性的讨论,怎么看起来 a.constructor === Foo
为 true 意味着 a
上实际拥有一个 .constructor
属性,指向 Foo
?不对。
这只是一种不幸的混淆。实际上,.constructor
引用也 委托 到了 Foo.prototype
,它 恰好 有一个指向 Foo
的默认属性。
这 看起来 方便得可怕,一个被 Foo
构建的对象可以访问指向 Foo
的 .constructor
属性。但这只不过是安全感上的错觉。它是一个欢乐的巧合,几乎是误打误撞,通过默认的 [[Prototype]]
委托 a.constructor
恰好 指向 Foo
。实际上 .construcor
意味着“被XX构建”这种注定失败的臆测会以几种方式来咬到你。
第一,在 Foo.prototype
上的 .constructor
属性仅当 Foo
函数被声明时才出现在对象上。如果你创建一个新对象,并用它替换函数默认的 .prototype
对象引用,这个新对象上将不会魔法般地得到 .contructor
。
考虑这段代码:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新的 prototype 对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
Object(..)
没有“构建” a1
,是吧?看起来确实是 Foo()
“构建了”它。许多开发者认为 Foo()
在执行构建,但当你认为“构造器”意味着“被XX构建”时,一切就都崩塌了,因为如果那样的话,a1.construcor
应当是 Foo
,但它不是!
发生了什么?a1
没有 .constructor
属性,所以它沿者 [[Prototype]]
链向上委托到了 Foo.prototype
。但是这个对象也没有 .constructor
(默认的 Foo.prototype
对象就会有!),所以它继续委托,这次轮到了 Object.prototype
,委托链的最顶端。那个 对象上确实拥有 .constructor
,它指向内建的 Object(..)
函数。
误解,消除。
当然,你可以把 .constructor
加回到 Foo.prototype
对象上,但是要做一些手动工作,特别是如果你想要它与原生的行为吻合,并不可枚举时(见第三章)。
举例来说:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新的 prototype 对象
// 需要正确地“修复”丢失的 `.construcor`
// 新对象上的属性以 `Foo.prototype` 的形式提供。
// `defineProperty(..)` 的内容见第三章。
Object.defineProperty( Foo.prototype, "constructor" , {
enumerable: false,
writable: true,
configurable: true,
value: Foo // 使 `.constructor` 指向 `Foo`
} );
修复 .constructor
要花不少功夫。而且,我们做的一切是为了延续“构造器”意味着“被XX构建”的误解。这是一种昂贵的假象。
事实上,一个对象上的 .construcor
默认地随意指向一个函数,而这个函数反过来拥有一个指向被这个对象称为 .prototype
的对象。“构造器”和“原型”这两个词仅有松散的默认含义,可能是真的也可能不是真的。最佳方案是提醒你自己,“构造器不是意味着被XX构建”。
.constructor
不是一个魔法般不可变的属性。它是不可枚举的(见上面的代码段),但是它的值是可写的(可以改变),而且,你可以用你感觉合适的任何值在 [[Prototype]]
链上的任何对象上添加或覆盖(有意或无意地)名为 constructor
的属性。
根据 [[Get]]
算法如何遍历 [[Prototype]]
链,在任何地方找到的一个 .constructor
属性引用解析的结果可能与你期望的十分不同。
看到它的实际意义有多随便了吗?
结果?某些像 a1.constructor
这样随意的对象属性引用实际上不能被认为是默认的函数引用。还有,我们马上就会看到,通过一个简单的省略,a1.constructor
可以最终指向某些令人诧异,没道理的地方。
a1.constructor
是极其不可靠的,在你的代码中不应依赖的不安全引用。一般来说,这样的引用应当尽量避免。