第五章: 原型 - “类”

优质
小牛编辑
129浏览
2023-12-01

“类”

现在你可能会想知道:“为什么 一个对象需要链到另一个对象?” 真正的好处是什么?这是一个很恰当的问题,但在我们能够完全理解和体味它是什么和如何有用之前,我们必须首先理解 [[Prototype]] 不是 什么。

正如我们在第四章讲解的,在 JavaScript 中,对于对象来说没有抽象模式/蓝图,即没有面向类的语言中那样的称为类的东西。JavaScript 只有 对象。

实际上,在所有语言中,JavaScript 几乎是独一无二的,也许是唯一的可以被称为“面向对象”的语言,因为可以根本没有类而直接创建对象的语言很少,而 JavaScript 就是其中之一。

在 JavaScript 中,类不能(因为根本不存在)描述对象可以做什么。对象直接定义它自己的行为。这里 仅有 对象

“类”函数

在 JavaScript 中有一种奇异的行为被无耻地滥用了许多年来 山寨 成某些 看起来 像“类”的东西。我们来仔细看看这种方式。

“某种程度的类” 这种奇特的行为取决于函数的一个奇怪的性质:所有的函数默认都会得到一个公有的,不可枚举的属性,称为 prototype,它可以指向任意的对象。

  1. function Foo() {
  2. // ...
  3. }
  4. Foo.prototype; // { }

这个对象经常被称为 “Foo 的原型”,因为我们通过一个不幸地被命名为 Foo.prototype 的属性引用来访问它。然而,我们马上会看到,这个术语命中注定地将我们搞糊涂。为了取代它,我将它称为 “以前被认为是 Foo 的原型的对象”。只是开个玩笑。“一个被随意标记为‘Foo 点儿原型’的对象”,怎么样?

不管我们怎么称呼它,这个对象到底是什么?

解释它的最直接的方法是,每个由调用 new Foo()(见第二章)而创建的对象将最终(有些随意地)被 [[Prototype]] 链接到这个 “Foo 点儿原型” 对象。

让我们描绘一下:

  1. function Foo() {
  2. // ...
  3. }
  4. var a = new Foo();
  5. 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)

让我们回到早先的代码:

  1. function Foo() {
  2. // ...
  3. }
  4. var a = new Foo();

到底是什么导致我们认为 Foo 是一个“类”?

其一,我们看到了 new 关键字的使用,就像面向类语言中人们构建类的对象那样。另外,它看起来我们事实上执行了一个类的 构造器 方法,因为 Foo() 实际上是个被调用的方法,就像当你初始化一个真实的类时这个类的构造器被调用的那样。

为了使“构造器”的语义更令人糊涂,被随意贴上标签的 Foo.prototype 对象还有另外一招。考虑这段代码:

  1. function Foo() {
  2. // ...
  3. }
  4. Foo.prototype.constructor === Foo; // true
  5. var a = new Foo();
  6. 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 在某种意义上劫持了普通函数并将它以另一种方式调用:构建一个对象,外加这个函数要做的其他任何事

举个例子:

  1. function NothingSpecial() {
  2. console.log( "Don't mind me!" );
  3. }
  4. var a = new NothingSpecial();
  5. // "Don't mind me!"
  6. a; // {}

NothingSpecial 仅仅是一个普通的函数,但当用 new 调用时,几乎是一种副作用,它会 构建 一个对象,并被我们赋值到 a。这个 调用 是一个 构造器调用,但是 NothingSpecial 本身并不是一个 构造器

换句话说,在 JavaScrip t中,更合适的说法是,“构造器”是在前面 new 关键字调用的任何函数

函数不是构造器,但是当且仅当 new 被使用时,函数调用是一个“构造器调用”。

机制

仅仅是这些原因使得 JavaScript 中关于“类”的讨论变得命运多舛吗?

不全是。 JS 开发者们努力尽可能地模拟面向类:

  1. function Foo(name) {
  2. this.name = name;
  3. }
  4. Foo.prototype.myName = function() {
  5. return this.name;
  6. };
  7. var a = new Foo( "a" );
  8. var b = new Foo( "b" );
  9. a.myName(); // "a"
  10. b.myName(); // "b"

这段代码展示了另外两种“面向类”的花招:

  1. this.name = name:在每个对象(分别在 ab 上;参照第二章关于 this 绑定的内容)上添加了 .name 属性,和类的实例包装数据值很相似。

  2. Foo.prototype.myName = ...:这也许是更有趣的技术,它在 Foo.prototype 对象上添加了一个属性(函数)。现在,也许让人惊奇,a.myName() 可以工作。但是是如何工作的?

在上面的代码段中,有很强的倾向认为当 ab 被创建时,Foo.prototype 上的属性/函数被 拷贝 到了 ab 俩个对象上。但是,这没有发生。

在本章开头,我们解释了 [[Prototype]] 链,以及它如何作为默认的 [[Get]] 算法的一部分,在不能直接在对象上找到属性引用时提供后备的查询步骤。

于是,得益于他们被创建的方式,ab 都最终拥有一个内部的 [[Prototype]] 链接链到 Foo.prototype。当无法分别在 ab 中找到 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

考虑这段代码:

  1. function Foo() { /* .. */ }
  2. Foo.prototype = { /* .. */ }; // 创建一个新的 prototype 对象
  3. var a1 = new Foo();
  4. a1.constructor === Foo; // false!
  5. 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 对象上,但是要做一些手动工作,特别是如果你想要它与原生的行为吻合,并不可枚举时(见第三章)。

举例来说:

  1. function Foo() { /* .. */ }
  2. Foo.prototype = { /* .. */ }; // 创建一个新的 prototype 对象
  3. // 需要正确地“修复”丢失的 `.construcor`
  4. // 新对象上的属性以 `Foo.prototype` 的形式提供。
  5. // `defineProperty(..)` 的内容见第三章。
  6. Object.defineProperty( Foo.prototype, "constructor" , {
  7. enumerable: false,
  8. writable: true,
  9. configurable: true,
  10. value: Foo // 使 `.constructor` 指向 `Foo`
  11. } );

修复 .constructor 要花不少功夫。而且,我们做的一切是为了延续“构造器”意味着“被XX构建”的误解。这是一种昂贵的假象。

事实上,一个对象上的 .construcor 默认地随意指向一个函数,而这个函数反过来拥有一个指向被这个对象称为 .prototype 的对象。“构造器”和“原型”这两个词仅有松散的默认含义,可能是真的也可能不是真的。最佳方案是提醒你自己,“构造器不是意味着被XX构建”。

.constructor 不是一个魔法般不可变的属性。它是不可枚举的(见上面的代码段),但是它的值是可写的(可以改变),而且,你可以用你感觉合适的任何值在 [[Prototype]] 链上的任何对象上添加或覆盖(有意或无意地)名为 constructor 的属性。

根据 [[Get]] 算法如何遍历 [[Prototype]] 链,在任何地方找到的一个 .constructor 属性引用解析的结果可能与你期望的十分不同。

看到它的实际意义有多随便了吗?

结果?某些像 a1.constructor 这样随意的对象属性引用实际上不能被认为是默认的函数引用。还有,我们马上就会看到,通过一个简单的省略,a1.constructor 可以最终指向某些令人诧异,没道理的地方。

a1.constructor 是极其不可靠的,在你的代码中不应依赖的不安全引用。一般来说,这样的引用应当尽量避免。