Files
CategoryResourceRepost/极客时间专栏/geek/JavaScript核心原理解析/从原型到类:JavaScript是如何一步步走向应用编程语言的/14 | super.xxx():虽然直到ES10还是个半吊子实现,却也值得一讲.md
louzefeng bf99793fd0 del
2024-07-09 18:38:56 +00:00

18 KiB
Raw Blame History

你好我是周爱民接下来我们继续讲述JavaScript中的那些奇幻代码。

今天要说的内容打根儿里起还是得从JavaScript的1.0谈起。在此前我已经讲过了JavaScript 1.0连继承都没有但是它实现了以“类抄写”为基础的、基本的面向对象模型。而在此之后才在JavaScript 1.1开始提出,并在后来逐渐完善了原型继承。

这样一来在JavaScript中从概念上来讲所谓对象就是一个从原型对象衍生过来的实例因此这个子级的对象也就具有原型对象的全部特征。

然而,既然是子级的对象,必然与它原型的对象有所不同。这一点很好理解,如果没有不同,那就没有必要派生出一级关系,直接使用原型的那一个抽象层级就可以了。

所以有了原型继承带来的子级对象这样的抽象层级在这个子级对象上就还需要有让它们跟原型表现得有所不同的方法。这时JavaScript 1.0里面的那个“类抄写”的特性就跳出来了它正好可以通过“抄写”往对象也就是构造出来的那个this上面添加些东西来制造这种不同。

也就是说JavaScript 1.1的面向对象系统的设计原则就是:用原型来实现继承,并在类(也就是构造器)中处理子一级的抽象差异。所以从JavaScript 1.1开始JavaScript有了自己的面向对象系统的完整方案这个示例代码大概如下

// 这里用于处理“不同的东西”
function CarEx(color) {
  this.color = color;
  ...
}

// 这里用于从父类继承“相同的东西”
CarEx.prototype = new Car("Eagle", "Talon TSi", 1993);

// 创建对象
myCar = new CarEx("red")

这个方案基本上来说就是两个解决思路的集合使用构造器函数来处理一些“不同的东西”使用原型继承来从父类继承“相同的东西”。最后new运算符在创建对象的过程中分别处理“原型继承”和构造器函数中的“类抄写”补齐了最后的一块木板。

你看,一个对象系统既能处理继承关系中那些“相同的东西”,又能处理“不同的东西”,所以显而易见:这个系统能处理基于对象的“全部的东西”。正是因为这种概念上的完整性所以从JavaScript 1.1开始一直到ECMAScript 5都在对象系统的设计上没能再有什么突破。

为什么要有super

但是有一个东西很奇怪,这也是对象继承的典型需求,就是说:子级的对象除了要继承父级的“全部的东西”之外,它还要继承“全部的能力”。

为什么只继承“全部的东西”还不够呢?如果只有全部的东西,那子级相对于父级,不过是一个系统的静态变化而已。就好像一棵枯死了的树,往上面添加些人造的塑料的叶子、假的果子,看起来还是树,可能还很好看,但根底里就是没有生命力的。而这样的一棵树,只有继承了原有的树的生命力,才可能是一棵活着的树。

如果继承来的树是活着的,那么装不装那些人造的叶子、果子,其实就不要紧了。

然而传统的JavaScript却做不到“继承全部的能力”。那个时候的JavaScript其实是能够在一定程度上继承来自原型的“部分能力”的譬如说原型有一个方法那么子级的实例就可以使用这个方法这时候子级也就继承了原型的能力。

然而这还不够。譬如说,如果子级的对象重写了这个方法,那么会怎么样呢?

在ECMAScript 6之前如果发生这样的事那么对不起原型中的这个方法相对于子级对象来说,就失效了。

原则上来讲,在子级对象中就再也找不到这个原型的方法了。这个问题非常地致命:这意味着子级对象必须重新实现原型中的能力,才能安全地覆盖原型中的方法。如果是这样,子级对象就等于要重新实现一遍原型,那继承性就毫无意义了。

这个问题追根溯源还是要怪到JavaScript 1.0~1.1的时候,设计面向对象模型时偷了的那一次懒。也就是直接将“类抄写”用于实现子级差异的这个原始设计,太过于简陋。“类抄写”只能处理那些显而易见的属性、属性名、属性性质,等等,却无法处理那些“方法/行为”背后的逻辑的继承。

由于这个缘故JavaScript 1.1之后的各种大规模系统中,都有人不断地在跳坑和补坑,致力于解决这么一个简单的问题:在“类抄写”导致的子类覆盖中,父类的能力丢失了

为了解决这种继承问题ECMAScript 6就提出了一个标准解决方案这就是今天我们讲述的这一行代码中“super”这个关键字的由来。ECMAScript 6约定如果父类中的名字被覆盖了那么你可以在子类中用super来找到它们。

super指向什么

既然我们知道super出现的目的就是解决父类的能力丢失这一问题那么我们也就很容易理解一个特殊的语言设计了在JavaScript中super只能在方法中使用。所谓方法其实就是“类的或者对象的能力”super正是用来弥补覆盖父类同名方法所导致的缺陷因此只能出现在方法之中这也就是很显而易见的事情了。

当然,从语言内核的角度上来说,这里还存在着一个严重的设计限制,这个问题是:怎么找到父类?

在传统的JavaScript中所谓方法就是函数类型的属性也就是说它与一般属性并没有什么不同可以被不同的对象抄写来抄写去。其实方法与普通属性没有区别也是“类抄写”机制得以实现的核心依赖条件之一。然而这也就意味着所谓“传统的方法”没有特殊性也就没有“归属于哪个类或哪个对象”这样的性质。因此这样的方法根本上也就找不到它自己所谓的类进而也就找不到它的父类。

所以实现super这个关键字的核心在于为每一个方法添加一个“它所属的类”这样的性质这个性质被称为“主对象HomeObject”。

所有在ECMAScript 6之后通过方法声明语法得到的“方法”虽然仍然是函数类型但是与传统的“函数类型的属性即传统的对象方法”存在着一个根本上的不同这些新的方法增加了一个内部槽用来存放这个主对象也就是ECMAScript规范中名为HomeObject的那个内部槽。这个主对象就用来对在类声明,或者字面量风格的对象声明中,(使用方法声明语法)所声明的那些方法的主对象做个登记。这有三种情况:

  1. 在类声明中如果是类静态声明也就是使用static声明的方法那么主对象就是这个类例如AClass。
  2. 就是一般声明那么该方法的主对象就是该类所使用的原型也就是AClass.prototype。
  3. 第三种情况,如果是对象声明,那么方法的主对象就是对象本身。

但这里就存在一个问题了super指向的是父类但是对象字面量并不是基于类继承的那么为什么字面量中声明的方法又能使用super.xxx既然对象本身不是类那么super“指向父类”或者“用于解决覆盖父类能力”的含义岂不是就没了?

这其实又回到了JavaScript 1.1的那项基础设计中,也就是“用原型来实现继承”。

原型就是一个对象,也就是说本质上子类或父类都是对象;而所谓的类声明只是这种继承关系的一个载体,真正继承的还是那个原型对象本身。既然子类和父类都可能是,或者说必须是对象,那么对象上的方法访问“父一级的原型上的方法”就是必然存在的逻辑了。

出于这个缘故在JavaScript中只要是方法——并且这个方法可以在声明时明确它的“主对象HomeObject那么它就可以使用super。这样一来对象方法也就可以引用到它父级原型中的方法了。这一点其实也是“利用原型继承和类抄写”来实现面向对象系统时在概念设计上的一个额外的负担。

但接下来所谓“怎么找到父类”的问题就变得简单了当每一个方法都在其内部登记了它的主对象之后ECMAScript约定只需要在方法中取出这个主对象HomeObject那么它的原型就一定是所谓的父类。这很明显因为方法登记的是它声明时所在的代码块的HomeObject也就是声明时它所在的类或对象所以这个HomeObject的原型就一定是父类。也就是把“通过原型继承得到子类”的概念反过来用一下就得到了父类的概念。

super.xxx()

我们今天讲的内容到现在为止只说明了两件事。第一件是为什么要有super第二件就是super指向什么。

接下来我们要讲super.xxx。简单地说这就是个属性存取。这从语法上一看就明白了似乎是没有什么特殊的对吧未必如此

回顾一下我们在第7讲中讲述到的内容super.xxx在语法上只是属性存取但super.xxx()却是方法调用而且super.xxx()是表达式计算中罕见的、在双表达式连用中传递引用的一个语法。

所以关键不是在于super.xxx如何存取属性而在于super.xxx存取到的属性在JavaScript内核中是一个“引用”。按照语法设计这个引用包括了左侧的对象并且在它连用“函数调用”语法的时候将这个左侧的对象作为this引用传入给后者。

更确切地说,假如我们要问“在 super.xxx()调用时,函数xxx()中得到的this是什么”那么按照传统的属性存取语法可以推论出来的答案是这个this值应该是super

但是很不幸,这不是真的。

super.xxx()中的this值

在super.xxx()这个语法中xxx()函数中得到的this值与super——没有“一点”关系不过还是有“半点”关系的。不过在具体讲这“半点”关系之前呢我需要先讲讲它会得到一个怎样的this以及如何能得到这个this。

super总是在一个方法如下例中的obj.foo函数中才能引用。这是我们今天这一讲前半段中所讨论的。这个方法自己被调用的时候理论上来说应该是在一个foo()方法内使用的、类似super.xxx()这样的代码。

obj = {
  foo() {
    super.xxx();
  }
}

// 调用foo方法
obj.foo();

这样在调用这个foo()方法时它总是会将obj传入作为this所以foo()函数内的this就该是obj。而我们看看其中的super.xxx()我们期望它调用父类的xxx()方法时传入的当前实例也就是obj正好在是在foo()函数内的那个this其实也就是obj。继承来的行为应该是施加给现实中的当前对象的施加给原型也就是这里的super是没什么用的。所以在这几个操作符的连续运算中只需要把当前函数中的那个this传给父类xxx()方法就行了。

然而怎么传呢?

我们说过super.xxx在语言内核上是一个“规范类型中的引用”ECMAScript约定将这个语法标记成“Super引用SuperReference并且为这个引用专门添加了一个thisValue域。这个域其实在函数的上下文中也有一个相同名字的也是相同的含义。然后ECMAScript约定了优先取Super引用中的thisValue值然后再取函数上下文中的。

所谓函数上下文之前略讲过一点就是函数在调用的时候创建的那个用于调度执行的东西而这个thisValue值就放在它的环境记录里面也就可以理解成函数执行环境的一部分。

如此一来在函数也就是我们这里的方法中取super的this值时就得到了为super专门设置的这个this对象。而且事实上这个thisValue是在执行引擎发现super这个标识符GetIdentifierReference的时候就从当前环境中取出来并绑定给super引用的。

回顾上述过程super.xxx()这个调用中有两个细节需要你多加注意:

  1. super关键字所代表的父类对象是通过当前方法的HomeObject的原型链来查找的;
  2. this引用是从当前环境所绑定的this中抄写过来并绑定给super的。

为什么要关注上面这两个特别特别小的细节呢?

我们知道在构造方法中this引用也就是将要构造出来的对象实例事实上是由祖先类创建的。关于这一点如果你印象不深了请回顾一下上一讲也就是第13讲 “new X”的内容。那么既然this是祖先类创建的也就意味着在刚刚进入构造方法时this引用其实是没有值的必须采用我们这里讲到的“继承父类的行为”的技术让父类以及祖先类先把this构造出来才行。

所以这里就存在了一个矛盾这是一个“先有鸡还是先有蛋”的问题一方面构造方法中要调用父类构造方法来得到this另一方面调用父类方法的super.xxx()需要先从环境中找到并绑定一个this。

概念上这是无解的。

ECMAScript为此约定只能在调用了父类构造方法之后才能使用super.xxx的方式来引用父类的属性或者调用父类的方法也就是访问SuperReference之前必须先调用父类构造方法这称为SuperCall在代码上就是直接的super()调用这一语法)。这其中也隐含了一个限制:在调用父类构造方法时,也就是super()这样的代码中super是不绑定this值的也不在调用中传入this值的。因为这个阶段根本还没有this。

super()中的父类构造方法

事实上不仅仅如此。因为如果你打算调用父类构造方法注意之前讲的是父类方法这里是父类构造方法也就是构造器那么很不幸事实上你也找不到super。

以new MyClass()为例类MyClass的constructor()方法声明时它的主对象其实是MyClass.prototype而不是MyClass。因为后者是静态类方法的主对象而显然constructor()方法只是一般方法而不是静态类方法例如没有static关键字。所以在MyClass的构造方法中访问super时通过HomeObject找到的将是原型的父级对象。而这并不是父类构造器,例如:

class MyClass extends Object {
  constructor() { ... }  // <- [[HomeObject]]指向MyClass.prototype
}

我们知道super()的语义是“调用父类构造方法”,也就应当是extends所指定的Object()。而上面讲述的意思是说,在当前构造方法中,无法通过HomeObject来找到父类构造方法。

那么JavaScript又是怎么做的呢其实很简单在这种情况下JavaScript会从当前调用栈上找到当前函数——也就是new MyClass()中的当前构造器并且返回该构造器的原型作为super。

也就是说,类的原型就是它的父类。这又是我们在上面讨论过的:把“通过原型继承得到子类”的概念反过来用一下,就得到了父类的概念。

为什么构造方法不是静态的?

也许你会提一个问题为什么不直接将constructor()声明为类静态方法呢?事实上我在分析清楚这个super()逻辑的时候,第一反应也是如此。类静态方法中的HomeObject就是MyClass自己啊如果这样的话就不必换个法子来找到super了。

是的这个逻辑没错。但是我们记得在构造方法consturctor()中也是可以使用super.xxx()的与调用父类一般方法即MyClass.prototype上的原型方法的方式是类似的。

因此根本问题在于一方面super()需要将父类构造器作为super另一方面super.xxx需要引用父类的原型上的属性。

这两个需求是无法通过同一个HomeObject来实现的。这个问题只会出现在构造方法中并且也只与super()冲突。所以super()中的super采用了别的方法这里是指在调用栈上查找当前函数的方式来查找当前类以及父类而且它也是作为特殊的语法来处理的。

现在JavaScript通过当前方法的HomeObject找到了super也找到了它的属性super.xxx这个称为Super引用SuperReference并且在背地里为这个SuperReference绑定了一个thisValue。于是接下来它只需要做一件事就可以了调用super.xxx()。

知识回顾

下面我来为第13讲做个总结这一讲有4个要点

  1. 只能在方法中使用super因为只有方法有HomeObject
  2. super.xxx()是对super.xxx这个引用SuperReference作函数调用操作调用中传入的this引用是在当前环境的上下文中查找的。
  3. super实际上是在通过原型链查找父一级的对象而与它是不是类继承无关。
  4. 如果在类的声明头部没有声明extends那么在构造方法中也就不能调用父类构造方法。

第4个要点涉及到两个问题其一是它显然显式的没有所谓super其二是没有声明extends的类其实是采用传统方式创建的构造器。但后者不是在本讲中讨论的内容。

思考题

  1. 请问x = super.xxx.bind(...)会发生什么这个过程中的thisValue会如何处理
  2. super引用是动态查找的但类声明是静态声明请问二者会有什么矛盾简单地说super引用的并不一定是你所预期的静态声明的请尝试写一个这种示例
  3. super.xxx如果是属性而不是函数/方法那么绑定this有什么用呢

希望你能将自己的答案分享出来,让我也有机会听听你的收获。